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)