-
Notifications
You must be signed in to change notification settings - Fork 2
cowork: namespace & side-effect imports consume a module's whole export surface #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2db2c27
0cc8c79
1c4ef90
6645ff0
8387e6c
1800931
30e09bb
2ef1848
efa7ce2
dd8167d
9624324
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"(?<![\w$.])require\(\s*['\"]([^'\"]+)['\"]\s*\)") | ||
|
|
||
| # className="..." or className={...} in JSX | ||
| _CLASSNAME_PATTERN = re.compile( | ||
| r"class(?:Name)?\s*[=:]\s*['\"]([^'\"]+)['\"]|" | ||
|
|
@@ -404,14 +429,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 +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))) | ||
|
Comment on lines
+460
to
+462
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a source file contains a commented example such as Useful? React with 👍 / 👎. |
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a TypeScript consumer uses the valid
import type * as Types from './types'form, thetypetoken prevents this pattern from matching. Consequently, exports referenced asTypes.Fooare still reported withremovable=True, and a one-line type or interface can be deleted bydeadcode remove; allow the optionaltypemodifier before*.Useful? React with 👍 / 👎.