From 2d37da18c9d4e0af09cdce4208902218974ac003 Mon Sep 17 00:00:00 2001 From: rikurunico Date: Sun, 27 Sep 2026 11:26:40 +0700 Subject: [PATCH] fix(php): resolve Class::method() static calls to the actual method scoped_call_expression (`Helper::format()`) used the scope text as the callee name instead of the method name, so the shared cross-file bare-name pass bound the call to whatever node shared the class's label - almost always the class definition - never the actual method. It also skipped member-call handling entirely, so the edge went through the import-evidence confidence gate: same-namespace PHP classes never need a `use` import for each other, so every same-namespace static call was stuck at INFERRED 0.85 even though the receiver class is named explicitly in source. Capture the real method name as the callee and the scope as the receiver, mark it a member call, and add a dedicated PHP resolver (mirroring the existing Java/Swift/C++ member-call resolvers) that binds self::/static:: to the enclosing class, parent:: through a single inherits edge, and Class:: to the class's own method - all EXTRACTED, since the receiver is named explicitly in source. A dynamic scope ($var::method()) has no declared-type table to resolve against and is left unresolved rather than guessed. Fixes #3872 (cherry picked from commit 2ec460168b222b3e5d7dfd8305e9306f08d7dcf6) --- graphify/extract.py | 121 +++++++++++++++++++++++ graphify/extractors/engine.py | 18 +++- tests/test_php_object_creation.py | 11 ++- tests/test_php_static_call_resolution.py | 94 ++++++++++++++++++ 4 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 tests/test_php_static_call_resolution.py diff --git a/graphify/extract.py b/graphify/extract.py index c1e59d9b8..1b2f925bd 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5236,6 +5236,124 @@ def _bind_member_field_tables( return bound +def _resolve_php_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve PHP ``ClassName::method()`` / ``self::``/``static::``/``parent::`` + static calls against the receiver's class (#3872). + + The shared engine's PHP branch used to treat ``scoped_call_expression`` as a + bare function call named after the *scope* (``Helper::format()`` bound the + callee name to ``"Helper"``), so the cross-file bare-name pass silently + resolved every static call to whichever node happened to share the class's + label — almost always the class definition itself — instead of the actual + method, and did so with the generic import-evidence confidence gate even + though the receiver class is named explicitly in source. The engine now + captures the real method name as the callee and the scope text as the + receiver and marks it a member call; this resolver binds it to the class's + actual method node, mirroring ``_resolve_java_member_calls``. + + ``self::``/``static::`` resolve to the enclosing class (exact — the keyword + names it as unambiguously as a literal class name would). ``parent::`` + resolves through a single `inherits` edge from the enclosing class. Any + other (lowercase, i.e. array/variable) scope is left unresolved — PHP has no + declared-type table for `$var::method()` dynamic scopes to resolve against, + and guessing would risk the same over-connection the #543/#1219 guard + exists to prevent. + """ + def key(label: str) -> str: + return str(label).strip().removeprefix(".").removesuffix("()") + + contained = {edge.get("target") for edge in all_edges + if edge.get("relation") == "contains"} + node_by_id = {node.get("id"): node for node in all_nodes} + + type_def_nids: dict[str, list[str]] = {} + for node in all_nodes: + if ( + node.get("source_file") + and node.get("id") in contained + and _is_type_like_definition(node) + ): + type_def_nids.setdefault(key(node.get("label", "")), []).append(node["id"]) + + method_index: dict[tuple[str, str], set[str]] = {} + enclosing_type: dict[str, str] = {} + for edge in all_edges: + if edge.get("relation") != "method": + continue + owner, method = edge.get("source"), edge.get("target") + method_node = node_by_id.get(method) + if method_node is None: + continue + enclosing_type.setdefault(method, owner) + method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method) + + inherits_bases: dict[str, list[str]] = {} + for edge in all_edges: + if edge.get("relation") == "inherits": + inherits_bases.setdefault(edge["source"], []).append(edge["target"]) + + existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} + for result in per_file: + for raw_call in result.get("raw_calls", []): + if not raw_call.get("is_member_call"): + continue + if not str(raw_call.get("source_file", "")).endswith(".php"): + continue + receiver = raw_call.get("receiver") + callee = raw_call.get("callee") + caller = raw_call.get("caller_nid") + if not receiver or not callee or not caller: + continue + + receiver_lower = receiver.lower() + if receiver_lower in ("self", "static"): + type_nid = enclosing_type.get(caller) + if not type_nid: + continue + elif receiver_lower == "parent": + bases = inherits_bases.get(enclosing_type.get(caller), []) + if len(bases) != 1: + continue + type_nid = bases[0] + elif receiver[:1].isupper(): + type_defs = type_def_nids.get(key(receiver), []) + if not type_defs: + _park_unresolved_member_call( + node_by_id.get(caller), callee, receiver, "php", raw_call, + ) + continue + if len(type_defs) != 1: + continue + type_nid = type_defs[0] + else: + # Dynamic scope ($var::method()) — no declared-type table to + # resolve against; leave unresolved rather than guess. + continue + + method_nids = method_index.get((type_nid, key(callee)), set()) + if len(method_nids) != 1: + continue + method_nid = next(iter(method_nids)) + if method_nid == caller or (caller, method_nid) in existing_pairs: + continue + existing_pairs.add((caller, method_nid)) + all_edges.append({ + "source": caller, + "target": method_nid, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + }) + + def _resolve_java_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -6272,6 +6390,9 @@ register_language_resolver( register_language_resolver( LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls) ) +register_language_resolver( + LanguageResolver("php_member_calls", frozenset({".php"}), _resolve_php_member_calls) +) register_language_resolver( LanguageResolver("rust_self_member_calls", frozenset({".rs"}), _resolve_rust_self_member_calls) ) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index adc58856b..dc841cd87 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -8002,10 +8002,24 @@ def _extract_generic( if callee_name.lower() in _PHP_LANGUAGE_CONSTRUCTS: callee_name = None elif node.type == "scoped_call_expression": - # Static method call: Helper::format() → callee = "Helper" + # Static method call: Helper::format() — the callee is the + # actual method (`format`), not the scope (`Helper`). Using + # the scope as callee_name previously bound every static call + # to whatever node was labeled after the scope text (usually + # the class definition itself), never the called method, and + # skipped member-call handling entirely so the cross-file + # bare-name pass resolved it by class name coincidence + # rather than the real callee (#3872). Capture the scope as + # the receiver instead, like every other language's + # qualified-call branch here, so a dedicated PHP resolver can + # bind it to the actual method on that class. + is_member_call = True + name_node = node.child_by_field_name("name") + if name_node: + callee_name = _read_text(name_node, source) scope_node = node.child_by_field_name("scope") if scope_node: - callee_name = _read_text(scope_node, source) + member_receiver = _read_text(scope_node, source) else: # member_call_expression: $obj->method() is_member_call = True diff --git a/tests/test_php_object_creation.py b/tests/test_php_object_creation.py index ac927ad1b..be8cd8465 100644 --- a/tests/test_php_object_creation.py +++ b/tests/test_php_object_creation.py @@ -67,14 +67,21 @@ def test_dynamic_and_self_construction_produce_no_junk(tmp_path): assert "self" not in labels and "static" not in labels -def test_existing_static_call_edges_are_unchanged(tmp_path): +def test_static_call_resolves_to_the_actual_method_not_the_class(tmp_path): + """`Baz::create()` must bind to Baz's `create` method, not to the `Baz` + class node itself (#3872). Before the fix, `scoped_call_expression` was + treated as a bare function call named after the *scope* (`Helper::format()` + set the callee to `"Helper"`), so the cross-file bare-name pass matched it + to whatever node shared that label - almost always the class definition - + never the method actually being called.""" calls, _ = _extract(tmp_path, {"Baz.php": ( "method()` was never resolved before this fix (no receiver-typed + PHP resolver existed) and must remain so - this fix only targets scoped + (`::`) calls, not instance (`->`) calls.""" + calls, _ = _extract(tmp_path, { + "Helper.php": ( + "sanitize($x); }\n}\n"), + }) + assert not any(s == ".run()" for s, _t in calls)