Skip to content

Commit feed852

Browse files
committed
fix: scope CSS module class uses to their imported stylesheet
1 parent f17f2e7 commit feed852

2 files changed

Lines changed: 139 additions & 16 deletions

File tree

‎src/deadcode/scanner.py‎

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,11 @@ def scan(self) -> ScanResult:
221221
imports: dict[str, set[str]] = {} # name -> set of files that import it
222222
css_classes: dict[str, list[tuple[str, int]]] = {} # class -> [(file, line)]
223223
used_css_classes: set[str] = set()
224+
used_css_module_classes: dict[Path, set[str]] = {}
224225
components: dict[str, str] = {} # ComponentName -> file
225226
routes: list[tuple[str, str]] = [] # (route_path, file)
226227
star_reexports: list[tuple[str, str]] = [] # (barrel_file, module_spec)
228+
file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files}
227229

228230
for filepath in all_files:
229231
try:
@@ -251,16 +253,11 @@ def scan(self) -> ScanResult:
251253
# Parse className usage in TSX/JSX files
252254
if rel_path.endswith((".tsx", ".jsx")):
253255
self._parse_classname_usage(content, used_css_classes)
254-
# CSS-module accessor usage (styles.card / styles['card']).
255-
# Only register accessors imported as a CSS module so we don't
256-
# treat every object property access (e.g. `user.name`) as a
257-
# CSS class.
258-
self._parse_css_module_usage(content, used_css_classes)
259256

260257
# CSS modules can also be consumed by plain TypeScript/JavaScript
261258
# files, for example when exporting class-name maps to components.
262259
if not self._is_css_file(rel_path):
263-
self._parse_css_module_usage(content, used_css_classes)
260+
self._parse_css_module_usage(content, rel_path, file_set, used_css_module_classes)
264261

265262
# Parse components
266263
if rel_path.endswith((".tsx", ".jsx")):
@@ -276,7 +273,6 @@ def scan(self) -> ScanResult:
276273
# Resolve `export * from './mod'` specifiers to scanned files so that
277274
# every export forwarded by a barrel is treated as part of the public
278275
# API surface (never reported as removable).
279-
file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files}
280276
star_reexported_files: set[str] = set()
281277
for barrel_file, module_spec in star_reexports:
282278
resolved = self._resolve_relative_module(barrel_file, module_spec, file_set)
@@ -290,7 +286,7 @@ def scan(self) -> ScanResult:
290286
self._find_dead_routes(routes, all_files, result)
291287

292288
# 2c. Orphaned CSS
293-
self._find_orphaned_css(css_classes, used_css_classes, result)
289+
self._find_orphaned_css(css_classes, used_css_classes, used_css_module_classes, result)
294290

295291
# 2d. Unreferenced components
296292
# Collect all names that are imported somewhere (i.e., actually used)
@@ -512,29 +508,40 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No
512508
for cls in group.split():
513509
used_css_classes.add(cls)
514510

515-
def _parse_css_module_usage(self, content: str, used_css_classes: set[str]) -> None:
511+
def _parse_css_module_usage(
512+
self,
513+
content: str,
514+
rel_path: str,
515+
file_set: set[str],
516+
used_css_module_classes: dict[Path, set[str]],
517+
) -> None:
516518
"""Extract CSS-module class names consumed via object accessors.
517519
518520
The canonical Next.js/React pattern is::
519521
520522
import styles from './Card.module.css';
521523
<div className={styles.card}>...</div>
522524
523-
We first find any ``*.module.css`` imports (binding name ``styles``)
524-
so that accessor use ``styles.card`` registers ``card`` as a used
525-
class. Bracket access ``styles['card-hover']`` is also captured.
525+
Resolve each relative ``*.module.css`` import from its importing file
526+
so that ``styles.card`` protects ``card`` only in that stylesheet.
527+
Bracket access ``styles['card-hover']`` is also captured. Canonical
528+
paths keep internal stylesheet symlinks and their targets consistent.
526529
Without this, every CSS-module class is falsely reported as orphaned
527530
and marked removable=True, risking deletion of live styles.
528531
"""
529532
# Map the local binding (e.g. ``styles``) to a CSS-module import so
530533
# only module accessors are treated as class names.
531-
module_bindings = {m.group(1) for m in _CSS_MODULE_IMPORT_PATTERN.finditer(content)}
534+
module_bindings: dict[str, Path] = {}
535+
for match in _CSS_MODULE_IMPORT_PATTERN.finditer(content):
536+
stylesheet = self._resolve_relative_module(rel_path, match.group(2), file_set)
537+
if stylesheet is not None and stylesheet.endswith(".module.css"):
538+
module_bindings[match.group(1)] = (self.project_dir / stylesheet).resolve()
532539
if not module_bindings:
533540
return
534541
for m in _CSS_MODULE_USAGE_PATTERN.finditer(content):
535542
binding, dot_name, _, bracket_name = m.groups()
536543
if binding in module_bindings:
537-
used_css_classes.add(dot_name or bracket_name)
544+
used_css_module_classes.setdefault(module_bindings[binding], set()).add(dot_name or bracket_name)
538545

539546
def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None:
540547
"""Extract React component definitions."""
@@ -661,9 +668,11 @@ def _find_orphaned_css(
661668
self,
662669
css_classes: dict[str, list[tuple[str, int]]],
663670
used_css_classes: set[str],
671+
used_css_module_classes: dict[Path, set[str]],
664672
result: ScanResult,
665673
) -> None:
666674
"""Find CSS classes defined but never used in JSX."""
675+
module_usage_by_file: dict[str, set[str]] = {}
667676
for cls, locations in css_classes.items():
668677
# Skip common utility classes and pseudo-selectors
669678
if cls.startswith(
@@ -680,9 +689,16 @@ def _find_orphaned_css(
680689
)
681690
):
682691
continue
683-
if cls in used_css_classes:
684-
continue
685692
for file, line in locations:
693+
if file.endswith(".module.css"):
694+
if file not in module_usage_by_file:
695+
module_usage_by_file[file] = used_css_module_classes.get(
696+
(self.project_dir / file).resolve(), set()
697+
)
698+
if cls in module_usage_by_file[file]:
699+
continue
700+
elif cls in used_css_classes:
701+
continue
686702
result.findings.append(
687703
Finding(
688704
file=file,

‎tests/test_css_module_scopes.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""CSS-module uses belong to one stylesheet, while literal classes stay global."""
2+
3+
import pytest
4+
from click.testing import CliRunner
5+
6+
from deadcode.cli import cli
7+
from deadcode.scanner import DeadCodeScanner
8+
9+
10+
def orphaned(project):
11+
return {(finding.file, finding.name) for finding in DeadCodeScanner(project).scan().orphaned_css}
12+
13+
14+
@pytest.mark.parametrize("extension", ["ts", "js", "tsx", "jsx"])
15+
@pytest.mark.parametrize(
16+
("binding", "accessor", "class_name"),
17+
[
18+
("styles", ".shared", "shared"),
19+
("styles", "[ 'card-hover' ]", "card-hover"),
20+
("$styles", "._active", "_active"),
21+
],
22+
)
23+
def test_module_accessor_only_protects_its_imported_stylesheet(tmp_path, extension, binding, accessor, class_name):
24+
for name in ["A.module.css", "B.module.css", "global.css"]:
25+
(tmp_path / name).write_text(f".{class_name} {{ color: red; }}\n")
26+
(tmp_path / f"consumer.{extension}").write_text(
27+
f"import {binding} from './B.module.css';\nconst selected = {binding}{accessor};\n"
28+
)
29+
30+
assert orphaned(tmp_path) == {("A.module.css", class_name), ("global.css", class_name)}
31+
32+
33+
def test_nested_imports_distinguish_stylesheets_with_the_same_filename(tmp_path):
34+
for directory in ["components", "first", "second"]:
35+
(tmp_path / directory).mkdir()
36+
for directory in ["first", "second"]:
37+
(tmp_path / directory / "Card.module.css").write_text(".shared {}\n")
38+
(tmp_path / "components" / "consumer.ts").write_text(
39+
"import styles from '../second/./Card.module.css';\nconst selected = styles.shared;\n"
40+
)
41+
42+
assert orphaned(tmp_path) == {("first/Card.module.css", "shared")}
43+
44+
45+
def test_bindings_are_local_to_each_importing_file(tmp_path):
46+
for name in ["A.module.css", "B.module.css"]:
47+
(tmp_path / name).write_text(".shared {}\n.other {}\n")
48+
(tmp_path / "first.ts").write_text("import styles from './A.module.css';\nconst selected = styles.shared;\n")
49+
(tmp_path / "second.ts").write_text("import styles from './B.module.css';\nconst selected = styles.other;\n")
50+
51+
assert orphaned(tmp_path) == {("A.module.css", "other"), ("B.module.css", "shared")}
52+
53+
54+
def test_literal_classes_protect_global_stylesheets_without_protecting_module_classes(tmp_path):
55+
for name in ["first.css", "second.scss", "Card.module.css"]:
56+
(tmp_path / name).write_text(".shared {}\n")
57+
(tmp_path / "consumer.tsx").write_text('export const View = () => <div className="shared" />;\n')
58+
59+
assert orphaned(tmp_path) == {("Card.module.css", "shared")}
60+
61+
62+
def test_global_literal_and_module_accessor_uses_coexist(tmp_path):
63+
for name in ["A.module.css", "B.module.css", "global.css"]:
64+
(tmp_path / name).write_text(".shared {}\n.literal {}\n")
65+
(tmp_path / "consumer.tsx").write_text(
66+
"import styles from './B.module.css';\n"
67+
'export const View = () => <div className="literal"><span className={styles.shared} /></div>;\n'
68+
)
69+
70+
assert orphaned(tmp_path) == {
71+
("A.module.css", "shared"),
72+
("A.module.css", "literal"),
73+
("B.module.css", "literal"),
74+
("global.css", "shared"),
75+
}
76+
77+
78+
@pytest.mark.parametrize("specifier", ["./missing.module.css", "package/Other.module.css"])
79+
def test_unresolved_imports_do_not_hide_unrelated_classes(tmp_path, specifier):
80+
(tmp_path / "A.module.css").write_text(".shared {}\n")
81+
(tmp_path / "global.css").write_text(".shared {}\n")
82+
(tmp_path / "consumer.ts").write_text(f"import styles from '{specifier}';\nconst selected = styles.shared;\n")
83+
84+
assert orphaned(tmp_path) == {("A.module.css", "shared"), ("global.css", "shared")}
85+
86+
87+
def test_internal_stylesheet_symlink_keeps_live_target_and_alias_classes(tmp_path):
88+
(tmp_path / "Actual.module.css").write_text(".shared {}\n.unused {}\n")
89+
(tmp_path / "Linked.module.css").symlink_to(tmp_path / "Actual.module.css")
90+
(tmp_path / "consumer.ts").write_text(
91+
"import styles from './Linked.module.css';\nconst selected = styles.shared;\n"
92+
)
93+
94+
assert orphaned(tmp_path) == {("Actual.module.css", "unused"), ("Linked.module.css", "unused")}
95+
96+
97+
def test_remove_preserves_live_module_and_only_removes_unused_definitions(tmp_path):
98+
for name in ["A.module.css", "B.module.css", "global.css"]:
99+
(tmp_path / name).write_text(".shared { color: red; }\n")
100+
(tmp_path / "consumer.ts").write_text("import styles from './B.module.css';\nconst selected = styles.shared;\n")
101+
102+
result = CliRunner().invoke(cli, ["-p", str(tmp_path), "remove", "-c", "orphaned_css"], input="y\n")
103+
104+
assert result.exit_code == 0, result.output
105+
assert (tmp_path / "B.module.css").read_text() == ".shared { color: red; }\n"
106+
assert ".shared" not in (tmp_path / "A.module.css").read_text()
107+
assert ".shared" not in (tmp_path / "global.css").read_text()

0 commit comments

Comments
 (0)