Files
graphify/tests/test_phantom_external_import.py
T
ayushcodes10 23d8a8798d Add an end to end regression test for the reporter's exact repro
A file that only imports a package subpath must resolve just like one
that imports the bare package, using the exact package.json plus two
file shape from the issue, instead of coming out fully disconnected
from the framework it depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 5fb7fc34c1)
2026-09-17 11:01:38 +01:00

190 lines
7.2 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")
# ── #3595: a package subpath import must resolve to the package root, not the ─
# whole specifier, so it lands on the same node as a bare import of that
# package (and the package.json dependency node itself) instead of dangling.
def test_package_subpath_import_resolves_to_the_same_target_as_a_bare_import():
bare_tgt, _ = _resolve_js_import_target("next", "src/bare.ts")
subpath_tgt, resolved_path = _resolve_js_import_target("next/image", "src/subpath.ts")
assert resolved_path is None
assert subpath_tgt == bare_tgt
def test_scoped_package_subpath_import_resolves_to_the_scope_and_package_only():
bare_tgt, _ = _resolve_js_import_target("@scope/pkg", "src/bare.ts")
subpath_tgt, resolved_path = _resolve_js_import_target("@scope/pkg/deep/sub", "src/subpath.ts")
assert resolved_path is None
assert subpath_tgt == bare_tgt
# Must not fold onto a DIFFERENT package under the same scope.
other_tgt, _ = _resolve_js_import_target("@scope/other", "src/other.ts")
assert subpath_tgt != other_tgt
# ── 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}"
def test_subpath_import_does_not_dangle_like_a_bare_import(tmp_path: Path):
"""The reporter's exact repro for #3595: a package subpath import
("next/image") produced no edge at all, while a bare import of the same
package ("next") resolved fine, so a file that only ever imports
subpaths came out fully disconnected from the framework it depends on."""
_write(
tmp_path / "package.json",
'{"name": "mre", "dependencies": {"next": "16.2.7"}}\n',
)
bare = _write(
tmp_path / "bare.ts",
'import type { Metadata } from "next";\n'
"export const meta: Metadata = {};\n",
)
subpath = _write(
tmp_path / "subpath.ts",
'import Image from "next/image";\n'
"export const i = Image;\n",
)
result = extract(
[tmp_path / "package.json", bare, subpath], cache_root=tmp_path / "graphify-out"
)
G = build_from_json(result, root=str(tmp_path))
def _imports_from(nid):
# The built graph may be undirected, which loses (u, v) edge-iteration
# order -- direction is preserved separately as _src/_tgt (build.py).
return {
d.get("_tgt") for u, v, d in G.edges(data=True)
if d.get("relation") == "imports_from" and d.get("_src") == nid
}
bare_id = next(
n for n, d in G.nodes(data=True)
if str(d.get("source_file", "")).endswith("bare.ts")
)
subpath_id = next(
n for n, d in G.nodes(data=True)
if str(d.get("source_file", "")).endswith("subpath.ts")
)
bare_targets = _imports_from(bare_id)
subpath_targets = _imports_from(subpath_id)
assert bare_targets, "the bare import must resolve"
assert subpath_targets, "the subpath import must resolve, not dangle"
assert subpath_targets == bare_targets, (
"a subpath import of a package must land on the same node as a bare "
"import of that package"
)