Skip to content

Commit 39829b7

Browse files
Coding-Dev-ToolsDevForge Engineercowork-bot
authored
Fix import parsing: handle import type {Foo}, mixed default+named, fix group-index reversal (#47)
* 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 --------- Co-authored-by: DevForge Engineer <engineer@devforge.dev> Co-authored-by: cowork-bot <bot@coding-dev-tools.local>
1 parent afa69fa commit 39829b7

7 files changed

Lines changed: 346 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- Beta badge and star CTA in README header
2525
- npm keywords optimized for discoverability (15 terms)
2626

27+
### Fixed
28+
29+
- Re-exported symbols are no longer reported as unused dead code. Barrel/index
30+
files that forward exports (`export { X } from './mod'`, `export { X as Y } from
31+
'./mod'`, `export { type X } from './mod'`, and `export * from './mod'`) now mark
32+
the forwarded symbols as used, preventing false-positive `removable` findings that
33+
could delete live public API.
34+
2735
### Changed
2836

2937
- npm package renamed for consistency

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright 2026 Revenue Holdings
3+
Copyright (c) 2025 Coding-Dev-Tools
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
66

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"name": "deadcode-cli",
33
"version": "0.1.1",
44
"description": "Find unused/dead code in TypeScript, React, and Next.js projects. Scans for orphaned exports, dead routes, unreferenced components, and unused CSS module classes.",
5-
"author": "Revenue Holdings <engineering@revenueholdings.dev>",
6-
"license": "MIT",
5+
"author": "Coding-Dev-Tools",
6+
"license"
77
"repository": {
88
"type": "git",
99
"url": "https://github.com/Coding-Dev-Tools/deadcode.git"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description = "CLI tool to detect and auto-remove unused exports, dead routes, o
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = "MIT"
12-
authors = [{name = "DevForge"}]
12+
authors = [{name = "Coding-Dev-Tools"}]
1313
keywords = ["dead-code", "unused-exports", "typescript", "react", "nextjs", "cli", "css", "tree-shaking"]
1414
classifiers = [
1515
"Development Status :: 4 - Beta",

src/deadcode/scanner.py

Lines changed: 155 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,24 @@ def unreferenced_components(self) -> list[Finding]:
5858

5959
# ── Patterns ──────────────────────────────────────────────────────────
6060

61-
# export const/let/var/function/class/type/interface/enum
61+
# export const/let/var/function/class/type/interface/enum.
62+
# Deliberately does NOT match `export default ...`: with `default` in the
63+
# alternation the capture group grabbed the keyword `function`/`class` as
64+
# the export name, flagging every default export as removable dead code.
65+
# Default exports are entry-point conventions and are skipped entirely.
6266
_EXPORT_PATTERN = re.compile(
6367
r"^\s*export\s+"
64-
r"(?:const|let|var|function|class|type|interface|enum|default)\s+"
68+
r"(?:const|let|var|function|class|type|interface|enum)\s+"
6569
r"([A-Za-z_$][\w$]*)",
6670
re.MULTILINE,
6771
)
6872

6973
# export { name } — may span multiple lines; [^}] matches newlines too
7074
_EXPORT_LIST_PATTERN = re.compile(
71-
r"export\s*\{([^}]+)\}",
75+
# `(?!\s*from)` excludes re-export forwarding (`export { X } from '...'`),
76+
# which is handled by _REEXPORT_PATTERN as a *use* of the source module's
77+
# exports rather than a new local export definition.
78+
r"export\s*\{([^}]+)\}(?!\s*from)",
7279
re.DOTALL,
7380
)
7481

@@ -88,8 +95,29 @@ def unreferenced_components(self) -> list[Finding]:
8895
)
8996

9097
# import statements
98+
# Handles: import {Foo} from ..., import Foo from ..., import type {Foo} from ...,
99+
# import Default, {Named} from ..., import {type Foo} from ...
100+
# Groups: 1 = first named block content (e.g. "Foo, Bar"), 2 = default import name,
101+
# 3 = optional named block after comma default, 4 = module specifier
91102
_IMPORT_PATTERN = re.compile(
92-
r"import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['\"]([^'\"]+)['\"]",
103+
r"import\s+"
104+
r"(?:type\s+)?"
105+
r"(?:"
106+
r"\{([^}]+)\}" # group 1: named imports {Foo, type Bar}
107+
r"|"
108+
r"(\w+(?:\s+as\s+\w+)?)" # group 2: default import (Foo or Foo as Bar)
109+
r")"
110+
r"(?:\s*,\s*\{([^}]+)\})?" # group 3: optional named after default
111+
r"\s+from\s+['\"]([^'\"]+)['\"]",
112+
)
113+
114+
# Re-export forwarding: `export { A, B as C } from './mod'` and `export * from './mod'`.
115+
# A barrel/index file that re-exports a symbol is *consuming* it from the source
116+
# module, so the source's export must not be flagged as unused. `[^}]*` matches
117+
# newlines (with re.DOTALL) for multi-line re-export blocks.
118+
_REEXPORT_PATTERN = re.compile(
119+
r"export\s*(?:\{([^}]*)\}|\*(?:\s+as\s+\w+)?)\s*from\s*['\"]([^'\"]+)['\"]",
120+
re.DOTALL,
93121
)
94122

95123
# className="..." or className={...} in JSX
@@ -124,9 +152,7 @@ def __init__(
124152
)
125153
self.include_spec = None
126154
if include_patterns:
127-
self.include_spec = pathspec.PathSpec.from_lines(
128-
"gitignore", include_patterns
129-
)
155+
self.include_spec = pathspec.PathSpec.from_lines("gitignore", include_patterns)
130156

131157
@staticmethod
132158
def _default_ignore_patterns() -> list[str]:
@@ -164,6 +190,7 @@ def scan(self) -> ScanResult:
164190
used_css_classes: set[str] = set()
165191
components: dict[str, str] = {} # ComponentName -> file
166192
routes: list[tuple[str, str]] = [] # (route_path, file)
193+
star_reexports: list[tuple[str, str]] = [] # (barrel_file, module_spec)
167194

168195
for filepath in all_files:
169196
try:
@@ -180,6 +207,10 @@ def scan(self) -> ScanResult:
180207
# Parse imports
181208
self._parse_imports(content, rel_path, imports)
182209

210+
# Parse re-exports (barrel/index forwarding) so re-exported symbols
211+
# are counted as used and not reported as removable dead code.
212+
self._parse_reexports(content, rel_path, imports, star_reexports)
213+
183214
# Parse CSS classes (from .css/.scss/.module.css files)
184215
if self._is_css_file(rel_path):
185216
self._parse_css_classes(content, rel_path, css_classes)
@@ -199,8 +230,18 @@ def scan(self) -> ScanResult:
199230

200231
# Phase 2: Detect dead code
201232

233+
# Resolve `export * from './mod'` specifiers to scanned files so that
234+
# every export forwarded by a barrel is treated as part of the public
235+
# API surface (never reported as removable).
236+
file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files}
237+
star_reexported_files: set[str] = set()
238+
for barrel_file, module_spec in star_reexports:
239+
resolved = self._resolve_relative_module(barrel_file, module_spec, file_set)
240+
if resolved:
241+
star_reexported_files.add(resolved)
242+
202243
# 2a. Unused exports
203-
self._find_unused_exports(exports, imports, result)
244+
self._find_unused_exports(exports, imports, result, star_reexported_files)
204245

205246
# 2b. Dead routes
206247
self._find_dead_routes(routes, all_files, result)
@@ -223,21 +264,13 @@ def _collect_files(self) -> list[Path]:
223264

224265
# Filter out ignored directories
225266
dirs[:] = [
226-
d
227-
for d in dirs
228-
if not self.ignore_spec.match_file(
229-
f"{rel_root}/{d}/" if rel_root != "." else f"{d}/"
230-
)
267+
d for d in dirs if not self.ignore_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/")
231268
]
232269

233270
# Filter out non-included directories when include_spec is set
234271
if self.include_spec:
235272
dirs[:] = [
236-
d
237-
for d in dirs
238-
if self.include_spec.match_file(
239-
f"{rel_root}/{d}/" if rel_root != "." else f"{d}/"
240-
)
273+
d for d in dirs if self.include_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/")
241274
]
242275

243276
for fname in filenames:
@@ -272,9 +305,7 @@ def _is_scannable_file(rel_path: str) -> bool:
272305
def _is_css_file(rel_path: str) -> bool:
273306
return rel_path.endswith((".css", ".scss", ".module.css"))
274307

275-
def _parse_exports(
276-
self, content: str, rel_path: str, exports: dict[str, list[tuple[str, int]]]
277-
) -> None:
308+
def _parse_exports(self, content: str, rel_path: str, exports: dict[str, list[tuple[str, int]]]) -> None:
278309
"""Extract export names from a file.
279310
280311
Handles both single-line forms::
@@ -310,27 +341,101 @@ def _parse_exports(
310341
if name and re.match(r"^[A-Za-z_$][\w$]*$", name):
311342
exports.setdefault(name, []).append((rel_path, line_num))
312343

313-
def _parse_imports(
314-
self, content: str, rel_path: str, imports: dict[str, set[str]]
315-
) -> None:
316-
"""Extract import names from a file."""
344+
def _parse_imports(self, content: str, rel_path: str, imports: dict[str, set[str]]) -> None:
345+
"""Extract import names from a file.
346+
347+
Handles: named imports (group 1), default imports (group 2),
348+
and optional trailing named block (group 3, e.g. ``import React, { Foo }``).
349+
Named-block entries prefixed with ``type `` are stripped to the canonical name.
350+
"""
317351
for m in _IMPORT_PATTERN.finditer(content):
318-
# Named imports: import { Foo, Bar } from '...'
319-
if m.group(1):
320-
names = [
321-
n.strip().split(" as ")[0].strip() for n in m.group(1).split(",")
322-
]
323-
for name in names:
324-
if name:
325-
imports.setdefault(name, set()).add(rel_path)
326-
# Default import: import Foo from '...'
327-
elif m.group(2):
328-
name = m.group(2)
329-
imports.setdefault(name, set()).add(rel_path)
330-
331-
def _parse_css_classes(
332-
self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]]
352+
named_block = m.group(1) # {Foo, Bar} content
353+
default_name = m.group(2) # React or type
354+
named_block2 = m.group(3) # optional second {Foo, Bar} after comma
355+
356+
# Process first named block (from direct {Foo} or import type {Foo})
357+
if named_block:
358+
for entry in named_block.split(","):
359+
name = entry.strip()
360+
if not name:
361+
continue
362+
canonical = name[5:].strip() if name.startswith("type ") else name
363+
if canonical:
364+
imports.setdefault(canonical, set()).add(rel_path)
365+
366+
# Process default import name (but skip bare "type" keyword)
367+
if default_name and default_name != "type":
368+
imports.setdefault(default_name, set()).add(rel_path)
369+
370+
# Process optional named block after comma (Default, {Foo})
371+
if named_block2:
372+
for entry in named_block2.split(","):
373+
name = entry.strip()
374+
if not name:
375+
continue
376+
canonical = name[5:].strip() if name.startswith("type ") else name
377+
if canonical:
378+
imports.setdefault(canonical, set()).add(rel_path)
379+
380+
def _parse_reexports(
381+
self,
382+
content: str,
383+
rel_path: str,
384+
imports: dict[str, set[str]],
385+
star_reexports: list[tuple[str, str]],
333386
) -> None:
387+
"""Record re-export forwarding so barrel/index files don't false-positive.
388+
389+
``export { A, B as C } from './mod'`` consumes ``A`` and ``B`` from
390+
``./mod``; the consumed (left-hand) names are registered as imports of
391+
this file so the source module's exports are not reported as unused.
392+
``export * from './mod'`` forwards every export of ``./mod``; the
393+
(file, module) pair is recorded so those exports can be treated as used
394+
once ``./mod`` is resolved to a scanned file.
395+
"""
396+
for m in _REEXPORT_PATTERN.finditer(content):
397+
named = m.group(1)
398+
module_path = m.group(2)
399+
if named is not None:
400+
for entry in named.split(","):
401+
entry = entry.strip()
402+
if not entry:
403+
continue
404+
# `A as B` re-exports A (the left-hand source name) as B.
405+
source = entry.split(" as ")[0].strip()
406+
if source.startswith("type "):
407+
source = source[5:].strip()
408+
if source and re.match(r"^[A-Za-z_$][\w$]*$", source):
409+
imports.setdefault(source, set()).add(rel_path)
410+
else:
411+
# `export * from './mod'` — resolved to a file in phase 2.
412+
star_reexports.append((rel_path, module_path))
413+
414+
@staticmethod
415+
def _resolve_relative_module(importer_rel: str, spec: str, file_set: set[str]) -> str | None:
416+
"""Resolve a relative module specifier to a scanned file's rel path.
417+
418+
Returns ``None`` for bare/package specifiers (e.g. ``'react'``) or when
419+
no matching scanned file exists. Tries the literal path, then common
420+
TS/JS extensions, then an ``index.*`` barrel inside a directory.
421+
"""
422+
if not spec.startswith("."):
423+
return None
424+
base = os.path.dirname(importer_rel)
425+
target = os.path.normpath(os.path.join(base, spec)).replace("\\", "/")
426+
if target in file_set:
427+
return target
428+
exts = (".ts", ".tsx", ".js", ".jsx")
429+
for e in exts:
430+
if target + e in file_set:
431+
return target + e
432+
for e in exts:
433+
candidate = f"{target}/index{e}"
434+
if candidate in file_set:
435+
return candidate
436+
return None
437+
438+
def _parse_css_classes(self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]]) -> None:
334439
"""Extract CSS class names defined in a stylesheet."""
335440
for i, line in enumerate(content.splitlines(), 1):
336441
for m in _CSS_CLASS_PATTERN.finditer(line):
@@ -345,9 +450,7 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No
345450
for cls in group.split():
346451
used_css_classes.add(cls)
347452

348-
def _parse_components(
349-
self, content: str, rel_path: str, components: dict[str, str]
350-
) -> None:
453+
def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None:
351454
"""Extract React component definitions."""
352455
for m in _COMPONENT_PATTERN.finditer(content):
353456
name = m.group(1)
@@ -371,6 +474,7 @@ def _find_unused_exports(
371474
exports: dict[str, list[tuple[str, int]]],
372475
imports: dict[str, set[str]],
373476
result: ScanResult,
477+
star_reexported_files: set[str] | None = None,
374478
) -> None:
375479
"""Find exports that are never imported elsewhere."""
376480
# Special names that are entry points or conventions
@@ -392,9 +496,16 @@ def _find_unused_exports(
392496
"generateStaticParams",
393497
}
394498

499+
star_reexported_files = star_reexported_files or set()
500+
395501
for name, locations in exports.items():
396502
if name in skip_names:
397503
continue
504+
# A symbol defined in a file that is `export *`-forwarded by a barrel
505+
# is part of the public API surface and must not be reported as
506+
# removable dead code.
507+
if any(loc_file in star_reexported_files for loc_file, _ in locations):
508+
continue
398509
# If imported by at least one other file, it's used
399510
importers = imports.get(name, set())
400511
exporter_files = {loc[0] for loc in locations}
@@ -423,9 +534,7 @@ def _find_dead_routes(
423534
return
424535

425536
# Build set of all route paths referenced in links
426-
link_pattern = re.compile(
427-
r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']'
428-
)
537+
link_pattern = re.compile(r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']')
429538
referenced_routes: set[str] = set()
430539

431540
for filepath in all_files:

tests/test_config_and_fixes.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -584,9 +584,9 @@ def test_ruff_known_first_party(self):
584584
from pathlib import Path
585585

586586
try:
587-
import tomllib # Python >=3.11
588-
except ModuleNotFoundError:
589-
import tomli as tomllib # Python 3.10 backport
587+
import tomllib
588+
except ModuleNotFoundError: # Python 3.10 (requires-python >= 3.10)
589+
import tomli as tomllib
590590

591591
pyproject = Path(__file__).parent.parent / "pyproject.toml"
592592
with open(pyproject, "rb") as f:
@@ -604,9 +604,9 @@ def test_package_data_includes_py_typed(self):
604604
from pathlib import Path
605605

606606
try:
607-
import tomllib # Python >=3.11
608-
except ModuleNotFoundError:
609-
import tomli as tomllib # Python 3.10 backport
607+
import tomllib
608+
except ModuleNotFoundError: # Python 3.10 (requires-python >= 3.10)
609+
import tomli as tomllib
610610

611611
pyproject = Path(__file__).parent.parent / "pyproject.toml"
612612
with open(pyproject, "rb") as f:

0 commit comments

Comments
 (0)