Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,11 @@ def _endpoint_source_file(node_id: str) -> str:
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
Expand Down
5 changes: 5 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1922,8 +1922,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}",
Expand Down
46 changes: 46 additions & 0 deletions tests/test_js_import_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading