Skip to content

[Feature]: link a substituted template-literal import() to the modules its pattern can load (INFERRED) instead of dropping it #4223

Description

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.

Activity

  1. github-actions commented on Oct 8, 2026

    @github-actions

    Thanks for opening this issue, @florian-trehaut-hillcode. A maintainer will take a look soon.

    If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.

    To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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