fix(extract): don't report deferred import() as a file cycle (#1241)

`_dynamic_import_js` emitted a deferred `import('./x')` as a plain
`imports_from` edge, so `find_import_cycles` counted it as a static import.
A file that statically imports another which dynamically imports it back was
reported as a phantom circular dependency.

Keep the edge as `imports_from` (the dependency stays visible in the graph)
but mark it `deferred`, and skip deferred edges in `find_import_cycles`.

Closes #1241
This commit is contained in:
Synvoya
2026-07-05 13:40:01 +10:00
committed by safishamsi
parent 5ffa921e27
commit 94392de640
3 changed files with 56 additions and 0 deletions
+5
View File
@@ -663,6 +663,11 @@ def find_import_cycles(
if rel not in ("imports_from", "re_exports"):
continue
# Deferred `import(...)` edges are real dependencies but do not form a
# hard file-level cycle, so they are excluded from cycle detection (#1241).
if data.get("deferred"):
continue
src_file_attr = data.get("source_file", "")
if not isinstance(src_file_attr, str) or not src_file_attr:
continue
+5
View File
@@ -1932,8 +1932,13 @@ def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edge
edges.append({
"source": caller_nid,
"target": tgt_nid,
# A deferred `import(...)` is a real dependency, so keep it as an
# `imports_from` edge (visible in the graph) but mark it `deferred`
# so find_import_cycles does not treat it as a static import and
# report a phantom file cycle (#1241).
"relation": "imports_from",
"context": "import",
"deferred": True,
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
+46
View File
@@ -404,6 +404,52 @@ def test_svelte_rune_import_resolves_svelte_ts_file(tmp_path: Path):
assert _has_edge(result, "src/routes/page.ts", "src/lib/hooks/is-mobile.svelte.ts")
def test_ts_dynamic_import_does_not_create_phantom_cycle(tmp_path: Path):
# A deferred `import('./x')` is not a static import: it must be emitted as a
# `dynamic_import` edge (like the Svelte/Astro/Vue emitters), not
# `imports_from`. Otherwise two files that reference each other via one static
# import + one dynamic import are reported as a phantom circular dependency.
# Regression test for #1241.
import networkx as nx
from graphify.analyze import find_import_cycles
actions = _write(
tmp_path / "actions.ts",
'export function doThing() {}\n'
'export async function lazy() {\n'
' const m = await import("./modal");\n'
' return m.openModal();\n'
'}\n',
)
modal = _write(
tmp_path / "modal.ts",
'import { doThing } from "./actions";\n'
'export function openModal() { doThing(); }\n',
)
result = _extract_for([actions, modal], tmp_path)
# The deferred import() edge stays in the graph as an `imports_from` edge
# marked `deferred` (the dependency remains visible); the real static import
# (modal.ts -> actions.ts) is unaffected.
deferred = [edge for edge in result["edges"] if edge.get("deferred")]
assert deferred and all(edge["relation"] == "imports_from" for edge in deferred)
assert _has_edge(result, "modal.ts", "actions.ts", "imports_from")
# End to end: the deferred import must not manufacture a file cycle.
graph = nx.DiGraph()
for node in result["nodes"]:
graph.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
for edge in result["edges"]:
graph.add_edge(
edge["source"],
edge["target"],
**{k: v for k, v in edge.items() if k not in ("source", "target")},
)
assert find_import_cycles(graph) == []
def test_tsconfig_alias_import_resolves_existing_ts_file(tmp_path: Path):
_write(
tmp_path / "tsconfig.json",