Files
graphify/tests/test_phantom_external_import.py
T
safishamsi e2ef4ef3d1 fix: harden semantic extraction and kill phantom import edges (#1631, #1638, #1632)
#1631: a malformed LLM chunk (a stray non-dict entry in edges/nodes/hyperedges)
crashed the AST+semantic merge and the semantic-cache write with
`AttributeError: 'list' object has no attribute 'get'`, discarding every
successful chunk and writing no graph.json. `_parse_llm_json` now sanitizes each
fragment at the single parse chokepoint (dict entries only; non-list values
coerced to []), protecting the cache writer, the adaptive-retry merge, and the
CLI merge in one place.

#1638: an unresolved bare npm import (`import colors from "tailwindcss/colors"`)
emitted an imports_from edge to the bare id `colors`, which build.py's
pre-migration alias index then remapped onto an unrelated local file of that
stem (backend/utils/colors.py) - a confident EXTRACTED cross-language phantom
edge, one per importing file. The external-import fallback now namespaces its
target with the `ref` prefix (the J-4 convention), so it can never collapse to a
local node id; the ref target has no node, so build drops it as an external
reference.

#1632: with a parallel LLM backend, extract_corpus_parallel merged chunk results
in completion order, so which network call returned first reordered nodes/edges
run-to-run even when the model returned identical content - churning graph.json.
Chunks are now merged in deterministic submission order after the pool drains
(matching the serial path); the progress callback still fires in completion
order. The model's own content variance is unchanged (irreducible).

Full suite: 2882 passed, 3 skipped. Validated end-to-end via a local wheel build
on a mixed TS+Python corpus: `explain colors.py` shows only the real importer,
and graph.json is byte-identical across repeated runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:12:23 +01:00

117 lines
4.3 KiB
Python

"""#1638 — an unresolved bare npm import must not alias onto an unrelated
same-named local file, producing a confident cross-language phantom edge.
`import colors from "tailwindcss/colors"` in a .tsx file used to emit an
`imports_from` edge to the bare id ``colors``. build.py's pre-migration alias
index registers every local file's bare stem (``backend/utils/colors.py`` ->
alias ``colors``), so the dangling ``colors`` target was remapped onto the
Python file — an EXTRACTED-confidence edge between two files in different
languages with no real relationship.
The fix namespaces the external-import fallback id with the ``ref`` prefix (the
J-4 convention), so it can never collide with a local file/symbol node id.
"""
from __future__ import annotations
from pathlib import Path
from graphify.build import build_from_json
from graphify.extract import _make_id, _resolve_js_import_target, extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
# ── unit: the resolver never returns a bare local-shaped id for an external ──
def test_unresolved_bare_import_is_ref_namespaced():
tgt, resolved_path = _resolve_js_import_target(
"tailwindcss/colors", "frontend/src/SomeChart.tsx"
)
assert resolved_path is None
# Must not be the bare last-segment id that collides with a local `colors` file.
assert tgt != _make_id("colors")
assert tgt != _make_id("colors.py")
assert tgt.startswith("ref")
def test_scoped_package_import_is_ref_namespaced():
tgt, resolved_path = _resolve_js_import_target(
"@scope/utils", "src/thing.ts"
)
assert resolved_path is None
assert tgt != _make_id("utils")
assert tgt.startswith("ref")
# ── end-to-end: the reporter's exact synthetic monorepo ─────────────────────
def test_no_phantom_edge_from_tsx_to_unrelated_python_file(tmp_path: Path):
py = _write(
tmp_path / "backend/utils/colors.py",
"def hex_to_rgb(value):\n return (0, 0, 0)\n",
)
tsx = _write(
tmp_path / "frontend/src/SomeChart.tsx",
'import colors from "tailwindcss/colors";\n\n'
"export const CHART_COLOR = colors.blue[500];\n",
)
result = extract([py, tsx], cache_root=tmp_path / "graphify-out")
G = build_from_json(result, root=str(tmp_path))
# Find the python file node.
py_ids = [
n for n, d in G.nodes(data=True)
if str(d.get("source_file", "")).endswith("colors.py")
]
assert py_ids, "colors.py should have produced at least one node"
# No edge from the TSX file (or any TS symbol) should land on the python file
# as an imports_from relationship.
for u, v, d in G.edges(data=True):
if d.get("relation") != "imports_from":
continue
endpoints = {u, v}
if endpoints & set(py_ids):
other = (endpoints - set(py_ids)) or endpoints
srcfiles = {str(G.nodes[e].get("source_file", "")) for e in other}
assert not any(sf.endswith((".tsx", ".ts")) for sf in srcfiles), (
f"phantom cross-language imports_from edge onto colors.py: "
f"{u} -> {v} ({d})"
)
def test_multiple_tsx_files_do_not_all_alias_onto_one_python_file(tmp_path: Path):
# The real-world symptom: N unrelated .tsx files all doing the same bare
# import showed up as N imports_from sources on one python module.
_write(
tmp_path / "backend/utils/colors.py",
"def hex_to_rgb(value):\n return (0, 0, 0)\n",
)
for i in range(3):
_write(
tmp_path / f"frontend/src/Chart{i}.tsx",
'import colors from "tailwindcss/colors";\n'
f"export const C{i} = colors.blue;\n",
)
paths = list((tmp_path).rglob("*.py")) + list((tmp_path / "frontend").rglob("*.tsx"))
result = extract(paths, cache_root=tmp_path / "graphify-out")
G = build_from_json(result, root=str(tmp_path))
py_ids = {
n for n, d in G.nodes(data=True)
if str(d.get("source_file", "")).endswith("colors.py")
}
phantom = [
(u, v) for u, v, d in G.edges(data=True)
if d.get("relation") == "imports_from" and ({u, v} & py_ids)
]
assert not phantom, f"phantom edges onto colors.py: {phantom}"