fix(php): stop typed-receiver member calls binding to the enclosing class's method by if12is · Pull Request #4627 · Graphify-Labs/graphify · GitHub
Skip to content

fix(php): stop typed-receiver member calls binding to the enclosing class's method - #4627

Open
if12is wants to merge 1 commit into
Graphify-Labs:v8from
CompertMe:upstream/php-typed-receiver-calls
Open

if12is wants to merge 1 commit into
Graphify-Labs:v8from
CompertMe:upstream/php-typed-receiver-calls

Conversation

@if12is

@if12is if12is commented Oct 21, 2026

Copy link
Copy Markdown

Branch: upstream/php-typed-receiver-calls (base: upstream/v8 at b90b9df). One commit.
Related: #3830 (closed, PHP inter-class member calls) and #2615 (open, PHP consolidated). This is
a different defect in the same area; it does not close either.

Problem

The generic extractor resolved every PHP member call whose receiver was not capitalised through the
file-wide bare-name map before the receiver's declared type was consulted. In a controller:

class BranchController {
    public function __construct(private BranchService $branchService) {}
    public function store() { return $this->branchService->create(); }   // -> BranchController::create (wrong)
    public function create() { ... }
}

$this->branchService->create() bound to BranchController::create, and $branch->update() bound
to the controller's own update, whenever the enclosing class had a method of that name. Only
$this->m() names the caller's own class. In addition, a typed receiver could not reach an
inherited or trait method, and a method declared only by a vendor ancestor got a wrong edge instead
of none.

Implementation

  • graphify/extractors/engine.py: only $this->m() may use the file-wide bare-name map. Every other
    instance receiver (typed property, constructor-promoted parameter, typed parameter, new X local,
    unknown variable, call chain) is deferred to _resolve_php_member_calls.
  • graphify/extract.py: _resolve_php_member_calls now also walks in-corpus ancestors and traits
    (inherits / mixes_in, breadth first, nearest level wins, ambiguity binds nothing) through the
    new _php_inherited_methods. A method that only a vendor ancestor declares yields no edge. The
    resolver's file gate uses _lang_family, so a language.inc=php override still reaches it.
    Table-typed edges stay INFERRED; static / self / parent calls stay EXTRACTED.
  • graphify/cache.py: AST cache schema 20 -> 21 (raw call output changes).
  • ARCHITECTURE.md: paragraph on the receiver rule.

Verification

New tests: tests/test_php_typed_receiver_calls.py (8 tests: promoted property, typed property,
typed parameter, method inherited from a vendor class gets no edge, inherited in-corpus method, trait
method, $this->m() still binds to the own method, unknown receiver does not fall back to the own
method).

Fails before, passes after (unmodified graphify/ of upstream/v8):

$ uv run pytest tests/test_php_typed_receiver_calls.py -q
7 failed, 1 passed      # e.g. {('BranchControllerstore()', 'BranchControllercreate()')}
                        #      != ('BranchControllerstore()', 'BranchServicecreate()')
$ uv run pytest tests/test_php_typed_receiver_calls.py -q
8 passed

Full checks on this branch:

$ uv run pytest tests -q
1 failed, 8477 passed, 15 skipped
$ uv run ruff check .
All checks passed!
$ uv run pyright
641 errors, 0 warnings, 0 informations

Bare upstream/v8 (b90b9df) gives 1 failed, 8469 passed, 15 skipped, ruff clean and the same
641 errors from pyright. The one failure is pre-existing and identical on v8:
tests/test_skill_no_backtick_substitution.py::test_control_a_backticked_unknown_command_is_caught.

Before/after on two real Laravel applications (private copies, uv run graphify update <copy> --force
from this branch and from bare upstream/v8). "Own-class typed-receiver call" = a calls edge between
two methods of the same class whose source line is $x->m() / $this->x->m() (a regex over the
source line, so it can over-count):

app A (erp) before after app B (easm) before after
own-class typed-receiver calls edges 63 0 10 1
calls edges, total 4705 4532 6902 6853
calls pairs removed / added 527 / 354 152 / 103

The one remaining match in app B is a line that contains both self::position(...) and
$service->position(...); the edge is the self:: call and is correct. Pairs removed are the wrong
bindings plus edges that are now bound to the typed receiver's class instead (hence the added pairs).

Limitations

  • A receiver whose type is not declared (untyped property, mixed, dynamic) gets no edge instead of
    a guessed one.
  • Methods supplied by __call, macros or facades are not modelled.
  • Ancestors outside the scanned corpus are not searched.

…lass's method

The engine resolved every PHP member call whose receiver was not capitalised
through the file-wide bare-name map, so `$this->branchService->create()` in
BranchController bound to BranchController::create (and `$branch->update()` to
the controller's own update) before the receiver's declared type was ever
consulted. Only `$this->m()` may use that map now; every other instance
receiver (typed property, promoted ctor param, typed param, `new X` local,
unknown variable, call chain) is deferred to _resolve_php_member_calls.

That resolver now also walks in-corpus ancestors and traits (inherits/mixes_in,
nearest level wins, ambiguity binds nothing). A method that only a vendor
ancestor declares yields no edge instead of a wrong one. The resolver's file
gate uses _lang_family so `language.inc=php` overrides still reach it.
Table-typed edges stay INFERRED; static/self/parent stay EXTRACTED.
AST cache schema 20 -> 21.
@if12is
if12is requested a review from safishamsi as a code owner October 21, 2026 13:06
Copilot AI balanced review requested due to automatic review settings October 21, 2026 13:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@github-actions

Copy link
Copy Markdown

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 2 changed function(s) (2 not verified).

Formal verification. PR-changed functions: 0/2 verified (0 proven, 0 may-equivalent, 0 distinguished) · 2 not verified (1 vacuous, 1 unsupported).

Not verified on this run: \_resolve\_php\_member\_calls (vacuous: never exercised), \_extract\_generic (unsupported).


Graphify review — findings

Fixes PHP member calls binding to the enclosing class's same-named method: only $this->m() resolves locally now, and every other receiver (typed property, promoted or typed parameter, new X local, call chain) is deferred to _resolve_php_member_calls. That resolver types the receiver and uses the new _php_inherited_methods to walk inherits and mixes_in edges breadth-first, so the nearest in-corpus declaration wins, ambiguity binds nothing, and a method declared only in a vendor ancestor gets no edge. Trait methods are treated as one more ancestor level alongside parent classes, so a trait and a parent class declaring the same method at the same depth count as ambiguous rather than ranked. The resolver now gates on _lang_family instead of a .php suffix so language.inc=php overrides still reach it, and the AST cache schema bumps to 21 to retire results from the old binding.

Worth a look

  • Trait methods tie with parent-class methods instead of taking precedence — graphify/extract.py:5881 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3974 functions depend on the 792 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 989 callers, 59 callees

Verification — 3974 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: 3723 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

437 of 437 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/pi.test.ts — full-run-safety
  • tests/test_affected_cli.py — full-run-safety
  • tests/test_affected_defines.py — full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_affected_overrides.py — impact, full-run-safety
  • tests/test_agents_platform.py — full-run-safety
  • tests/test_alias_folding.py — full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — impact, full-run-safety
  • tests/test_astro_import_ids.py — impact, full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_backend_opt_in.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_benchmark_real_query.py — full-run-safety
  • tests/test_blade_extractor.py — impact, full-run-safety
  • tests/test_build.py — impact, full-run-safety
  • tests/test_build_located_semantic_identity.py — full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — impact, full-run-safety
  • tests/test_c_include_resolution.py — impact, full-run-safety
  • tests/test_cache.py — impact, full-run-safety
  • tests/test_cache_stale_import_target.py — impact, full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_cargo_missing_manifest.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — impact, full-run-safety
  • tests/test_charmap_encoding.py — impact, full-run-safety
  • tests/test_chunking.py — impact, full-run-safety
  • tests/test_cjs_module_extension.py — impact, full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cloud_cta.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_cluster_ambiguous_scale.py — full-run-safety
  • tests/test_cluster_exclude_hubs.py — full-run-safety
  • … and 387 more

non-code file(s) changed (ARCHITECTURE.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (ARCHITECTURE.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests

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.

Formal verification

Could not verify: Could not verify \_resolve\_php\_member\_calls.

The verifier did not have enough to check \_resolve\_php\_member\_calls, 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: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

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 more finding(s) on lines outside this diff (see the check run).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants