diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdfa09..c44dabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Beta badge and star CTA in README header - npm keywords optimized for discoverability (15 terms) +### Fixed + +- Re-exported symbols are no longer reported as unused dead code. Barrel/index + files that forward exports (`export { X } from './mod'`, `export { X as Y } from + './mod'`, `export { type X } from './mod'`, and `export * from './mod'`) now mark + the forwarded symbols as used, preventing false-positive `removable` findings that + could delete live public API. + ### Changed - npm package renamed for consistency diff --git a/LICENSE b/LICENSE index 2e0fcb2..3c4d9d8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright 2026 Revenue Holdings +Copyright (c) 2025 Coding-Dev-Tools 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: diff --git a/package.json b/package.json index d6a7e2b..560949a 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "deadcode-cli", "version": "0.1.1", "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.", - "author": "Revenue Holdings ", - "license": "MIT", + "author": "Coding-Dev-Tools", + "license" "repository": { "type": "git", "url": "https://github.com/Coding-Dev-Tools/deadcode.git" diff --git a/pyproject.toml b/pyproject.toml index 82c3726..ac2d4e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "CLI tool to detect and auto-remove unused exports, dead routes, o readme = "README.md" requires-python = ">=3.10" license = "MIT" -authors = [{name = "DevForge"}] +authors = [{name = "Coding-Dev-Tools"}] keywords = ["dead-code", "unused-exports", "typescript", "react", "nextjs", "cli", "css", "tree-shaking"] classifiers = [ "Development Status :: 4 - Beta", diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index 36de8c4..c1a5e1c 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -58,17 +58,24 @@ def unreferenced_components(self) -> list[Finding]: # ── Patterns ────────────────────────────────────────────────────────── -# export const/let/var/function/class/type/interface/enum +# export const/let/var/function/class/type/interface/enum. +# Deliberately does NOT match `export default ...`: with `default` in the +# alternation the capture group grabbed the keyword `function`/`class` as +# the export name, flagging every default export as removable dead code. +# Default exports are entry-point conventions and are skipped entirely. _EXPORT_PATTERN = re.compile( r"^\s*export\s+" - r"(?:const|let|var|function|class|type|interface|enum|default)\s+" + r"(?:const|let|var|function|class|type|interface|enum)\s+" r"([A-Za-z_$][\w$]*)", re.MULTILINE, ) # export { name } — may span multiple lines; [^}] matches newlines too _EXPORT_LIST_PATTERN = re.compile( - r"export\s*\{([^}]+)\}", + # `(?!\s*from)` excludes re-export forwarding (`export { X } from '...'`), + # which is handled by _REEXPORT_PATTERN as a *use* of the source module's + # exports rather than a new local export definition. + r"export\s*\{([^}]+)\}(?!\s*from)", re.DOTALL, ) @@ -88,8 +95,29 @@ def unreferenced_components(self) -> list[Finding]: ) # import statements +# Handles: import {Foo} from ..., import Foo from ..., import type {Foo} from ..., +# import Default, {Named} from ..., import {type Foo} from ... +# Groups: 1 = first named block content (e.g. "Foo, Bar"), 2 = default import name, +# 3 = optional named block after comma default, 4 = module specifier _IMPORT_PATTERN = re.compile( - r"import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['\"]([^'\"]+)['\"]", + r"import\s+" + r"(?:type\s+)?" + r"(?:" + r"\{([^}]+)\}" # group 1: named imports {Foo, type Bar} + r"|" + r"(\w+(?:\s+as\s+\w+)?)" # group 2: default import (Foo or Foo as Bar) + r")" + r"(?:\s*,\s*\{([^}]+)\})?" # group 3: optional named after default + r"\s+from\s+['\"]([^'\"]+)['\"]", +) + +# Re-export forwarding: `export { A, B as C } from './mod'` and `export * from './mod'`. +# A barrel/index file that re-exports a symbol is *consuming* it from the source +# module, so the source's export must not be flagged as unused. `[^}]*` matches +# newlines (with re.DOTALL) for multi-line re-export blocks. +_REEXPORT_PATTERN = re.compile( + r"export\s*(?:\{([^}]*)\}|\*(?:\s+as\s+\w+)?)\s*from\s*['\"]([^'\"]+)['\"]", + re.DOTALL, ) # className="..." or className={...} in JSX @@ -124,9 +152,7 @@ def __init__( ) self.include_spec = None if include_patterns: - self.include_spec = pathspec.PathSpec.from_lines( - "gitignore", include_patterns - ) + self.include_spec = pathspec.PathSpec.from_lines("gitignore", include_patterns) @staticmethod def _default_ignore_patterns() -> list[str]: @@ -164,6 +190,7 @@ def scan(self) -> ScanResult: used_css_classes: set[str] = set() components: dict[str, str] = {} # ComponentName -> file routes: list[tuple[str, str]] = [] # (route_path, file) + star_reexports: list[tuple[str, str]] = [] # (barrel_file, module_spec) for filepath in all_files: try: @@ -180,6 +207,10 @@ def scan(self) -> ScanResult: # Parse imports self._parse_imports(content, rel_path, imports) + # Parse re-exports (barrel/index forwarding) so re-exported symbols + # are counted as used and not reported as removable dead code. + self._parse_reexports(content, rel_path, imports, star_reexports) + # Parse CSS classes (from .css/.scss/.module.css files) if self._is_css_file(rel_path): self._parse_css_classes(content, rel_path, css_classes) @@ -199,8 +230,18 @@ def scan(self) -> ScanResult: # Phase 2: Detect dead code + # Resolve `export * from './mod'` specifiers to scanned files so that + # every export forwarded by a barrel is treated as part of the public + # API surface (never reported as removable). + file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files} + star_reexported_files: set[str] = set() + for barrel_file, module_spec in star_reexports: + resolved = self._resolve_relative_module(barrel_file, module_spec, file_set) + if resolved: + star_reexported_files.add(resolved) + # 2a. Unused exports - self._find_unused_exports(exports, imports, result) + self._find_unused_exports(exports, imports, result, star_reexported_files) # 2b. Dead routes self._find_dead_routes(routes, all_files, result) @@ -223,21 +264,13 @@ def _collect_files(self) -> list[Path]: # Filter out ignored directories dirs[:] = [ - d - for d in dirs - if not self.ignore_spec.match_file( - f"{rel_root}/{d}/" if rel_root != "." else f"{d}/" - ) + d for d in dirs if not self.ignore_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/") ] # Filter out non-included directories when include_spec is set if self.include_spec: dirs[:] = [ - d - for d in dirs - if self.include_spec.match_file( - f"{rel_root}/{d}/" if rel_root != "." else f"{d}/" - ) + d for d in dirs if self.include_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/") ] for fname in filenames: @@ -272,9 +305,7 @@ def _is_scannable_file(rel_path: str) -> bool: def _is_css_file(rel_path: str) -> bool: return rel_path.endswith((".css", ".scss", ".module.css")) - def _parse_exports( - self, content: str, rel_path: str, exports: dict[str, list[tuple[str, int]]] - ) -> None: + def _parse_exports(self, content: str, rel_path: str, exports: dict[str, list[tuple[str, int]]]) -> None: """Extract export names from a file. Handles both single-line forms:: @@ -310,27 +341,101 @@ def _parse_exports( if name and re.match(r"^[A-Za-z_$][\w$]*$", name): exports.setdefault(name, []).append((rel_path, line_num)) - def _parse_imports( - self, content: str, rel_path: str, imports: dict[str, set[str]] - ) -> None: - """Extract import names from a file.""" + def _parse_imports(self, content: str, rel_path: str, imports: dict[str, set[str]]) -> None: + """Extract import names from a file. + + Handles: named imports (group 1), default imports (group 2), + and optional trailing named block (group 3, e.g. ``import React, { Foo }``). + Named-block entries prefixed with ``type `` are stripped to the canonical name. + """ for m in _IMPORT_PATTERN.finditer(content): - # Named imports: import { Foo, Bar } from '...' - if m.group(1): - names = [ - n.strip().split(" as ")[0].strip() for n in m.group(1).split(",") - ] - for name in names: - if name: - imports.setdefault(name, set()).add(rel_path) - # Default import: import Foo from '...' - elif m.group(2): - name = m.group(2) - imports.setdefault(name, set()).add(rel_path) - - def _parse_css_classes( - self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]] + named_block = m.group(1) # {Foo, Bar} content + default_name = m.group(2) # React or type + named_block2 = m.group(3) # optional second {Foo, Bar} after comma + + # Process first named block (from direct {Foo} or import type {Foo}) + if named_block: + for entry in named_block.split(","): + name = entry.strip() + if not name: + continue + canonical = name[5:].strip() if name.startswith("type ") else name + if canonical: + imports.setdefault(canonical, set()).add(rel_path) + + # Process default import name (but skip bare "type" keyword) + if default_name and default_name != "type": + imports.setdefault(default_name, set()).add(rel_path) + + # Process optional named block after comma (Default, {Foo}) + if named_block2: + for entry in named_block2.split(","): + name = entry.strip() + if not name: + continue + canonical = name[5:].strip() if name.startswith("type ") else name + if canonical: + imports.setdefault(canonical, set()).add(rel_path) + + def _parse_reexports( + self, + content: str, + rel_path: str, + imports: dict[str, set[str]], + star_reexports: list[tuple[str, str]], ) -> None: + """Record re-export forwarding so barrel/index files 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. + """ + for m in _REEXPORT_PATTERN.finditer(content): + named = m.group(1) + module_path = m.group(2) + if named is not None: + for entry in named.split(","): + entry = entry.strip() + if not entry: + continue + # `A as B` re-exports A (the left-hand source name) as B. + source = entry.split(" as ")[0].strip() + if source.startswith("type "): + source = source[5:].strip() + if source and re.match(r"^[A-Za-z_$][\w$]*$", source): + imports.setdefault(source, set()).add(rel_path) + else: + # `export * from './mod'` — resolved to a file in phase 2. + star_reexports.append((rel_path, module_path)) + + @staticmethod + def _resolve_relative_module(importer_rel: str, spec: str, file_set: set[str]) -> str | None: + """Resolve a relative module specifier to a scanned file's rel path. + + Returns ``None`` for bare/package specifiers (e.g. ``'react'``) or when + no matching scanned file exists. Tries the literal path, then common + TS/JS extensions, then an ``index.*`` barrel inside a directory. + """ + if not spec.startswith("."): + return None + base = os.path.dirname(importer_rel) + target = os.path.normpath(os.path.join(base, spec)).replace("\\", "/") + if target in file_set: + return target + exts = (".ts", ".tsx", ".js", ".jsx") + for e in exts: + if target + e in file_set: + return target + e + for e in exts: + candidate = f"{target}/index{e}" + if candidate in file_set: + return candidate + return None + + def _parse_css_classes(self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]]) -> None: """Extract CSS class names defined in a stylesheet.""" for i, line in enumerate(content.splitlines(), 1): 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 for cls in group.split(): used_css_classes.add(cls) - def _parse_components( - self, content: str, rel_path: str, components: dict[str, str] - ) -> None: + def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None: """Extract React component definitions.""" for m in _COMPONENT_PATTERN.finditer(content): name = m.group(1) @@ -371,6 +474,7 @@ def _find_unused_exports( exports: dict[str, list[tuple[str, int]]], imports: dict[str, set[str]], result: ScanResult, + star_reexported_files: set[str] | None = None, ) -> None: """Find exports that are never imported elsewhere.""" # Special names that are entry points or conventions @@ -392,9 +496,16 @@ def _find_unused_exports( "generateStaticParams", } + star_reexported_files = star_reexported_files or set() + for name, locations in exports.items(): if name in skip_names: continue + # A symbol defined in a file that is `export *`-forwarded by a barrel + # is part of the public API surface and must not be reported as + # removable dead code. + if any(loc_file in star_reexported_files for loc_file, _ in locations): + continue # If imported by at least one other file, it's used importers = imports.get(name, set()) exporter_files = {loc[0] for loc in locations} @@ -423,9 +534,7 @@ def _find_dead_routes( return # Build set of all route paths referenced in links - link_pattern = re.compile( - r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']' - ) + link_pattern = re.compile(r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']') referenced_routes: set[str] = set() for filepath in all_files: diff --git a/tests/test_config_and_fixes.py b/tests/test_config_and_fixes.py index 23c442e..035346a 100644 --- a/tests/test_config_and_fixes.py +++ b/tests/test_config_and_fixes.py @@ -584,9 +584,9 @@ def test_ruff_known_first_party(self): from pathlib import Path try: - import tomllib # Python >=3.11 - except ModuleNotFoundError: - import tomli as tomllib # Python 3.10 backport + import tomllib + except ModuleNotFoundError: # Python 3.10 (requires-python >= 3.10) + import tomli as tomllib pyproject = Path(__file__).parent.parent / "pyproject.toml" with open(pyproject, "rb") as f: @@ -604,9 +604,9 @@ def test_package_data_includes_py_typed(self): from pathlib import Path try: - import tomllib # Python >=3.11 - except ModuleNotFoundError: - import tomli as tomllib # Python 3.10 backport + import tomllib + except ModuleNotFoundError: # Python 3.10 (requires-python >= 3.10) + import tomli as tomllib pyproject = Path(__file__).parent.parent / "pyproject.toml" with open(pyproject, "rb") as f: diff --git a/tests/test_scanner.py b/tests/test_scanner.py index adfbc60..0ddfbbb 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -179,6 +179,62 @@ def test_used_exports_not_reported(self, tmp_path): assert "myFunc" not in unused_names + def test_type_import_counts_as_used(self, tmp_path): + """import type { Foo } should mark Foo as used.""" + mod = tmp_path / "mod.ts" + mod.write_text('export type Foo = string;\n') + app = tmp_path / "app.ts" + app.write_text('import type { Foo } from "./mod";\nconst x: Foo = "hello";\n') + + scanner = DeadCodeScanner(tmp_path) + result = scanner.scan() + + unused_names = {f.name for f in result.unused_exports} + assert "Foo" not in unused_names + + def test_mixed_default_and_named_import_counts_as_used(self, tmp_path): + """Default + named should mark both as used.""" + mod = tmp_path / "mod.ts" + mod.write_text('export function myFunc() { return 1; }\n') + app = tmp_path / "app.ts" + app.write_text('import Default, { myFunc } from "./mod";\nmyFunc();\n') + + scanner = DeadCodeScanner(tmp_path) + result = scanner.scan() + + unused_names = {f.name for f in result.unused_exports} + assert "myFunc" not in unused_names + assert "Default" not in unused_names + + def test_type_only_import_marks_as_used(self, tmp_path): + """`import { type Foo } from ...` should mark Foo as used.""" + mod = tmp_path / "mod.ts" + mod.write_text('export type Foo = string;\n') + app = tmp_path / "app.ts" + app.write_text('import { type Foo } from "./mod";\nconst x: Foo = "hi";\n') + + scanner = DeadCodeScanner(tmp_path) + result = scanner.scan() + + unused_names = {f.name for f in result.unused_exports} + assert "Foo" not in unused_names + + def test_mixed_default_and_type_only_import_marks_as_used(self, tmp_path): + """`import Default, { type Foo } from ...` should mark Foo as used.""" + mod = tmp_path / "mod.ts" + mod.write_text('export default function Default() { return 1; }\nexport type Foo = string;\n') + app = tmp_path / "app.ts" + app.write_text('import Default, { type Foo } from "./mod";\nconst x: Foo = "hi";\n') + + scanner = DeadCodeScanner(tmp_path) + result = scanner.scan() + + unused_names = {f.name for f in result.unused_exports} + assert "Default" not in unused_names + assert "Foo" not in unused_names + + + class TestCSSParsing: def test_orphaned_css_detection(self, tmp_path): css = tmp_path / "styles.css" @@ -568,3 +624,120 @@ def test_format_pretty_default(self, runner, sample_project): result = runner.invoke(cli, ["-p", str(sample_project), "scan"]) assert result.exit_code == 0 assert "DeadCode Scan" in result.output + + +class TestReexportForwarding: + """Re-exports (barrel/index files) must count the forwarded symbols as used. + + A dead-code tool that flags re-exported symbols as removable is dangerous: + removing them breaks the barrel's public API. These tests pin the behaviour. + """ + + def test_named_reexport_marks_source_as_used(self, tmp_path): + src = tmp_path / "foo.ts" + src.write_text('export function helper() { return 1; }\n') + index = tmp_path / "index.ts" + index.write_text('export { helper } from "./foo";\n') + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + assert "helper" not in unused + + def test_renamed_reexport_marks_source_as_used(self, tmp_path): + src = tmp_path / "foo.ts" + src.write_text('export function helper() { return 1; }\n') + index = tmp_path / "index.ts" + index.write_text('export { helper as primaryHelper } from "./foo";\n') + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + assert "helper" not in unused + + def test_type_named_reexport_marks_source_as_used(self, tmp_path): + src = tmp_path / "types.ts" + src.write_text('export type Foo = string;\n') + index = tmp_path / "index.ts" + index.write_text('export { type Foo } from "./types";\n') + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + assert "Foo" not in unused + + def test_multiline_named_reexport(self, tmp_path): + src = tmp_path / "foo.ts" + src.write_text( + 'export const alpha = 1;\n' + 'export const beta = 2;\n' + ) + index = tmp_path / "index.ts" + index.write_text( + 'export {\n' + ' alpha,\n' + ' beta,\n' + '} from "./foo";\n' + ) + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + assert "alpha" not in unused + assert "beta" not in unused + + def test_star_reexport_marks_all_source_exports_as_used(self, tmp_path): + src = tmp_path / "widgets.ts" + src.write_text( + 'export function widgetA() {}\n' + 'export function widgetB() {}\n' + ) + index = tmp_path / "index.ts" + index.write_text('export * from "./widgets";\n') + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + assert "widgetA" not in unused + assert "widgetB" not in unused + + def test_star_reexport_from_directory_index(self, tmp_path): + mod = tmp_path / "widgets" / "index.ts" + mod.parent.mkdir(parents=True, exist_ok=True) + mod.write_text('export function widgetA() {}\n') + index = tmp_path / "index.ts" + index.write_text('export * from "./widgets";\n') + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + assert "widgetA" not in unused + + def test_local_export_list_still_reported_when_unused(self, tmp_path): + # Regression guard: a *local* `export { ... }` (no `from`) must still be + # tracked and flagged when unused — the re-export fix must not suppress it. + f = tmp_path / "test.ts" + f.write_text( + 'const alpha = 1;\n' + 'const beta = 2;\n' + 'export { alpha, beta };\n' + ) + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + assert "alpha" in unused + assert "beta" in unused + + def test_reexport_from_package_does_not_crash_or_falsely_mark(self, tmp_path): + # Re-export from a bare package specifier must be ignored for resolution. + src = tmp_path / "foo.ts" + src.write_text('export function localOnly() {}\n') + index = tmp_path / "index.ts" + index.write_text('export { useState } from "react";\n') + + result = DeadCodeScanner(tmp_path).scan() + + unused = {f.name for f in result.unused_exports} + # The project-local export nobody consumes is still correctly flagged. + assert "localOnly" in unused