diff --git a/src/pact/codebase_analyzer.py b/src/pact/codebase_analyzer.py index 4dfcc47..cb5eb53 100644 --- a/src/pact/codebase_analyzer.py +++ b/src/pact/codebase_analyzer.py @@ -372,6 +372,22 @@ 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 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"""(? 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..a58d61c 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,131 @@ 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) == [] + + def test_concatenated_specifier_is_skipped(self): + """`"./x" + name` is not statically resolvable; the prefix is not the module.""" + source = 'const m = await import("./pms/" + name + ".ts")\n' + assert _extract_ts_imports(source) == [] + + def test_specifier_with_a_method_call_is_skipped(self): + source = 'const m = await import("./x.ts".trim())\n' + assert _extract_ts_imports(source) == [] + + def test_ternary_specifier_is_skipped(self): + source = 'const m = await import(dev ? "./dev.ts" : "./prod.ts")\n' + assert _extract_ts_imports(source) == [] + + def test_concatenated_specifier_does_not_hide_neighbours(self): + source = textwrap.dedent("""\ + import { before } from "./before.ts" + const m = await import("./pms/" + name + ".ts") + const n = await import("./after.ts") + """) + assert _extract_ts_imports(source) == ["./before.ts", "./after.ts"] + + def test_dynamic_import_with_options_is_extracted(self): + """An import attributes object follows the specifier after a comma.""" + source = 'const data = await import("./data.json", { with: { type: "json" } })\n' + assert _extract_ts_imports(source) == ["./data.json"] + + def test_dynamic_import_with_padding_inside_the_parens(self): + source = 'const m = await import( "./x.ts" )\n' + assert _extract_ts_imports(source) == ["./x.ts"] + + # ── Function Extraction: Standard TypeScript ────────────────────────