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
31 changes: 26 additions & 5 deletions src/pact/codebase_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,19 @@ def _extract_referenced_names(tree: ast.Module) -> list[str]:
re.MULTILINE,
)

# Side-effect import: `import "spec"` — no clause, no `from`, evaluated purely
# for what loading the module does (installing a polyfill, registering a plugin,
# running a decorator shim).
#
# Requiring the quote to follow `import` directly is what keeps this pattern off
# every other import form: a clause, a default binding, or `type` all put a
# non-quote token there. Line-anchoring keeps the specifier of a clause wrapped
# onto its own line from being read as a bare side-effect import.
_TS_SIDE_EFFECT_IMPORT_RE = re.compile(
r"""^[ \t]*import\s+['"]([^'"]+)['"]""",
re.MULTILINE,
)

# Import names: import { foo, bar } from '...'
_TS_IMPORT_NAMES_RE = re.compile(
r"""import\s*\{([^}]+)\}\s*from""",
Expand Down Expand Up @@ -809,11 +822,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 `import ... from "spec"` and the bare side-effect form `import
"spec"`. Results come back 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_SIDE_EFFECT_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
94 changes: 94 additions & 0 deletions tests/test_typescript_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import pytest

from pact.codebase_analyzer import (
_TS_SIDE_EFFECT_IMPORT_RE,
_extract_ts_imports,
analyze_codebase,
discover_source_files,
discover_tests,
Expand Down Expand Up @@ -76,6 +78,98 @@ 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_relative_side_effect_import(self):
source = 'import "./polyfill.ts"\n'
assert _extract_ts_imports(source) == ["./polyfill.ts"]

def test_bare_package_side_effect_import(self):
source = 'import "reflect-metadata"\n'
assert _extract_ts_imports(source) == ["reflect-metadata"]

def test_side_effect_import_single_quotes(self):
source = "import './polyfill.ts'\n"
assert _extract_ts_imports(source) == ["./polyfill.ts"]

def test_side_effect_import_with_semicolon(self):
source = 'import "./polyfill.ts";\n'
assert _extract_ts_imports(source) == ["./polyfill.ts"]

def test_indented_side_effect_import(self):
source = ' import "./polyfill.ts"\n'
assert _extract_ts_imports(source) == ["./polyfill.ts"]

def test_side_effect_import_with_trailing_comment(self):
source = 'import "./polyfill.ts" // installs the global\n'
assert _extract_ts_imports(source) == ["./polyfill.ts"]

def test_side_effect_and_static_imports_are_returned_in_source_order(self):
source = textwrap.dedent("""\
import "reflect-metadata"
import { Effect } from "effect"
import "./polyfill.ts"
import { last } from "./last.ts"
""")
assert _extract_ts_imports(source) == [
"reflect-metadata",
"effect",
"./polyfill.ts",
"./last.ts",
]

def test_repeated_side_effect_import_is_not_deduplicated(self):
source = textwrap.dedent("""\
import "./polyfill.ts"
import "./polyfill.ts"
""")
assert _extract_ts_imports(source) == ["./polyfill.ts", "./polyfill.ts"]

def test_static_import_is_not_counted_twice(self):
"""`import x from "y"` must match one pattern, not both."""
source = 'import foo from "./foo.ts"\n'
assert _extract_ts_imports(source) == ["./foo.ts"]

def test_multiline_clause_is_not_matched_as_a_side_effect_import(self):
"""The inner specifier of a wrapped clause is not a bare side-effect import.

Asserted against the pattern rather than `_extract_ts_imports` so the
guard stays true whatever the `from` pattern is later taught to match.
"""
source = textwrap.dedent("""\
import {
foo,
bar,
} from "./foo.ts"
""")
assert _TS_SIDE_EFFECT_IMPORT_RE.findall(source) == []

def test_export_from_is_not_matched_as_a_side_effect_import(self):
source = 'export { helper } from "./helper.ts"\n'
assert _TS_SIDE_EFFECT_IMPORT_RE.findall(source) == []

def test_static_import_is_not_matched_as_a_side_effect_import(self):
source = 'import foo from "./foo.ts"\n'
assert _TS_SIDE_EFFECT_IMPORT_RE.findall(source) == []

def test_import_inside_an_identifier_is_not_a_side_effect_import(self):
source = 'importSomething "./x.ts"\n'
assert _extract_ts_imports(source) == []


# ── Function Extraction: Standard TypeScript ────────────────────────


Expand Down