diff --git a/src/pact/codebase_analyzer.py b/src/pact/codebase_analyzer.py index 4dfcc47..36acbee 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, ) +# 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""", @@ -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]: diff --git a/tests/test_typescript_support.py b/tests/test_typescript_support.py index a1a6400..1a8531c 100644 --- a/tests/test_typescript_support.py +++ b/tests/test_typescript_support.py @@ -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, @@ -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 ────────────────────────