Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/pact/codebase_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""(?<![\w$.])import\s*\(\s*['"]([^'"]+)['"]\s*[,)]""",
)

# Import names: import { foo, bar } from '...'
_TS_IMPORT_NAMES_RE = re.compile(
r"""import\s*\{([^}]+)\}\s*from""",
Expand Down Expand Up @@ -809,11 +825,19 @@ def _extract_ts_test_function_names(source: str) -> 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]:
Expand Down
126 changes: 126 additions & 0 deletions tests/test_typescript_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from pact.codebase_analyzer import (
_extract_ts_imports,
analyze_codebase,
discover_source_files,
discover_tests,
Expand Down Expand Up @@ -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 ────────────────────────


Expand Down