fix(php): interface/trait/enum declarations mint canonical nodes; sourced declarations win over sourceless lookup rivals; refuse relative-scope callees - #2536
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. 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>
There was a problem hiding this comment.
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 dropped —
graphify/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).
| return _read_text(node, source).rsplit("\\", 1)[-1] or None | ||
|
|
There was a problem hiding this comment.
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:
| 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 | |
| return sourced[0] if len(sourced) == 1 else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str) -> str | None: |
There was a problem hiding this comment.
resolve_seed()
16 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
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): PHPinterface/trait/enumdeclarations mint canonical nodes (325d614)_PHP_CONFIG.class_typesheldclass_declarationalone, 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-useminted a bare sourceless stub that shadowed the real name;Foo::CONSTfan-in fragmented across per-file stubs;imports/parameter-type references parked on the file node or an FQN stub. The three kinds joinclass_declaration(mirroring Java/Groovy, which always hadinterface_declaration), with two grammar details: enum bodies areenum_declaration_list, and the_resolve_php_type_referencesraw-scan now reads all four declaration kinds and both body shapes — sointerface Reader extends Sub\Reporesolves 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_ambiguitymust not collapse sourceless rivals into one group (9e73fc0)The ambiguity check groups the winning tier by
source_file— but every extractor-minted stub carriessource_file == "", so N stubs collapsed into one bucket, no ambiguity was reported, andexplainsilently answered withmatches[0](graph-iteration order), whileaffectedon 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; andaffected.resolve_seedlearns 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_expressionhandler took the scope text as the callee name, so everyparent::__construct()/parent::setUp()in the corpus emitted a raw call namedparent— and the cross-file label pass then bound them all to whatever callable happened to be namedparent(): 1,698 fabricatedcallsedges into one model accessor on the measured corpus.parent/self/staticare now refused as callee names (resolution would need inheritance context the raw-call facts don't carry — refusal over guessing). LegitHelper::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 cleanv8before the fix commit, not just green after. Zero pre-existing tests modified.Two sibling fixes deliberately not in this PR
importsedges from their owntarget_fqnmetadata (kills ~19k dangling import edges on the measured corpus) requires theuse-statement metadata capture proposed in fix(php): preserve use-statement facts and stop group-form use function/const from claiming class names #2502; it is a ~60-line follow-up once that merges.this.<prop>receivers from constructor-body assignment of a typed param requires the PHP receiver-typing subsystem proposed in fix(php): resolve instance-method calls edges from typed receivers (#1682) #2492 (with follow-ups fix(php): member-call follow-ups — FCC relation, union refusal, qualified receiver types, use-claim refusal #2505/feat(php): bind claimed receiver types to their declared FQNs, with incremental-rebuild parity #2506).🤖 Generated with Claude Code