diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index eaa2351..350d124 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -120,6 +120,22 @@ 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 +) + # className="..." or className={...} in JSX _CLASSNAME_PATTERN = re.compile( r"class(?:Name)?\s*[=:]\s*['\"]([^'\"]+)['\"]|" @@ -404,14 +420,16 @@ def _parse_reexports( imports: dict[str, set[str]], star_reexports: list[tuple[str, str]], ) -> 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 +448,12 @@ 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))) @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..9b9462f --- /dev/null +++ b/tests/test_namespace_sideeffect_imports.py @@ -0,0 +1,80 @@ +"""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