From 2db2c27d25bfe93750f661f139b55269d45d548a Mon Sep 17 00:00:00 2001 From: DevForge Engineer Date: Wed, 8 Jul 2026 01:34:02 -0400 Subject: [PATCH 1/5] cowork-bot: standardize copyright holder to 2025 Coding-Dev-Tools (was Revenue Holdings / stale 2026 year); W-directed fleet-wide pass --- LICENSE | 2 +- package.json | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 c6247a7..1e56902 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "deadcode-cli", "version": "0.1.1", "description": "Find unused/dead code in Python projects. Static analysis tool to identify orphaned functions, classes, and imports.", - "author": "Revenue Holdings ", + "author": "Coding-Dev-Tools ", "license": "MIT", "repository": { "type": "git", diff --git a/pyproject.toml b/pyproject.toml index d912d35..676947e 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 = "Revenue Holdings"}] +authors = [{name = "Coding-Dev-Tools"}] keywords = ["dead-code", "unused-exports", "typescript", "react", "nextjs", "cli", "css", "tree-shaking"] classifiers = [ "Development Status :: 4 - Beta", From 0cc8c7929cfa8ca5de1c44f6d73cb4fbef4be64c Mon Sep 17 00:00:00 2001 From: DevForge Engineer Date: Fri, 10 Jul 2026 09:39:20 -0400 Subject: [PATCH 2/5] chore: standardize license and package metadata; scanner/test fixes --- src/deadcode/scanner.py | 8 ++++++-- tests/test_config_and_fixes.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index 4aa40a8..57d0e90 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -58,10 +58,14 @@ 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, ) diff --git a/tests/test_config_and_fixes.py b/tests/test_config_and_fixes.py index 4d737ea..b882ca7 100644 --- a/tests/test_config_and_fixes.py +++ b/tests/test_config_and_fixes.py @@ -517,7 +517,10 @@ def test_ruff_known_first_party(self): """ruff known-first-party should be ['deadcode'], not ['*'].""" from pathlib import Path - import tomllib + try: + 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: @@ -530,7 +533,10 @@ def test_package_data_includes_py_typed(self): """pyproject.toml should have package-data config for py.typed.""" from pathlib import Path - import tomllib + try: + 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: From 1c4ef903c6e24bb6fb748e3776245bf497e54232 Mon Sep 17 00:00:00 2001 From: DevForge Engineer Date: Fri, 10 Jul 2026 10:40:28 -0400 Subject: [PATCH 3/5] cowork-bot: treat type-only named imports as used in dead-code scan --- src/deadcode/scanner.py | 4 +++- tests/test_scanner.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index 57d0e90..2d3ecf4 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -323,7 +323,9 @@ def _parse_imports( names = [n.strip().split(" as ")[0].strip() for n in named_imports.split(",")] for name in names: if name: - imports.setdefault(name, set()).add(rel_path) + canonical = name[5:].strip() if name.startswith("type ") else name + if canonical: + imports.setdefault(canonical, set()).add(rel_path) def _parse_css_classes( self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]] diff --git a/tests/test_scanner.py b/tests/test_scanner.py index ff3006b..cefd009 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -199,7 +199,7 @@ def test_type_import_counts_as_used(self, tmp_path): assert "Foo" not in unused_names def test_mixed_default_and_named_import_counts_as_used(self, tmp_path): - """import默认 + named should mark both as used.""" + """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" @@ -212,6 +212,33 @@ def test_mixed_default_and_named_import_counts_as_used(self, tmp_path): 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): From 6645ff0367811b21082aaee76ff3f029432b011c Mon Sep 17 00:00:00 2001 From: cowork-bot Date: Sat, 11 Jul 2026 08:52:37 -0400 Subject: [PATCH 4/5] 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. --- CHANGELOG.md | 8 +++ src/deadcode/scanner.py | 103 +++++++++++++++++++++++++++++++++-- tests/test_scanner.py | 117 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 3 deletions(-) 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/src/deadcode/scanner.py b/src/deadcode/scanner.py index 2d3ecf4..4977775 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -72,7 +72,10 @@ def unreferenced_components(self) -> list[Finding]: # 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, ) @@ -96,6 +99,15 @@ def unreferenced_components(self) -> list[Finding]: r"import\s+(?:type\s+)?(?:(\w+))?\s*,?\s*(?:\{([^}]+)\})?\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 _CLASSNAME_PATTERN = re.compile( r"class(?:Name)?\s*[=:]\s*['\"]([^'\"]+)['\"]|" @@ -168,6 +180,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: @@ -184,6 +197,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) @@ -203,8 +220,20 @@ 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) @@ -315,7 +344,7 @@ def _parse_imports( for m in _IMPORT_PATTERN.finditer(content): default_import = m.group(1) named_imports = m.group(2) - module_path = m.group(3) + # m.group(3) is the module specifier; imports are tracked by name only. if default_import: imports.setdefault(default_import, set()).add(rel_path) @@ -327,6 +356,66 @@ def _parse_imports( 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: @@ -370,6 +459,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 @@ -379,9 +469,16 @@ def _find_unused_exports( "loader", "action", "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} diff --git a/tests/test_scanner.py b/tests/test_scanner.py index cefd009..38149dd 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -595,3 +595,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 From 1800931b9c1e2e23ebfe7e3e4d1e5da9a4c547c8 Mon Sep 17 00:00:00 2001 From: cowork-bot Date: Mon, 13 Jul 2026 23:14:30 -0400 Subject: [PATCH 5/5] =?UTF-8?q?cowork-bot:=20fix=20import=20parsing=20in?= =?UTF-8?q?=20scanner=20=E2=80=94=20handle=20import=20type=20{Foo},=20mixe?= =?UTF-8?q?d=20default+named=20imports,=20and=20correct=20group-index=20re?= =?UTF-8?q?versal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/deadcode/scanner.py | 107 +++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index fb407b2..c1a5e1c 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -95,8 +95,20 @@ 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'`. @@ -140,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]: @@ -223,9 +233,7 @@ def scan(self) -> ScanResult: # 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 - } + 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) @@ -256,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: @@ -305,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:: @@ -343,25 +341,41 @@ 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.""" - for m in _IMPORT_PATTERN.finditer(content): - - default_import = m.group(1) - named_imports = m.group(2) - # m.group(3) is the module specifier; imports are tracked by name only. + def _parse_imports(self, content: str, rel_path: str, imports: dict[str, set[str]]) -> None: + """Extract import names from a file. - if default_import: - imports.setdefault(default_import, set()).add(rel_path) - if named_imports: - names = [n.strip().split(" as ")[0].strip() for n in named_imports.split(",")] - for name in names: - if name: - canonical = name[5:].strip() if name.startswith("type ") else name - if canonical: - imports.setdefault(canonical, set()).add(rel_path) + 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_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, @@ -398,9 +412,7 @@ def _parse_reexports( star_reexports.append((rel_path, module_path)) @staticmethod - def _resolve_relative_module( - importer_rel: str, spec: str, file_set: set[str] - ) -> str | None: + 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 @@ -423,10 +435,7 @@ def _resolve_relative_module( return candidate return None - - def _parse_css_classes( - self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]] - ) -> 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): @@ -441,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) @@ -527,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: