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
This commit is contained in:
rikurunico committed 2026-09-27 11:26:40 +07:00
1 parent 4377ee93aa
commit 2ec460168b
4 files changed
+240 -4

No files matched your search

+121
View File
@@ -4565,6 +4565,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],
@@ -5504,6 +5622,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)
)
+16 -2
View File
@@ -6347,10 +6347,24 @@ def _extract_generic(
if func_node:
callee_name = _read_text(func_node, source)
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
+9 -2
View File
@@ -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": (
"<?php\nnamespace App;\nclass Baz {\n"
" public static function create(): self { return new self(); }\n}\n"),
"Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run() { return Baz::create(); }\n}\n")})
assert any(s == ".run()" and "Baz" in t for s, t in calls)
assert (".run()", ".create()") in calls
assert not any(s == ".run()" and t == "Baz" for s, t in calls)
def test_cross_file_dispatcher_reaches_the_command_class(tmp_path):
+94
View File
@@ -0,0 +1,94 @@
"""PHP `Class::method()` static calls must resolve to the actual method with
EXTRACTED confidence, not to a bare-name match on the class itself (#3872).
`scoped_call_expression` used the scope text (`Helper` in `Helper::format()`)
as the callee name instead of the method name, so the shared cross-file
bare-name pass bound every static call to whichever node happened to share
the class's label - almost always the class definition - and did so gated on
import evidence (INFERRED 0.85) even though the receiver class is named
explicitly in source. Same-namespace PHP classes never need a `use` import
for each other, so every same-namespace static call was stuck at INFERRED.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from graphify.extract import extract
def _extract(tmp_path, files: dict[str, str]):
for name, body in files.items():
p = tmp_path / name
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body, encoding="utf-8")
r = extract([tmp_path / n for n in files], cache_root=Path(tempfile.mkdtemp()),
root=tmp_path, parallel=False)
labels = {n["id"]: n["label"] for n in r["nodes"]}
call_edges = [e for e in r["edges"] if e["relation"] == "calls"]
calls = {(labels[e["source"]], labels[e["target"]]): e for e in call_edges}
return calls, r
def test_same_namespace_static_call_is_extracted_not_inferred(tmp_path):
"""The exact shape from #3872: a same-namespace static call has no `use`
import (none needed), so it must not be penalized as INFERRED."""
calls, _ = _extract(tmp_path, {
"Helper.php": (
"<?php\nnamespace App;\nclass Helper {\n"
" public static function sanitize($x) { return $x; }\n}\n"),
"Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run($x) { return Helper::sanitize($x); }\n}\n"),
})
edge = calls.get((".run()", ".sanitize()"))
assert edge is not None
assert edge["confidence"] == "EXTRACTED"
assert edge["confidence_score"] == 1.0
def test_self_and_static_scope_resolve_to_enclosing_class(tmp_path):
calls, _ = _extract(tmp_path, {"Widget.php": (
"<?php\nnamespace App;\nclass Widget {\n"
" public static function build() { return self::helper(); }\n"
" public static function render() { return static::helper(); }\n"
" public static function helper() { return 1; }\n}\n")})
assert (".build()", ".helper()") in calls
assert (".render()", ".helper()") in calls
assert calls[(".build()", ".helper()")]["confidence"] == "EXTRACTED"
def test_parent_scope_resolves_through_inheritance(tmp_path):
calls, _ = _extract(tmp_path, {
"Base.php": (
"<?php\nnamespace App;\nclass Base {\n"
" public static function helper() { return 1; }\n}\n"),
"Child.php": (
"<?php\nnamespace App;\nclass Child extends Base {\n"
" public static function run() { return parent::helper(); }\n}\n"),
})
assert (".run()", ".helper()") in calls
def test_dynamic_scope_produces_no_phantom_edge(tmp_path):
"""`$cls::method()` has no declared-type table to resolve against - it
must stay unresolved rather than guess and mint a wrong edge."""
calls, r = _extract(tmp_path, {"Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run($cls) { return $cls::helper(); }\n}\n")})
assert not any(s == ".run()" for s, _t in calls)
def test_instance_call_is_still_unresolved_no_regression(tmp_path):
"""`$obj->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": (
"<?php\nnamespace App;\nclass Helper {\n"
" public function sanitize($x) { return $x; }\n}\n"),
"Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run(Helper $h, $x) { return $h->sanitize($x); }\n}\n"),
})
assert not any(s == ".run()" for s, _t in calls)