Skip to content

Extract dynamic TypeScript import specifiers - #16

Merged
jmcentire merged 2 commits into
wandercom:mainfrom
MrJoy:fix/ts-dynamic-imports
Aug 21, 2026
Merged

Extract dynamic TypeScript import specifiers#16
jmcentire merged 2 commits into
wandercom:mainfrom
MrJoy:fix/ts-dynamic-imports

Conversation

@MrJoy

@MrJoy MrJoy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Defect

_extract_ts_imports runs only _TS_IMPORT_FROM_RE, which requires the from keyword. Dynamic imports don't have one, so a module reached only through import(...) produces no edge at all.

Repro against main:

from pact.codebase_analyzer import _extract_ts_imports

src = '''import { Effect } from "effect"

export async function boot() {
  const { filteredLogger } = await import("./middleware.ts")
  return filteredLogger
}
'''
print(_extract_ts_imports(src))
# main:  ['effect']
# fixed: ['effect', './middleware.ts']

_extract_ts_imports feeds both SourceFile.imports and TestFile.imported_modules, so the miss surfaces as a coverage lie rather than as an obviously absent edge. Found on a real Deno service: src/rpc/middleware.ts there is imported only dynamically —

  • src/testing/kit/e2e.ts:610const { filteredLogger } = await import("../../rpc/middleware.ts");
  • src/testing/__tests__/internal-booking.e2e.test.ts:54 — same call

— and map_test_coverage reported it as covered by no test while an e2e test does load it. With the fix applied, both files yield the specifier (e2e.ts twice, since it has the call at lines 610 and 930).

Fix

A second pattern, _TS_DYNAMIC_IMPORT_RE, matching import( plus a quoted specifier:

_TS_DYNAMIC_IMPORT_RE = re.compile(
    r"""(?<![\w$.])import\s*\(\s*['"]([^'"]+)['"]""",
)

The lookbehind on word characters, $, and . keeps notimport(...) and loader.import(...) out; the word boundary after the keyword keeps importAll(...) out.

_extract_ts_imports merges the two match sets and sorts by source offset, so a file's specifiers come back in the order they appear regardless of form. That is a deliberate choice over appending dynamic imports at the end — the return value is read as "this file's dependencies", and interleaving keeps it in step with the file. Duplicates are still preserved, matching the existing behavior when a module is imported twice.

Deliberately not handled

Non-statically-analyzable specifiers. import(./${name}.ts) and import(someVar) are skipped. Resolving them needs the expression evaluated; emitting the raw text would put a module path in the graph that resolves to nothing, which is worse than the absent edge because it is silently wrong rather than merely missing. Two tests pin the skip, including one proving that a computed specifier does not hide a real dynamic import on the next line.

Also untouched, both out of scope: bare side-effect imports (import "./x.ts", no from, no parens) are still missed on main and still missed here; import.meta and import attributes (with { type: "json" }) are unaffected since the pattern stops at the closing quote.

Tests

14 tests in TestTypeScriptImportExtraction in tests/test_typescript_support.py, written before the fix.

8 failed before, pass after — bare import("./x.ts"), await import(...), destructured assignment from await import(...), single-quoted specifier, dynamic import inside a function body, a dynamic import call split across lines, mixed static/dynamic returned in source order, and a computed specifier not hiding the real dynamic import after it.

6 are guards that already passed on main and must keep passing — static named import unchanged, static default + namespace imports unchanged, computed specifier yields nothing, and three negative cases: notimport(...), loader.import(...), importAll(...).

The class does not exist on main; this branch creates it.

Suite

Baseline on origin/main, then with the change, run as .venv/bin/python -m pytest tests/ -p no:cacheprovider --override-ini="addopts=-q":

  • before: 15 failed, 2246 passed, 3 skipped
  • after: 15 failed, 2260 passed, 3 skipped

The FAILED sets are byte-identical (diff clean), so nothing regressed; the +14 is exactly the new tests. All 15 failures are pre-existing and environmental: 6 in test_openai_backend.py (no openai installed), 7 in test_tool_index.py (no tree-sitter/cscope), and 2 — test_adopt.py::TestAdoptDryRun::test_smoke_tests_are_runnable and test_environment.py::TestEnvironmentSpec::test_validate_finds_pytest — that shell out to a bare python3/pytest on PATH and fail on a machine where the interpreter outside the venv has no pytest. None are touched by this change.

Relationship to the other PRs

Independent of #13, #14, and #15 — this branch is cut from origin/main and needs none of them.

It does textually conflict with #13, honestly: both add _extract_ts_imports to the import list at the top of tests/test_typescript_support.py and both introduce a TestTypeScriptImportExtraction class at the same insertion point. git merge-tree reports the conflict in tests/test_typescript_support.py only — src/pact/codebase_analyzer.py merges clean, since #13 rewrites _TS_IMPORT_FROM_RE and this adds a separate pattern below it. Resolution is taking both sets of test methods into the one class and the single import line; no logic to reconcile. Merge order doesn't matter.

Copilot AI lite review requested due to automatic review settings August 20, 2026 18:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends Pact’s TypeScript/JavaScript dependency extraction so that dynamic import("...") specifiers are recognized, preventing missing edges in SourceFile.imports / TestFile.imported_modules that can mislead test coverage mapping.

Changes:

  • Add a _TS_DYNAMIC_IMPORT_RE pattern and merge its matches into _extract_ts_imports, preserving source order.
  • Expand the TypeScript support test suite with a dedicated TestTypeScriptImportExtraction class covering dynamic import forms and guards.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/pact/codebase_analyzer.py Adds dynamic import extraction and returns combined static+dynamic import specifiers in source order.
tests/test_typescript_support.py Adds a focused test class validating dynamic import extraction and negative guard cases.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +375 to +386
# Dynamic import: `import("spec")`, with or without `await`.
#
# The lookbehind rejects anything where `import` is only the tail of a longer
# identifier (`notimport(...)`) or a member access (`loader.import(...)`), and
# `\b` rejects the leading-substring case (`importAll(...)`).
#
# Only quoted string literals are captured. A computed specifier such as
# `import(`./${name}.ts`)` is not statically resolvable, so it is skipped rather
# than recorded as a junk module path.
_TS_DYNAMIC_IMPORT_RE = re.compile(
r"""(?<![\w$.])import\s*\(\s*['"]([^'"]+)['"]""",
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The literal now has to be the whole argument: closing quote, optional whitespace, then ) or , for the import attributes form. import("./pms/" + name + ".ts") and import("./x.ts".trim()) are skipped, same as a template literal already was. Recording ./pms/ as a dependency was worse than recording nothing — it is an edge to a path the project does not have.

Comment thread src/pact/codebase_analyzer.py Outdated
`_extract_ts_imports` only ran `_TS_IMPORT_FROM_RE`, which requires the
`from` keyword. Dynamic imports have no `from`:

    const { filteredLogger } = await import("../../rpc/middleware.ts")

so every module reached only through `import(...)` was invisible to the
analyzer. A module that is lazily loaded — the usual reasons being a cycle
break, an optional dependency, or deferring a heavy import out of module
init — looks like it has no inbound edge at all.

Because `_extract_ts_imports` feeds both `SourceFile.imports` and
`TestFile.imported_modules`, the loss shows up as a coverage lie rather
than a missing edge. In the Deno service this was found on,
`src/rpc/middleware.ts` is imported only dynamically, by `e2e.ts` and by
`internal-booking.e2e.test.ts`, and `map_test_coverage` therefore reported
it as reachable from no test while an e2e test does in fact load it.

`_TS_DYNAMIC_IMPORT_RE` matches `import(` followed by a quoted specifier.
A lookbehind for word characters, `$`, and `.` keeps `notimport(...)` and
`loader.import(...)` from matching, and the word boundary on the keyword
keeps `importAll(...)` out.

Only quoted string literals are captured. A computed specifier such as
`import(`./${name}.ts`)` cannot be resolved without evaluating the
expression, so it is skipped rather than recorded as a module path that
resolves to nothing.

The two match sets are merged by source offset, so a file's specifiers
come back in the order they appear regardless of which form each one
takes. Duplicates are still preserved, matching the existing behavior for
a module imported twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: the pattern stopped at the closing quote, so a computed
specifier that merely starts with a literal was captured as if it were
the module — `import("./pms/" + name + ".ts")` recorded `./pms/`, and
`import("./x.ts".trim())` recorded `./x.ts`. Either one is a dependency
edge to a path the project does not have.

The literal now has to be the whole argument: the closing quote is
followed by optional whitespace and then `)`, or `,` for the import
attributes form. A concatenation or a method call puts something else
there and is skipped, which is what already happened to a template
literal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jmcentire added a commit that referenced this pull request Aug 21, 2026
# Conflicts:
#	tests/test_typescript_support.py
@jmcentire
jmcentire merged commit f27f56f into wandercom:main Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants