fix(php): resolve Class::method() static calls to the actual method - #3874
rikurunico wants to merge 3 commits into
Conversation
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 Graphify-Labs#3872
|
Thanks for the pull request, @rikurunico. A maintainer will review it soon. Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions. A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic. |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.
Graphify review — findings
Resolves PHP Class::method(), self::/static::, and parent:: static calls to the actual method node via a new _resolve_php_member_calls pass, replacing the old behaviour where scoped_call_expression bound the callee to the scope text and let the cross-file bare-name pass match it to the class definition itself. The engine now emits the real method name as the callee and the scope as a member-call receiver, so these calls land at EXTRACTED confidence instead of being gated on use-import evidence that same-namespace classes never carry. parent:: resolves only through a single inherits edge, and dynamic $var::method() scopes are deliberately left unresolved rather than guessed.
Worth a look
- Lowercase PHP static class calls are dropped —
graphify/extract.py:4642· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- PHP static resolver ignores case-insensitive method names —
graphify/extract.py:4664· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Fully qualified PHP static calls are no longer resolved —
graphify/extract.py:4642· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2455 functions depend on the 518 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 713 callers, 48 callees - new:
_rebuild_code()— 144 callers, 55 callees - new:
_extract_generic()— 18 callers, 29 callees - new:
extract_js()— 87 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
main()— 98 callers, 3 callees - new:
dispatch_command()— 2 callers, 125 callees - new:
extract_objc()— 27 callers, 9 callees - …and 44 more — each is listed as a finding
Verification — 2455 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2280 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
129 of 303 test file(s) selected (43%) via static blast radius.
tests/test_astro_extraction.py— impacttests/test_astro_import_ids.py— impacttests/test_build.py— impacttests/test_builtin_global_type_refs.py— impacttests/test_case_sensitive_resolution.py— impacttests/test_cjs_module_extension.py— impacttests/test_cobol_extractor.py— impacttests/test_cpp_nested_and_cli.py— impacttests/test_cpp_objc_cross_file_calls.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_cross_language_call_resolution.py— impacttests/test_cross_repo_external_call_guards.py— impacttests/test_cross_repo_member_calls.py— impacttests/test_csharp_call_site_generic_args.py— impacttests/test_csharp_enum_members.py— impacttests/test_csharp_field_generic_args.py— impacttests/test_csharp_generic_callsites.py— impacttests/test_csharp_interface_dispatch.py— impacttests/test_csharp_member_calls.py— impacttests/test_csharp_member_nodes.py— impacttests/test_csharp_object_creation.py— impacttests/test_csharp_partial_classes.py— impacttests/test_csharp_type_resolution.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_dotnet.py— impacttests/test_duplicate_annotation_edges.py— impacttests/test_elixir_import_resolution.py— impacttests/test_erlang_extractor.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_php_closures.py— impacttests/test_file_label_disambiguation.py— impacttests/test_file_node_id_spec.py— impacttests/test_forwarding_review_findings.py— impacttests/test_go_builtin_call_targets.py— impacttests/test_go_import_repoint.py— impacttests/test_go_interface_methods.py— impacttests/test_go_qualified_resolution.py— impacttests/test_import_extension_resolution.py— impacttests/test_import_self_loops.py— impacttests/test_imported_export_forwarding.py— impacttests/test_incremental.py— impacttests/test_indirect_call_arrow_single_param_shadow.py— impacttests/test_indirect_call_block_scoped_shadow.py— impacttests/test_indirect_call_catch_binding_shadow.py— impacttests/test_indirect_call_external_import_shadow.py— impacttests/test_indirect_call_for_of_binding_shadow.py— impacttests/test_indirect_call_function_expression_shadow.py— impacttests/test_indirect_call_nested_closure_shadow.py— impact- … and 79 more
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Docs that may be stale (advisory)
BENCHMARKS.md§ Harness (lines 36-56): references changed symbolskeyCHANGELOG.md§ 0.9.65 (2026-09-20) (lines 53-65): references changed symbolsnodeCHANGELOG.md§ 0.9.64 (2026-09-18) (lines 66-77): references changed symbolsnodeCHANGELOG.md§ 0.9.62 (2026-09-15) (lines 86-104): references changed symbolsnodeCHANGELOG.md§ 0.9.39 (2026-08-10) (lines 397-404): references changed symbolsnodeCHANGELOG.md§ 0.9.37 (2026-08-08) (lines 414-421): references changed symbolsnodeCHANGELOG.md§ 0.9.35 (2026-08-06) (lines 429-437): references changed symbolsnodeCHANGELOG.md§ 0.9.31 (2026-07-30) (lines 468-478): references changed symbolskeyCHANGELOG.md§ 0.9.22 (2026-07-20) (lines 570-579): references changed symbolsnodeCHANGELOG.md§ 0.9.19 (2026-07-18) (lines 600-610): references changed symbolsnode
…and 10 more.
· 1 grounded finding(s) anchored inline below; 51 more finding(s) on lines outside this diff (see the check run).
…atic-call resolution Addresses 3 findings from the automated review on PR Graphify-Labs#3874: - Fully-qualified scopes (\App\Models\User::method() or App\Models\User::method()) were left parked as unresolved: the class lookup keyed on the full scope text, which never matches a class node's own unqualified label. Now normalizes to the last namespace segment before lookup. - Method names were matched case-sensitively even though PHP method calls are case-insensitive; a call spelled with different case than the declaration silently produced no edge. The lookup key is now lowercased on both sides. - A lowercase-first class name (legal PHP, just non-PSR) fell through the `receiver[:1].isupper()` gate into "dynamic scope" and was dropped entirely. The gate is now `not receiver.startswith("$")`, since a `$var::` scope is the only case actually requiring the declared-type table this resolver doesn't have.
|
Addressed all 3 findings from the automated review in 7ed0e1d:
Added 3 regression tests ( |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Resolves PHP Class::method(), self::/static::/parent:: static calls to the receiver class's actual method node via the new _resolve_php_member_calls resolver, replacing the old behavior where scoped_call_expression was treated as a bare function call named after the scope and bound to the class definition instead of the method. The engine now captures the method as the callee and the scope as the receiver and flags it a member call, so binding happens with EXTRACTED confidence rather than the import-gated INFERRED path that stalled same-namespace calls. Dynamic $var::method() scopes and ambiguous class/method matches are left unresolved rather than guessed, and unqualified receivers with no matching class are parked as unresolved.
Worth a look
- PHP static calls resolve by short class name across namespaces —
graphify/extract.py:4594· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- PHP static resolver ignores namespaces for fully-qualified receivers —
graphify/extract.py:4595· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- parent:: resolution requires exactly one inherits edge but PHP interfaces also produce inherits edges —
graphify/extract.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2462 functions depend on the 525 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 713 callers, 48 callees - new:
_rebuild_code()— 144 callers, 55 callees - new:
_extract_generic()— 18 callers, 29 callees - new:
extract_js()— 87 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
main()— 98 callers, 3 callees - new:
dispatch_command()— 2 callers, 125 callees - new:
extract_objc()— 27 callers, 9 callees - …and 44 more — each is listed as a finding
Verification — 2462 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2287 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
129 of 303 test file(s) selected (43%) via static blast radius.
tests/test_astro_extraction.py— impacttests/test_astro_import_ids.py— impacttests/test_build.py— impacttests/test_builtin_global_type_refs.py— impacttests/test_case_sensitive_resolution.py— impacttests/test_cjs_module_extension.py— impacttests/test_cobol_extractor.py— impacttests/test_cpp_nested_and_cli.py— impacttests/test_cpp_objc_cross_file_calls.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_cross_language_call_resolution.py— impacttests/test_cross_repo_external_call_guards.py— impacttests/test_cross_repo_member_calls.py— impacttests/test_csharp_call_site_generic_args.py— impacttests/test_csharp_enum_members.py— impacttests/test_csharp_field_generic_args.py— impacttests/test_csharp_generic_callsites.py— impacttests/test_csharp_interface_dispatch.py— impacttests/test_csharp_member_calls.py— impacttests/test_csharp_member_nodes.py— impacttests/test_csharp_object_creation.py— impacttests/test_csharp_partial_classes.py— impacttests/test_csharp_type_resolution.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_dotnet.py— impacttests/test_duplicate_annotation_edges.py— impacttests/test_elixir_import_resolution.py— impacttests/test_erlang_extractor.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_php_closures.py— impacttests/test_file_label_disambiguation.py— impacttests/test_file_node_id_spec.py— impacttests/test_forwarding_review_findings.py— impacttests/test_go_builtin_call_targets.py— impacttests/test_go_import_repoint.py— impacttests/test_go_interface_methods.py— impacttests/test_go_qualified_resolution.py— impacttests/test_import_extension_resolution.py— impacttests/test_import_self_loops.py— impacttests/test_imported_export_forwarding.py— impacttests/test_incremental.py— impacttests/test_indirect_call_arrow_single_param_shadow.py— impacttests/test_indirect_call_block_scoped_shadow.py— impacttests/test_indirect_call_catch_binding_shadow.py— impacttests/test_indirect_call_external_import_shadow.py— impacttests/test_indirect_call_for_of_binding_shadow.py— impacttests/test_indirect_call_function_expression_shadow.py— impacttests/test_indirect_call_nested_closure_shadow.py— impact- … and 79 more
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Docs that may be stale (advisory)
BENCHMARKS.md§ Harness (lines 36-56): references changed symbolskeyCHANGELOG.md§ 0.9.65 (2026-09-20) (lines 53-65): references changed symbolsnodeCHANGELOG.md§ 0.9.64 (2026-09-18) (lines 66-77): references changed symbolsnodeCHANGELOG.md§ 0.9.62 (2026-09-15) (lines 86-104): references changed symbolsnodeCHANGELOG.md§ 0.9.39 (2026-08-10) (lines 397-404): references changed symbolsnodeCHANGELOG.md§ 0.9.37 (2026-08-08) (lines 414-421): references changed symbolsnodeCHANGELOG.md§ 0.9.35 (2026-08-06) (lines 429-437): references changed symbolsnodeCHANGELOG.md§ 0.9.31 (2026-07-30) (lines 468-478): references changed symbolskeyCHANGELOG.md§ 0.9.22 (2026-07-20) (lines 570-579): references changed symbolsnodeCHANGELOG.md§ 0.9.19 (2026-07-18) (lines 600-610): references changed symbolsnode
…and 10 more.
Formal verification
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `config` is annotated `LanguageConfig` — outside the synthesizable primitive/collection set
· 1 grounded finding(s) anchored inline below; 51 more finding(s) on lines outside this diff (see the check run).
|
Thanks for this @rikurunico — the diagnosis is right and the resolver design (mirroring The engine's defer guard (
The tests pass only because each fixture has a single node with the target label, so the bare-name path happens to hit the right one. Two concrete gaps this hides:
Suggested fix: since the engine already knows the call is a Everything else (parent:: via a single |
…ppercase 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 Graphify-Labs#3874.
|
Pushed the fix @safishamsi flagged: tagged `scoped_call_expression` with `is_scoped_call` and added it unconditionally to the defer condition in `engine.py`, so every `::` call (self/static/parent/$var/lowercase-class) now routes to `_resolve_php_member_calls` instead of only uppercase receivers. Also tightened the self::/static:: and $var:: fixtures with a same-named decoy method on another class, plus a new test proving `self::` doesn't fall back to a bare-name match. All PHP tests (56 passed, 4 skipped) still green. |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Resolves PHP Class::method(), self::/static::, and parent:: static calls to the receiver class's actual method node with EXTRACTED confidence via a new php_member_calls resolver, replacing the old behaviour where scoped_call_expression bound the callee to the scope text and the cross-file bare-name pass matched it to the class definition itself at INFERRED confidence. The engine now captures the method name as the callee and the scope as the receiver, flags scoped calls as member calls, and defers them so the resolver can bind them. parent:: resolves only through a single inherits edge, $var::method() dynamic scopes are left unresolved, and an unmatched named class parks the call rather than guessing.
Worth a look
- Fully-qualified PHP receivers are collapsed to basename and become ambiguous —
graphify/extract.py:4601· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Inherited static methods are not resolved for self/static PHP calls —
graphify/extract.py:4658· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Uppercase .PHP files are skipped by the PHP member-call resolver —
graphify/extract.py:4635· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2465 functions depend on the 528 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 713 callers, 48 callees - new:
_rebuild_code()— 144 callers, 55 callees - new:
_extract_generic()— 18 callers, 29 callees - new:
extract_js()— 87 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
main()— 98 callers, 3 callees - new:
dispatch_command()— 2 callers, 125 callees - new:
extract_objc()— 27 callers, 9 callees - …and 44 more — each is listed as a finding
Verification — 2465 functions in the blast radius were not formally verified this run (proofs are advisory here).
Health delta baseline: last indexed commit 4df8d4d (diverged from this PR's base — delta is approximate).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2290 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
129 of 303 test file(s) selected (43%) via static blast radius.
tests/test_astro_extraction.py— impacttests/test_astro_import_ids.py— impacttests/test_build.py— impacttests/test_builtin_global_type_refs.py— impacttests/test_case_sensitive_resolution.py— impacttests/test_cjs_module_extension.py— impacttests/test_cobol_extractor.py— impacttests/test_cpp_nested_and_cli.py— impacttests/test_cpp_objc_cross_file_calls.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_cross_language_call_resolution.py— impacttests/test_cross_repo_external_call_guards.py— impacttests/test_cross_repo_member_calls.py— impacttests/test_csharp_call_site_generic_args.py— impacttests/test_csharp_enum_members.py— impacttests/test_csharp_field_generic_args.py— impacttests/test_csharp_generic_callsites.py— impacttests/test_csharp_interface_dispatch.py— impacttests/test_csharp_member_calls.py— impacttests/test_csharp_member_nodes.py— impacttests/test_csharp_object_creation.py— impacttests/test_csharp_partial_classes.py— impacttests/test_csharp_type_resolution.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_dotnet.py— impacttests/test_duplicate_annotation_edges.py— impacttests/test_elixir_import_resolution.py— impacttests/test_erlang_extractor.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_php_closures.py— impacttests/test_file_label_disambiguation.py— impacttests/test_file_node_id_spec.py— impacttests/test_forwarding_review_findings.py— impacttests/test_go_builtin_call_targets.py— impacttests/test_go_import_repoint.py— impacttests/test_go_interface_methods.py— impacttests/test_go_qualified_resolution.py— impacttests/test_import_extension_resolution.py— impacttests/test_import_self_loops.py— impacttests/test_imported_export_forwarding.py— impacttests/test_incremental.py— impacttests/test_indirect_call_arrow_single_param_shadow.py— impacttests/test_indirect_call_block_scoped_shadow.py— impacttests/test_indirect_call_catch_binding_shadow.py— impacttests/test_indirect_call_external_import_shadow.py— impacttests/test_indirect_call_for_of_binding_shadow.py— impacttests/test_indirect_call_function_expression_shadow.py— impacttests/test_indirect_call_nested_closure_shadow.py— impact- … and 79 more
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Docs that may be stale (advisory)
BENCHMARKS.md§ Harness (lines 36-56): references changed symbolskeyCHANGELOG.md§ 0.9.65 (2026-09-20) (lines 53-65): references changed symbolsnodeCHANGELOG.md§ 0.9.64 (2026-09-18) (lines 66-77): references changed symbolsnodeCHANGELOG.md§ 0.9.62 (2026-09-15) (lines 86-104): references changed symbolsnodeCHANGELOG.md§ 0.9.39 (2026-08-10) (lines 397-404): references changed symbolsnodeCHANGELOG.md§ 0.9.37 (2026-08-08) (lines 414-421): references changed symbolsnodeCHANGELOG.md§ 0.9.35 (2026-08-06) (lines 429-437): references changed symbolsnodeCHANGELOG.md§ 0.9.31 (2026-07-30) (lines 468-478): references changed symbolskeyCHANGELOG.md§ 0.9.22 (2026-07-20) (lines 570-579): references changed symbolsnodeCHANGELOG.md§ 0.9.19 (2026-07-18) (lines 600-610): references changed symbolsnode
…and 10 more.
Formal verification
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `config` is annotated `LanguageConfig` — outside the synthesizable primitive/collection set
· 1 grounded finding(s) anchored inline below; 51 more finding(s) on lines outside this diff (see the check run).
| }) | ||
|
|
||
|
|
||
| def _resolve_java_member_calls( |
There was a problem hiding this comment.
_resolve_java_member_calls()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
There was a problem hiding this comment.
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.
Fixes #3872.
Root cause
scoped_call_expression(Helper::format()) was handled in the shared engine's PHP branch by using the scope text as the callee name:So
Helper::format()was extracted as a bare call to"Helper", not"format". It also never setis_member_call, so it fell straight into the generic cross-file bare-name resolver, which matches by label — andHelperthe class is itself a labeled node. The result: every PHP static call resolved to the class definition, never the actual method, and picked up whatever confidence the bare-name resolver's import-evidence gate produced (INFERRED 0.85, since same-namespace PHP classes never need auseimport for each other).Verified against a Laravel app:
ChartColors::palette()calls across 8 files all resolved theircallsedge target to theChartColorsclass node, never.palette().Fix
extractors/engine.py:scoped_call_expressionnow captures the real method name (namefield) as the callee, the scope text as the receiver, and marks it a member call — same shape as the C++qualified_identifier/ Javamethod_invocationbranches just above it.extract.py: new_resolve_php_member_calls, registered via the existingLanguageResolverframework, mirroring_resolve_java_member_calls. Binds:self::/static::→ enclosing class (exact)parent::→ through a singleinheritsedge (exact)Class::→ the class's own method, when the class is declared in-corpus and unambiguous (exact — the receiver is named explicitly in source)$var::method()) is left unresolved rather than guessed, consistent with the Cross-file INFERREDcallsresolution lacks import evidence + ambiguous-candidate handling — short common names blow up god_nodes ranking #543/Cross-filecallsresolution drops EXTRACTED edges when the callee name has multiple definitions (even with unambiguous import evidence) #1219 god-node guardEXTRACTED/1.0, matching the existing type-qualified promotion other languages already get (Swift/Python/C++/Java/C#/Kotlin)member_call_expression($obj->method()) behavior is unchanged — still unresolved, as before this patch (no receiver-typed PHP resolver existed for instance calls; this fix only targets scoped::calls).Tests
test_php_object_creation.py::test_existing_static_call_edges_are_unchanged→ renamed/rewritten astest_static_call_resolves_to_the_actual_method_not_the_class, since the old assertion encoded the bug ("Baz" in target_labelwas true only because the target was the class).tests/test_php_static_call_resolution.py: same-namespace no-import case (the exact PHP Class::method() static calls without an explicit 'use' import stay INFERRED instead of EXTRACTED #3872 repro),self::/static::,parent::, dynamic-scope no-phantom-edge, and an explicit no-regression check that$obj->method()stays unresolved.uv run pytest tests/ -q: 6044 passed, 14 skipped (pre-existing/unrelated), 0 failed.Real-world validation
Ran the patched extractor over a 1027-file Laravel
app/corpus (no crashes, 8 parallel workers):callsedge confidence distribution went from before (INFERRED-heavy for scoped calls) to3383 EXTRACTED / 553 INFERRED, withEXTRACTEDtargets now landing on the actual called methods (spot-checked a random sample of 12).