From f7d32ac09f8fe2a50a0f30504b79a687e86b39eb Mon Sep 17 00:00:00 2001 From: Gadi Evron Date: Thu, 18 Jun 2026 01:20:39 +0300 Subject: [PATCH] fix(parsers/php): resolve $this->/self:: calls into used traits A class that composes a method via an in-class `use TraitName;` had no call edge from `$this->m()` / `self::m()` to the trait's method: `_resolve_self_call` only considered methods physically declared in the class body, never the methods pulled in from used traits. - function_extractor.py: capture in-class `use_declaration` trait names onto a new `traits` field on the class record (grouped + namespaced uses reduced to the unqualified trait name). - call_graph_builder.py: build a `traits_by_class` index and make `_resolve_self_call` fall back to the used traits' methods (cross-file, via `_resolve_class_call`) when no own method matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../parsers/php/call_graph_builder.py | 18 ++++ .../parsers/php/function_extractor.py | 26 +++++ .../tests/test_php_trait_self_call.py | 101 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 libs/openant-core/tests/test_php_trait_self_call.py diff --git a/libs/openant-core/parsers/php/call_graph_builder.py b/libs/openant-core/parsers/php/call_graph_builder.py index 4354631..2cbb231 100644 --- a/libs/openant-core/parsers/php/call_graph_builder.py +++ b/libs/openant-core/parsers/php/call_graph_builder.py @@ -134,6 +134,8 @@ def __init__(self, extractor_output: Dict, options: Optional[Dict] = None): self.functions_by_name: Dict[str, List[str]] = {} self.functions_by_file: Dict[str, List[str]] = {} self.methods_by_class: Dict[str, List[str]] = {} + # class_key -> list of trait names the class composes via in-class `use`. + self.traits_by_class: Dict[str, List[str]] = {} self._build_indexes() @@ -162,6 +164,13 @@ def _build_indexes(self) -> None: self.methods_by_class[class_key] = [] self.methods_by_class[class_key].append(func_id) + # Index each class's composed traits (in-class `use TraitName;`) so a + # $this->/self:: call can fall back to a method pulled in from a trait. + for class_key, class_data in self.classes.items(): + traits = class_data.get('traits') + if traits: + self.traits_by_class[class_key] = list(traits) + def _is_builtin(self, name: str) -> bool: """Check if name is a PHP builtin or common function.""" return name.lower() in PHP_BUILTINS # PHP function names are case-insensitive @@ -438,6 +447,15 @@ def _resolve_self_call(self, method_name: str, caller_file: str, if func_data.get('name') == method_name: return func_id + # Fall back to methods composed in via traits (`use TraitName;`). A trait + # method is invoked exactly like an own method ($this->m()/self::m()), but + # it lives under the trait's own class_key, so resolve it there. The trait + # may be declared in a different file, hence the cross-file lookup. + for trait_name in self.traits_by_class.get(class_key, []): + resolved = self._resolve_class_call(trait_name, method_name, caller_file) + if resolved: + return resolved + return None def _resolve_class_call(self, class_name: str, method_name: str, diff --git a/libs/openant-core/parsers/php/function_extractor.py b/libs/openant-core/parsers/php/function_extractor.py index 236e06a..f1f2e82 100644 --- a/libs/openant-core/parsers/php/function_extractor.py +++ b/libs/openant-core/parsers/php/function_extractor.py @@ -138,6 +138,23 @@ def _is_static_method(self, node, source: bytes) -> bool: return True return False + def _extract_trait_names(self, use_node, source: bytes) -> List[str]: + """Extract trait names from an in-class `use_declaration` node. + + Handles grouped uses (`use A, B\\C;`). Each trait is a `name` or + `qualified_name` child; namespace-qualified names are reduced to their + last segment so resolution matches the trait's unqualified class name. + """ + names = [] + for child in use_node.children: + if child.type in ('name', 'qualified_name'): + trait = self._node_text(child, source) + if '\\' in trait: + trait = trait.rsplit('\\', 1)[-1] + if trait: + names.append(trait) + return names + def _get_visibility(self, node, source: bytes) -> Optional[str]: """Extract visibility modifier from a method_declaration node.""" for child in node.children: @@ -306,6 +323,7 @@ def _extract_functions_from_tree(self, tree, source: bytes, file_path: Path, if new_class_name: class_id = f"{relative_path}:{new_class_name}" methods = [] + traits = [] # Find declaration_list (class body) body_node = node.child_by_field_name('body') if body_node is None: @@ -323,6 +341,13 @@ def _extract_functions_from_tree(self, tree, source: bytes, file_path: Path, methods.append(f"static:{mname}") else: methods.append(mname) + elif child.type == 'use_declaration': + # In-class `use TraitA, NS\TraitB;` composes traits into the class. + # tree-sitter-php emits this as `use_declaration` (distinct from the + # top-level `namespace_use_declaration`), with each trait as a + # `name`/`qualified_name` child. Record the unqualified trait name so + # the call-graph builder can resolve $this->/self:: into the trait. + traits.extend(self._extract_trait_names(child, source)) self.classes[class_id] = { 'name': new_class_name, @@ -330,6 +355,7 @@ def _extract_functions_from_tree(self, tree, source: bytes, file_path: Path, 'start_line': node.start_point[0] + 1, 'end_line': node.end_point[0] + 1, 'methods': methods, + 'traits': traits, 'superclass': superclass, 'interfaces': interfaces, 'namespace_name': namespace_name, diff --git a/libs/openant-core/tests/test_php_trait_self_call.py b/libs/openant-core/tests/test_php_trait_self_call.py new file mode 100644 index 0000000..dc070d2 --- /dev/null +++ b/libs/openant-core/tests/test_php_trait_self_call.py @@ -0,0 +1,101 @@ +"""Regression test for F12 sub-defect (3): trait-composition self-calls. + +Sub-defects (1) [the `trait composition index: a class that pulls a method in via +`use TraitName;` had no edge from `$this->m()` / `self::m()` to the trait's +method, because `_resolve_self_call` only looked at methods physically declared +in the class body and never the methods of its used traits. + +Two layers are exercised: + * builder layer -- given a `traits` field on the class record, the + CallGraphBuilder must fall back to the used traits' methods. + * extractor layer -- the FunctionExtractor must populate that `traits` field + from the in-class `use_declaration` node so the builder has data to use. + +Loads both modules under UNIQUE importlib names (call_graph_builder / +function_extractor are basenames shared by every parser). +""" +import importlib.util +import sys +from pathlib import Path + +CORE = Path(__file__).resolve().parents[1] +if str(CORE) not in sys.path: + sys.path.insert(0, str(CORE)) + + +def _load(unique, relpath): + spec = importlib.util.spec_from_file_location(unique, str(CORE / relpath)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +_cgb = _load("php_call_graph_builder_trait", "parsers/php/call_graph_builder.py") +_fe = _load("php_function_extractor_trait", "parsers/php/function_extractor.py") +CallGraphBuilder = _cgb.CallGraphBuilder +FunctionExtractor = _fe.FunctionExtractor + + +def _build(funcs, classes=None, imports=None): + b = CallGraphBuilder({"functions": funcs, "classes": classes or {}, "imports": imports or {}, + "repository": "/r"}) + b.build_call_graph() + return b + + +# F12 #3 builder layer: $this->g() and self::g() resolve into a used trait's method. +def test_trait_self_call_resolves_into_used_trait(): + b = _build({ + "a.php:C.f": {"name": "f", "file_path": "a.php", "class_name": "C", + "code": "function f() { $this->g(); }"}, + "a.php:C.h": {"name": "h", "file_path": "a.php", "class_name": "C", + "code": "function h() { self::g(); }"}, + "a.php:T.g": {"name": "g", "file_path": "a.php", "class_name": "T", + "code": "function g() {}"}, + }, classes={ + "a.php:C": {"name": "C", "file_path": "a.php", "superclass": None, "traits": ["T"]}, + "a.php:T": {"name": "T", "file_path": "a.php", "superclass": None, "traits": []}, + }) + assert b.call_graph.get("a.php:C.f") == ["a.php:T.g"], b.call_graph + assert b.call_graph.get("a.php:C.h") == ["a.php:T.g"], b.call_graph + + +# F12 #3 extractor layer: the in-class `use T;` is captured onto the class record, +# and the full extract->build pipeline yields the trait edges from real source. +def test_extractor_captures_trait_use_and_builds_edges(tmp_path): + src = ( + "g(); }\n" + " function h() { self::g(); }\n" + "}\n" + ) + f = tmp_path / "trait_case.php" + f.write_text(src) + + ex = FunctionExtractor(str(tmp_path)) + out = ex.extract_all() + + # Class record must carry the used trait. + c_key = next(k for k in out["classes"] if k.endswith(":C")) + assert "T" in out["classes"][c_key].get("traits", []), out["classes"][c_key] + + b = CallGraphBuilder(out) + b.build_call_graph() + + f_id = next(k for k in out["functions"] + if out["functions"][k].get("name") == "f" + and out["functions"][k].get("class_name") == "C") + h_id = next(k for k in out["functions"] + if out["functions"][k].get("name") == "h" + and out["functions"][k].get("class_name") == "C") + g_id = next(k for k in out["functions"] + if out["functions"][k].get("name") == "g" + and out["functions"][k].get("class_name") == "T") + + assert g_id in b.call_graph.get(f_id, []), b.call_graph.get(f_id) + assert g_id in b.call_graph.get(h_id, []), b.call_graph.get(h_id)