diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index eaa2351..9ec2726 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -120,6 +120,31 @@ def unreferenced_components(self) -> list[Finding]: re.DOTALL, ) +# Namespace import: `import * as Utils from './utils'`. A namespace binding +# reaches every export of the module through one object (`Utils.foo`), so +# individual names cannot be attributed to use sites. Like a barrel re-export, +# the whole export surface of the target module counts as consumed; otherwise +# exports used only via a namespace are falsely reported as unused with +# removable=True — live code queued for deletion. +_NAMESPACE_IMPORT_PATTERN = re.compile( + r"import\s+\*\s+as\s+\w+\s+from\s*['\"]([^'\"]+)['\"]" +) + +# Bare side-effect import: `import './polyfill';` — executes the module without +# binding any names, consuming its entire export surface. +_SIDE_EFFECT_IMPORT_PATTERN = re.compile( + r"^\s*import\s*['\"]([^'\"]+)['\"]", re.MULTILINE +) + +# Dynamic import: `const m = await import('./heavy');` — lazily loads the whole +# module at runtime; individual consumed names are invisible statically, so the +# module's entire export surface counts as used. +_DYNAMIC_IMPORT_PATTERN = re.compile(r"import\(\s*['\"]([^'\"]+)['\"]\s*\)") + +# CommonJS require: `const m = require('./legacy');` — same whole-module +# consumption semantics as a dynamic import. +_REQUIRE_PATTERN = re.compile(r"(? None: - """Record re-export forwarding so barrel/index files don't false-positive. + """Record whole-module consumption so source modules don't false-positive. ``export { A, B as C } from './mod'`` consumes ``A`` and ``B`` from ``./mod``; the consumed (left-hand) names are registered as imports of this file so the source module's exports are not reported as unused. - ``export * from './mod'`` forwards every export of ``./mod``; the - (file, module) pair is recorded so those exports can be treated as used - once ``./mod`` is resolved to a scanned file. + ``export * from './mod'``, ``import * as NS from './mod'``, and bare + ``import './mod'`` all consume ``./mod``'s *entire* export surface — + individual names cannot be attributed — so each (file, module) pair is + recorded; those exports are treated as used once ``./mod`` resolves to + a scanned file. """ for m in _REEXPORT_PATTERN.finditer(content): named = m.group(1) @@ -430,6 +457,17 @@ def _parse_reexports( else: # `export * from './mod'` — resolved to a file in phase 2. star_reexports.append((rel_path, module_path)) + for m in _NAMESPACE_IMPORT_PATTERN.finditer(content): + # `import * as NS from './mod'` — whole-module consumption. + star_reexports.append((rel_path, m.group(1))) + for m in _SIDE_EFFECT_IMPORT_PATTERN.finditer(content): + # `import './mod'` — side-effect-only consumption. + star_reexports.append((rel_path, m.group(1))) + for pattern in (_DYNAMIC_IMPORT_PATTERN, _REQUIRE_PATTERN): + for m in pattern.finditer(content): + # `import('./mod')` / `require('./mod')` — lazily loads the + # whole module; consumed names are invisible statically. + star_reexports.append((rel_path, m.group(1))) @staticmethod def _resolve_relative_module(importer_rel: str, spec: str, file_set: set[str]) -> str | None: diff --git a/tests/test_namespace_sideeffect_imports.py b/tests/test_namespace_sideeffect_imports.py new file mode 100644 index 0000000..eb9cd6a --- /dev/null +++ b/tests/test_namespace_sideeffect_imports.py @@ -0,0 +1,111 @@ +"""Regression tests: namespace and side-effect imports consume a module's exports. + +``import * as Utils from './utils'`` binds every export of ``./utils`` behind a +single object, and ``import './polyfill'`` loads a module purely for its side +effects. In both cases individual exported names cannot be attributed to usage +sites, so the scanner must treat the target module's whole export surface as +used. Before this fix, exports reachable ONLY through such imports were falsely +reported as unused with removable=True — i.e. live code flagged for deletion. +""" + +from __future__ import annotations + +from pathlib import Path + +from deadcode.scanner import DeadCodeScanner + + +def _make_project(tmp_path: Path, consumer_source: str) -> Path: + """utils.ts defines two exports; main.ts consumes it per consumer_source.""" + utils = tmp_path / "src" / "utils.ts" + utils.parent.mkdir(parents=True, exist_ok=True) + utils.write_text( + "export function helper() { return 1; }\nexport const RATE = 2;\n" + ) + main = tmp_path / "src" / "main.ts" + main.write_text(consumer_source) + return tmp_path + + +def _unused_names(project: Path) -> set[tuple[str, str]]: + result = DeadCodeScanner(project).scan() + return {(f.name, f.file) for f in result.unused_exports} + + +def _flagged_names(project: Path) -> set[str]: + return {name for name, _file in _unused_names(project)} + + +class TestNamespaceImports: + def test_namespace_import_marks_all_exports_used(self, tmp_path): + project = _make_project( + tmp_path, + "import * as Utils from './utils';\n\n" + "const total = Utils.helper() + Utils.RATE;\nexport default total;\n", + ) + # Neither utils.ts export may be flagged: both are consumed via the + # namespace binding. + assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")] + + def test_export_star_as_reexport_marks_all_exports_used(self, tmp_path): + project = _make_project( + tmp_path, + "export * as internals from './utils';\n", + ) + assert not list(_unused_names(project)) + + def test_bare_specifier_namespace_import_cannot_mark_used(self, tmp_path): + # A namespace import from an unresolvable package ('lodash') says + # nothing about local modules — utils.ts must still be reported. + project = _make_project( + tmp_path, + "import * as _ from 'lodash';\n", + ) + flagged = _flagged_names(project) + assert "helper" in flagged + assert "RATE" in flagged + + +class TestSideEffectImports: + def test_side_effect_import_marks_all_exports_used(self, tmp_path): + project = _make_project(tmp_path, "import './utils';\n\nconst app = 'app';\n") + assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")] + + def test_no_consumer_still_flags_exports(self, tmp_path): + # Control: without any consumer, the exports must still be detected — + # guards against the fix over-marking everything as used. + project = _make_project(tmp_path, "export const unrelated = 1;\n") + flagged = _flagged_names(project) + assert "helper" in flagged + assert "RATE" in flagged + + +class TestDynamicAndRequireImports: + """`import('./mod')` and `require('./mod')` load the whole module at + runtime; statically invisible name consumption must not flag exports.""" + + def test_dynamic_import_marks_all_exports_used(self, tmp_path): + project = _make_project( + tmp_path, + "export async function load() {\n" + " const u = await import('./utils');\n" + " return u.helper() + u.RATE;\n" + "}\n", + ) + assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")] + + def test_require_marks_all_exports_used(self, tmp_path): + project = _make_project( + tmp_path, + "const u = require('./utils');\nexport const total = u.helper();\n", + ) + assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")] + + def test_bare_specifier_dynamic_import_cannot_mark_used(self, tmp_path): + # Dynamic import of a package ('lodash') says nothing about local + # modules — utils.ts must still be reported as unused. + project = _make_project( + tmp_path, + "export async function load() {\n return import('lodash');\n}\n", + ) + assert {n for n, _f in _unused_names(project)} == {"helper", "RATE"}