feat(php): resolve instance member calls through the receiver's declared type

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 <noreply@anthropic.com>
(cherry picked from commit 199ba1af0d)
This commit is contained in:
xiongjianxu authored and safishamsi committed 2026-10-13 11:14:30 +01:00
1 parent ed2443f959
commit 0d635ff429
4 files changed
+315 -8

No files matched your search

+31 -3
View File
@@ -5711,7 +5711,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)
):
@@ -5734,6 +5734,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", []):
@@ -5747,6 +5753,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)
@@ -5757,6 +5764,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:
@@ -5784,8 +5810,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,
+92
View File
@@ -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
@@ -8375,6 +8446,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
@@ -8751,6 +8833,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
@@ -9314,6 +9402,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}
@@ -9327,6 +9417,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:
+184
View File
@@ -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 = "<?php\nclass Greeter {\n public function greet(): void {}\n}\n"
def _calls(tmp_path, files: dict[str, str]):
for name, body in files.items():
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
result = extract([tmp_path / n for n in files],
cache_root=tmp_path / "graphify-out", parallel=False)
label = {n["id"]: n["label"] for n in result["nodes"]}
calls = {(label.get(e["source"]), label.get(e["target"])): e
for e in result["edges"] if e["relation"] == "calls"}
return calls, result
def _greet_edge(calls: dict) -> 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": "<?php\nclass App {\n private Greeter $greeter;\n"
" public function run(): void { $this->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": "<?php\nclass App {\n"
" public function __construct(private Greeter $greeter) {}\n"
" public function run(): void { $this->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": "<?php\nclass App {\n"
" public function run(Greeter $greeter): void { $greeter->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": "<?php\nclass App {\n public function run(): void {\n"
" $greeter = new Greeter();\n $greeter->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": "<?php\nclass App {\n private ?Greeter $greeter = null;\n"
" public function run(): void { $this->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": "<?php\nclass App {\n"
" public function run($greeter): void { $greeter->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": "<?php\nclass Other {\n public function greet(): void {}\n}\n",
"App.php": "<?php\nclass App {\n"
" public function run(Greeter|Other $greeter): void "
"{ $greeter->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": "<?php\nclass App {\n private Greeter $greeter;\n"
" public function run(): void { $this->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": "<?php\nclass App {\n private Greeter $greeter;\n"
" public function run(): void { $this->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": "<?php\nclass Other {\n public function greet(): void {}\n}\n",
"App.php": "<?php\nclass App {\n private Greeter $greeter;\n"
" public function run(): void { $this->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": "<?php\nclass Helper {\n public static function format(): void {}\n}\n",
"App.php": "<?php\nclass App {\n"
" public function run(): void { Helper::format(); }\n}\n",
})
edge = next((e for (src, tgt), e in calls.items()
if src and "run" in src and tgt == ".format()"), None)
assert edge is not None, calls
assert edge["confidence"] == "EXTRACTED"
def test_a_class_from_another_language_never_answers_a_php_receiver(tmp_path):
# The declaration index is corpus-wide, so a same-named Java class would both answer
# the receiver and hide that no PHP file declares it — the call belongs to the merge.
calls, result = _calls(tmp_path, {
"App.php": "<?php\nclass App {\n private Greeter $greeter;\n"
" public function run(): void { $this->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
+8 -5
View File
@@ -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": (
"<?php\nnamespace App;\nclass Helper {\n"
@@ -170,4 +170,7 @@ def test_instance_call_is_still_unresolved_no_regression(tmp_path):
"<?php\nnamespace App;\nclass Caller {\n"
" public function run(Helper $h, $x) { return $h->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"