Skip to content

fix(php,serve): function calls never bind methods/classes; lift interface/enum receiver refusal; sourced-first endpoint scoring (#52/#53/#54) - #57

Merged
filipechagas merged 4 commits into
v8from
fix/resolution-gaps-52-53-54
Aug 7, 2026
Merged

fix(php,serve): function calls never bind methods/classes; lift interface/enum receiver refusal; sourced-first endpoint scoring (#52/#53/#54)#57
filipechagas merged 4 commits into
v8from
fix/resolution-gaps-52-53-54

Conversation

@filipechagas

Copy link
Copy Markdown
Collaborator

Closes nothing (all three issues — #52, #53, #54 — were closed per-slice as each landed, with acceptance evidence on the tickets).

The three follow-ups triaged out of the #46 cycle: two PHP call-resolution gaps and the serve-side half of #49 that query/shortest_path never got. Three fix slices, one commit each (mirroring #51's shape), each implemented red-first in an isolated worktree session and adversarially reviewed (two slices took a rework round; one regression was caught by corpus grading and reworked+re-reviewed); changelog rode with each fix, version-bump commit last.

Suite: 4,411 passed / 3 skipped / 0 failed (base v8: 4,371 / 3; +40 new tests across 2 new test files and 4 amended ones, zero pre-existing tests weakened — 23 #11/#12-era contract tests flipped red-first to the new correct-edge expectation with the stranger-refusal intent preserved in every one).

Graded live on a throwaway rebuild of api.lawnstarter.com @ d2d4bed7ae (46,636 nodes): −1,341 fabricated calls edges (every loss attributable to the #52 family), +2,398 legitimate edges (#53 recall), 0 INFERRED gains to non-method targets, GetProviderBillingEventsTest.event() fan-in 848 → 2 same-file residuals (0 cross-file).

Follow-up filed: #55 (pre-existing short-name-conflict drop in the FQN pre-scan; trigger set widened by #53). Upstream: #53 widens the fork/upstream divergence deliberately (upstream has no pre-scan); recorded for the next port cycle.

🤖 Generated with Claude Code

filipechagas and others added 4 commits August 7, 2026 14:48
…s nodes cross-file (#52)

A PHP `function_call_expression` — a bare `name(...)` — can only invoke a
global or namespaced function. Reaching a method requires `$obj->`, `Class::`
or first-class-callable syntax, and a class-like declaration is not invocable
at all (`Report($e)` with only `class Report` in scope is a "Call to undefined
function" fatal, not a constructor call). The shared cross-file pass matched by
normalized label, and the label index strips the member marker (`.event()` and
`event()` both key as `event`), so Laravel's `event(...)` helper bound to
whichever class declared an `event()` METHOD — 848 incoming `calls` edges on a
single test method in the measured corpus, as a single-candidate bind before
any tie-break could apply. A CLASS reached the same way through the
case-insensitive fold (`foo(...)` → `class Foo`).

The PHP extractor now tags function-call raw_calls (`php_function_call`), which
the raw-call facts otherwise cannot tell from a `scoped_call` — also not a
member call. The cross-file pass keeps only plausible function targets for
those sites, dropping member-labelled and class-like candidates, and filters
the candidate LIST rather than re-keying the index so the refusal covers every
consumer at once: single-candidate bind, import-evidence disambiguation and the
god-node tie-break, on both the exact-case and the case-insensitive path. The
folded lookup is retried after the refusal, not skipped: it only fires on an
empty exact-case list, so a method shadowing the exact-case key would otherwise
hide a real `function Event()` reachable solely under the folded key. The retry
carries the refusal with it — without that it offers a capitalized class under
the shadowed lowercase key that the exact-case path never had. Both
discriminators survive an incremental rebuild: the member marker rides the
persisted label, and `_callable_class` is replayed onto
`resolution_context_nodes` by `graphify update` and `watch`.

Real global functions still resolve, and candidate filtering for every other
language is unchanged — Ruby and Python bare calls reach methods through
implicit self, so a method stays a plausible target there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rceless stubs (#54)

`_find_node_tiers` drops sourceless stubs from a mixed exact tier (#49), so
`explain` and `affected` resolve a shadowed label to its real declaration. The
scored path `graphify query` / `shortest_path` run on never learned that rule:
a stub and a sourced declaration sharing a label score identically (5619.08 on
the two-node repro), so `_score_nodes`' sort fell through to its node-id
tie-break and `_pick_scored_endpoint` / `_pick_seeds` answered with whichever
id sorted first — violating the design intent stated at serve.py:515-520 that
`path`/`query` resolve the same node `explain` does.

A stub can never out-score an otherwise-identical sourced node (the source-file
tier only ever adds), so a shadowing stub always arrives as an exact score tie.
Insert the sourced preference directly under the score in both tie-break keys
(the combined ranking sort and the per-term singleton winner), above the
label-length and node-id keys, which are arbitrary with respect to which node
is real. #49's carve-out is preserved by construction: an all-sourceless field
has nothing to prefer, so a lone stub still wins its query.

`_find_node`'s existing behavior is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#53)

`_resolve_php_member_calls` refused every receiver whose declared type named a
PHP `interface`, `enum` or `trait`, off the corpus-wide pre-scan marker. That
was the right call while the three minted no definition node: the Laravel
Contracts collision (`App\Contracts\Notifier` interface beside an unrelated
`App\Support\Notifier` class) left exactly ONE definition under the short name,
so the single-definition guard could not see the ambiguity and would have bound
the stranger.

their own methods attach to them. The collision therefore censuses TWO
definitions and is refused by the single-definition guard unaided, while an
interface named by exactly one declaration — or picked out of the namesakes by
the calling file's own `use` — was still being refused, leaving recoverable
member-call fan-in unrealized. Lift the refusal so those receivers resolve
through the normal path and land on the declaration's own method node.

Lifting it exposed a hole that the blanket refusal had been covering:
`_php_pre_scan_class_namespaces` read `class_declaration` only, so an
interface/enum/trait node never carried a declared FQN, and `PhpNameResolver`'s
`use`-claim guard fell back to comparing the node's PSR-4 PATH — which
`_php_fqn_names_another_class` treats as NO EVIDENCE whenever the path has fewer
segments than the written name. A full build kept refusing (paths are still
absolute at resolver time), but an incremental rebuild replays context nodes
with relativized paths, so `use Illuminate\Contracts\Notifications\Notifier;`
(4 segments) against `app/Contracts/Notifier.php` (3) bound the in-corpus
interface the vendor import provably does not name: a wrong edge of the #16
class, in the mode `graphify update`/`watch` actually runs in. All four
declaration kinds now feed `php_class_fqns` / `_php_class_fqns` / `fqn_def_nid`,
which makes the guard whole-name and decisive on both build paths — and, as the
same fix's recall half, lets a `use`-claimed in-corpus interface resolve to the
declaration it names instead of dying on the two-candidate census.

The persisted `_php_non_class_types` / `_php_interfaces` channel is kept whole —
pre-scan, stamping, replay, both spellings read back — so older graphs still
round-trip; only the refusal that consumed it goes. Comments and docstrings that
asserted the pre-#47 reality are rewritten across extract.py, engine.py,
watch.py, cli.py and php.py.

Tests: interface-, enum- and trait-typed receivers bind their declaration's
method, with a same-method-name decoy in every positive case; the vendor-import
fixture is asserted on the FULL and INCREMENTAL build together (a full-build-only
assertion is green on the broken code); the #11/#12 contract tests keep their
intent — the same-short-named stranger is what may never be bound — and now
assert full/incremental agreement rather than a refusal the guards make on their
own. The legacy `_php_interfaces` spelling test pins the channel it actually
covers now: `_php_context_interface_entry` recovering the names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…coring fixes (#52/#53/#54)

The CHANGELOG bullets landed with each fix commit; this rolls the version
so the version-namespaced AST cache (graphify-out/cache/ast/v{version}/)
invalidates — #52 and #53 are extraction-side and a same-version rebuild
would replay pre-fix raw_calls untouched. uv.lock version line edited by
hand, mirroring 1606462: a full uv lock churns unrelated lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant