Skip to content

JS/TS: affected cannot traverse a dynamic import('…') made inside a function — edge exists, blast radius is short by 20% #2584

Description

@phudayyy

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions