From c7bc5fe4c0b87b52e9e5d3efa2ae325e2cd3fbb9 Mon Sep 17 00:00:00 2001 From: rikurunico Date: Mon, 28 Sep 2026 01:28:52 +0700 Subject: [PATCH] fix(php): route ALL scoped calls through the PHP resolver, not just uppercase receivers engine.py's defer guard only deferred a scoped_call_expression into raw_calls when member_receiver[:1].isupper() (or is_this_field_call/_csharp_defer), so self::, static::, parent::, $var::, and lowercase-first class receivers fell through to the pre-existing per-file bare-name path instead of reaching _resolve_php_member_calls. That path is last-write-wins across the whole file and can mis-bind a same-named method on an unrelated class. Tag scoped_call_expression with is_scoped_call and add it to the defer condition unconditionally, so every `::` call is deferred to raw_calls and resolved by the dedicated resolver regardless of receiver casing or sigil. Also tighten the self::/static:: and $var:: fixtures with a same-named decoy method on another class, so they actually prove enclosing-class scoping and no-fabrication instead of passing on a bare-name coincidence. Addresses review feedback on PR #3874. (cherry picked from commit 801c2a2dfd90740581ad77a7300a25ddf4a51d95) --- graphify/extractors/engine.py | 4 ++- tests/test_php_static_call_resolution.py | 44 ++++++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index dc841cd87..6fb2616b9 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -7689,6 +7689,7 @@ def _extract_generic( # self): kept apart from member_receiver, which feeds the # receiver-typed resolvers and raw_calls. self_receiver: str | None = None + is_scoped_call: bool = False swift_receiver: str | None = None member_receiver: str | None = None kotlin_qualified_prefix: str | None = None @@ -8014,6 +8015,7 @@ def _extract_generic( # 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) @@ -8264,7 +8266,7 @@ def _extract_generic( and is_member_call and not lua_self_qualified ) - if _python_defer or _java_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or ( + if _python_defer or _java_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or is_scoped_call or ( is_member_call and member_receiver and ( diff --git a/tests/test_php_static_call_resolution.py b/tests/test_php_static_call_resolution.py index c97cfcba5..0f49bc5d8 100644 --- a/tests/test_php_static_call_resolution.py +++ b/tests/test_php_static_call_resolution.py @@ -48,16 +48,40 @@ def test_same_namespace_static_call_is_extracted_not_inferred(tmp_path): def test_self_and_static_scope_resolve_to_enclosing_class(tmp_path): - calls, _ = _extract(tmp_path, {"Widget.php": ( - "