FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

cowork: namespace & side-effect imports consume a module's whole export surface by Coding-Dev-Tools · Pull Request #49 · Coding-Dev-Tools/deadcode · GitHub

Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .py  (2) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
46 changes: 42 additions & 4 deletions src/deadcode/scanner.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -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*['\"]([^'\"]+)['\"]|"
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
111 changes: 111 additions & 0 deletions tests/test_namespace_sideeffect_imports.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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"}
Loading

Back | FazBrowse Home | New Git URL