Skip to content

Commit 0de365d

Browse files
gadievronclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 322624f commit 0de365d

3 files changed

Lines changed: 145 additions & 0 deletions

File tree

libs/openant-core/parsers/php/call_graph_builder.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ def __init__(self, extractor_output: Dict, options: Optional[Dict] = None):
134134
self.functions_by_name: Dict[str, List[str]] = {}
135135
self.functions_by_file: Dict[str, List[str]] = {}
136136
self.methods_by_class: Dict[str, List[str]] = {}
137+
# class_key -> list of trait names the class composes via in-class `use`.
138+
self.traits_by_class: Dict[str, List[str]] = {}
137139

138140
self._build_indexes()
139141

@@ -162,6 +164,13 @@ def _build_indexes(self) -> None:
162164
self.methods_by_class[class_key] = []
163165
self.methods_by_class[class_key].append(func_id)
164166

167+
# Index each class's composed traits (in-class `use TraitName;`) so a
168+
# $this->/self:: call can fall back to a method pulled in from a trait.
169+
for class_key, class_data in self.classes.items():
170+
traits = class_data.get('traits')
171+
if traits:
172+
self.traits_by_class[class_key] = list(traits)
173+
165174
def _is_builtin(self, name: str) -> bool:
166175
"""Check if name is a PHP builtin or common function."""
167176
return name.lower() in PHP_BUILTINS # PHP function names are case-insensitive
@@ -495,6 +504,15 @@ def _resolve_self_call(self, method_name: str, caller_file: str,
495504
if func_data.get('name') == method_name:
496505
return func_id
497506

507+
# Fall back to methods composed in via traits (`use TraitName;`). A trait
508+
# method is invoked exactly like an own method ($this->m()/self::m()), but
509+
# it lives under the trait's own class_key, so resolve it there. The trait
510+
# may be declared in a different file, hence the cross-file lookup.
511+
for trait_name in self.traits_by_class.get(class_key, []):
512+
resolved = self._resolve_class_call(trait_name, method_name, caller_file)
513+
if resolved:
514+
return resolved
515+
498516
return None
499517

500518
def _resolve_class_call(self, class_name: str, method_name: str,

libs/openant-core/parsers/php/function_extractor.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,23 @@ def _is_static_method(self, node, source: bytes) -> bool:
138138
return True
139139
return False
140140

141+
def _extract_trait_names(self, use_node, source: bytes) -> List[str]:
142+
"""Extract trait names from an in-class `use_declaration` node.
143+
144+
Handles grouped uses (`use A, B\\C;`). Each trait is a `name` or
145+
`qualified_name` child; namespace-qualified names are reduced to their
146+
last segment so resolution matches the trait's unqualified class name.
147+
"""
148+
names = []
149+
for child in use_node.children:
150+
if child.type in ('name', 'qualified_name'):
151+
trait = self._node_text(child, source)
152+
if '\\' in trait:
153+
trait = trait.rsplit('\\', 1)[-1]
154+
if trait:
155+
names.append(trait)
156+
return names
157+
141158
def _get_visibility(self, node, source: bytes) -> Optional[str]:
142159
"""Extract visibility modifier from a method_declaration node."""
143160
for child in node.children:
@@ -306,6 +323,7 @@ def _extract_functions_from_tree(self, tree, source: bytes, file_path: Path,
306323
if new_class_name:
307324
class_id = f"{relative_path}:{new_class_name}"
308325
methods = []
326+
traits = []
309327
# Find declaration_list (class body)
310328
body_node = node.child_by_field_name('body')
311329
if body_node is None:
@@ -323,13 +341,21 @@ def _extract_functions_from_tree(self, tree, source: bytes, file_path: Path,
323341
methods.append(f"static:{mname}")
324342
else:
325343
methods.append(mname)
344+
elif child.type == 'use_declaration':
345+
# In-class `use TraitA, NS\TraitB;` composes traits into the class.
346+
# tree-sitter-php emits this as `use_declaration` (distinct from the
347+
# top-level `namespace_use_declaration`), with each trait as a
348+
# `name`/`qualified_name` child. Record the unqualified trait name so
349+
# the call-graph builder can resolve $this->/self:: into the trait.
350+
traits.extend(self._extract_trait_names(child, source))
326351

327352
self.classes[class_id] = {
328353
'name': new_class_name,
329354
'file_path': relative_path,
330355
'start_line': node.start_point[0] + 1,
331356
'end_line': node.end_point[0] + 1,
332357
'methods': methods,
358+
'traits': traits,
333359
'superclass': superclass,
334360
'interfaces': interfaces,
335361
'namespace_name': namespace_name,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Regression test for F12 sub-defect (3): trait-composition self-calls.
2+
3+
Sub-defects (1) [the `<?php ` re-parse prepend] and (2) [the `relative_scope`
4+
branch for self::/static::/parent::] already ship on this branch. What remained
5+
is the class->trait composition index: a class that pulls a method in via
6+
`use TraitName;` had no edge from `$this->m()` / `self::m()` to the trait's
7+
method, because `_resolve_self_call` only looked at methods physically declared
8+
in the class body and never the methods of its used traits.
9+
10+
Two layers are exercised:
11+
* builder layer -- given a `traits` field on the class record, the
12+
CallGraphBuilder must fall back to the used traits' methods.
13+
* extractor layer -- the FunctionExtractor must populate that `traits` field
14+
from the in-class `use_declaration` node so the builder has data to use.
15+
16+
Loads both modules under UNIQUE importlib names (call_graph_builder /
17+
function_extractor are basenames shared by every parser).
18+
"""
19+
import importlib.util
20+
import sys
21+
from pathlib import Path
22+
23+
CORE = Path(__file__).resolve().parents[1]
24+
if str(CORE) not in sys.path:
25+
sys.path.insert(0, str(CORE))
26+
27+
28+
def _load(unique, relpath):
29+
spec = importlib.util.spec_from_file_location(unique, str(CORE / relpath))
30+
mod = importlib.util.module_from_spec(spec)
31+
spec.loader.exec_module(mod)
32+
return mod
33+
34+
35+
_cgb = _load("php_call_graph_builder_trait", "parsers/php/call_graph_builder.py")
36+
_fe = _load("php_function_extractor_trait", "parsers/php/function_extractor.py")
37+
CallGraphBuilder = _cgb.CallGraphBuilder
38+
FunctionExtractor = _fe.FunctionExtractor
39+
40+
41+
def _build(funcs, classes=None, imports=None):
42+
b = CallGraphBuilder({"functions": funcs, "classes": classes or {}, "imports": imports or {},
43+
"repository": "/r"})
44+
b.build_call_graph()
45+
return b
46+
47+
48+
# F12 #3 builder layer: $this->g() and self::g() resolve into a used trait's method.
49+
def test_trait_self_call_resolves_into_used_trait():
50+
b = _build({
51+
"a.php:C.f": {"name": "f", "file_path": "a.php", "class_name": "C",
52+
"code": "function f() { $this->g(); }"},
53+
"a.php:C.h": {"name": "h", "file_path": "a.php", "class_name": "C",
54+
"code": "function h() { self::g(); }"},
55+
"a.php:T.g": {"name": "g", "file_path": "a.php", "class_name": "T",
56+
"code": "function g() {}"},
57+
}, classes={
58+
"a.php:C": {"name": "C", "file_path": "a.php", "superclass": None, "traits": ["T"]},
59+
"a.php:T": {"name": "T", "file_path": "a.php", "superclass": None, "traits": []},
60+
})
61+
assert b.call_graph.get("a.php:C.f") == ["a.php:T.g"], b.call_graph
62+
assert b.call_graph.get("a.php:C.h") == ["a.php:T.g"], b.call_graph
63+
64+
65+
# F12 #3 extractor layer: the in-class `use T;` is captured onto the class record,
66+
# and the full extract->build pipeline yields the trait edges from real source.
67+
def test_extractor_captures_trait_use_and_builds_edges(tmp_path):
68+
src = (
69+
"<?php\n"
70+
"trait T { function g() {} }\n"
71+
"class C {\n"
72+
" use T;\n"
73+
" function f() { $this->g(); }\n"
74+
" function h() { self::g(); }\n"
75+
"}\n"
76+
)
77+
f = tmp_path / "trait_case.php"
78+
f.write_text(src)
79+
80+
ex = FunctionExtractor(str(tmp_path))
81+
out = ex.extract_all()
82+
83+
# Class record must carry the used trait.
84+
c_key = next(k for k in out["classes"] if k.endswith(":C"))
85+
assert "T" in out["classes"][c_key].get("traits", []), out["classes"][c_key]
86+
87+
b = CallGraphBuilder(out)
88+
b.build_call_graph()
89+
90+
f_id = next(k for k in out["functions"]
91+
if out["functions"][k].get("name") == "f"
92+
and out["functions"][k].get("class_name") == "C")
93+
h_id = next(k for k in out["functions"]
94+
if out["functions"][k].get("name") == "h"
95+
and out["functions"][k].get("class_name") == "C")
96+
g_id = next(k for k in out["functions"]
97+
if out["functions"][k].get("name") == "g"
98+
and out["functions"][k].get("class_name") == "T")
99+
100+
assert g_id in b.call_graph.get(f_id, []), b.call_graph.get(f_id)
101+
assert g_id in b.call_graph.get(h_id, []), b.call_graph.get(h_id)

0 commit comments

Comments
 (0)