Repository navigation
Cross-file type-annotation refs create phantom duplicate nodes (sourced stub blocks _rewire_unique_stub_nodes) #1402
Description
Summary
Cross-file type-annotation (and other bare-name) references create phantom duplicate nodes instead of resolving to the single canonical definition. A class defined once but referenced via type annotations in N other files appears as 1 + N nodes in the graph, inflating node counts and god-node/centrality rankings, and splitting edges across duplicates.
Observed on graphifyy==0.8.44 (AST/structural extraction, no LLM key set), Python, but the faulty code path is shared across all 6 language extractors.
Minimal reproduction
pkg/thing.py
class Thing:
def run(self):
return 1
pkg/a.py
from pkg.thing import Thing
def use_a(obj: Thing) -> Thing:
return obj
pkg/b.py
from pkg.thing import Thing
def use_b(obj: Thing) -> Thing:
return obj
from graphify.extract import extract
from pathlib import Path
r = extract([Path('pkg/thing.py'), Path('pkg/a.py'), Path('pkg/b.py')], cache_root=Path('.'))
print(sorted(n['id'] for n in r['nodes'] if n['label'] == 'Thing'))Actual (0.8.44):
['pkg_a_py_thing', 'pkg_b_py_thing', 'pkg_thing_thing'] # 3 nodes
Note pkg_a_py_thing / pkg_b_py_thing — the file path with extension baked into the id, the signature of the bug.
Expected:
['pkg_thing_thing'] # 1 canonical node; the annotation refs become edges to it
Root cause
ensure_named_node (defined identically as a nested closure in all 6 per-language extractors) builds the bare cross-file reference node via add_node, which always stamps source_file = str_path (the using file):
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line) # <-- stamps source_file = using file
return nidA sourced stub looks like a real definition, which breaks the two passes that are supposed to clean it up:
_disambiguate_colliding_node_idssees the same bare id (thing) emitted from two files with two differentsource_files, treats them as distinct same-named symbols, and scatters them into per-file ids_make_id(source_key, old_id)→pkg_a_py_thing,pkg_b_py_thing._rewire_unique_stub_nodes— which would merge a stub into the unique real definition by label — only considers no-source stubs (if node.get("source_file"): ... continue), so the now-sourced phantoms are never merged.
This contradicts the documented design intent in _resolve_cross_file_* (extract.py ~9038-9046): "Cross-file type references resolve by bare name and fall back to a no-source 'shadow' stub. _rewire_unique_stub_nodes repairs that." The fallback simply isn't sourceless.
Suggested fix
Make ensure_named_node's bare fallback emit a no-source shadow stub (matching the documented intent), so disambiguation leaves it alone (source_keys == {""}, len < 2) and _rewire_unique_stub_nodes merges it into the unique canonical def:
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
return nidApplied to all 6 closures, this fixes the repro (3 → 1 node). On a real 348-file Python project it dropped the graph from 3771 → 3574 nodes (−197 phantoms merged repo-wide) with no over-merge: multi-definition names (main(), __init__, etc.) stay distinct because _rewire only merges when exactly one real def exists. Orphan count stayed low (8) and top god-node rankings were unchanged except the previously-split classes consolidated to their correct single node.
Related (not duplicates)
- fix(extract): skip Python primitive/library type-annotation nodes #1332 (skip Python primitive/library annotation nodes) — suppresses a different class of annotation node (primitives/external types); this issue is about cross-file refs to types that do have a canonical def in the corpus.
- Node ID collisions when multiple files share the same stem (e.g. Program.cs) #438 (node id collisions for same-stem files) — different mechanism (file nodes), but same family of id-scheme issues.
Activity
Resolve cross-file type-annotation refs to a single node, not phantom…
safishamsi commentedon Jun 22, 2026
Fixed on
v8in 0aeda15 (ships next release).ensure_named_node's cross-file fallback minted a sourced stub (the referencing file assource_file), which_disambiguate_colliding_node_idsthen baked the file path — extension and all — into the id (pkg_a_py_thing), and_rewire_unique_stub_nodesskipped because a sourced node reads as a real definition. The fallback now emits a sourceless stub (like the inheritance-base path), so the annotation refs resolve to the single canonical node. Fixed across all six language extractors that share the helper. Verified: your exact repro now yields onepkg_thing_thing; a regression test asserts it and fails against the pre-fix code. Thanks for the precise root-cause writeup, @ZedUserdesign!Release 0.8.46
fix(extract): Go cross-file type refs emit sourceless stubs (#1500)
fix(extract): disambiguate imported type stubs across files without b…
fix(extract): add origin_file to cross-file stubs in the six dedicate…
fix(update): prune edges owned by a re-extracted file (#1521)
fix(sql): sourceless stubs + identifier normalization for cross-file …
Resolve cross-file type-annotation refs to a single node, not phantom…
Release 0.8.46
fix(extract): Go cross-file type refs emit sourceless stubs (Graphify…
fix(extract): disambiguate imported type stubs across files without b…
fix(extract): add origin_file to cross-file stubs in the six dedicate…
fix(update): prune edges owned by a re-extracted file (Graphify-Labs#…
fix(sql): sourceless stubs + identifier normalization for cross-file …
feat(ocaml): add OCaml .ml/.mli extractor (optional tree-sitter-ocaml…
fix(verilog): emit referenced modules and packages as stubs (Graphify…
fix(verilog): link a module instantiation to the local definition, no…