You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PHP node identity: interfaces/traits/enums get no declaration node — sourceless stubs shadow the real name, absorb its edges, and break lookup (failure mode #3, PE-78803) #46
Root-cause investigation of the last remaining PoC-gate blocker (node-identity stub shadowing), measured on the 0.9.37 pinned graph of api.lawnstarter.com @ d2d4bed7ae (46,406 nodes / 139,497 links). Fork code referenced @ a9adfc6 (v8). Related evidence: #37 (grading comment), open siblings #38/#39 (different bugs in the same repro neighborhood).
Symptom (Q4 / Q2 of the gate evals)
graphify explain "BalanceitemRepository" silently resolves to a bare stub node (id balanceitemrepository, no source, degree 1) while graphify affected on the same name refuses with "No unique node match". The interface's real fan-in (38 production files) is invisible from the stub.
graphify explain "ChargeCustomerCancellationFee" returns an ambiguity list: bare id chargecustomercancellationfee vs the real class node.
Root cause 1 (primary): PHP type declarations other than class produce no declaration node
_PHP_CONFIG.class_types = frozenset({"class_declaration"}) — graphify/extract.py:1065. tree-sitter-php emits interface_declaration, trait_declaration, enum_declaration (all with a name field; enums use enum_declaration_list as body), but none are in class_types, so no node is ever minted for a PHP interface, trait, or enum. Compare Java (extract.py:936) and Groovy (extract.py:952), which both include interface_declaration.
In the pinned api corpus that is 142 interfaces, 30 traits, 119 enums (291 declarations) with no canonical node. interface BalanceitemRepository (app/Repositories/Balanceitem/BalanceitemRepository.php:22) is one of them: the graph has its file node (label BalanceitemRepository.php) containing 28 method nodes directly, but no interface node (app_repositories_balanceitem_balanceitemrepository_balanceitemrepository is absent).
Everything below cascades from this: every resolution pass that could canonicalize edges has nothing to land on —
fqn_to_id in _resolve_php_type_references (graphify/extractors/resolution.py:2721-2732) skips file nodes (label contains .) and methods (label ends )), so the interface's FQN resolves to nothing.
_rewire_unique_stub_nodes (graphify/extract.py:2037) finds no sourced type-like node with the label, so stubs are never collapsed.
Cascade A — the bare shadow stub (the explain hijacker)
_php_emit_base (graphify/extractors/engine.py:3056-3071) handles implements/extends/trait-use and mints a sourceless stub without origin_file for an unresolved base name. _disambiguate_colliding_node_ids skips nodes with an empty source key (resolution.py:704-706), so this stub keeps the bare id (balanceitemrepository). Same-namespace implements (no use statement, as in DbBalanceitemRepository implements BalanceitemRepository) is non-"explicit" in the repoint pass, so it is left "for the legacy rewire" (resolution.py:2789-2792,2803) — which finds no unique real target (RC1). The bare stub survives with exactly the implements edge.
Cascade B — per-file stub fragmentation (Foo::CONST fan-in)
ensure_named_node (engine.py:2856-2878) mints per-file sourceless stubs withorigin_file for type annotations. When N files each mint the same bare id, _disambiguate_colliding_node_ids salts each into <referencing-file-path>_php_<name> (resolution.py:694-714). references_constant edges (engine.py:5311-5330) resolve their target through the file-local label map, and references_constant is not in _PHP_REPOINT_RELATIONS (resolution.py:2490), so those edges are never re-pointed: the constant fan-in fragments across per-file stubs. Pinned graph: 17 stubs *_php_balanceitemrepository holding 20 references_constant edges.
Cascade C — imports/param-types land on the file node only by id collision
For files that do write use App\...\BalanceitemRepository, the repoint pass computes the FQN, misses fqn_to_id (RC1), and parks the edge on an "external" stub _make_id(fqn) (resolution.py:2798-2802, 2744-2760). Under PSR-4, _make_id("App\Repositories\Balanceitem\BalanceitemRepository")happens to equal the file node's id, so 69 references + 60 imports edges land on the file node (degree 171) — the fan-in survives, but attached to a node whose label (BalanceitemRepository.php) no longer exact-matches the type name. When the file name differs from the type name (e.g. a trait in Extras.php), the same path yields a sourceless FQN-labeled stub instead (verified in the minimal repro below).
Root cause 2 (independent): an imports edge whose target never got a stub node is invisible to all resolution
_import_php (extract.py:810-822) emits the edge with target _make_id(short_name) and creates no node. If the importing file never uses the name as a base/param type (e.g. only Foo::class), no stub node ever exists, so:
the PHP repoint pass skips the edge (stub_label.get(tgt) → continue, resolution.py:2770-2773),
_rewire_unique_stub_nodes never sees it (it iterates nodes, not edge endpoints),
the dangling bare target survives into graph.json, and serve materializes an attributeless degree-1 node at load.
This hits classes too — the Q2 case: CustomerScheduleControllerTest.php has use App\Actions\Schedules\Cancellation\ChargeCustomerCancellationFee; (L7) and uses it only as ::class (L730, L764). The real class node exists; the import edge still dangles on bare chargecustomercancellationfee. The ::class/::CONST references themselves are silently dropped (file-local label-map miss at engine.py:5315 emits nothing).
Pinned-graph scale: 2,123 dangling endpoints; 19,303 imports edges have no target node; 1,802 distinct dangling ids match a real in-corpus declared type's normalized name, carrying 11,456 edges of recoverable fan-in. 1,471 sourceless stub nodes absorb ~16.3k edges (imports 6,307, references 4,426, calls 1,703, inherits 1,539, references_constant 1,150, mixes_in 780, implements 383).
Root cause 3 (lookup side): ambiguity detection groups rivals by source_file
find_node_ambiguity (graphify/serve.py:1382-1389) returns rivals only when the winning tier spans >1 distinct source_file. All sourceless stubs share source_file="", so Q4's 18 exact-tier stubs collapse into one group → no ambiguity reported → explain silently answers with matches[0] (graph-iteration order) = the bare stub. Q2's exact tier contains one sourced node + one sourceless → 2 groups → ambiguity reported. affected uses a different resolver (resolve_seed, graphify/affected.py:99-143) that requires a unique label match → refuses. That is the whole explain-vs-affected inconsistency.
Minimal repro (7 PHP files, AST-only, no API cost)
Corpus: interface FooRepository { const BAR; find(); } + same-namespace class DbFooRepository implements FooRepository + two consumers (use + ctor param + FooRepository::BAR) + trait Loggable/enum Status in Extras.php + a consumer + OnlyImport.php (use App\Repo\DbFooRepository; used only via ::class). Run graphify.extract.extract() on it. Observed (0.9.37):
no FooRepository interface node; file node contains find() directly
bare sourceless stub foorepository ← implements edge
per-file stubs app_uses_consumer_php_foorepository, app_uses_consumer2_php_foorepository, each holding one references_constant edge
imports/references [parameter_type] land on the file node app_repo_foorepository
trait: mixes_in/imports land on sourceless FQN stub app_repo_loggable (label App\Repo\Loggable)
enum: imports --> statusdangling (no node); Status::Active reference dropped
OnlyImport: imports --> dbfoorepositorydangling even though the real class node exists in the corpus
Control: change interface FooRepository to class FooRepository, re-extract → zero sourceless stubs; implements/imports/references/references_constant ALL canonicalize onto app_repo_foorepository_foorepository. The one missing declaration-node type is the load-bearing difference.
Fix directions (not implemented)
RC1: add interface_declaration, trait_declaration, enum_declaration to _PHP_CONFIG.class_types (extract.py:1065), mirroring Java/Groovy. Caveats: enum bodies are enum_declaration_list (check body_fallback_child_types); the resolution.py:2688 raw-scan (class_declaration only) should also learn interface_declaration so interface-extends-interface edges resolve; check knock-on id shifts for interface methods (currently file-scoped, would become interface-scoped) on incremental rebuilds.
RC3: find_node_ambiguity should not treat N sourceless rivals as one file; and/or exclude sourceless stubs from the exact tier when a sourced match exists.
Root-cause investigation of the last remaining PoC-gate blocker (node-identity stub shadowing), measured on the 0.9.37 pinned graph of api.lawnstarter.com @
d2d4bed7ae(46,406 nodes / 139,497 links). Fork code referenced @a9adfc6(v8). Related evidence: #37 (grading comment), open siblings #38/#39 (different bugs in the same repro neighborhood).Symptom (Q4 / Q2 of the gate evals)
graphify explain "BalanceitemRepository"silently resolves to a bare stub node (idbalanceitemrepository, no source, degree 1) whilegraphify affectedon the same name refuses with "No unique node match". The interface's real fan-in (38 production files) is invisible from the stub.graphify explain "ChargeCustomerCancellationFee"returns an ambiguity list: bare idchargecustomercancellationfeevs the real class node.Root cause 1 (primary): PHP type declarations other than
classproduce no declaration node_PHP_CONFIG.class_types = frozenset({"class_declaration"})—graphify/extract.py:1065. tree-sitter-php emitsinterface_declaration,trait_declaration,enum_declaration(all with anamefield; enums useenum_declaration_listas body), but none are inclass_types, so no node is ever minted for a PHP interface, trait, or enum. Compare Java (extract.py:936) and Groovy (extract.py:952), which both includeinterface_declaration.In the pinned api corpus that is 142 interfaces, 30 traits, 119 enums (291 declarations) with no canonical node.
interface BalanceitemRepository(app/Repositories/Balanceitem/BalanceitemRepository.php:22) is one of them: the graph has its file node (labelBalanceitemRepository.php) containing 28 method nodes directly, but no interface node (app_repositories_balanceitem_balanceitemrepository_balanceitemrepositoryis absent).Everything below cascades from this: every resolution pass that could canonicalize edges has nothing to land on —
fqn_to_idin_resolve_php_type_references(graphify/extractors/resolution.py:2721-2732) skips file nodes (label contains.) and methods (label ends)), so the interface's FQN resolves to nothing._rewire_unique_stub_nodes(graphify/extract.py:2037) finds no sourced type-like node with the label, so stubs are never collapsed.Cascade A — the bare shadow stub (the
explainhijacker)_php_emit_base(graphify/extractors/engine.py:3056-3071) handlesimplements/extends/trait-useand mints a sourceless stub withoutorigin_filefor an unresolved base name._disambiguate_colliding_node_idsskips nodes with an empty source key (resolution.py:704-706), so this stub keeps the bare id (balanceitemrepository). Same-namespace implements (nousestatement, as inDbBalanceitemRepository implements BalanceitemRepository) is non-"explicit" in the repoint pass, so it is left "for the legacy rewire" (resolution.py:2789-2792,2803) — which finds no unique real target (RC1). The bare stub survives with exactly the implements edge.Cascade B — per-file stub fragmentation (
Foo::CONSTfan-in)ensure_named_node(engine.py:2856-2878) mints per-file sourceless stubs withorigin_filefor type annotations. When N files each mint the same bare id,_disambiguate_colliding_node_idssalts each into<referencing-file-path>_php_<name>(resolution.py:694-714).references_constantedges (engine.py:5311-5330) resolve their target through the file-local label map, andreferences_constantis not in_PHP_REPOINT_RELATIONS(resolution.py:2490), so those edges are never re-pointed: the constant fan-in fragments across per-file stubs. Pinned graph: 17 stubs*_php_balanceitemrepositoryholding 20references_constantedges.Cascade C — imports/param-types land on the file node only by id collision
For files that do write
use App\...\BalanceitemRepository, the repoint pass computes the FQN, missesfqn_to_id(RC1), and parks the edge on an "external" stub_make_id(fqn)(resolution.py:2798-2802, 2744-2760). Under PSR-4,_make_id("App\Repositories\Balanceitem\BalanceitemRepository")happens to equal the file node's id, so 69references+ 60importsedges land on the file node (degree 171) — the fan-in survives, but attached to a node whose label (BalanceitemRepository.php) no longer exact-matches the type name. When the file name differs from the type name (e.g. a trait inExtras.php), the same path yields a sourceless FQN-labeled stub instead (verified in the minimal repro below).Root cause 2 (independent): an
importsedge whose target never got a stub node is invisible to all resolution_import_php(extract.py:810-822) emits the edge with target_make_id(short_name)and creates no node. If the importing file never uses the name as a base/param type (e.g. onlyFoo::class), no stub node ever exists, so:stub_label.get(tgt)→continue,resolution.py:2770-2773),_rewire_unique_stub_nodesnever sees it (it iterates nodes, not edge endpoints),This hits classes too — the Q2 case:
CustomerScheduleControllerTest.phphasuse App\Actions\Schedules\Cancellation\ChargeCustomerCancellationFee;(L7) and uses it only as::class(L730, L764). The real class node exists; the import edge still dangles on barechargecustomercancellationfee. The::class/::CONSTreferences themselves are silently dropped (file-local label-map miss atengine.py:5315emits nothing).Pinned-graph scale: 2,123 dangling endpoints; 19,303
importsedges have no target node; 1,802 distinct dangling ids match a real in-corpus declared type's normalized name, carrying 11,456 edges of recoverable fan-in. 1,471 sourceless stub nodes absorb ~16.3k edges (imports 6,307, references 4,426, calls 1,703, inherits 1,539, references_constant 1,150, mixes_in 780, implements 383).Root cause 3 (lookup side): ambiguity detection groups rivals by
source_filefind_node_ambiguity(graphify/serve.py:1382-1389) returns rivals only when the winning tier spans >1 distinctsource_file. All sourceless stubs sharesource_file="", so Q4's 18 exact-tier stubs collapse into one group → no ambiguity reported →explainsilently answers withmatches[0](graph-iteration order) = the bare stub. Q2's exact tier contains one sourced node + one sourceless → 2 groups → ambiguity reported.affecteduses a different resolver (resolve_seed,graphify/affected.py:99-143) that requires a unique label match → refuses. That is the wholeexplain-vs-affectedinconsistency.Minimal repro (7 PHP files, AST-only, no API cost)
Corpus:
interface FooRepository { const BAR; find(); }+ same-namespaceclass DbFooRepository implements FooRepository+ two consumers (use+ ctor param +FooRepository::BAR) +trait Loggable/enum StatusinExtras.php+ a consumer +OnlyImport.php(use App\Repo\DbFooRepository;used only via::class). Rungraphify.extract.extract()on it. Observed (0.9.37):FooRepositoryinterface node; file node containsfind()directlyfoorepository← implements edgeapp_uses_consumer_php_foorepository,app_uses_consumer2_php_foorepository, each holding onereferences_constantedgeimports/references [parameter_type]land on the file nodeapp_repo_foorepositorymixes_in/importsland on sourceless FQN stubapp_repo_loggable(labelApp\Repo\Loggable)imports --> statusdangling (no node);Status::Activereference droppedOnlyImport:imports --> dbfoorepositorydangling even though the real class node exists in the corpusControl: change
interface FooRepositorytoclass FooRepository, re-extract → zero sourceless stubs; implements/imports/references/references_constant ALL canonicalize ontoapp_repo_foorepository_foorepository. The one missing declaration-node type is the load-bearing difference.Fix directions (not implemented)
interface_declaration,trait_declaration,enum_declarationto_PHP_CONFIG.class_types(extract.py:1065), mirroring Java/Groovy. Caveats: enum bodies areenum_declaration_list(checkbody_fallback_child_types); theresolution.py:2688raw-scan (class_declarationonly) should also learninterface_declarationso interface-extends-interface edges resolve; check knock-on id shifts for interface methods (currently file-scoped, would become interface-scoped) on incremental rebuilds._resolve_php_type_referencesshould resolveimportsedges from their owntarget_fqnmetadata (already stamped by_import_phpsince PHP use-import map 1/5: capture use FQN/alias metadata on imports edges #19) instead of requiring a stub node to read a label from.find_node_ambiguityshould not treat N sourceless rivals as one file; and/or exclude sourceless stubs from the exact tier when a sourced match exists.Fixing RC1 alone flips Q4 (canonical node exists; stubs rewire onto it; explain/affected agree); RC2 fixes Q2's disambiguation round-trip; RC3 removes the silent-wrong-answer failure mode generally.