Summary
graphify affected under-reports blast radius when a dynamic import('…') sits inside a
function. The edge is present in graph.json — this is not the missing-edge bug from #2575,
which 0.9.38 fixed — but reverse traversal dead-ends on it, because a dynamic edge originates
from the enclosing symbol while a static edge originates from the file.
Measured on a real repo (~700 JS/TS files), affected --depth 3 returns 39 of 49 truly
affected files for one hub module and 24 of 30 for another: recall 0.80. Precision stays
1.00 — nothing is invented, files are simply unreachable. Raising --depth to 4, 5 or 6 does not
help, which is the signature of a dead end rather than a depth limit.
Root cause
Two lines in graphify/extractors/engine.py, both emitting relation: "imports_from":
# L1736 — static import
"source": file_nid, # file → file
# L1289 — dynamic import, _dynamic_import_js()
"source": caller_nid, # ENCLOSING SYMBOL → file
caller_nid is the file node only when the import() is at module level. Inside a function it is
that function's node, so the graph gets load() --imports_from--> target.ts and no file-level
edge dyn.ts → target.ts. Reverse traversal from target.ts therefore lands on load() and stops:
the only edge pointing at load() is dyn.ts --contains--> load(), and contains is not in
DEFAULT_AFFECTED_RELATIONS. Anything reachable only through that file is lost.
It escapes notice because it is invisible in the common case: if the next importer happens to import
that exact symbol by name, there is an edge into load() and traversal continues.
Minimal reproduction
Four files. mid.ts is byte-identical in both runs; only the position of the dynamic import
changes.
// src/target.ts
export const value = 1;
// src/dyn.ts ← the only file that differs between the two runs
export async function load() { // variant A: inside a function
const m = await import('./target');
return m.value;
}
// variant B: export const p = import('./target'); // module level
// src/mid.ts
import './dyn';
export const run = () => 1;
// src/top.ts
import { run } from './mid';
export const go = () => run();
$ graphify update . && graphify affected src/target.ts --depth 3
import('./target') is… |
top.ts in the result |
| at module level |
✅ yes |
| inside a function |
❌ no |
A second, independent axis reproduces it with the import fixed inside a function and only mid.ts
changing — traversal survives only when the importer names the enclosing symbol:
mid.ts imports… |
top.ts reached |
import { load } from './dyn' (the enclosing symbol) |
✅ yes |
import { other } from './dyn' (a different symbol) |
❌ no |
import * as ns from './dyn' |
❌ no |
import './dyn' (side-effect) |
❌ no |
Environment: graphify 0.9.38 (PyPI), macOS 15 / arm64, Python 3.13, TypeScript sources.
Suggested fix
Emit the dynamic edge at file granularity as well, so blast radius matches static imports:
# in _dynamic_import_js(), alongside the caller_nid edge
edges.append({**edge, "source": file_nid})
Keeping the symbol-level edge preserves the precision that makes graphify explain useful — the
call site is still recorded — while the file-level edge restores traversal. The deferred: True
flag added for #1241 should be kept on both so find_import_cycles still ignores them and no
phantom file cycle reappears.
Adding contains to DEFAULT_AFFECTED_RELATIONS would also reconnect it, but far more bluntly:
it would let every traversal climb from any symbol to its whole file, which would cost precision
everywhere to fix one relation.
Happy to send a PR with the fix plus a test extending tests/test_js_dynamic_imports.py — the
existing test asserts the edge exists, so an assertion that affected actually traverses it
would have caught this.
Summary
graphify affectedunder-reports blast radius when a dynamicimport('…')sits inside afunction. The edge is present in
graph.json— this is not the missing-edge bug from #2575,which 0.9.38 fixed — but reverse traversal dead-ends on it, because a dynamic edge originates
from the enclosing symbol while a static edge originates from the file.
Measured on a real repo (~700 JS/TS files),
affected --depth 3returns 39 of 49 trulyaffected files for one hub module and 24 of 30 for another: recall 0.80. Precision stays
1.00 — nothing is invented, files are simply unreachable. Raising
--depthto 4, 5 or 6 does nothelp, which is the signature of a dead end rather than a depth limit.
Root cause
Two lines in
graphify/extractors/engine.py, both emittingrelation: "imports_from":caller_nidis the file node only when theimport()is at module level. Inside a function it isthat function's node, so the graph gets
load() --imports_from--> target.tsand no file-leveledge
dyn.ts → target.ts. Reverse traversal fromtarget.tstherefore lands onload()and stops:the only edge pointing at
load()isdyn.ts --contains--> load(), andcontainsis not inDEFAULT_AFFECTED_RELATIONS. Anything reachable only through that file is lost.It escapes notice because it is invisible in the common case: if the next importer happens to import
that exact symbol by name, there is an edge into
load()and traversal continues.Minimal reproduction
Four files.
mid.tsis byte-identical in both runs; only the position of the dynamic importchanges.
$ graphify update . && graphify affected src/target.ts --depth 3import('./target')is…top.tsin the resultA second, independent axis reproduces it with the import fixed inside a function and only
mid.tschanging — traversal survives only when the importer names the enclosing symbol:
mid.tsimports…top.tsreachedimport { load } from './dyn'(the enclosing symbol)import { other } from './dyn'(a different symbol)import * as ns from './dyn'import './dyn'(side-effect)Environment: graphify 0.9.38 (PyPI), macOS 15 / arm64, Python 3.13, TypeScript sources.
Suggested fix
Emit the dynamic edge at file granularity as well, so blast radius matches static imports:
Keeping the symbol-level edge preserves the precision that makes
graphify explainuseful — thecall site is still recorded — while the file-level edge restores traversal. The
deferred: Trueflag added for #1241 should be kept on both so
find_import_cyclesstill ignores them and nophantom file cycle reappears.
Adding
containstoDEFAULT_AFFECTED_RELATIONSwould also reconnect it, but far more bluntly:it would let every traversal climb from any symbol to its whole file, which would cost precision
everywhere to fix one relation.
Happy to send a PR with the fix plus a test extending
tests/test_js_dynamic_imports.py— theexisting test asserts the edge exists, so an assertion that
affectedactually traverses itwould have caught this.