Skip to content

fix(php): mint interface/trait/enum declaration nodes; resolve imports from target_fqn; guard sourceless rivals; two call-edge fixes (#46) - #51

Merged
filipechagas merged 6 commits into
v8from
fix/stub-shadowing-46
Aug 7, 2026
Merged

fix(php): mint interface/trait/enum declaration nodes; resolve imports from target_fqn; guard sourceless rivals; two call-edge fixes (#46)#51
filipechagas merged 6 commits into
v8from
fix/stub-shadowing-46

Conversation

@filipechagas

Copy link
Copy Markdown
Collaborator

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 — explain answered from an arbitrary stub while affected refused. 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.

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; dangling imports 19,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

filipechagas and others added 6 commits August 7, 2026 10:21
)

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>
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