mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
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>
This commit is contained in:
1 parent
a9860f70dc
commit
199ba1af0d
4 files changed
+315
-8
No files matched your search
+31
-3
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user