Skip to content

fix(php): interface/trait/enum declarations mint canonical nodes; sourced declarations win over sourceless lookup rivals; refuse relative-scope callees - #2536

Open
filipechagas wants to merge 3 commits into
Graphify-Labs:v8from
lawnstarter:upstream-fix/php-node-identity
Open

fix(php): interface/trait/enum declarations mint canonical nodes; sourced declarations win over sourceless lookup rivals; refuse relative-scope callees#2536
filipechagas wants to merge 3 commits into
Graphify-Labs:v8from
lawnstarter:upstream-fix/php-node-identity

Conversation

@filipechagas

Copy link
Copy Markdown

Three PHP fixes for one failure family — node identity: types that never mint a declaration node leave sourceless stubs to shadow the real name, absorb its fan-in, and poison both lookup and call edges. Found and measured on a 46k-node Laravel corpus; adapted from lawnstarter#51 (each commit carries the provenance line).

1. fix(extract): PHP interface/trait/enum declarations mint canonical nodes (325d614)

_PHP_CONFIG.class_types held class_declaration alone, so none of the three ever minted a node — 291 declarations with no node on the measured corpus (142 interfaces / 30 traits / 119 enums). Their fan-in scattered: implements/extends/trait-use minted a bare sourceless stub that shadowed the real name; Foo::CONST fan-in fragmented across per-file stubs; imports/parameter-type references parked on the file node or an FQN stub. The three kinds join class_declaration (mirroring Java/Groovy, which always had interface_declaration), with two grammar details: enum bodies are enum_declaration_list, and the _resolve_php_type_references raw-scan now reads all four declaration kinds and both body shapes — so interface Reader extends Sub\Repo resolves as written instead of falling to a same-namespace guess.

Not backward compatible with an existing graph: interface/trait/enum methods move from file-scoped to type-scoped ids (labels gain the member dot). A full graphify update . lands it consistently; a hook-driven incremental rebuild against a pre-fix graph drops (never repoints) stale-id edges until the next full update. Since AST cache entries are content-hash-keyed within the version namespace, a same-version rebuild replays pre-fix nodes — this PR deliberately does not bump the version (matching #2502/#2503 convention), so you may want to pair the merge with one.

Behavior consequence worth stating: a member-call receiver typed by an interface/trait/enum can now bind (there is now a definition to find) — consistent with how Java/Groovy interfaces already behave here.

2. fix(serve): find_node_ambiguity must not collapse sourceless rivals into one group (9e73fc0)

The ambiguity check groups the winning tier by source_file — but every extractor-minted stub carries source_file == "", so N stubs collapsed into one bucket, no ambiguity was reported, and explain silently answered with matches[0] (graph-iteration order), while affected on the same name refused with "No unique node match". Now: the exact tier prefers a sourced declaration over sourceless rivals; a tier of only stubs reports each stub as its own rival instead of picking one silently; and affected.resolve_seed learns the same rule so the two commands agree on every shape. Sourced-vs-sourced ties and lone-stub resolution are unchanged. Independent of #2516 (different functions).

3. fix(engine): refuse relative-scope names as PHP scoped-call callees (088ce5c)

The scoped_call_expression handler took the scope text as the callee name, so every parent::__construct() / parent::setUp() in the corpus emitted a raw call named parent — and the cross-file label pass then bound them all to whatever callable happened to be named parent(): 1,698 fabricated calls edges into one model accessor on the measured corpus. parent/self/static are now refused as callee names (resolution would need inheritance context the raw-call facts don't carry — refusal over guessing). Legit Helper::format() scoped calls are unaffected.

Tests

Full suite on this branch (based on v8 @ 9f25a3a): 4,109 passed / 3 skipped / 0 failed. Three new test files (+26 tests); each was re-verified red against clean v8 before the fix commit, not just green after. Zero pre-existing tests modified.

Two sibling fixes deliberately not in this PR

🤖 Generated with Claude Code

filipechagas and others added 3 commits August 7, 2026 11:32
)

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. Absolute scopes are unaffected.

The fork gates this on `_PHP_NON_CONCRETE_TYPE_NAMES`, a 17-name set it also
applies to written-type reads and to its `(new X())->m()` receiver capture,
neither of which exists here. This introduces instead the three-name
`_PHP_RELATIVE_SCOPE_NAMES` — every name in it is justified at the one call
site that reads it — which is the whole of the set that can fire on a
scoped-call scope anyway: no PHP builtin is a legal `::` scope.

Adapted from #51.

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.

Adapted from #51.

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 a pinned
46.4k-node Laravel corpus. 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 fork's version of this commit also rewords `_php_non_class_types`, its
receiver-binding refusal for these three kinds, whose claim that they mint no
node this change falsifies. That pre-scan does not exist here, so nothing is
carried over: a receiver typed by an interface, trait or enum was never refused
in this tree and can now bind, since the definition the single-definition guard
was looking for finally exists.

The residual dangling `imports` edge for a type used only via `::class` is a
separate root cause and is NOT fixed here — this change supplies the node that
fix needs to land on. It is left pinned as a dangling target by the test, and
selected there by the target id rather than by the `target_fqn` edge metadata
the fork uses, which `_import_php` does not stamp in this tree (that metadata
is Graphify-Labs#2502, still open).

Adapted from #51.

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

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


Graphify review — findings

This PR appears to make several changes to PHP extraction and node resolution in a code-graph tool (graphify): 1. PHP relative-scope call handling: Modifies the scoped_call_expression handler so that parent::, self::, and static:: calls no longer emit the scope keyword as the callee name (previously they minted raw calls to callees literally named e.g. parent). 2. PHP declaration kinds: Adds interface_declaration, trait_declaration, and enum_declaration to PHP's class_types so these mint declaration nodes like class does, plus supporting grammar adjustments (enum_declaration_list body type, extended raw-scan coverage). This shifts interface/trait/enum method ids and labels to a type-scoped form. 3. Sourceless-stub resolution: Introduces _is_sourced/_prefer_sourced_node helpers in affected.py and adjusts tier/ambiguity logic in serve so a sourceless stub no longer shadows a real declaration and so explain and affected resolve/refuse consistently. The surface area spans extract.py, extractors/engine.py, extractors/resolution.py, affected.py, serve.py, the CHANGELOG, and associated tests. The changelog notes these are extraction-side changes requiring PHP corpus re-extraction. Note: the diff was truncated in the prompt, so my summary of serve.py, resolution.py, and the test files is inferred from the changelog and symbol names rather than read directly.

Worth a look

  • Relative-scope PHP calls (parent::/self::/static::) silently droppedgraphify/extractors/engine.py:582 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2219 functions depend on the 850 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 378 callers, 39 callees
  • worse: resolve_seed() — 16 callers, 5 callees

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

· 2 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).

Comment on lines 583 to 584
return _read_text(node, source).rsplit("\\", 1)[-1] or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Relative-scope PHP calls (parent::/self::/static::) silently dropped — agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Graphify suggests a fix:

Suggested change
return _read_text(node, source).rsplit("\\", 1)[-1] or None
text = _read_text(node, source).rsplit("\\", 1)[-1]
if not text:
return None
# PHP keywords are case-insensitive; relative scopes (parent::/self::/static::)
# name no concrete class, so report them as "no scope" and let the caller emit
# an unqualified call instead of dropping the fact entirely.
if text.lower() in _PHP_RELATIVE_SCOPE_NAMES:
return None
return text

Comment thread graphify/affected.py
return sourced[0] if len(sourced) == 1 else None


def resolve_seed(graph: nx.Graph, query: str) -> str | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionresolve_seed()

16 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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