Files
graphify/tests/test_php_receiver_member_calls.py
T
xiongjianxu 775ddb9d5a fix(cross-repo): point a cross-repo call edge at its call site, not the caller's header
A cross-repo `calls` edge took its `source_file` from the caller node
(an ObjC method node is minted at its `@interface` in the `.h`) while its
`source_location` is the call-site line, recorded in the `.m`. Pairing the
two pointed `source_file:source_location` into the wrong file — often past
the end of a short header.

The park payload now carries the call-site file alongside the line, and
`link_cross_repo_member_calls` prefers it, falling back to the caller
node's file for pre-#3152 graphs. The parked path is relativized in the
same extract-time pass that relativizes node source_files, so it never
leaks the absolute checkout path into graph.json.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 3281d42492)
2026-10-22 13:46:54 +01:00

186 lines
8.0 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 = [[{k: v for k, v in e.items() if k != "file"} for e in uc]
for n in result["nodes"] if "run" in str(n["label"]) and n.get("metadata")
for uc in [(n.get("metadata") or {}).get("unresolved_calls")] if uc]
assert parked == [[{"callee": "greet", "receiver_type": "Greeter",
"lang": "php", "line": "L4"}]], parked