Skip to content

Commit aa1810a

Browse files
Coding-Dev-ToolsDevForge Engineercowork-bot
authored
chore: copyright standardization and optional license fallback (#50)
* cowork-bot: standardize copyright holder to 2025 Coding-Dev-Tools (was Revenue Holdings / stale 2026 year); W-directed fleet-wide pass * chore: standardize license and package metadata; scanner/test fixes * cowork-bot: treat type-only named imports as used in dead-code scan * cowork-bot: treat re-exports (barrel/index forwarding) as used in dead-code scan Named (`export { X } from './mod'`), renamed (`export { X as Y }`), type (`export { type X }`), and star (`export * from './mod'`) re-exports now mark the forwarded symbols as used, so barrel/index files no longer produce false-positive 'unused_export' findings flagged removable=True (which could delete live public API). Resolves `export *` specifiers to scanned files (incl. directory index.*). Adds TestReexportForwarding (8 cases) + removes a pre-existing F841 unused var. 113 tests pass, ruff clean. * cowork-bot: fix import parsing in scanner — handle import type {Foo}, mixed default+named imports, and correct group-index reversal - Rewrote _IMPORT_PATTERN regex to handle: import type {Foo}, import Default, {Named}, import {type Foo}, and import Foo as Bar forms - Fixed _parse_imports group-number reversal (group 1 = named imports block, group 2 = default) - Strips 'type ' prefix from named import entries in both named-block positions - All 113 existing tests pass; ruff clean * cowork-bot: treat namespace imports (import * as NS) and bare side-effect imports as whole-module consumption A namespace binding (import * as Utils from './utils') or a bare side-effect import (import './polyfill') consumes the target module's entire export surface. The scanner previously ignored both forms entirely, so exports used ONLY through them were falsely reported as unused with removable=True — live code queued for deletion by 'deadcode remove'. Both now resolve like barrel star-reexports: the resolved module's exports are treated as used. Bare package specifiers stay unresolvable and keep flagging. +5 regression tests (namespace, export * as ns, side-effect, bare-specifier, no-consumer control). Full suite: 121 passed, ruff clean. * cowork-bot: restore working tree dropped by malformed previous commit The previous commit (2ef1848) was built from a stale temp index and accidentally recorded deletions of 34 unrelated tracked files. This commit restores the full tree of 30e09bb while keeping the intended scanner fix (namespace/side-effect imports as whole-module consumption) and its 5 regression tests. No force-push used. * cowork-bot: re-add namespace/side-effect import consumption lost by checkout-index efa7ce2 restored the tree but its checkout-index step reverted src/deadcode/scanner.py to the pre-fix version. This commit re-applies the scanner fix from 2ef1848: import * as NS / bare side-effect imports consume the target module's whole export surface (resolves like barrel star-reexports). Final tree vs master-base 30e09bb = exactly scanner.py fix + 5-test file. * 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 --------- Co-authored-by: DevForge Engineer <engineer@devforge.dev> Co-authored-by: cowork-bot <bot@coding-dev-tools.local>
1 parent 77edf21 commit aa1810a

2 files changed

Lines changed: 153 additions & 4 deletions

File tree

‎src/deadcode/scanner.py‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,31 @@ def unreferenced_components(self) -> list[Finding]:
120120
re.DOTALL,
121121
)
122122

123+
# Namespace import: `import * as Utils from './utils'`. A namespace binding
124+
# reaches every export of the module through one object (`Utils.foo`), so
125+
# individual names cannot be attributed to use sites. Like a barrel re-export,
126+
# the whole export surface of the target module counts as consumed; otherwise
127+
# exports used only via a namespace are falsely reported as unused with
128+
# removable=True — live code queued for deletion.
129+
_NAMESPACE_IMPORT_PATTERN = re.compile(
130+
r"import\s+\*\s+as\s+\w+\s+from\s*['\"]([^'\"]+)['\"]"
131+
)
132+
133+
# Bare side-effect import: `import './polyfill';` — executes the module without
134+
# binding any names, consuming its entire export surface.
135+
_SIDE_EFFECT_IMPORT_PATTERN = re.compile(
136+
r"^\s*import\s*['\"]([^'\"]+)['\"]", re.MULTILINE
137+
)
138+
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+
123148
# className="..." or className={...} in JSX
124149
_CLASSNAME_PATTERN = re.compile(
125150
r"class(?:Name)?\s*[=:]\s*['\"]([^'\"]+)['\"]|"
@@ -404,14 +429,16 @@ def _parse_reexports(
404429
imports: dict[str, set[str]],
405430
star_reexports: list[tuple[str, str]],
406431
) -> None:
407-
"""Record re-export forwarding so barrel/index files don't false-positive.
432+
"""Record whole-module consumption so source modules don't false-positive.
408433
409434
``export { A, B as C } from './mod'`` consumes ``A`` and ``B`` from
410435
``./mod``; the consumed (left-hand) names are registered as imports of
411436
this file so the source module's exports are not reported as unused.
412-
``export * from './mod'`` forwards every export of ``./mod``; the
413-
(file, module) pair is recorded so those exports can be treated as used
414-
once ``./mod`` is resolved to a scanned file.
437+
``export * from './mod'``, ``import * as NS from './mod'``, and bare
438+
``import './mod'`` all consume ``./mod``'s *entire* export surface —
439+
individual names cannot be attributed — so each (file, module) pair is
440+
recorded; those exports are treated as used once ``./mod`` resolves to
441+
a scanned file.
415442
"""
416443
for m in _REEXPORT_PATTERN.finditer(content):
417444
named = m.group(1)
@@ -430,6 +457,17 @@ def _parse_reexports(
430457
else:
431458
# `export * from './mod'` — resolved to a file in phase 2.
432459
star_reexports.append((rel_path, module_path))
460+
for m in _NAMESPACE_IMPORT_PATTERN.finditer(content):
461+
# `import * as NS from './mod'` — whole-module consumption.
462+
star_reexports.append((rel_path, m.group(1)))
463+
for m in _SIDE_EFFECT_IMPORT_PATTERN.finditer(content):
464+
# `import './mod'` — side-effect-only consumption.
465+
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)))
433471

434472
@staticmethod
435473
def _resolve_relative_module(importer_rel: str, spec: str, file_set: set[str]) -> str | None:
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Regression tests: namespace and side-effect imports consume a module's exports.
2+
3+
``import * as Utils from './utils'`` binds every export of ``./utils`` behind a
4+
single object, and ``import './polyfill'`` loads a module purely for its side
5+
effects. In both cases individual exported names cannot be attributed to usage
6+
sites, so the scanner must treat the target module's whole export surface as
7+
used. Before this fix, exports reachable ONLY through such imports were falsely
8+
reported as unused with removable=True — i.e. live code flagged for deletion.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from pathlib import Path
14+
15+
from deadcode.scanner import DeadCodeScanner
16+
17+
18+
def _make_project(tmp_path: Path, consumer_source: str) -> Path:
19+
"""utils.ts defines two exports; main.ts consumes it per consumer_source."""
20+
utils = tmp_path / "src" / "utils.ts"
21+
utils.parent.mkdir(parents=True, exist_ok=True)
22+
utils.write_text(
23+
"export function helper() { return 1; }\nexport const RATE = 2;\n"
24+
)
25+
main = tmp_path / "src" / "main.ts"
26+
main.write_text(consumer_source)
27+
return tmp_path
28+
29+
30+
def _unused_names(project: Path) -> set[tuple[str, str]]:
31+
result = DeadCodeScanner(project).scan()
32+
return {(f.name, f.file) for f in result.unused_exports}
33+
34+
35+
def _flagged_names(project: Path) -> set[str]:
36+
return {name for name, _file in _unused_names(project)}
37+
38+
39+
class TestNamespaceImports:
40+
def test_namespace_import_marks_all_exports_used(self, tmp_path):
41+
project = _make_project(
42+
tmp_path,
43+
"import * as Utils from './utils';\n\n"
44+
"const total = Utils.helper() + Utils.RATE;\nexport default total;\n",
45+
)
46+
# Neither utils.ts export may be flagged: both are consumed via the
47+
# namespace binding.
48+
assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")]
49+
50+
def test_export_star_as_reexport_marks_all_exports_used(self, tmp_path):
51+
project = _make_project(
52+
tmp_path,
53+
"export * as internals from './utils';\n",
54+
)
55+
assert not list(_unused_names(project))
56+
57+
def test_bare_specifier_namespace_import_cannot_mark_used(self, tmp_path):
58+
# A namespace import from an unresolvable package ('lodash') says
59+
# nothing about local modules — utils.ts must still be reported.
60+
project = _make_project(
61+
tmp_path,
62+
"import * as _ from 'lodash';\n",
63+
)
64+
flagged = _flagged_names(project)
65+
assert "helper" in flagged
66+
assert "RATE" in flagged
67+
68+
69+
class TestSideEffectImports:
70+
def test_side_effect_import_marks_all_exports_used(self, tmp_path):
71+
project = _make_project(tmp_path, "import './utils';\n\nconst app = 'app';\n")
72+
assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")]
73+
74+
def test_no_consumer_still_flags_exports(self, tmp_path):
75+
# Control: without any consumer, the exports must still be detected —
76+
# guards against the fix over-marking everything as used.
77+
project = _make_project(tmp_path, "export const unrelated = 1;\n")
78+
flagged = _flagged_names(project)
79+
assert "helper" in flagged
80+
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)