diff --git a/CHANGELOG.md b/CHANGELOG.md
index c44dabd..dd26d53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
'./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.
+- CSS-module classes consumed via the object-accessor pattern
+ (`import styles from './x.module.css';
`) are now
+ treated as used. Previously every `*.module.css` class was falsely reported as
+ orphaned CSS and marked `removable=True`, risking deletion of live styles. Bracket
+ accessors (`styles['card-hover']`) are also recognized; non-module object access
+ (e.g. `util.foo`) is not over-matched.
### Changed
diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py
index c1a5e1c..eaa2351 100644
--- a/src/deadcode/scanner.py
+++ b/src/deadcode/scanner.py
@@ -132,6 +132,21 @@ def unreferenced_components(self) -> list[Finding]:
r'"([^"]+)"\s*:\s*\["([^"]+)"\]',
)
+# CSS-module accessor usage, two forms::
+# - dot: `styles.card` -> capture group 1 empty, full = "styles.card"
+# - bracket: `styles['card-hover']` / `styles["card"]` -> capture group 1 = name
+# We deliberately match either form after a binding identifier so both
+# `styles.card` and `styles['card-hover']` register the class as used.
+_CSS_MODULE_USAGE_PATTERN = re.compile(
+ r"""\b\w+\.(?:_?[\w$]+)|(\w+)\[['"]([\w$-]+)['"]\]""",
+)
+
+# Detect a CSS-module import so we know which source-file class accessors
+# correspond to module classes (``import styles from './x.module.css'``).
+_CSS_MODULE_IMPORT_PATTERN = re.compile(
+ r"""import\s+(?:type\s+)?(\w+)\s+from\s+['"]([^'"]*\.module\.css)['"]""",
+)
+
# ── Scanner ───────────────────────────────────────────────────────────
@@ -218,6 +233,11 @@ def scan(self) -> ScanResult:
# Parse className usage in TSX/JSX files
if rel_path.endswith((".tsx", ".jsx")):
self._parse_classname_usage(content, used_css_classes)
+ # CSS-module accessor usage (styles.card / styles['card']).
+ # Only register accessors imported as a CSS module so we don't
+ # treat every object property access (e.g. `user.name`) as a
+ # CSS class.
+ self._parse_css_module_usage(content, used_css_classes)
# Parse components
if rel_path.endswith((".tsx", ".jsx")):
@@ -450,6 +470,42 @@ 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_css_module_usage(self, content: str, used_css_classes: set[str]) -> None:
+ """Extract CSS-module class names consumed via object accessors.
+
+ The canonical Next.js/React pattern is::
+
+ import styles from './Card.module.css';
+
...
+
+ We first find any ``*.module.css`` imports (binding name ``styles``)
+ so that accessor use ``styles.card`` registers ``card`` as a used
+ class. Bracket access ``styles['card-hover']`` is also captured.
+ Without this, every CSS-module class is falsely reported as orphaned
+ and marked removable=True, risking deletion of live styles.
+ """
+ # Map the local binding (e.g. ``styles``) to a CSS-module import so
+ # only module accessors are treated as class names.
+ module_bindings = {m.group(1) for m in _CSS_MODULE_IMPORT_PATTERN.finditer(content)}
+ if not module_bindings:
+ return
+ for m in _CSS_MODULE_USAGE_PATTERN.finditer(content):
+ groups = m.groups()
+ # Bracket form `styles['card-hover']` -> groups = (binding, name).
+ # Dot form `styles.card` -> groups = (None, None); the whole match
+ # is "binding.attr". Distinguish by whether a bracket name exists.
+ bracket_binding, bracket_name = groups
+ if bracket_name is not None:
+ if bracket_binding in module_bindings:
+ used_css_classes.add(bracket_name)
+ continue
+ # Dot form `styles.card` -> the whole match is "binding.attr".
+ full = m.group(0)
+ binding, _, attr = full.partition(".")
+ if binding in module_bindings and attr and attr[0] != "_":
+ # Skip internal/private-ish accessors (e.g. styles.toString).
+ used_css_classes.add(attr)
+
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):
diff --git a/tests/test_scanner.py b/tests/test_scanner.py
index 0ddfbbb..77db3d6 100644
--- a/tests/test_scanner.py
+++ b/tests/test_scanner.py
@@ -43,33 +43,24 @@ def sample_project(tmp_path):
# src/components/UnusedWidget.tsx - never imported
widget = tmp_path / "src" / "components" / "UnusedWidget.tsx"
- widget.write_text(
- "export function UnusedWidget() {\n return
Unused
;\n}\n"
- )
+ widget.write_text("export function UnusedWidget() {\n return
Unused
;\n}\n")
# src/styles/main.css - with orphaned class
css = tmp_path / "src" / "styles" / "main.css"
css.parent.mkdir(parents=True, exist_ok=True)
- css.write_text(
- ".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n"
- )
+ css.write_text(".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n")
# src/app/page.tsx - Next.js page (entry point)
page = tmp_path / "src" / "app" / "page.tsx"
page.parent.mkdir(parents=True, exist_ok=True)
page.write_text(
- 'import { Button } from "../components/Button";\n'
- "export default function Page() {\n"
- " return
;\n"
- "}\n"
+ 'import { Button } from "../components/Button";\nexport default function Page() {\n return
;\n}\n'
)
# src/app/deadpage/page.tsx - dead route
deadpage = tmp_path / "src" / "app" / "deadpage" / "page.tsx"
deadpage.parent.mkdir(parents=True, exist_ok=True)
- deadpage.write_text(
- "export default function DeadPage() {\n return
Dead
;\n}\n"
- )
+ deadpage.write_text("export default function DeadPage() {\n return
Dead
;\n}\n")
return tmp_path
@@ -142,10 +133,7 @@ class TestExportParsing:
def test_named_exports(self, tmp_path):
f = tmp_path / "test.ts"
f.write_text(
- "export function foo() {}\n"
- "export const bar = 1;\n"
- "export type Baz = string;\n"
- "export interface Qux {}\n"
+ "export function foo() {}\nexport const bar = 1;\nexport type Baz = string;\nexport interface Qux {}\n"
)
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
@@ -178,11 +166,10 @@ def test_used_exports_not_reported(self, tmp_path):
unused_names = {f.name for f in result.unused_exports}
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')
+ 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')
@@ -195,7 +182,7 @@ def test_type_import_counts_as_used(self, tmp_path):
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')
+ mod.write_text("export function myFunc() { return 1; }\n")
app = tmp_path / "app.ts"
app.write_text('import Default, { myFunc } from "./mod";\nmyFunc();\n')
@@ -209,7 +196,7 @@ def test_mixed_default_and_named_import_counts_as_used(self, tmp_path):
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')
+ 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')
@@ -222,7 +209,7 @@ def test_type_only_import_marks_as_used(self, tmp_path):
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')
+ 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')
@@ -234,19 +221,12 @@ def test_mixed_default_and_type_only_import_marks_as_used(self, tmp_path):
assert "Foo" not in unused_names
-
class TestCSSParsing:
def test_orphaned_css_detection(self, tmp_path):
css = tmp_path / "styles.css"
- css.write_text(
- ".used-class { color: blue; }\n.orphaned-class { color: red; }\n"
- )
+ css.write_text(".used-class { color: blue; }\n.orphaned-class { color: red; }\n")
component = tmp_path / "Component.tsx"
- component.write_text(
- "export function Component() {\n"
- ' return
Hi
;\n'
- "}\n"
- )
+ component.write_text('export function Component() {\n return
Hi
;\n}\n')
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
@@ -256,13 +236,59 @@ def test_orphaned_css_detection(self, tmp_path):
assert "used-class" not in orphaned
+class TestCSSModuleUsage:
+ """CSS-module class accessors (``styles.card``) must count as uses.
+
+ Regression coverage for a bug where every ``*.module.css`` class was
+ falsely reported as orphaned (and removable=True), risking deletion of
+ live styles consumed via the object-accessor pattern.
+ """
+
+ def test_module_accessor_used_not_orphaned(self, tmp_path):
+ css = tmp_path / "Card.module.css"
+ css.write_text(".card { color: red; }\n.unusedClass { color: blue; }\n")
+ component = tmp_path / "Card.tsx"
+ component.write_text(
+ "import styles from './Card.module.css';\n"
+ "export function Card() { return
hi
; }\n"
+ )
+
+ result = DeadCodeScanner(tmp_path).scan()
+ orphaned = {f.name for f in result.orphaned_css}
+ assert "card" not in orphaned
+ assert "unusedClass" in orphaned
+
+ def test_module_bracket_accessor(self, tmp_path):
+ css = tmp_path / "M.module.css"
+ css.write_text(".card-hover { } .orphan { }\n")
+ component = tmp_path / "M.tsx"
+ component.write_text(
+ "import s from './M.module.css';\nexport const X = () =>
;\n"
+ )
+
+ result = DeadCodeScanner(tmp_path).scan()
+ orphaned = {f.name for f in result.orphaned_css}
+ assert "card-hover" not in orphaned
+ assert "orphan" in orphaned
+
+ def test_non_module_object_access_not_counted(self, tmp_path):
+ # `util.foo` where util is NOT a *.module.css import must not be
+ # treated as a CSS-module class use (guards against over-matching).
+ css = tmp_path / "M.module.css"
+ css.write_text(".secret { }\n")
+ component = tmp_path / "M.tsx"
+ component.write_text("import util from './util';\nexport const X = () =>
;\n")
+
+ result = DeadCodeScanner(tmp_path).scan()
+ orphaned = {f.name for f in result.orphaned_css}
+ assert "secret" in orphaned
+
+
class TestRouteDetection:
def test_nextjs_app_router_route(self, tmp_path):
page = tmp_path / "app" / "about" / "page.tsx"
page.parent.mkdir(parents=True, exist_ok=True)
- page.write_text(
- "export default function About() { return
About
; }\n"
- )
+ page.write_text("export default function About() { return
About
; }\n")
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
@@ -285,14 +311,10 @@ def test_root_route_not_dead(self, tmp_path):
def test_linked_route_not_dead(self, tmp_path):
page = tmp_path / "app" / "page.tsx"
page.parent.mkdir(parents=True, exist_ok=True)
- page.write_text(
- 'export default function Home() { return
About ; }\n'
- )
+ page.write_text('export default function Home() { return
About ; }\n')
about = tmp_path / "app" / "about" / "page.tsx"
about.parent.mkdir(parents=True, exist_ok=True)
- about.write_text(
- "export default function About() { return
About
; }\n"
- )
+ about.write_text("export default function About() { return
About
; }\n")
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
@@ -320,18 +342,14 @@ def test_scan_command(self, runner, sample_project):
assert "DeadCode Scan" in result.output
def test_scan_json_output(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--json-output"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--json-output"])
assert result.exit_code == 0
data = json.loads(result.output, strict=False)
assert "findings" in data
assert "files_scanned" in data
def test_scan_category_filter(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "-c", "orphaned_css"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "-c", "orphaned_css"])
assert result.exit_code == 0
# Text output should mention the category
assert "Orphaned CSS" in result.output
@@ -343,11 +361,7 @@ def test_scan_nonexistent_dir(self, runner):
def test_remove_dry_run(self, runner, sample_project):
result = runner.invoke(cli, ["-p", str(sample_project), "remove", "--dry-run"])
assert result.exit_code == 0
- assert (
- "WOULD REMOVE" in result.output
- or "Nothing removable" in result.output
- or result.exit_code == 0
- )
+ assert "WOULD REMOVE" in result.output or "Nothing removable" in result.output or result.exit_code == 0
def test_stats_command(self, runner, sample_project):
result = runner.invoke(cli, ["-p", str(sample_project), "stats"])
@@ -361,9 +375,7 @@ def test_scan_ignore_option(self, runner, tmp_path):
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text("export function unusedFunc() { return 1; }\n")
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "-i", "src/", "scan", "--json-output"]
- )
+ result = runner.invoke(cli, ["-p", str(tmp_path), "-i", "src/", "scan", "--json-output"])
assert result.exit_code == 0
import json
@@ -382,9 +394,7 @@ def test_remove_category_filter(self, runner, sample_project):
def test_remove_nonexistent_dir(self, runner):
"""remove should give graceful error for nonexistent project dir."""
- result = runner.invoke(
- cli, ["-p", "/nonexistent/test/path", "remove", "--dry-run"]
- )
+ result = runner.invoke(cli, ["-p", "/nonexistent/test/path", "remove", "--dry-run"])
assert result.exit_code != 0
assert "not found" in result.output
@@ -421,12 +431,7 @@ def test_multiline_export_list_detected(self, tmp_path):
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text(
- "function Alpha() { return 1; }\n"
- "function Beta() { return 2; }\n"
- "export {\n"
- " Alpha,\n"
- " Beta,\n"
- "}\n"
+ "function Alpha() { return 1; }\nfunction Beta() { return 2; }\nexport {\n Alpha,\n Beta,\n}\n"
)
scanner = DeadCodeScanner(tmp_path)
@@ -455,23 +460,14 @@ def test_multiline_export_list_used_not_reported(self, tmp_path):
export_names = {f.name for f in result.unused_exports}
# usedInApp appears in both an inline export and the export-list; it's imported so should be absent
- assert "usedInApp" not in export_names, (
- "usedInApp is imported — should not be reported"
- )
- assert "alsoUnused" in export_names, (
- "alsoUnused is never imported — should be reported"
- )
+ assert "usedInApp" not in export_names, "usedInApp is imported — should not be reported"
+ assert "alsoUnused" in export_names, "alsoUnused is never imported — should be reported"
def test_multiline_export_list_with_aliases(self, tmp_path):
"""export { Foo as Bar } aliases: the local name Foo should be tracked, not the alias."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
- mod.write_text(
- "function InternalName() { return 1; }\n"
- "export {\n"
- " InternalName as PublicName,\n"
- "}\n"
- )
+ mod.write_text("function InternalName() { return 1; }\nexport {\n InternalName as PublicName,\n}\n")
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
@@ -569,43 +565,31 @@ class TestScanFormat:
"""Tests for the new --format scan option."""
def test_format_compact(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--format=compact"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=compact"])
assert result.exit_code == 0
def test_format_github(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--format=github"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=github"])
assert result.exit_code == 0
def test_format_compact_with_findings(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--format=compact"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=compact"])
assert result.exit_code == 0
assert "unusedHelper" in result.output or "No dead code" in result.output
def test_format_github_with_findings(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--format=github"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=github"])
assert result.exit_code == 0
assert "::" in result.output or "No dead code" in result.output
def test_format_json_legacy_alias(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--json-output"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--json-output"])
assert result.exit_code == 0
data = json.loads(result.output, strict=False)
assert "findings" in data
def test_format_json_explicit(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--format=json"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=json"])
assert result.exit_code == 0
data = json.loads(result.output, strict=False)
assert "findings" in data
@@ -635,7 +619,7 @@ class TestReexportForwarding:
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')
+ src.write_text("export function helper() { return 1; }\n")
index = tmp_path / "index.ts"
index.write_text('export { helper } from "./foo";\n')
@@ -646,7 +630,7 @@ def test_named_reexport_marks_source_as_used(self, tmp_path):
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')
+ src.write_text("export function helper() { return 1; }\n")
index = tmp_path / "index.ts"
index.write_text('export { helper as primaryHelper } from "./foo";\n')
@@ -657,7 +641,7 @@ def test_renamed_reexport_marks_source_as_used(self, tmp_path):
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')
+ src.write_text("export type Foo = string;\n")
index = tmp_path / "index.ts"
index.write_text('export { type Foo } from "./types";\n')
@@ -668,17 +652,9 @@ def test_type_named_reexport_marks_source_as_used(self, tmp_path):
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'
- )
+ src.write_text("export const alpha = 1;\nexport const beta = 2;\n")
index = tmp_path / "index.ts"
- index.write_text(
- 'export {\n'
- ' alpha,\n'
- ' beta,\n'
- '} from "./foo";\n'
- )
+ index.write_text('export {\n alpha,\n beta,\n} from "./foo";\n')
result = DeadCodeScanner(tmp_path).scan()
@@ -688,10 +664,7 @@ def test_multiline_named_reexport(self, tmp_path):
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'
- )
+ src.write_text("export function widgetA() {}\nexport function widgetB() {}\n")
index = tmp_path / "index.ts"
index.write_text('export * from "./widgets";\n')
@@ -704,7 +677,7 @@ def test_star_reexport_marks_all_source_exports_as_used(self, tmp_path):
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')
+ mod.write_text("export function widgetA() {}\n")
index = tmp_path / "index.ts"
index.write_text('export * from "./widgets";\n')
@@ -717,11 +690,7 @@ 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'
- )
+ f.write_text("const alpha = 1;\nconst beta = 2;\nexport { alpha, beta };\n")
result = DeadCodeScanner(tmp_path).scan()
@@ -732,7 +701,7 @@ def test_local_export_list_still_reported_when_unused(self, tmp_path):
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')
+ src.write_text("export function localOnly() {}\n")
index = tmp_path / "index.ts"
index.write_text('export { useState } from "react";\n')