Skip to content

Commit aca5651

Browse files
committed
fix(php): resolve builtin-named methods on known self receivers
Assisted-by: OpenAI Codex (GPT-6)
1 parent 35adf43 commit aca5651

2 files changed

Lines changed: 117 additions & 6 deletions

File tree

‎graphify/extractors/engine.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,7 +1400,7 @@ def _python_collect_assignment_targets(node, source: bytes, out: set[str]) -> No
14001400
# Languages whose `self`/`this` member calls bind through _self_call_target.
14011401
_SELF_CALL_LANGUAGES = frozenset({
14021402
"tree_sitter_python", "tree_sitter_javascript", "tree_sitter_typescript",
1403-
"tree_sitter_swift", "tree_sitter_ruby",
1403+
"tree_sitter_swift", "tree_sitter_ruby", "tree_sitter_php",
14041404
})
14051405

14061406
def _self_call_target(
@@ -1413,9 +1413,9 @@ def _self_call_target(
14131413
methods_by_owner: dict[tuple[str, str], str],
14141414
class_bases: dict[str, list[str]],
14151415
walk_bases: bool = True,
1416+
require_method_owner: bool = False,
14161417
) -> str | None:
1417-
"""In-file target of `self.m()` / `cls.m()` / `super().m()` in Python and
1418-
`this.m()` / `super.m()` in JS/TS, else None.
1418+
"""In-file target of a known current-instance or base-instance method call.
14191419
14201420
``walk_bases=False`` stops after the caller's own class and otherwise keeps
14211421
the plain lookup: JS/TS `extends` edges come from the later symbol pass, so
@@ -1436,7 +1436,7 @@ class to whichever class declared save() last. A file-wide hit that is not
14361436
scope = scope_parents.get(scope)
14371437
fallback = label_to_nid.get(callee)
14381438
if not scope:
1439-
return fallback
1439+
return None if require_method_owner else fallback
14401440
level = [method_owner[scope]]
14411441
seen: set[str] = set()
14421442
skip_own = receiver == "super"
@@ -6532,6 +6532,8 @@ def scala_base_name(type_node) -> str | None:
65326532
if e["relation"] == "method":
65336533
method_owner[e["target"]] = e["source"]
65346534
name = label_by_nid.get(e["target"], "").strip("()").lstrip(".")
6535+
if config.ts_module == "tree_sitter_php":
6536+
name = name.casefold()
65356537
methods_by_owner.setdefault((e["source"], name), e["target"])
65366538

65376539
def _fields_up_chain(tables: dict, class_nid) -> dict:
@@ -7099,6 +7101,12 @@ def walk_calls(
70997101
name_node = node.child_by_field_name("name")
71007102
if name_node:
71017103
callee_name = _read_text(name_node, source)
7104+
receiver = node.child_by_field_name("object")
7105+
if (receiver is not None
7106+
and _read_text(receiver, source) == "$this"
7107+
and callee_name
7108+
and callee_name.casefold() in _LANGUAGE_BUILTIN_GLOBALS):
7109+
self_receiver = "this"
71027110
elif config.ts_module == "tree_sitter_cpp":
71037111
# C++: function field, then field_expression/qualified_identifier
71047112
func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None
@@ -7288,7 +7296,9 @@ def walk_calls(
72887296
# receiver-typed defers just past this comment) means it can only ever
72897297
# reach an edge through a guarded, receiver-typed resolver, never the
72907298
# unguarded bare-name path a real god node would need.
7299+
# A known PHP $this receiver resolves against its owning class's methods.
72917300
_builtin_member_call = is_member_call and callee_name in _LANGUAGE_BUILTIN_GLOBALS
7301+
php_builtin_self_call = config.ts_module == "tree_sitter_php" and self_receiver == "this"
72927302
if callee_name and (
72937303
callee_name not in _LANGUAGE_BUILTIN_GLOBALS or _builtin_member_call
72947304
):
@@ -7329,7 +7339,7 @@ def walk_calls(
73297339
and is_member_call
73307340
and not lua_self_qualified
73317341
)
7332-
if _python_defer or _java_defer or _builtin_member_call or _lua_member_defer or (
7342+
if _python_defer or _java_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or (
73337343
is_member_call
73347344
and member_receiver
73357345
and (
@@ -7356,13 +7366,17 @@ def walk_calls(
73567366
and member_receiver in ("self", "cls", "super")
73577367
):
73587368
tgt_nid = _self_call_target(
7359-
caller_nid, callee_name, self_receiver or member_receiver or "",
7369+
caller_nid, callee_name.casefold() if php_builtin_self_call else callee_name,
7370+
self_receiver or member_receiver or "",
73607371
label_to_nid, scope_parents, method_owner, methods_by_owner,
73617372
_local_bases,
73627373
walk_bases=config.ts_module not in (
73637374
"tree_sitter_javascript", "tree_sitter_typescript",
73647375
),
7376+
require_method_owner=php_builtin_self_call,
73657377
)
7378+
if php_builtin_self_call and tgt_nid not in method_owner:
7379+
tgt_nid = None
73667380
else:
73677381
tgt_nid = label_to_nid.get(callee_name)
73687382
# A qualified `new A.B.Foo()` whose bare name matches only a

‎tests/test_php_language_construct_calls.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
reason: `$bag->empty()` is a genuine member call into that method and must still
1515
resolve, and the construct's arguments must still be walked for calls.
1616
"""
17+
import pytest
18+
1719
from graphify.extract import extract
1820

1921

@@ -256,3 +258,98 @@ def test_non_construct_function_call_still_resolves(tmp_path):
256258

257259
resolved = _edges_between(result, caller_ids, target_ids)
258260
assert resolved, "a genuine cross-file function call must still resolve"
261+
262+
263+
@pytest.mark.parametrize("name, statement", [
264+
("die", "die($value);"),
265+
("eval", "eval($value);"),
266+
("array", "$result = array($value);"),
267+
("exit", "exit($value);"),
268+
("list", "list($value) = $values;"),
269+
("unset", "unset($value);"),
270+
])
271+
def test_construct_named_methods_keep_only_real_member_calls(tmp_path, name, statement):
272+
"""Keyword use must not bind, while the same-named member remains callable."""
273+
(tmp_path / "Bag.php").write_text(
274+
"<?php\nclass Bag {\n"
275+
f" public function {name}($value) {{ return $value; }}\n"
276+
f" public function constructUse($value, $values) {{ {statement} }}\n"
277+
f" public function memberUse($value) {{ return $this->{name}($value); }}\n"
278+
"}\n",
279+
encoding="utf-8",
280+
)
281+
(tmp_path / "outside.php").write_text(
282+
f"<?php\nfunction externalUse($value, $values) {{ {statement} }}\n",
283+
encoding="utf-8",
284+
)
285+
result = _extract_php(tmp_path)
286+
target = _ids(result, "Bag.php", name)
287+
local = _ids(result, "Bag.php", "constructUse")
288+
external = _ids(result, "outside.php", "externalUse")
289+
member = _ids(result, "Bag.php", "memberUse")
290+
assert target and local and external and member
291+
assert not _edges_between(result, local | external, target)
292+
assert _edges_between(result, member, target)
293+
294+
295+
def test_builtin_named_self_method_uses_its_own_class_and_php_case_rules(tmp_path):
296+
source = tmp_path / "Bag.php"
297+
source.write_text(
298+
"<?php\nclass Bag { public function list() {}\n"
299+
" public function useList() { $this->LIST(); } }\n"
300+
"class Other { public function list() {} }\n",
301+
encoding="utf-8",
302+
)
303+
result = _extract_php(tmp_path)
304+
bag = next(n["id"] for n in result["nodes"] if n["label"] == "Bag")
305+
other = next(n["id"] for n in result["nodes"] if n["label"] == "Other")
306+
targets = _ids(result, "Bag.php", "list")
307+
bag_targets = {e["target"] for e in result["edges"]
308+
if e["source"] == bag and e["relation"] == "method"} & targets
309+
other_targets = {e["target"] for e in result["edges"]
310+
if e["source"] == other and e["relation"] == "method"} & targets
311+
caller = _ids(result, "Bag.php", "useList")
312+
assert bag_targets and other_targets and caller
313+
assert _edges_between(result, caller, bag_targets)
314+
assert not _edges_between(result, caller, other_targets)
315+
316+
317+
def test_builtin_named_self_call_does_not_bind_to_another_class(tmp_path):
318+
(tmp_path / "Bag.php").write_text(
319+
"<?php\nclass Bag { public function useList() { $this->list(); } }\n"
320+
"class Other { public function list() {} }\n",
321+
encoding="utf-8",
322+
)
323+
result = _extract_php(tmp_path)
324+
caller = _ids(result, "Bag.php", "useList")
325+
target = _ids(result, "Bag.php", "list")
326+
assert caller and target
327+
assert not _edges_between(result, caller, target)
328+
329+
330+
def test_builtin_named_self_call_does_not_bind_to_a_free_function(tmp_path):
331+
(tmp_path / "Bag.php").write_text(
332+
"<?php\nfunction open() {}\n"
333+
"class Bag { public function useOpen() { $this->open(); } }\n",
334+
encoding="utf-8",
335+
)
336+
result = _extract_php(tmp_path)
337+
caller = _ids(result, "Bag.php", "useOpen")
338+
target = _ids(result, "Bag.php", "open")
339+
assert caller and target
340+
assert not _edges_between(result, caller, target)
341+
342+
343+
def test_builtin_named_this_call_without_a_class_owner_stays_unresolved(tmp_path):
344+
(tmp_path / "Bag.php").write_text(
345+
"<?php\nfunction useList() { $this->list(); }\n"
346+
"function makeListCallable() { return function() { $this->list(); }; }\n"
347+
"class Other { public function list() {} }\n",
348+
encoding="utf-8",
349+
)
350+
result = _extract_php(tmp_path)
351+
target = _ids(result, "Bag.php", "list")
352+
assert target and _ids(result, "Bag.php", "useList")
353+
assert _ids(result, "Bag.php", "makeListCallable")
354+
assert not any(e["relation"] == "calls" and e["target"] in target
355+
for e in result["edges"])

0 commit comments

Comments
 (0)