Skip to content

Commit 9624324

Browse files
author
cowork-bot
committed
cowork-bot: dynamic import() and require() now consume target module's whole export surface (previously invisible -> exports used only via lazy loading flagged removable=True); +3 regression tests
1 parent dd8167d commit 9624324

2 files changed

Lines changed: 45 additions & 0 deletions

File tree

src/deadcode/scanner.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ def unreferenced_components(self) -> list[Finding]:
136136
r"^\s*import\s*['\"]([^'\"]+)['\"]", re.MULTILINE
137137
)
138138

139+
# Dynamic import: `const m = await import('./heavy');` — lazily loads the whole
140+
# module at runtime; individual consumed names are invisible statically, so the
141+
# module's entire export surface counts as used.
142+
_DYNAMIC_IMPORT_PATTERN = re.compile(r"import\(\s*['\"]([^'\"]+)['\"]\s*\)")
143+
144+
# CommonJS require: `const m = require('./legacy');` — same whole-module
145+
# consumption semantics as a dynamic import.
146+
_REQUIRE_PATTERN = re.compile(r"(?<![\w$.])require\(\s*['\"]([^'\"]+)['\"]\s*\)")
147+
139148
# className="..." or className={...} in JSX
140149
_CLASSNAME_PATTERN = re.compile(
141150
r"class(?:Name)?\s*[=:]\s*['\"]([^'\"]+)['\"]|"
@@ -454,6 +463,11 @@ def _parse_reexports(
454463
for m in _SIDE_EFFECT_IMPORT_PATTERN.finditer(content):
455464
# `import './mod'` — side-effect-only consumption.
456465
star_reexports.append((rel_path, m.group(1)))
466+
for pattern in (_DYNAMIC_IMPORT_PATTERN, _REQUIRE_PATTERN):
467+
for m in pattern.finditer(content):
468+
# `import('./mod')` / `require('./mod')` — lazily loads the
469+
# whole module; consumed names are invisible statically.
470+
star_reexports.append((rel_path, m.group(1)))
457471

458472
@staticmethod
459473
def _resolve_relative_module(importer_rel: str, spec: str, file_set: set[str]) -> str | None:

tests/test_namespace_sideeffect_imports.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,34 @@ def test_no_consumer_still_flags_exports(self, tmp_path):
7878
flagged = _flagged_names(project)
7979
assert "helper" in flagged
8080
assert "RATE" in flagged
81+
82+
83+
class TestDynamicAndRequireImports:
84+
"""`import('./mod')` and `require('./mod')` load the whole module at
85+
runtime; statically invisible name consumption must not flag exports."""
86+
87+
def test_dynamic_import_marks_all_exports_used(self, tmp_path):
88+
project = _make_project(
89+
tmp_path,
90+
"export async function load() {\n"
91+
" const u = await import('./utils');\n"
92+
" return u.helper() + u.RATE;\n"
93+
"}\n",
94+
)
95+
assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")]
96+
97+
def test_require_marks_all_exports_used(self, tmp_path):
98+
project = _make_project(
99+
tmp_path,
100+
"const u = require('./utils');\nexport const total = u.helper();\n",
101+
)
102+
assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")]
103+
104+
def test_bare_specifier_dynamic_import_cannot_mark_used(self, tmp_path):
105+
# Dynamic import of a package ('lodash') says nothing about local
106+
# modules — utils.ts must still be reported as unused.
107+
project = _make_project(
108+
tmp_path,
109+
"export async function load() {\n return import('lodash');\n}\n",
110+
)
111+
assert {n for n, _f in _unused_names(project)} == {"helper", "RATE"}

0 commit comments

Comments
 (0)