Skip to content
Merged
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
41 changes: 41 additions & 0 deletions libs/openant-core/parsers/php/call_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]:
caller_class, caller_namespace, root)
if resolved:
calls.add(resolved)
if node.type == 'function_call_expression':
# A bare call that resolved to a same-file global may be one
# of several same-name globals in the file (e.g. two method-
# nested `function g(){}` re-keyed to file-scope globals).
# Emit an edge to EVERY same-name, same-namespace same-file
# global, not just the first _resolve_simple_call picked —
# dropping the others hides their reachable subtrees.
calls.update(self._same_file_global_siblings(
resolved, caller_file, caller_namespace))
stack.extend(reversed(node.children))

return calls
Expand Down Expand Up @@ -493,6 +502,33 @@ def norm(ns):
return (ns or '').strip('\\')
return norm(candidate_ns) == norm(caller_ns)

def _same_file_global_siblings(self, resolved_id: str, caller_file: str,
caller_namespace: Optional[str] = None) -> List[str]:
"""Same-name, same-namespace global functions in ``caller_file`` other than
the one already resolved.

Over-approximates a bare call when several same-name globals collide in one
file (e.g. two method-nested ``function g(){}`` re-keyed to file-scope
globals get de-collided ids ``file:g`` / ``file:g#L13``, but
``_resolve_simple_call`` returns only the first). Returns [] unless the
resolved target is itself a same-file global — a self-call (class_name set),
import, or cross-file-unique resolution is left as its single edge. Purely
additive: never drops the primary edge."""
data = self.functions.get(resolved_id, {})
if data.get('class_name') or resolved_id.split(':')[0] != caller_file:
return []
name = data.get('name')
siblings = []
for fid in self.functions_by_file.get(caller_file, []):
if fid == resolved_id:
continue
fd = self.functions.get(fid, {})
if (fd.get('name') == name and not fd.get('class_name')
and self._namespace_compatible(
fd.get('namespace_name'), caller_namespace)):
siblings.append(fid)
return siblings

def _resolve_self_call(self, method_name: str, caller_file: str,
caller_class: str) -> Optional[str]:
"""Resolve a $this->method() or self::method() call within a class."""
Expand Down Expand Up @@ -567,6 +603,7 @@ def _extract_calls_regex(self, code: str, caller_id: str) -> Set[str]:
"""Fallback regex-based call extraction for unparseable code."""
calls = set()
caller_file = caller_id.split(':')[0]
caller_namespace = self.functions.get(caller_id, {}).get('namespace_name')

# Match function calls: name(
pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*)\s*[\(]'
Expand All @@ -579,6 +616,10 @@ def _extract_calls_regex(self, code: str, caller_id: str) -> Set[str]:
resolved = self._resolve_simple_call(func_name, caller_file, None)
if resolved:
calls.add(resolved)
# Mirror the tree-walk path: widen a same-file-global resolution
# to every same-name same-namespace global in the file.
calls.update(self._same_file_global_siblings(
resolved, caller_file, caller_namespace))

return calls

Expand Down
15 changes: 9 additions & 6 deletions libs/openant-core/parsers/php/function_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,16 +259,19 @@ def _extract_functions_from_tree(self, tree, source: bytes, file_path: Path,
node, class_name, namespace_name = stack.pop()

if node.type == 'function_definition':
# A named `function foo(){}` is ALWAYS a GLOBAL function in PHP,
# even when it lexically appears inside a method/function body:
# it is registered in the global function table when the
# enclosing code runs, not attached to the class. Emit it with
# class_name=None so it is not keyed as a phantom Class.foo, and
# recurse with class_name=None so anything nested in its body is
# likewise not attributed to the enclosing class.
self._process_function_node(
node, source, relative_path, class_name, namespace_name,
node, source, relative_path, None, namespace_name,
is_static=False
)
# Recurse into the body so nested closures/arrow functions are
# reached; named definitions cannot lexically nest other named
# functions/classes in PHP, so this only surfaces
# anonymous_function / arrow_function units.
for child in reversed(node.children):
stack.append((child, class_name, namespace_name))
stack.append((child, None, namespace_name))
continue

elif node.type == 'method_declaration':
Expand Down
49 changes: 49 additions & 0 deletions libs/openant-core/tests/parsers/php/test_call_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,52 @@ def test_bare_call_resolves_within_same_namespace():
assert b.call_graph.get("consumer.php:caller") == ["utils.php:helper"], (
f"same-namespace bare call must still resolve: {b.call_graph}"
)


def test_same_file_name_colliding_globals_all_resolved():
"""Two method-nested `function g(){}` in one file (re-keyed to file-scope
globals with de-collided ids `app.php:g` / `app.php:g#L9`) must BOTH receive an
edge from a bare `g()` — not just the first.

`_resolve_simple_call` returns the first same-file global, so both Alpha::run
and Beta::run resolved only to `app.php:g`; `app.php:g#L9` (and its sink
subtree) was orphaned — a reachability false negative. The resolution must
over-approximate to every same-name same-namespace file-scope global.
"""
funcs = {
"app.php:Alpha.run": {
"name": "run", "file_path": "app.php",
"class_name": "Alpha", "namespace_name": None,
"code": "<?php function run() { g(); }",
},
"app.php:Beta.run": {
"name": "run", "file_path": "app.php",
"class_name": "Beta", "namespace_name": None,
"code": "<?php function run() { g(); }",
},
"app.php:g": {
"name": "g", "file_path": "app.php",
"class_name": None, "namespace_name": None,
"code": "<?php function g() { sinkC(); }",
},
"app.php:g#L9": {
"name": "g", "file_path": "app.php",
"class_name": None, "namespace_name": None,
"code": "<?php function g() { sinkD(); }",
},
"app.php:sinkC": {
"name": "sinkC", "file_path": "app.php",
"class_name": None, "namespace_name": None, "code": "<?php function sinkC() {}",
},
"app.php:sinkD": {
"name": "sinkD", "file_path": "app.php",
"class_name": None, "namespace_name": None, "code": "<?php function sinkD() {}",
},
}
b = _build(funcs)
assert set(b.call_graph.get("app.php:Alpha.run", [])) == {"app.php:g", "app.php:g#L9"}, (
f"Alpha::run must edge to BOTH colliding globals: {b.call_graph.get('app.php:Alpha.run')}"
)
assert set(b.call_graph.get("app.php:Beta.run", [])) == {"app.php:g", "app.php:g#L9"}, (
f"Beta::run must edge to BOTH colliding globals: {b.call_graph.get('app.php:Beta.run')}"
)
50 changes: 50 additions & 0 deletions libs/openant-core/tests/parsers/php/test_php_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,5 +261,55 @@ def test_closure_units_capture_their_body(tmp_path):
assert "transform" in bodies # from the arrow function body


# ---------------------------------------------------------------------------
# A named function nested in a method body is GLOBAL in PHP (bug B7)
# ---------------------------------------------------------------------------

NESTED_GLOBAL_FUNCTION_SOURCE = """<?php
class Widget {
public function m() {
function format_label() {
return 1;
}
return format_label();
}
}
"""


def test_named_function_nested_in_method_is_global_not_phantom_method(tmp_path):
"""A `function foo(){}` declared inside a method body is a GLOBAL function in
PHP, not a method of the enclosing class. It must be keyed with
class_name=None (global), never as a phantom Widget.format_label."""
result = _extract(tmp_path, "widget.php", NESTED_GLOBAL_FUNCTION_SOURCE)
units = result["functions"]

fmt = [fd for fd in units.values() if fd["name"] == "format_label"]
assert len(fmt) == 1, (
"expected exactly one format_label unit; got "
f"{[fd['qualified_name'] for fd in fmt]}"
)
assert fmt[0]["class_name"] is None, (
"format_label is a GLOBAL PHP function (declared inside a method body); "
f"it must have class_name=None, got class_name={fmt[0]['class_name']!r} "
f"(qualified_name={fmt[0]['qualified_name']!r})"
)
assert fmt[0]["qualified_name"] == "format_label", (
"global function must not be qualified with the enclosing class; got "
f"{fmt[0]['qualified_name']!r}"
)
# The phantom Class.method key must not exist.
assert not any(
fd["qualified_name"] == "Widget.format_label" for fd in units.values()
), "format_label must not be keyed as the phantom Widget.format_label"

# The enclosing method m must remain intact as Widget.m.
m_units = [fd for fd in units.values() if fd["name"] == "m"]
assert len(m_units) == 1 and m_units[0]["class_name"] == "Widget", (
"method m must stay intact as Widget.m; got "
f"{[(fd['name'], fd['class_name']) for fd in m_units]}"
)


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
Loading