diff --git a/libs/openant-core/parsers/php/call_graph_builder.py b/libs/openant-core/parsers/php/call_graph_builder.py index 566e79c1..8349fa62 100644 --- a/libs/openant-core/parsers/php/call_graph_builder.py +++ b/libs/openant-core/parsers/php/call_graph_builder.py @@ -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 @@ -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.""" @@ -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*[\(]' @@ -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 diff --git a/libs/openant-core/parsers/php/function_extractor.py b/libs/openant-core/parsers/php/function_extractor.py index d68b1b72..6021facd 100644 --- a/libs/openant-core/parsers/php/function_extractor.py +++ b/libs/openant-core/parsers/php/function_extractor.py @@ -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': diff --git a/libs/openant-core/tests/parsers/php/test_call_graph_builder.py b/libs/openant-core/tests/parsers/php/test_call_graph_builder.py index f0839fa6..3f62465d 100644 --- a/libs/openant-core/tests/parsers/php/test_call_graph_builder.py +++ b/libs/openant-core/tests/parsers/php/test_call_graph_builder.py @@ -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": "