Skip to content
Open
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
127 changes: 127 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4565,6 +4565,130 @@ 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:
r"""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. A
``$var::method()`` dynamic scope is left unresolved — PHP has no
declared-type table for it to resolve against, and guessing would risk the
same over-connection the #543/#1219 guard exists to prevent. Every other
scope is treated as a class reference regardless of case (PHP class and
method names are case-insensitive) or namespace qualification (a
fully-qualified ``\App\Models\User::method()`` or ``App\Models\User::``
matches the class's own unqualified label by its last segment).
"""
def key(label: str) -> str:
return str(label).strip().removeprefix(".").removesuffix("()").lower()

def class_key(name: str) -> str:
return str(name).strip().lstrip("\\").rsplit("\\", 1)[-1].lower()

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(class_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 not receiver.startswith("$"):
type_defs = type_def_nids.get(class_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(
Comment thread
rikurunico marked this conversation as resolved.
Comment thread
rikurunico marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _resolve_java_member_calls()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — _resolve_java_member_calls wasn't touched by this PR at all (it's the pre-existing Java resolver this PHP fix mirrors in its docstring reference only). The 6-callee count is just where it happened to land in the diff context; no lines inside that function changed. Not applicable here.

per_file: list[dict],
all_nodes: list[dict],
Expand Down Expand Up @@ -5504,6 +5628,9 @@ def _resolve_kotlin_member_calls(
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)
)
Expand Down
22 changes: 19 additions & 3 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6094,6 +6094,7 @@ def walk_calls(
callee_name: str | None = None
is_member_call: bool = False
is_this_field_call: bool = False
is_scoped_call: bool = False
swift_receiver: str | None = None
member_receiver: str | None = None
kotlin_qualified_prefix: str | None = None
Expand Down Expand Up @@ -6347,10 +6348,25 @@ def walk_calls(
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
is_scoped_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
Expand Down Expand Up @@ -6531,7 +6547,7 @@ def walk_calls(
_java_defer = (
config.ts_module == "tree_sitter_java" and is_member_call
)
if _python_defer or _java_defer or _builtin_member_call or (
if _python_defer or _java_defer or _builtin_member_call or is_scoped_call or (
is_member_call
and member_receiver
and (
Expand Down
11 changes: 9 additions & 2 deletions tests/test_php_object_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,21 @@ class - none may mint an edge to a phantom."""
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):
Expand Down
173 changes: 173 additions & 0 deletions tests/test_php_static_call_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""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):
"""A same-named decoy method on an unrelated class proves `self::`/
`static::` bind to the ENCLOSING class, not to any node sharing the bare
method name (review finding on PR #3874: without a decoy, a bare-name
coincidence would pass this test even if self/static were never scoped)."""
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"),
"Decoy.php": (
"<?php\nnamespace App;\nclass Decoy {\n"
" public static function helper() { return 2; }\n}\n"),
})
assert (".build()", ".helper()") in calls
assert (".render()", ".helper()") in calls
assert calls[(".build()", ".helper()")]["confidence"] == "EXTRACTED"


def test_self_scope_does_not_resolve_to_other_class(tmp_path):
"""`self::helper()` must NOT bind to another class's `helper()` even when
the enclosing class has no method of that name - `self::` names the
enclosing class, not a bare method-name match anywhere in the corpus."""
calls, _ = _extract(tmp_path, {
"A.php": (
"<?php\nnamespace App;\nclass A {\n"
" public function build() { return self::helper(); }\n}\n"),
"B.php": (
"<?php\nnamespace App;\nclass B {\n"
" public function helper() { return 1; }\n}\n"),
})
assert not any(s == ".build()" for s, _t in calls)


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. A same-file,
same-named decoy method proves the resolver actually ran and declined to
bind, rather than the guard never being exercised (review finding on PR
#3874: without the decoy, the old bare-name path would silently produce
the same "no edge to a DIFFERENT-labeled node" result for the wrong
reason)."""
calls, r = _extract(tmp_path, {"Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run($cls) { return $cls::helper(); }\n"
" public function helper() { return 1; }\n}\n")})
assert not any(s == ".run()" for s, _t in calls)


def test_lowercase_class_name_still_resolves(tmp_path):
"""PHP class names are conventionally StudlyCase but the language does not
enforce it - a lowercase-first class is still a class, not a variable, and
must resolve the same way (review finding on PR #3874)."""
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"


def test_fully_qualified_scope_resolves_by_last_segment(tmp_path):
"""A fully-qualified scope (`\\App\\Helper::method()` or
`App\\Helper::method()`) must match the class's own unqualified label,
not be left parked as unresolved (review finding on PR #3874)."""
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 Other;\nclass Caller {\n"
" public function run($x) { return \\App\\Helper::sanitize($x); }\n}\n"),
})
edge = calls.get((".run()", ".sanitize()"))
assert edge is not None
assert edge["confidence"] == "EXTRACTED"


def test_method_name_case_insensitivity(tmp_path):
"""PHP method names are case-insensitive at the call site - a call spelled
with different case than the declaration must still resolve (review
finding on PR #3874)."""
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"),
})
assert any(s == ".run()" and t.lower() == ".sanitize()" 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)