Skip to content

Commit 9dbded5

Browse files
committed
fix: keep unresolved CSS module imports safe during removal
1 parent feed852 commit 9dbded5

2 files changed

Lines changed: 55 additions & 6 deletions

File tree

‎src/deadcode/scanner.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ def scan(self) -> ScanResult:
222222
css_classes: dict[str, list[tuple[str, int]]] = {} # class -> [(file, line)]
223223
used_css_classes: set[str] = set()
224224
used_css_module_classes: dict[Path, set[str]] = {}
225+
unresolved_css_module_classes: set[str] = set()
225226
components: dict[str, str] = {} # ComponentName -> file
226227
routes: list[tuple[str, str]] = [] # (route_path, file)
227228
star_reexports: list[tuple[str, str]] = [] # (barrel_file, module_spec)
@@ -257,7 +258,9 @@ def scan(self) -> ScanResult:
257258
# CSS modules can also be consumed by plain TypeScript/JavaScript
258259
# files, for example when exporting class-name maps to components.
259260
if not self._is_css_file(rel_path):
260-
self._parse_css_module_usage(content, rel_path, file_set, used_css_module_classes)
261+
self._parse_css_module_usage(
262+
content, rel_path, file_set, used_css_module_classes, unresolved_css_module_classes
263+
)
261264

262265
# Parse components
263266
if rel_path.endswith((".tsx", ".jsx")):
@@ -286,7 +289,9 @@ def scan(self) -> ScanResult:
286289
self._find_dead_routes(routes, all_files, result)
287290

288291
# 2c. Orphaned CSS
289-
self._find_orphaned_css(css_classes, used_css_classes, used_css_module_classes, result)
292+
self._find_orphaned_css(
293+
css_classes, used_css_classes, used_css_module_classes, unresolved_css_module_classes, result
294+
)
290295

291296
# 2d. Unreferenced components
292297
# Collect all names that are imported somewhere (i.e., actually used)
@@ -514,6 +519,7 @@ def _parse_css_module_usage(
514519
rel_path: str,
515520
file_set: set[str],
516521
used_css_module_classes: dict[Path, set[str]],
522+
unresolved_css_module_classes: set[str],
517523
) -> None:
518524
"""Extract CSS-module class names consumed via object accessors.
519525
@@ -526,22 +532,30 @@ def _parse_css_module_usage(
526532
so that ``styles.card`` protects ``card`` only in that stylesheet.
527533
Bracket access ``styles['card-hover']`` is also captured. Canonical
528534
paths keep internal stylesheet symlinks and their targets consistent.
535+
Classes used through unresolved imports remain uncertain so alias or
536+
bundler-specific resolution cannot make live module classes removable.
529537
Without this, every CSS-module class is falsely reported as orphaned
530538
and marked removable=True, risking deletion of live styles.
531539
"""
532540
# Map the local binding (e.g. ``styles``) to a CSS-module import so
533541
# only module accessors are treated as class names.
534-
module_bindings: dict[str, Path] = {}
542+
module_bindings: dict[str, Path | None] = {}
535543
for match in _CSS_MODULE_IMPORT_PATTERN.finditer(content):
536544
stylesheet = self._resolve_relative_module(rel_path, match.group(2), file_set)
537545
if stylesheet is not None and stylesheet.endswith(".module.css"):
538546
module_bindings[match.group(1)] = (self.project_dir / stylesheet).resolve()
547+
else:
548+
module_bindings[match.group(1)] = None
539549
if not module_bindings:
540550
return
541551
for m in _CSS_MODULE_USAGE_PATTERN.finditer(content):
542552
binding, dot_name, _, bracket_name = m.groups()
543553
if binding in module_bindings:
544-
used_css_module_classes.setdefault(module_bindings[binding], set()).add(dot_name or bracket_name)
554+
stylesheet_path = module_bindings[binding]
555+
if stylesheet_path is None:
556+
unresolved_css_module_classes.add(dot_name or bracket_name)
557+
else:
558+
used_css_module_classes.setdefault(stylesheet_path, set()).add(dot_name or bracket_name)
545559

546560
def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None:
547561
"""Extract React component definitions."""
@@ -669,6 +683,7 @@ def _find_orphaned_css(
669683
css_classes: dict[str, list[tuple[str, int]]],
670684
used_css_classes: set[str],
671685
used_css_module_classes: dict[Path, set[str]],
686+
unresolved_css_module_classes: set[str],
672687
result: ScanResult,
673688
) -> None:
674689
"""Find CSS classes defined but never used in JSX."""
@@ -699,14 +714,19 @@ def _find_orphaned_css(
699714
continue
700715
elif cls in used_css_classes:
701716
continue
717+
uncertain = file.endswith(".module.css") and cls in unresolved_css_module_classes
702718
result.findings.append(
703719
Finding(
704720
file=file,
705721
line=line,
706722
name=cls,
707723
category="orphaned_css",
708-
detail=f"CSS class '.{cls}' is not used in any JSX className",
709-
removable=True,
724+
detail=(
725+
f"CSS module class '.{cls}' may be used through an unresolved stylesheet import"
726+
if uncertain
727+
else f"CSS class '.{cls}' is not used in any JSX className"
728+
),
729+
removable=not uncertain,
710730
)
711731
)
712732

‎tests/test_css_module_scopes.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,32 @@ def test_remove_preserves_live_module_and_only_removes_unused_definitions(tmp_pa
105105
assert (tmp_path / "B.module.css").read_text() == ".shared { color: red; }\n"
106106
assert ".shared" not in (tmp_path / "A.module.css").read_text()
107107
assert ".shared" not in (tmp_path / "global.css").read_text()
108+
109+
110+
def test_unresolved_alias_keeps_module_findings_visible_and_nonremovable(tmp_path):
111+
for name in ["A.module.css", "B.module.css", "global.css"]:
112+
(tmp_path / name).write_text(".shared {}\n")
113+
(tmp_path / "consumer.ts").write_text("import styles from '@/Card.module.css';\nconst selected = styles.shared;\n")
114+
115+
findings = DeadCodeScanner(tmp_path).scan().orphaned_css
116+
117+
assert {(finding.file, finding.name) for finding in findings} == {
118+
("A.module.css", "shared"),
119+
("B.module.css", "shared"),
120+
("global.css", "shared"),
121+
}
122+
assert {finding.file for finding in findings if finding.removable} == {"global.css"}
123+
assert all("unresolved stylesheet import" in finding.detail for finding in findings if not finding.removable)
124+
125+
126+
def test_remove_retains_uncertain_module_classes_and_removes_unreferenced_global_class(tmp_path):
127+
for name in ["A.module.css", "B.module.css", "global.css"]:
128+
(tmp_path / name).write_text(".shared { color: red; }\n")
129+
(tmp_path / "consumer.ts").write_text("import styles from '@/Card.module.css';\nconst selected = styles.shared;\n")
130+
131+
result = CliRunner().invoke(cli, ["-p", str(tmp_path), "remove", "-c", "orphaned_css"], input="y\n")
132+
133+
assert result.exit_code == 0, result.output
134+
for name in ["A.module.css", "B.module.css"]:
135+
assert (tmp_path / name).read_text() == ".shared { color: red; }\n"
136+
assert ".shared" not in (tmp_path / "global.css").read_text()

0 commit comments

Comments
 (0)