Skip to content

Cross-language phantom edges: unresolved imports/references bind by name across language boundaries #1749

Description

@philberndt

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

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