From 199ba1af0df4f1ce566e3350ff95db453ad288d1 Mon Sep 17 00:00:00 2001 From: xiongjianxu <6457197+xiongjianxu@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:21:37 +0800 Subject: [PATCH] feat(php): resolve instance member calls through the receiver's declared type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v8's `_resolve_php_member_calls` (#3872) binds scoped `Class::method()` calls but leaves instance `$g->method()` calls unresolved. This adds the instance tier beside it: a per-file receiver type table (`_php_receiver_type_table` over typed properties, promoted constructor params, typed params and `new` bindings) names the receiver's class, and the shared resolver takes the single PHP declaration of that class and emits the `calls` edge, INFERRED. The declaration index is gated to the PHP interop family: the index is corpus-wide, so a same-named class in another language would both answer the receiver and hide that no PHP file declares it. A receiver the table cannot type to one class — untyped, union, longer chain — stays unresolved. Co-Authored-By: Claude Code --- graphify/extract.py | 34 ++++- graphify/extractors/engine.py | 92 ++++++++++++ tests/test_php_receiver_member_calls.py | 184 +++++++++++++++++++++++ tests/test_php_static_call_resolution.py | 13 +- 4 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 tests/test_php_receiver_member_calls.py diff --git a/graphify/extract.py b/graphify/extract.py index bd8d541c5..7ecd9da04 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5710,7 +5710,7 @@ def _resolve_php_member_calls( type_def_nids: dict[str, list[str]] = {} for node in all_nodes: if ( - node.get("source_file") + _lang_family(node.get("source_file")) == "php" and node.get("id") in contained and _is_type_like_definition(node) ): @@ -5733,6 +5733,12 @@ def _resolve_php_member_calls( if edge.get("relation") == "inherits": inherits_bases.setdefault(edge["source"], []).append(edge["target"]) + type_table_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + tt = result.get("php_type_table") + if tt and tt.get("path"): + type_table_by_file[str(tt["path"])] = tt.get("table", {}) + existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} for result in per_file: for raw_call in result.get("raw_calls", []): @@ -5746,6 +5752,7 @@ def _resolve_php_member_calls( if not receiver or not callee or not caller: continue + inferred = False receiver_lower = receiver.lower() if receiver_lower in ("self", "static"): type_nid = enclosing_type.get(caller) @@ -5756,6 +5763,25 @@ def _resolve_php_member_calls( if len(bases) != 1: continue type_nid = bases[0] + elif not raw_call.get("php_scoped") and not receiver.startswith("$"): + # Instance receiver (`$g->m()`, `$this->prop->m()`): the type comes + # from the annotation table, never the call site, so this rung is + # INFERRED. An untyped, union or longer-chain receiver is absent from + # the table and stays unresolved rather than guessing a class name. + typed = type_table_by_file.get( + str(raw_call.get("source_file", "")), {}).get(receiver) + if typed is None: + continue + inferred = True + type_defs = type_def_nids.get(class_key(typed), []) + if not type_defs: + _park_unresolved_member_call( + node_by_id.get(caller), callee, typed, "php", raw_call, + ) + continue + if len(type_defs) != 1: + continue + type_nid = type_defs[0] elif not receiver.startswith("$"): type_defs = type_def_nids.get(class_key(receiver), []) if not type_defs: @@ -5783,8 +5809,10 @@ def _resolve_php_member_calls( "target": method_nid, "relation": "calls", "context": "call", - "confidence": "EXTRACTED", - "confidence_score": 1.0, + "confidence": "INFERRED" if inferred else "EXTRACTED", + # Table-typed receiver -> the discrete INFERRED rung (0.85); + # a source-named class scope stays EXTRACTED (1.0). + "confidence_score": 0.85 if inferred else 1.0, "source_file": raw_call.get("source_file", ""), "source_location": raw_call.get("source_location"), "weight": 1.0, diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 443af97ce..1b34b6bee 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -788,6 +788,77 @@ def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s if c.is_named: _php_collect_type_refs(c, source, generic, out) +def _php_declared_type_name(node, source: bytes) -> str | None: + """The single class name a PHP type annotation names, or None. + + Only `Greeter` and `?Greeter` qualify: a union, an intersection or a primitive names + no one class, and binding a receiver to the first arm of `A|B` would be a guess. + """ + if node is None: + return None + if node.type == "optional_type": + node = next((c for c in node.children if c.is_named), None) + if node is None: + return None + if node.type != "named_type": + return None + return next((_php_name_text(c, source) for c in node.children + if c.type in ("name", "qualified_name")), None) + + +def _php_variable_text(node, source: bytes) -> str | None: + """The bare name a PHP `variable_name` binds: `$greeter` -> `greeter`.""" + if node is None or node.type != "variable_name": + return None + return next((_read_text(c, source) for c in node.children if c.type == "name"), None) + + +def _php_first_declared_type(node, source: bytes) -> str | None: + """The first single-class type annotation among ``node``'s direct children.""" + return next((name for name in (_php_declared_type_name(c, source) for c in node.children) + if name), None) + + +def _php_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: + """Collect ``name -> TypeName`` for every PHP receiver whose type is written down. + + Four sources, all needed: a typed property (`private Greeter $g;`), a constructor + promotion (`__construct(private Greeter $g)`), a typed parameter, and + `$g = new Greeter()`. File-scoped and flat, first binding wins — a parameter + shadowing a property in another method must not retype the property's own calls. + Children are pushed reversed so the walk yields document order and "first" means + first in the file. + """ + stack = [root] + while stack: + n = stack.pop() + t = n.type + if t == "property_declaration": + type_name = _php_first_declared_type(n, source) + for element in n.children if type_name else (): + if element.type != "property_element": + continue + name = next((_php_variable_text(c, source) for c in element.children + if c.type == "variable_name"), None) + if name and name not in table: + table[name] = type_name + elif t in ("property_promotion_parameter", "simple_parameter"): + type_name = _php_first_declared_type(n, source) + name = next((_php_variable_text(c, source) for c in n.children + if c.type == "variable_name"), None) + if name and type_name and name not in table: + table[name] = type_name + elif t == "assignment_expression": + right = n.child_by_field_name("right") + if right is not None and right.type == "object_creation_expression": + name = _php_variable_text(n.child_by_field_name("left"), source) + type_name = next((_php_name_text(c, source) for c in right.children + if c.type in ("name", "qualified_name")), None) + if name and type_name and name not in table: + table[name] = type_name + stack.extend(reversed(n.children)) + + def _php_method_return_type_node(method_node): """Return the named_type/primitive_type node sitting after formal_parameters.""" saw_params = False @@ -8227,6 +8298,17 @@ def _extract_generic( and callee_name and callee_name.casefold() in _LANGUAGE_BUILTIN_GLOBALS): self_receiver = "this" + # `$this->greeter->greet()` types the receiver by the property name, + # `$greeter->greet()` by the variable; a longer chain names neither. + obj = node.child_by_field_name("object") + if obj is not None and obj.type == "variable_name": + member_receiver = _php_variable_text(obj, source) + elif obj is not None and obj.type == "member_access_expression": + inner = obj.child_by_field_name("object") + if inner is not None and inner.type == "variable_name" and ( + _php_variable_text(inner, source) == "this"): + member_receiver = _read_text( + obj.child_by_field_name("name"), source) elif config.ts_module == "tree_sitter_cpp": # C++: function field, then field_expression/qualified_identifier func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None @@ -8603,6 +8685,12 @@ def _extract_generic( # suffix sets, so a source_file suffix alone can't separate them. if config.ts_module == "tree_sitter_cpp": rc_entry["lang"] = "cpp" + # PHP: separate a scoped class call (`Helper::m()`, source- + # qualified) from an instance member call (`$g->m()`), whose + # receiver is a variable the resolver must type through the + # per-file table instead of reading it as a class name. + if config.ts_module == "tree_sitter_php": + rc_entry["php_scoped"] = is_scoped_call # C#: tag the raw_call so _resolve_csharp_member_calls claims # it, and stamp the receiver's type from the method's SCOPED # bindings by the call's byte offset (#1609, per-method since @@ -9166,6 +9254,8 @@ def _extract_generic( if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): _ts_receiver_type_table(root, source, type_table) _ts_mark_public_twins(root, source, result) + if config.ts_module == "tree_sitter_php": + _php_receiver_type_table(root, source, type_table) if config.ts_module == "tree_sitter_swift": if type_table or swift_factory_bindings: result["swift_type_table"] = {"path": str_path, "table": type_table} @@ -9179,6 +9269,8 @@ def _extract_generic( result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} + elif config.ts_module == "tree_sitter_php": + result["php_type_table"] = {"path": str_path, "table": type_table} return result def _ts_mark_public_twins(root, source: bytes, result: dict) -> None: diff --git a/tests/test_php_receiver_member_calls.py b/tests/test_php_receiver_member_calls.py new file mode 100644 index 000000000..bf1d0a39e --- /dev/null +++ b/tests/test_php_receiver_member_calls.py @@ -0,0 +1,184 @@ +"""PHP member calls resolve through the receiver's declared type. + +The shared cross-file pass skips member calls, and PHP recorded no receiver at all, so +`$greeter->greet()` on a receiver whose class lives in another file produced no edge — +the PHP twin of the Swift gap in #1356. Each case below pins one source of the +receiver's type, and the negative cases pin what must stay unresolved: an untyped +parameter, a union type, a longer chain, and an ambiguous class name. +""" +from __future__ import annotations + +import importlib + +import pytest + +from graphify.extract import extract + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("tree_sitter_php") is None, + reason="tree_sitter_php not installed", +) + +GREETER = " dict | None: + return next((e for (src, tgt), e in calls.items() + if src and "run" in src and tgt == ".greet()"), None) + + +def test_a_typed_property_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + edge = _greet_edge(calls) + assert edge is not None, calls + # The type came from the table, never from the call site: `$greeter` names a + # variable, so there is no spelling of this call that would be exact. + assert edge["confidence"] == "INFERRED" + + +def test_a_promoted_constructor_parameter_types_the_receiver(tmp_path): + # Constructor promotion declares no property, so nothing else in the walk ever + # names the receiver's type. + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_typed_parameter_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greet(); }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_new_binding_types_an_unannotated_local(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greet();\n }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_nullable_property_type_still_names_one_class(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_an_untyped_parameter_resolves_to_nothing(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_a_union_typed_receiver_resolves_to_nothing(tmp_path): + # `Greeter|Other` names no one class, and binding the first arm would be a guess. + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "Other.php": "greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_a_longer_chain_resolves_to_nothing(tmp_path): + # `$this->a->b->greet()` types neither `a` nor `b` as the receiver. + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->inner->greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_two_classes_of_the_same_name_resolve_to_neither(tmp_path): + # The single-definition guard: guessing one of two `Greeter`s is worse than + # leaving the call unresolved. + calls, _ = _calls(tmp_path, { + "a/Greeter.php": GREETER, + "b/Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_the_first_binding_of_a_name_wins(tmp_path): + # The table is flat per file, so a parameter named like a property has to lose: + # otherwise `other`'s signature would redirect the property's own calls. + calls, result = _calls(tmp_path, { + "Greeter.php": GREETER, + "Other.php": "greeter->greet(); }\n" + " public function other(Other $greeter): void { $greeter->greet(); }\n}\n", + }) + source_of = {n["id"]: str(n.get("source_file") or "") for n in result["nodes"]} + label_of = {n["id"]: n["label"] for n in result["nodes"]} + run_targets = {source_of[e["target"]] for e in result["edges"] + if e["relation"] == "calls" + and "run" in str(label_of.get(e["source"])) + and label_of.get(e["target"]) == ".greet()"} + assert len(run_targets) == 1, run_targets + assert run_targets.pop().endswith("Greeter.php") + + +def test_a_static_call_keeps_its_own_path(tmp_path): + # `Helper::format()` is a scoped call: source-qualified, so it binds to the + # method itself and stays EXTRACTED, never routed through the type table. + calls, _ = _calls(tmp_path, { + "Helper.php": "greeter->greet(); }\n}\n", + "Greeter.java": "public class Greeter { public void greet() {} }\n", + }) + assert _greet_edge(calls) is None, calls + parked = [(n.get("metadata") or {}).get("unresolved_calls") for n in result["nodes"] + if "run" in str(n["label"]) and n.get("metadata")] + assert parked == [[{"callee": "greet", "receiver_type": "Greeter", + "lang": "php", "line": "L4"}]], parked diff --git a/tests/test_php_static_call_resolution.py b/tests/test_php_static_call_resolution.py index 0f49bc5d8..9d3274e02 100644 --- a/tests/test_php_static_call_resolution.py +++ b/tests/test_php_static_call_resolution.py @@ -158,10 +158,10 @@ def test_method_name_case_insensitivity(tmp_path): assert any(s == ".run()" and t.lower() == ".sanitize()" for s, t in calls) -def test_instance_call_is_still_unresolved_no_regression(tmp_path): - """`$obj->method()` was never resolved before this fix (no receiver-typed - PHP resolver existed) and must remain so - this fix only targets scoped - (`::`) calls, not instance (`->`) calls.""" +def test_instance_call_resolves_through_the_receivers_declared_type(tmp_path): + """`$h->sanitize()` on a `Helper $h` parameter now resolves through the + receiver's declared type (INFERRED), the instance-receiver tier that extends + the scoped (`::`) resolver.""" calls, _ = _extract(tmp_path, { "Helper.php": ( "sanitize($x); }\n}\n"), }) - assert not any(s == ".run()" for s, _t in calls) + edge = next((e for (s, t), e in calls.items() + if s == ".run()" and t == ".sanitize()"), None) + assert edge is not None, calls + assert edge["confidence"] == "INFERRED"