Extract dynamic TypeScript import specifiers - #16
Conversation
There was a problem hiding this comment.
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_REpattern and merge its matches into_extract_ts_imports, preserving source order. - Expand the TypeScript support test suite with a dedicated
TestTypeScriptImportExtractionclass 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.
| # 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*['"]([^'"]+)['"]""", | ||
| ) |
There was a problem hiding this comment.
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.
`_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>
e56e332 to
91732c7
Compare
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>
# Conflicts: # tests/test_typescript_support.py
Defect
_extract_ts_importsruns only_TS_IMPORT_FROM_RE, which requires thefromkeyword. Dynamic imports don't have one, so a module reached only throughimport(...)produces no edge at all.Repro against
main:_extract_ts_importsfeeds bothSourceFile.importsandTestFile.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.tsthere is imported only dynamically —src/testing/kit/e2e.ts:610—const { filteredLogger } = await import("../../rpc/middleware.ts");src/testing/__tests__/internal-booking.e2e.test.ts:54— same call— and
map_test_coveragereported it as covered by no test while an e2e test does load it. With the fix applied, both files yield the specifier (e2e.tstwice, since it has the call at lines 610 and 930).Fix
A second pattern,
_TS_DYNAMIC_IMPORT_RE, matchingimport(plus a quoted specifier:The lookbehind on word characters,
$, and.keepsnotimport(...)andloader.import(...)out; the word boundary after the keyword keepsimportAll(...)out._extract_ts_importsmerges 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)andimport(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", nofrom, no parens) are still missed onmainand still missed here;import.metaand import attributes (with { type: "json" }) are unaffected since the pattern stops at the closing quote.Tests
14 tests in
TestTypeScriptImportExtractionintests/test_typescript_support.py, written before the fix.8 failed before, pass after — bare
import("./x.ts"),await import(...), destructured assignment fromawait 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
mainand 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":The
FAILEDsets are byte-identical (diffclean), so nothing regressed; the +14 is exactly the new tests. All 15 failures are pre-existing and environmental: 6 intest_openai_backend.py(noopenaiinstalled), 7 intest_tool_index.py(no tree-sitter/cscope), and 2 —test_adopt.py::TestAdoptDryRun::test_smoke_tests_are_runnableandtest_environment.py::TestEnvironmentSpec::test_validate_finds_pytest— that shell out to a barepython3/pytestonPATHand 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/mainand needs none of them.It does textually conflict with #13, honestly: both add
_extract_ts_importsto the import list at the top oftests/test_typescript_support.pyand both introduce aTestTypeScriptImportExtractionclass at the same insertion point.git merge-treereports the conflict intests/test_typescript_support.pyonly —src/pact/codebase_analyzer.pymerges clean, since #13 rewrites_TS_IMPORT_FROM_REand 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.