diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 3db8e7b09f..91503e02e6 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1400,7 +1400,7 @@ def _python_collect_assignment_targets(node, source: bytes, out: set[str]) -> No # Languages whose `self`/`this` member calls bind through _self_call_target. _SELF_CALL_LANGUAGES = frozenset({ "tree_sitter_python", "tree_sitter_javascript", "tree_sitter_typescript", - "tree_sitter_swift", "tree_sitter_ruby", + "tree_sitter_swift", "tree_sitter_ruby", "tree_sitter_php", }) def _self_call_target( @@ -1413,9 +1413,9 @@ def _self_call_target( methods_by_owner: dict[tuple[str, str], str], class_bases: dict[str, list[str]], walk_bases: bool = True, + require_method_owner: bool = False, ) -> str | None: - """In-file target of `self.m()` / `cls.m()` / `super().m()` in Python and - `this.m()` / `super.m()` in JS/TS, else None. + """In-file target of a known current-instance or base-instance method call. ``walk_bases=False`` stops after the caller's own class and otherwise keeps 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 scope = scope_parents.get(scope) fallback = label_to_nid.get(callee) if not scope: - return fallback + return None if require_method_owner else fallback level = [method_owner[scope]] seen: set[str] = set() skip_own = receiver == "super" @@ -6532,6 +6532,8 @@ def scala_base_name(type_node) -> str | None: if e["relation"] == "method": method_owner[e["target"]] = e["source"] name = label_by_nid.get(e["target"], "").strip("()").lstrip(".") + if config.ts_module == "tree_sitter_php": + name = name.casefold() methods_by_owner.setdefault((e["source"], name), e["target"]) def _fields_up_chain(tables: dict, class_nid) -> dict: @@ -7099,6 +7101,12 @@ def walk_calls( name_node = node.child_by_field_name("name") if name_node: callee_name = _read_text(name_node, source) + receiver = node.child_by_field_name("object") + if (receiver is not None + and _read_text(receiver, source) == "$this" + and callee_name + and callee_name.casefold() in _LANGUAGE_BUILTIN_GLOBALS): + self_receiver = "this" 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 @@ -7288,7 +7296,9 @@ def walk_calls( # receiver-typed defers just past this comment) means it can only ever # reach an edge through a guarded, receiver-typed resolver, never the # unguarded bare-name path a real god node would need. + # A known PHP $this receiver resolves against its owning class's methods. _builtin_member_call = is_member_call and callee_name in _LANGUAGE_BUILTIN_GLOBALS + php_builtin_self_call = config.ts_module == "tree_sitter_php" and self_receiver == "this" if callee_name and ( callee_name not in _LANGUAGE_BUILTIN_GLOBALS or _builtin_member_call ): @@ -7329,7 +7339,7 @@ def walk_calls( and is_member_call and not lua_self_qualified ) - if _python_defer or _java_defer or _builtin_member_call or _lua_member_defer or ( + if _python_defer or _java_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or ( is_member_call and member_receiver and ( @@ -7356,13 +7366,17 @@ def walk_calls( and member_receiver in ("self", "cls", "super") ): tgt_nid = _self_call_target( - caller_nid, callee_name, self_receiver or member_receiver or "", + caller_nid, callee_name.casefold() if php_builtin_self_call else callee_name, + self_receiver or member_receiver or "", label_to_nid, scope_parents, method_owner, methods_by_owner, _local_bases, walk_bases=config.ts_module not in ( "tree_sitter_javascript", "tree_sitter_typescript", ), + require_method_owner=php_builtin_self_call, ) + if php_builtin_self_call and tgt_nid not in method_owner: + tgt_nid = None else: tgt_nid = label_to_nid.get(callee_name) # A qualified `new A.B.Foo()` whose bare name matches only a diff --git a/tests/test_php_language_construct_calls.py b/tests/test_php_language_construct_calls.py index 79b17e2c52..7a0aa9d851 100644 --- a/tests/test_php_language_construct_calls.py +++ b/tests/test_php_language_construct_calls.py @@ -14,6 +14,8 @@ reason: `$bag->empty()` is a genuine member call into that method and must still resolve, and the construct's arguments must still be walked for calls. """ +import pytest + from graphify.extract import extract @@ -256,3 +258,98 @@ def test_non_construct_function_call_still_resolves(tmp_path): resolved = _edges_between(result, caller_ids, target_ids) assert resolved, "a genuine cross-file function call must still resolve" + + +@pytest.mark.parametrize("name, statement", [ + ("die", "die($value);"), + ("eval", "eval($value);"), + ("array", "$result = array($value);"), + ("exit", "exit($value);"), + ("list", "list($value) = $values;"), + ("unset", "unset($value);"), +]) +def test_construct_named_methods_keep_only_real_member_calls(tmp_path, name, statement): + """Keyword use must not bind, while the same-named member remains callable.""" + (tmp_path / "Bag.php").write_text( + "{name}($value); }}\n" + "}\n", + encoding="utf-8", + ) + (tmp_path / "outside.php").write_text( + f"LIST(); } }\n" + "class Other { public function list() {} }\n", + encoding="utf-8", + ) + result = _extract_php(tmp_path) + bag = next(n["id"] for n in result["nodes"] if n["label"] == "Bag") + other = next(n["id"] for n in result["nodes"] if n["label"] == "Other") + targets = _ids(result, "Bag.php", "list") + bag_targets = {e["target"] for e in result["edges"] + if e["source"] == bag and e["relation"] == "method"} & targets + other_targets = {e["target"] for e in result["edges"] + if e["source"] == other and e["relation"] == "method"} & targets + caller = _ids(result, "Bag.php", "useList") + assert bag_targets and other_targets and caller + assert _edges_between(result, caller, bag_targets) + assert not _edges_between(result, caller, other_targets) + + +def test_builtin_named_self_call_does_not_bind_to_another_class(tmp_path): + (tmp_path / "Bag.php").write_text( + "list(); } }\n" + "class Other { public function list() {} }\n", + encoding="utf-8", + ) + result = _extract_php(tmp_path) + caller = _ids(result, "Bag.php", "useList") + target = _ids(result, "Bag.php", "list") + assert caller and target + assert not _edges_between(result, caller, target) + + +def test_builtin_named_self_call_does_not_bind_to_a_free_function(tmp_path): + (tmp_path / "Bag.php").write_text( + "open(); } }\n", + encoding="utf-8", + ) + result = _extract_php(tmp_path) + caller = _ids(result, "Bag.php", "useOpen") + target = _ids(result, "Bag.php", "open") + assert caller and target + assert not _edges_between(result, caller, target) + + +def test_builtin_named_this_call_without_a_class_owner_stays_unresolved(tmp_path): + (tmp_path / "Bag.php").write_text( + "list(); }\n" + "function makeListCallable() { return function() { $this->list(); }; }\n" + "class Other { public function list() {} }\n", + encoding="utf-8", + ) + result = _extract_php(tmp_path) + target = _ids(result, "Bag.php", "list") + assert target and _ids(result, "Bag.php", "useList") + assert _ids(result, "Bag.php", "makeListCallable") + assert not any(e["relation"] == "calls" and e["target"] in target + for e in result["edges"])