fix(php): mint interface/trait/enum declaration nodes; resolve imports from target_fqn; guard sourceless rivals; two call-edge fixes (#46) - #51
Merged
Conversation
) The scoped_call_expression handler took the scope text as the callee name, so parent::setUp() minted a raw call to a callee literally named 'parent'. Unresolved in-file, that reached the cross-file pass, which matches by normalized label and bound it to any unrelated ->parent() method in the corpus (1,698 wrong inbound edges on ServiceCategory::parent() at api scale). parent/self/static are relative scopes: which class they denote needs the inheritance context the raw-call facts do not carry, so refuse rather than guess. Gated on _PHP_NON_CONCRETE_TYPE_NAMES, the same set the (new X())->m() branch already applies. Absolute scopes are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nto one group (#49) `find_node_ambiguity` grouped the winning match tier by `source_file`. Every stub the extractor mints for a reference it could not resolve carries `source_file == ""`, so N unrelated stubs collapsed into a single `""` bucket and looked like N members of one file: no ambiguity was reported, and `explain` answered with `matches[0]` — whichever stub `G.nodes()` yielded first. On the pinned corpus that is the 18 stubs shadowing `BalanceitemRepository`; reorder the graph and the same query answered with a different stub, equally confidently, while `affected` refused with "No unique node match" (RC3 of #46). - `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one. A stub is a broken duplicate of the real declaration, never the better answer, and not something the caller could disambiguate anyway — it has no path to retry with. - Sourceless nodes are keyed individually in the ambiguity grouping, so a tier made only of stubs reports rivals instead of picking one silently. Neither change depends on stub counts staying high. - `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in its exact-label and bare-name passes, so `explain` and `affected` now agree: both resolve to the sourced declaration when one exists, both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…des (#47) `_PHP_CONFIG.class_types` held only `class_declaration`, so no node was ever minted for a PHP interface, trait or enum — 291 declarations in the pinned api.lawnstarter.com corpus (#46). Every resolution pass that could canonicalize an edge then had nothing to land on: `implements`/`mixes_in` kept bare sourceless stubs that shadow the real name in `explain`, `Foo::CONST` fan-in fragmented across per-file stubs, and `imports`/parameter-type `references` parked on the *file* node (or, when the filename differs from the type name, on a sourceless FQN-labeled stub). Add the three declaration kinds to `class_types`, mirroring Java and Groovy. An enum's body is an `enum_declaration_list` rather than a `declaration_list`, so `body_fallback_child_types` learns it. The `_resolve_php_type_references` raw-scan, which read `class_declaration` bodies only, now scans every declaration kind and both body shapes: without it `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded no raw text and fell through to the same-namespace guess, resolving to the wrong `Repo`/`Describes`. The `_php_non_class_types` pre-scan keeps refusing interface/enum/trait-typed receivers — a behavior change this does not make — but its comment and docstring no longer claim the three kinds mint no node, which is now false. RC1 of #46. The residual dangling `imports` edge for a type used only via `::class` (repro's `enum Status`, `OnlyImport.php`) is RC2 and stays with #48 — this change supplies the node that fix needs to land on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…data (#48) `_import_php` keys the `imports` edge on the imported short name and mints no node for it, so a `use` whose name is only ever written as `Foo::class` leaves no stub node behind. `_resolve_php_type_references` read the name to resolve off a stub node's label, so it skipped those edges outright and the bare target survived into graph.json with no node behind it — serve then materialized an attributeless degree-1 node at load (RC2 of #46). Resolve `imports` from the edge's own `target_fqn` metadata instead, stamped by `_import_php` since #19. The FQN is explicit proof of what the `use` statement said, so an in-corpus target lands on its real node and an external one parks on an FQN-labeled stub the bare-name rewire cannot collapse — never a bare match onto an unrelated same-short-name type. Aliased imports resolve for the same reason. `use function` / `use const` name callables, not types, and are left alone. The vacated target only feeds the orphan prune when it really was a stub: the old control flow guaranteed that for free by reaching the prune set only after a `stub_label` hit, but a metadata-resolved import target is a bare `_make_id(<short name>)` that can collide with an unrelated real node (a `Page.md` doc node vs `use Vendor\Pkg\Page;`), and pruning that would delete a sourced node the import never touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…yped param (#38) PHP receiver typing read the class's typed properties and constructor-promoted params only, so the pre-promotion Laravel idiom — property declared untyped with its type in a `@var` docblock, collaborator arriving as a typed `__construct` param the body assigns across — bound no receiver type and minted no `calls` edge. Measured case: `BalanceCustomerAccountService::handle()` had no edge to `ChargeCustomerService::handle()` on the 48.5k-node Laravel corpus, while its two sibling callers (promoted param, typed method param) resolved fine. `_php_ctor_assigned_field_types` types the properties a constructor body assigns from a typed constructor parameter and merges them UNDER the declared and promoted types, so a real annotation — including a union's multi-class refusal (#9) — always wins. The parameter's signature is the evidence; `@var` docblocks stay out of scope and are not parsed. Refusal discipline mirrors the local-variable one (#4): the property is poisoned rather than guessed at unless its binding is provably single-typed — a second assignment of a different or untypable type, an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body (including by a shadowing closure parameter, which the reused local-table walk already poisons). A property is not a local, so that refusal is PROPERTY-scoped rather than constructor-scoped: every other method of the class is walked for writes to `$this->prop` too, and any it finds refuses the binding. Such a write is the same category as one made from inside a closure — deferred, possibly running never, later, or repeatedly — so both take one `deferred` path that can only refuse, never bind. Without it a setter reassigning the property to another class left the constructor's binding standing and minted a wrong INFERRED edge. The declared path needs no such sweep: PHP enforces a native property type on every write, so a setter cannot retype a typed or promoted property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#46/#47/#48/#49/#38/#39) Moves the five fix bullets the implementers filed under 0.9.37 into a new 0.9.38 section (0.9.37 is the currently-installed pre-fix version) and adds the missing #39 bullet. The bump is load-bearing, not cosmetic: the AST cache is version-namespaced, so a same-version rebuild replays pre-fix cached nodes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 7, 2026
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 six issues — #46, #47, #48, #49, #38, #39 — were closed per-slice as each landed, with acceptance evidence on the tickets).
PHP node identity failure mode #3 (PE-78803): interfaces, traits and enums minted no declaration node, so sourceless stubs shadowed the real name, absorbed its fan-in, and broke lookup —
explainanswered from an arbitrary stub whileaffectedrefused. Plus two independent call-edge bugs found in the same neighborhood. Five fix slices, one commit each (mirroring #44's shape), each implemented red-first and adversarially reviewed in an isolated worktree session; changelog/version-bump commit last.9c4db41) —interface_declaration/trait_declaration/enum_declarationjoin_PHP_CONFIG.class_types(mirroring Java/Groovy);enum_declaration_listbody handling; the_resolve_php_type_referencesraw-scan reads all four declaration kinds so interface-extends/enum trait-useresolve as written. Members move file-scoped → type-scoped ids (see CHANGELOG for the incremental-rebuild note).258d8e3) — classimportsedges resolve from their owntarget_fqnmetadata instead of a stub node's label; unresolvable FQNs park on FQN-labelled external stubs, deduped by FQN. Review caught and fixed a repoint/orphan-prune interaction that could delete an unrelated same-short-name node (mutation-tested regression guard included).313a168) —find_node_ambiguityno longer collapses N sourceless rivals into one group; the exact tier prefers a sourced declaration over stubs;affected.resolve_seedlearns the same rule soexplainandaffectedagree on every shape.075038e) —scoped_call_expressionrefusesparent/self/static(all of_PHP_NON_CONCRETE_TYPE_NAMES) as callee names, killing 1,698 fabricatedcallsedges intoServiceCategory::parent(). Theevent()-helper family (848 edges) is deliberately untouched pending a maintainer policy call.293d57e) —this.<prop>receivers bind from a constructor-body assignment of a typed param (pre-promotion Laravel idiom), with property-scoped poison-on-reassignment (any write from any method refuses; can only suppress edges, never mint wrong ones).1606462) — 0.9.38 bump (rolls the version-namespaced AST cache, forcing re-parse) + CHANGELOG reconciliation.Suite: 4,371 passed / 3 skipped / 0 failed (base v8: 4,284 / 3; +87 new tests across 5 new test files, zero pre-existing tests weakened — one split into capture/resolution halves with both invariants kept).
Graded live on a throwaway rebuild of api.lawnstarter.com @
d2d4bed7ae(full pre/post table on #46): 302 missing declarations → 0; danglingimports19,303 → 0 (11,738 edges recovered onto real declarations); dangling endpoints 2,123 → 0; sourceless nodes at load 3,594 → 1,031 (−71%, all residuals genuine vendor externals);ServiceCategory::parent()inbound calls 1,698 → 2 (both genuine); gates Q1–Q4 all PASS with Q4 and Q2 flipped as predicted.Upstream PR to follow on this same branch (RC1/RC2/RC3 and both call-edge fixes are upstream code paths, not fork divergence).
🤖 Generated with Claude Code