Skip to content

Extract bare side-effect TypeScript imports - #17

Merged
jmcentire merged 1 commit into
wandercom:mainfrom
MrJoy:fix/ts-side-effect-imports
Aug 21, 2026
Merged

Extract bare side-effect TypeScript imports#17
jmcentire merged 1 commit into
wandercom:mainfrom
MrJoy:fix/ts-side-effect-imports

Conversation

@MrJoy

@MrJoy MrJoy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Defect

_extract_ts_imports runs only _TS_IMPORT_FROM_RE, which requires the from
keyword. A bare side-effect import has no clause and no from, so it matches
nothing:

from pact.codebase_analyzer import _extract_ts_imports

print(_extract_ts_imports('import "reflect-metadata"\n'))   # []  — want ['reflect-metadata']
print(_extract_ts_imports('import "./polyfill.ts"\n'))      # []  — want ['./polyfill.ts']
print(_extract_ts_imports('import { foo } from "./foo.ts"\n'))  # ['./foo.ts'] — already fine

A module imported purely for what loading it does — installing a polyfill,
registering a plugin or codec, running a decorator shim — therefore appears in
no SourceFile.imports list. Because _extract_ts_imports also feeds
TestFile.imported_modules, a test that reaches such a module only through a
side-effect import contributes no edge toward its coverage either. The module
reads as dead code with no inbound import and no covering test, while deleting
it would break the build.

Fix

A second pattern, _TS_SIDE_EFFECT_IMPORT_RE, matches a line whose import
keyword is followed directly by a quoted specifier:

_TS_SIDE_EFFECT_IMPORT_RE = re.compile(
    r"""^[ \t]*import\s+['"]([^'"]+)['"]""",
    re.MULTILINE,
)

Requiring the quote to come next is what keeps the pattern off every other
import form — a clause, a default binding, a namespace binding, and type all
put a non-quote token in that position — so nothing is counted twice by the two
patterns. Line-anchoring, matching the existing from pattern, keeps the
specifier of a clause wrapped onto its own line from being read as a bare
side-effect import.

The two match sets are merged and sorted by source offset, so a file's
specifiers come back in the order they appear whichever form each one takes.
Duplicates are preserved, matching existing behavior for a module imported
twice.

Deliberately not handled

  • Two statements on one line (import "./a.ts"; import "./b.ts") yields only
    the first. The line anchor is inherited from _TS_IMPORT_FROM_RE, which has
    the same limit; lifting the anchor for one pattern and not the other would be
    an inconsistency, and the form is vanishingly rare in real source.
  • An import that appears inside a comment or a string literal is still matched.
    Same pre-existing limit, unchanged here.
  • Import attributes (import "./x.ts" with { type: "json" }) capture the
    specifier correctly; the attribute clause is ignored.

Tests

All in tests/test_typescript_support.py, in a new TestTypeScriptImportExtraction
class.

Failed before the fix (8):

  • test_relative_side_effect_import
  • test_bare_package_side_effect_import
  • test_side_effect_import_single_quotes
  • test_side_effect_import_with_semicolon
  • test_indented_side_effect_import
  • test_side_effect_import_with_trailing_comment
  • test_side_effect_and_static_imports_are_returned_in_source_order
  • test_repeated_side_effect_import_is_not_deduplicated

Guards, passing before and after (7) — these pin what the new pattern must not
break or swallow:

  • test_static_named_import_is_unchanged
  • test_static_default_and_namespace_imports_are_unchanged
  • test_static_import_is_not_counted_twice (via _extract_ts_imports)
  • test_static_import_is_not_matched_as_a_side_effect_import
  • test_multiline_clause_is_not_matched_as_a_side_effect_import
  • test_export_from_is_not_matched_as_a_side_effect_import
  • test_import_inside_an_identifier_is_not_a_side_effect_import

The three "is not matched as a side-effect import" guards assert against
_TS_SIDE_EFFECT_IMPORT_RE directly rather than against _extract_ts_imports.
That is deliberate: those inputs currently extract to [] on main, but what
the guard is really about is the new pattern not firing on them, and asserting
at the pattern keeps the guard true whatever the from pattern is later taught
to match.

Suite

Baseline on origin/main, in a fresh worktree venv:

15 failed, 2246 passed, 3 skipped in 90.72s

After:

15 failed, 2261 passed, 3 skipped in 70.66s

+15 tests, all passing. The FAILED set is byte-identical before and after
(captured with grep '^FAILED' | sort and diffed). The 15 pre-existing
failures are environmental and untouched by this change: tree-sitter and cscope
are not installed, the openai package is absent, and two tests shell out to a
python3 on PATH that has no pytest.

Run as:

.venv/bin/python -m pytest tests/ -p no:cacheprovider --override-ini="addopts=-q"

This repo has no CI, so a local run is the only evidence here — nothing will go
green on the PR itself.

Conflicts with the sibling TypeScript PRs

Branched from origin/main, independently reviewable. git merge-tree --write-tree against each sibling:

  • Extract dynamic TypeScript import specifiers #16 fix/ts-dynamic-imports — conflicts, in both
    src/pact/codebase_analyzer.py and tests/test_typescript_support.py. Both
    PRs add a pattern in the same spot and rewrite _extract_ts_imports the same
    way, and both add a TestTypeScriptImportExtraction class at the same offset.
    The conflict is purely textual and additive. I resolved it in a scratch merge
    to check: the source resolution is one merged matches list over all three
    patterns, the tests are a union of the two classes, and all 69 tests pass
    afterward. The two patterns do not overlap — a dynamic import puts ( where
    mine requires a quote, and a bare side-effect import has no paren — so nothing
    is double-counted.
  • Extract multi-line and re-exporting TypeScript imports #13 fix/ts-multiline-imports — conflicts in the test file only;
    codebase_analyzer.py auto-merges. Same cause: two new classes at the same
    offset. I ran my 15 tests against the auto-merged source and all pass, so
    teaching the from pattern about wrapped clauses does not disturb this fix.
  • Resolve relative import specifiers when mapping test coverage #14 fix/ts-relative-import-coverage — no conflict.
  • Compute cyclomatic complexity for TypeScript functions #15 feat/ts-cyclomatic-complexity — no conflict.

Whichever of #16 or #13 lands first, the other and this one need a trivial
rebase.

Copilot AI lite review requested due to automatic review settings August 20, 2026 18:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes TypeScript import extraction in codebase_analyzer so that bare side-effect imports (e.g., import "./polyfill.ts") are included in the extracted module list. That improves both dependency graph accuracy (SourceFile.imports) and coverage mapping inputs (TestFile.imported_modules) for TS/JS projects that rely on side-effect-only modules.

Changes:

  • Add _TS_SIDE_EFFECT_IMPORT_RE to match bare import "spec" / import 'spec' lines.
  • Update _extract_ts_imports to merge matches from both the existing from-based pattern and the new side-effect pattern, returning specifiers in source order.
  • Add a focused test suite covering positive cases and regression guards for non-matches.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/pact/codebase_analyzer.py Adds a side-effect import regex and merges it into _extract_ts_imports while preserving source order.
tests/test_typescript_support.py Adds import-extraction tests validating side-effect imports and guarding against false positives.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

`_extract_ts_imports` only ran `_TS_IMPORT_FROM_RE`, which requires the
`from` keyword. A side-effect import has no clause and no `from`:

    import "reflect-metadata"
    import "./polyfill.ts"

so a module pulled in purely for what loading it does — installing a
polyfill, registering a plugin or a codec, running a decorator shim — was
invisible to the analyzer. It appears in no `SourceFile.imports` list, and
since `_extract_ts_imports` also feeds `TestFile.imported_modules`, a test
that reaches a module only through a side-effect import contributes no
edge toward its coverage. The module reads as dead: no inbound import,
no covering test, while removing it would break the build.

`_TS_SIDE_EFFECT_IMPORT_RE` matches a line whose `import` keyword is
followed directly by a quoted specifier. Requiring the quote to come next
is what keeps the pattern off every other import form — a clause, a
default binding, a namespace binding, and `type` all put a non-quote token
in that position — so no import is counted twice by the two patterns.
Line-anchoring, matching the existing `from` pattern, keeps the specifier
of a clause wrapped onto its own line from being read as a bare
side-effect import.

The two match sets are merged by source offset, so a file's specifiers
come back in the order they appear whichever form each one takes.
Duplicates are preserved, matching the existing behavior for a module
imported twice.

Not handled: two statements on one line (`import "./a.ts"; import
"./b.ts"`) yields only the first, and an import inside a comment or a
string literal is still matched. Both limits are inherited from the
existing line-anchored `from` pattern rather than introduced here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MrJoy
MrJoy force-pushed the fix/ts-side-effect-imports branch from 7fbce21 to f33bdbc Compare August 20, 2026 18:53
@jmcentire
jmcentire merged commit bfef0ce into wandercom:main Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants