fix(php,serve): function calls never bind methods/classes; lift interface/enum receiver refusal; sourced-first endpoint scoring (#52/#53/#54) - #57
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_pathnever 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.9069df5) — a PHPfunction_call_expressionnever bare-matches a method node cross-file — and, widened during review by the same language-semantic argument, never a class-like node either; only plausible function targets survive both label-index paths (exact-case and folded retry). Kills the fabricated helper-call fan-in:event()848→2 (0 cross-file),config()−290,dispatch()−186,request()−15. Grading caught a 7-edgereport()→class Reportregression in the first rework; the final commit eliminates it (mutation-tested).26f7b03) — the_php_non_class_typesreceiver refusal is lifted (post-Stub-shadow fix 1/4: PHP interface/trait/enum declarations mint canonical nodes (RC1 of #46) #47 it was redundant conservatism: the collision it guarded presents two definitions and the single-definition guard refuses on its own). Review found the refusal had been masking an incremental-path hole — vendoruseimports wrongly binding same-short-named in-corpus interfaces because the FQN pre-scan recorded classes only; fixed by extending_php_pre_scan_class_namespacesto all four declaration kinds through the existing channel (full/incremental parity regression-tested). Realizes +2,398callsedges of genuine fan-in on the api corpus (interface/enum-typed receivers,use-claimed collision resolution), spot-verified in source, zero stranger-bindings.630072e) —_score_nodes/_pick_scored_endpointlearn Stub-shadow fix 3/4: find_node_ambiguity must not collapse sourceless rivals into one group (RC3 of #46) #49's sourced-beats-sourceless rule as a tie-break, not a penalty (a stub can only ever tie an identical sourced node, never beat it), in both the combined sort and the per-term singleton key; lone-stub carve-out holds by construction.query/path/shortest_pathnow agree withexplainon shadowed labels; kills false "No directed path found" on reachable queries.ec4d97e) — 0.9.39 bump (rolls the version-namespaced AST cache; PHP: global function calls (event()-family) bare-matching method nodes cross-file — needs a resolution policy #52/PHP: _php_non_class_types receiver refusal is now redundant conservatism — recoverable member-call fan-in left unrealized #53 are extraction-side and a same-version rebuild would replay pre-fix raw_calls) + hand-edited uv.lock version line, mirroring1606462.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 fabricatedcallsedges (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