From 91732c78f8cf47e4dec72cec904b4967c2ae6337 Mon Sep 17 00:00:00 2001 From: Jon Frisby Date: Thu, 20 Aug 2026 11:23:37 -0700 Subject: [PATCH 1/2] Extract dynamic TypeScript import specifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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 --- src/pact/codebase_analyzer.py | 31 +++++++++-- tests/test_typescript_support.py | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/pact/codebase_analyzer.py b/src/pact/codebase_analyzer.py index 4dfcc47..5686777 100644 --- a/src/pact/codebase_analyzer.py +++ b/src/pact/codebase_analyzer.py @@ -372,6 +372,19 @@ def _extract_referenced_names(tree: ast.Module) -> list[str]: re.MULTILINE, ) +# 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"""(? list[str]: def _extract_ts_imports(source: str) -> list[str]: - """Extract imported module paths from TypeScript source.""" - modules: list[str] = [] - for m in _TS_IMPORT_FROM_RE.finditer(source): - modules.append(m.group(1)) - return modules + """Extract imported module paths from TypeScript source. + + Covers static `import ... from "spec"` and dynamic `import("spec")`. + Results are returned in source order so a reader of `SourceFile.imports` + sees the file's dependencies in the order they appear, whichever form each + one takes. + """ + matches = [ + *_TS_IMPORT_FROM_RE.finditer(source), + *_TS_DYNAMIC_IMPORT_RE.finditer(source), + ] + matches.sort(key=lambda m: m.start()) + return [m.group(1) for m in matches] def _extract_ts_referenced_names(source: str) -> list[str]: diff --git a/tests/test_typescript_support.py b/tests/test_typescript_support.py index a1a6400..3f3b821 100644 --- a/tests/test_typescript_support.py +++ b/tests/test_typescript_support.py @@ -7,6 +7,7 @@ import pytest from pact.codebase_analyzer import ( + _extract_ts_imports, analyze_codebase, discover_source_files, discover_tests, @@ -76,6 +77,101 @@ def test_discovers_test_files(self, tmp_path): assert "__tests__/baz.ts" in paths +# ── Import Extraction ────────────────────────────────────────────── + + +class TestTypeScriptImportExtraction: + def test_static_named_import_is_unchanged(self): + source = 'import { foo } from "./foo.ts"\n' + assert _extract_ts_imports(source) == ["./foo.ts"] + + def test_static_default_and_namespace_imports_are_unchanged(self): + source = textwrap.dedent("""\ + import def from "./def.ts" + import * as ns from "./ns.ts" + """) + assert _extract_ts_imports(source) == ["./def.ts", "./ns.ts"] + + def test_bare_dynamic_import(self): + source = 'import("./x.ts")\n' + assert _extract_ts_imports(source) == ["./x.ts"] + + def test_awaited_dynamic_import(self): + source = 'await import("./x.ts")\n' + assert _extract_ts_imports(source) == ["./x.ts"] + + def test_dynamic_import_with_destructuring_assignment(self): + source = 'const { filteredLogger } = await import("../../rpc/middleware.ts")\n' + assert _extract_ts_imports(source) == ["../../rpc/middleware.ts"] + + def test_dynamic_import_single_quotes(self): + source = "await import('./x.ts')\n" + assert _extract_ts_imports(source) == ["./x.ts"] + + def test_dynamic_import_inside_function_body(self): + source = textwrap.dedent("""\ + export async function boot() { + const mod = await import("./boot-impl.ts") + return mod.default + } + """) + assert _extract_ts_imports(source) == ["./boot-impl.ts"] + + def test_dynamic_import_spanning_lines(self): + source = textwrap.dedent("""\ + const mod = await import( + "./wrapped.ts", + ) + """) + assert _extract_ts_imports(source) == ["./wrapped.ts"] + + def test_dynamic_and_static_imports_are_returned_in_source_order(self): + source = textwrap.dedent("""\ + import { Effect } from "effect" + + export async function boot() { + const a = await import("./a.ts") + const b = await import("./b.ts") + return [a, b] + } + + import { last } from "./last.ts" + """) + assert _extract_ts_imports(source) == [ + "effect", + "./a.ts", + "./b.ts", + "./last.ts", + ] + + def test_template_literal_specifier_is_skipped(self): + """A computed specifier is not statically resolvable — emit nothing, not junk.""" + source = textwrap.dedent("""\ + const name = "alpha" + const mod = await import(`./${name}.ts`) + """) + assert _extract_ts_imports(source) == [] + + def test_template_literal_specifier_does_not_hide_neighbours(self): + source = textwrap.dedent("""\ + const mod = await import(`./${name}.ts`) + const other = await import("./other.ts") + """) + assert _extract_ts_imports(source) == ["./other.ts"] + + def test_identifier_ending_in_import_is_not_a_dynamic_import(self): + source = 'notimport("./x.ts")\n' + assert _extract_ts_imports(source) == [] + + def test_method_named_import_is_not_a_dynamic_import(self): + source = 'loader.import("./x.ts")\n' + assert _extract_ts_imports(source) == [] + + def test_importsomething_call_is_not_a_dynamic_import(self): + source = 'importAll("./x.ts")\n' + assert _extract_ts_imports(source) == [] + + # ── Function Extraction: Standard TypeScript ──────────────────────── From 3345f005feaa67a9f8f583dbb1ad925479a03ce0 Mon Sep 17 00:00:00 2001 From: Jon Frisby Date: Thu, 20 Aug 2026 12:21:18 -0700 Subject: [PATCH 2/2] Require the dynamic specifier to be the whole argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/pact/codebase_analyzer.py | 11 +++++++---- tests/test_typescript_support.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/pact/codebase_analyzer.py b/src/pact/codebase_analyzer.py index 5686777..cb5eb53 100644 --- a/src/pact/codebase_analyzer.py +++ b/src/pact/codebase_analyzer.py @@ -378,11 +378,14 @@ def _extract_referenced_names(tree: ast.Module) -> list[str]: # 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. +# Only a whole quoted string literal is captured, and the trailing `[,)]` +# is what makes it whole. A computed specifier is not statically resolvable, so +# it is skipped rather than recorded as a junk module path -- whether it is a +# template literal (`import(`./${name}.ts`)`), a concatenation +# (`import("./pms/" + name)`), or a call on a literal (`import("./x".trim())`). +# The comma admits the import-attributes form, `import("./x.json", { with: ... })`. _TS_DYNAMIC_IMPORT_RE = re.compile( - r"""(?