Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions libs/openant-core/parsers/php/call_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions libs/openant-core/parsers/php/function_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -323,13 +341,21 @@ 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,
'file_path': relative_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,
Expand Down
101 changes: 101 additions & 0 deletions libs/openant-core/tests/test_php_trait_self_call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Regression test for F12 sub-defect (3): trait-composition self-calls.

Sub-defects (1) [the `<?php ` re-parse prepend] and (2) [the `relative_scope`
branch for self::/static::/parent::] already ship on this branch. What remained
is the class->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 = (
"<?php\n"
"trait T { function g() {} }\n"
"class C {\n"
" use T;\n"
" function f() { $this->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)
Loading