Skip to content

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

Description

@filipechagas

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 with origin_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 --> status dangling (no node); Status::Active reference dropped
  • OnlyImport: imports --> dbfoorepository dangling 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)

  1. 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.
  2. RC2: _resolve_php_type_references should resolve imports edges from their own target_fqn metadata (already stamped by _import_php since 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.
  3. 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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions