Summary
Unresolved import/reference targets can bind across a language boundary to any corpus node that happens to share their name. On a polyglot repo (Python backend + TypeScript frontend) this welds the two halves together at phantom edges and corrupts every path-based metric across the seam.
In my repo, three occurrences of Python's stdlib import time bound to src/lib/time.ts. Those 3 edges were the only thing connecting 2409 Python nodes to 1403 TS nodes, so every backend↔frontend shortest path routed through time.ts — inflating its betweenness centrality ~90x and making it the reported #1 "god node" bridge.
The extraction spec already forbids this for calls edges:
calls edges MUST stay within one language: a Python function cannot calls a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
The same rule is not enforced for imports and references.
Minimal reproduction
Three files, 7 nodes:
mkdir -p repro/backend repro/src && cd repro
cat > backend/worker.py <<'EOF'
import time
STATUS_LABELS = {"open": "Open", "done": "Done"}
def render_row(status: str) -> str:
time.sleep(0)
return STATUS_LABELS.get(status, status)
EOF
cat > src/time.ts <<'EOF'
export function formatTime(d: Date): string {
return d.toISOString();
}
EOF
cat > src/Grid.tsx <<'EOF'
const STATUS_LABELS: Record<string, string> = { open: "Open", done: "Done" };
export function Grid({ status }: { status: string }) {
return <span>{STATUS_LABELS[status]}</span>;
}
EOF
python -c "
from graphify.extract import collect_files, extract
from graphify.build import build_from_json
from pathlib import Path
r = extract(collect_files(Path('.')), cache_root=Path('.'))
print('raw imports edge target:', [e['target'] for e in r['edges'] if e['relation']=='imports'])
for e in r['edges']:
if e['relation'] == 'references':
print('raw references edge:', e['source'], '->', e['target'])
G = build_from_json(r, root='.', directed=False)
for u, v, d in G.edges(data=True):
print(f'built: {u} --{d[\"relation\"]}--> {v}')
"
Actual
raw imports edge target: ['time'] # correctly dangling at extract time
raw references edge: backend_worker_render_row -> src_grid_status_labels # (A) WRONG
built: backend_worker --contains--> backend_worker_render_row
built: backend_worker --imports--> src_time # (B) WRONG
built: backend_worker_render_row --references--> src_grid_status_labels # (A) WRONG
built: src_grid --contains--> src_grid_grid
built: src_grid --contains--> src_grid_status_labels
built: src_time --contains--> src_time_formattime
Note the two failures arise at different stages: (A) is already wrong in extract()'s raw output, while (B) is correctly dangling (target: 'time') after extract() and only goes wrong inside build_from_json().
Expected
import time is the Python standard library and worker.py's STATUS_LABELS is a module-local constant. Neither has anything to do with src/. Both edges should dangle (or be dropped), exactly as import sqlalchemy / import typing / import datetime already do.
Two distinct root causes, same flawed heuristic
Both sites decide "is this binding safe?" by asking "is there exactly one candidate in the corpus?" — never "is the candidate even in the same language?"
(A) extract.py — _rewire_unique_stub_nodes (~L1806)
real_by_label: dict[str, list[dict]] = {} # exact-case (all languages)
...
candidates = real_by_label.get(_node_label_key(stub), [])
if len(candidates) != 1:
...
target_id = candidates[0].get("id")
The Python extractor emits zero module-level constant nodes (STATUS_LABELS in worker.py never becomes a node), but still emits a references edge for it. That leaves an unresolved stub, and the stub rewires to the one real STATUS_LABELS definition in the corpus — the .tsx one.
Side note: the missing-Python-constant-node asymmetry is itself surprising. On my repo the TS extractor produced 81 UPPER_CASE constant nodes and the Python extractor produced 0. That asymmetry is what creates the unresolved stub in the first place.
(B) build.py — pre-migration alias index (~L505, #1504)
The comment here shows the hazard was already anticipated:
# ... Collecting
# every candidate for an alias BEFORE committing any of them — and only
# committing when exactly one candidate claims it — keeps this a precise
# re-keying aid instead of a silent cross-file (and cross-language) merge.
But the uniqueness guard only protects against ambiguity between corpus files. src/time.ts registers the zero-parent alias time via _old_file_stems; exactly one node claims it, so it commits to norm_to_id. The dangling time target from Python's import time then resolves straight onto it.
Unambiguous ≠ correct. The true owner of the name time (the stdlib module) is not in the corpus at all, so it can never enter the candidate race. Uniqueness is the wrong safety property when the correct answer is "none of the above."
Suggested fix
Gate both bindings on language/extension compatibility, not just candidate uniqueness:
- In
_rewire_unique_stub_nodes, require the stub's originating source_file and the candidate's source_file to belong to the same language family before remapping.
- In the
build.py alias index, don't let a dangling endpoint resolve through an alias whose only claimant is in a different language family. (Or: never resolve a bare, extension-less import target onto a file node from a different language.)
A cheaper, narrower mitigation for (B) alone: skip alias resolution entirely for imports edges whose target matches a known stdlib/builtin module name for the source file's language.
Happy to send a PR if you'd like a particular shape.
Impact
On a 4,668-node / 11,699-edge polyglot graph this produced only 4 bad edges — but they were 100% of the cross-language connectivity, and removing them changes the graph's headline conclusions:
| metric |
with phantom edges |
without |
time.ts betweenness |
0.2534 (rank #1) |
0.0028 (rank #43) |
| py↔ts connected components |
1 (merged) |
0 mixed (correctly separate) |
Top-betweenness leaderboard before → after:
1. time.ts 0.2534 1. routes.py 0.0974
2. sync.py 0.2073 -> 2. models.py 0.0639
3. routes.py 0.1835 3. extensions.py 0.0569
Anything consuming the graph for GraphRAG — graphify query, path, explain — will confidently traverse a backend↔frontend coupling that does not exist in the code. The two halves of the app communicate over HTTP, never by import.
Environment
graphifyy 0.9.11 (uv tool install)
- Python 3.14
- macOS (darwin 25.5.0)
- Corpus: 671 files, ~618k words, Python/Flask backend + React/TS frontend
Summary
Unresolved import/reference targets can bind across a language boundary to any corpus node that happens to share their name. On a polyglot repo (Python backend + TypeScript frontend) this welds the two halves together at phantom edges and corrupts every path-based metric across the seam.
In my repo, three occurrences of Python's stdlib
import timebound tosrc/lib/time.ts. Those 3 edges were the only thing connecting 2409 Python nodes to 1403 TS nodes, so every backend↔frontend shortest path routed throughtime.ts— inflating its betweenness centrality ~90x and making it the reported #1 "god node" bridge.The extraction spec already forbids this for
callsedges:The same rule is not enforced for
importsandreferences.Minimal reproduction
Three files, 7 nodes:
Actual
Note the two failures arise at different stages: (A) is already wrong in
extract()'s raw output, while (B) is correctly dangling (target: 'time') afterextract()and only goes wrong insidebuild_from_json().Expected
import timeis the Python standard library andworker.py'sSTATUS_LABELSis a module-local constant. Neither has anything to do withsrc/. Both edges should dangle (or be dropped), exactly asimport sqlalchemy/import typing/import datetimealready do.Two distinct root causes, same flawed heuristic
Both sites decide "is this binding safe?" by asking "is there exactly one candidate in the corpus?" — never "is the candidate even in the same language?"
(A)
extract.py—_rewire_unique_stub_nodes(~L1806)The Python extractor emits zero module-level constant nodes (
STATUS_LABELSinworker.pynever becomes a node), but still emits areferencesedge for it. That leaves an unresolved stub, and the stub rewires to the one realSTATUS_LABELSdefinition in the corpus — the.tsxone.Side note: the missing-Python-constant-node asymmetry is itself surprising. On my repo the TS extractor produced 81
UPPER_CASEconstant nodes and the Python extractor produced 0. That asymmetry is what creates the unresolved stub in the first place.(B)
build.py— pre-migration alias index (~L505, #1504)The comment here shows the hazard was already anticipated:
But the uniqueness guard only protects against ambiguity between corpus files.
src/time.tsregisters the zero-parent aliastimevia_old_file_stems; exactly one node claims it, so it commits tonorm_to_id. The danglingtimetarget from Python'simport timethen resolves straight onto it.Unambiguous ≠ correct. The true owner of the name
time(the stdlib module) is not in the corpus at all, so it can never enter the candidate race. Uniqueness is the wrong safety property when the correct answer is "none of the above."Suggested fix
Gate both bindings on language/extension compatibility, not just candidate uniqueness:
_rewire_unique_stub_nodes, require the stub's originatingsource_fileand the candidate'ssource_fileto belong to the same language family before remapping.build.pyalias index, don't let a dangling endpoint resolve through an alias whose only claimant is in a different language family. (Or: never resolve a bare, extension-less import target onto a file node from a different language.)A cheaper, narrower mitigation for (B) alone: skip alias resolution entirely for
importsedges whose target matches a known stdlib/builtin module name for the source file's language.Happy to send a PR if you'd like a particular shape.
Impact
On a 4,668-node / 11,699-edge polyglot graph this produced only 4 bad edges — but they were 100% of the cross-language connectivity, and removing them changes the graph's headline conclusions:
time.tsbetweennessTop-betweenness leaderboard before → after:
Anything consuming the graph for GraphRAG —
graphify query,path,explain— will confidently traverse a backend↔frontend coupling that does not exist in the code. The two halves of the app communicate over HTTP, never by import.Environment
graphifyy0.9.11 (uv tool install)