Skip to content
Draft
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
32 changes: 28 additions & 4 deletions src/deadcode/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*['\"]([^'\"]+)['\"]|"
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
80 changes: 80 additions & 0 deletions tests/test_namespace_sideeffect_imports.py
Original file line number Diff line number Diff line change
@@ -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
Loading