Problem or use case
import() with a substituted template literal is skipped on purpose since 563ee80 ("skip dynamic template literals in import() args"), which removed a garbage edge to a path containing the literal ${name}. The side effect is that a module loaded only this way gets no inbound edge at all, so it reads as unreachable/dead code, and affected and reachability questions miss it.
Real case (NestJS service, TypeScript monorepo):
// apps/backend/src/email/email.service.ts
const templateModule = await import(`./templates/${lang}/${templateName}.template`);
templates/<lang>/<name>.template.ts holds 61 modules (10 languages). They are really used: our staging logs show emails rendered from them. Yet in the graph none had an edge from the service that loads them, and 59 of the 61 had no inbound edge at all (the other 2 are imported by a spec). Static dead-code tools share the blind spot (knip's docs recommend declaring such files as entry), so two tools agreeing is not evidence here.
Proposed solution
When the template literal has substitutions, treat it as a glob instead of dropping it, fail-closed:
- only relative specifiers (
./, ../);
- each
${...} becomes *, i.e. exactly one path segment (no **); static text is glob-escaped;
- the last segment must keep some static text:
`./handlers/${name}` still produces no edge, so test_ts_dynamic_template_literal_skipped keeps passing unchanged;
- extensions follow the static-import rules (runtime suffix substituted like TypeScript, otherwise source extensions appended);
- above a cap (128 matches) the pattern is considered too broad and links nothing.
Each match gets an imports_from edge with deferred: true, confidence: "INFERRED", confidence_score: 0.85 and a dynamic_pattern attribute holding the readable pattern (./templates/*/*.template). The runtime loads one module of the set, so these stay visibly inferred, never EXTRACTED.
I have this implemented on top of v0.9.79 (_link_dynamic_import_pattern in extractors/engine.py, _expand_js_dynamic_import_pattern in extractors/resolution.py) with 5 tests: all matches linked, edges INFERRED with the pattern, ./${x} links nothing, non-relative links nothing, over-cap links nothing. Full suite: no new failure compared to plain v0.9.79 in my environment (the same 68 failures, all from missing optional extras). On the repo above it adds 61 edges, all from that one call site; no other pattern in the repo matched.
Alternatives considered
- Keep skipping and rely on per-project configuration (what knip does with
entry): pushes the knowledge to every user, and the graph stays wrong by default.
- Emit EXTRACTED edges: rejected, the runtime picks one module, so it would overstate certainty.
- Link only the first match or the folder node: loses which modules are candidates.
Area
Extraction or language support
Are you willing to submit a PR?
Yes, I can work on this
Compatibility / behavior impact
- Node/edge IDs: unchanged; target IDs are the same
_make_id of the resolved file as static imports.
- graph.json schema: new optional edge attribute
dynamic_pattern; edges are ordinary imports_from + deferred.
- Cache: JS/TS files bypass the AST cache, so no cache semantics change.
- Incremental updates: the edges are owned by the importer file. With a changed-files rebuild (hook / watch), a new module added to the folder is linked only once the importer is re-extracted; a full
graphify update picks it up. Happy to discuss whether that is acceptable or needs handling.
Additional context
Related but distinct: #2575 / #2584 / #3210 (plain-string dynamic imports, fixed). Version 0.9.79, macOS, Python 3.14.
Problem or use case
import()with a substituted template literal is skipped on purpose since 563ee80 ("skip dynamic template literals in import() args"), which removed a garbage edge to a path containing the literal${name}. The side effect is that a module loaded only this way gets no inbound edge at all, so it reads as unreachable/dead code, andaffectedand reachability questions miss it.Real case (NestJS service, TypeScript monorepo):
templates/<lang>/<name>.template.tsholds 61 modules (10 languages). They are really used: our staging logs show emails rendered from them. Yet in the graph none had an edge from the service that loads them, and 59 of the 61 had no inbound edge at all (the other 2 are imported by a spec). Static dead-code tools share the blind spot (knip's docs recommend declaring such files asentry), so two tools agreeing is not evidence here.Proposed solution
When the template literal has substitutions, treat it as a glob instead of dropping it, fail-closed:
./,../);${...}becomes*, i.e. exactly one path segment (no**); static text is glob-escaped;`./handlers/${name}`still produces no edge, sotest_ts_dynamic_template_literal_skippedkeeps passing unchanged;Each match gets an
imports_fromedge withdeferred: true,confidence: "INFERRED",confidence_score: 0.85and adynamic_patternattribute holding the readable pattern (./templates/*/*.template). The runtime loads one module of the set, so these stay visibly inferred, never EXTRACTED.I have this implemented on top of v0.9.79 (
_link_dynamic_import_patterninextractors/engine.py,_expand_js_dynamic_import_patterninextractors/resolution.py) with 5 tests: all matches linked, edges INFERRED with the pattern,./${x}links nothing, non-relative links nothing, over-cap links nothing. Full suite: no new failure compared to plain v0.9.79 in my environment (the same 68 failures, all from missing optional extras). On the repo above it adds 61 edges, all from that one call site; no other pattern in the repo matched.Alternatives considered
entry): pushes the knowledge to every user, and the graph stays wrong by default.Area
Extraction or language support
Are you willing to submit a PR?
Yes, I can work on this
Compatibility / behavior impact
_make_idof the resolved file as static imports.dynamic_pattern; edges are ordinaryimports_from+deferred.graphify updatepicks it up. Happy to discuss whether that is acceptable or needs handling.Additional context
Related but distinct: #2575 / #2584 / #3210 (plain-string dynamic imports, fixed). Version 0.9.79, macOS, Python 3.14.