mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 16:09:26 +00:00
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)
185 lines
7.9 KiB
Python
185 lines
7.9 KiB
Python
"""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
|