Skip to content

Commit 77edf21

Browse files
fix: false-positive orphaned CSS for CSS-module class accessors (#48)
* cowork-bot: fix false-positive orphaned CSS for CSS-module class accessors Detect and treat `styles.card` / `styles['card-hover']` (the canonical Next.js/React CSS-module consumption pattern) as used classes. Previously every *.module.css class was reported as orphaned and marked removable=True, which could delete live styles. Bracket accessors are recognized and non-module object access (util.foo) is not over-matched. Adds TestCSSModuleUsage regression tests; all 116 tests pass, ruff clean. Co-Authored-By: cowork-bot <cowork-bot@users.noreply.github.com> * fix: remove unused _CLASSLIST_PATTERN (dead code in CSS-module fix) --------- Co-authored-by: cowork-bot <cowork-bot@users.noreply.github.com>
1 parent 39829b7 commit 77edf21

3 files changed

Lines changed: 148 additions & 117 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
'./mod'`, `export { type X } from './mod'`, and `export * from './mod'`) now mark
3232
the forwarded symbols as used, preventing false-positive `removable` findings that
3333
could delete live public API.
34+
- CSS-module classes consumed via the object-accessor pattern
35+
(`import styles from './x.module.css'; <div className={styles.card}>`) are now
36+
treated as used. Previously every `*.module.css` class was falsely reported as
37+
orphaned CSS and marked `removable=True`, risking deletion of live styles. Bracket
38+
accessors (`styles['card-hover']`) are also recognized; non-module object access
39+
(e.g. `util.foo`) is not over-matched.
3440

3541
### Changed
3642

src/deadcode/scanner.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ def unreferenced_components(self) -> list[Finding]:
132132
r'"([^"]+)"\s*:\s*\["([^"]+)"\]',
133133
)
134134

135+
# CSS-module accessor usage, two forms::
136+
# - dot: `styles.card` -> capture group 1 empty, full = "styles.card"
137+
# - bracket: `styles['card-hover']` / `styles["card"]` -> capture group 1 = name
138+
# We deliberately match either form after a binding identifier so both
139+
# `styles.card` and `styles['card-hover']` register the class as used.
140+
_CSS_MODULE_USAGE_PATTERN = re.compile(
141+
r"""\b\w+\.(?:_?[\w$]+)|(\w+)\[['"]([\w$-]+)['"]\]""",
142+
)
143+
144+
# Detect a CSS-module import so we know which source-file class accessors
145+
# correspond to module classes (``import styles from './x.module.css'``).
146+
_CSS_MODULE_IMPORT_PATTERN = re.compile(
147+
r"""import\s+(?:type\s+)?(\w+)\s+from\s+['"]([^'"]*\.module\.css)['"]""",
148+
)
149+
135150

136151
# ── Scanner ───────────────────────────────────────────────────────────
137152

@@ -218,6 +233,11 @@ def scan(self) -> ScanResult:
218233
# Parse className usage in TSX/JSX files
219234
if rel_path.endswith((".tsx", ".jsx")):
220235
self._parse_classname_usage(content, used_css_classes)
236+
# CSS-module accessor usage (styles.card / styles['card']).
237+
# Only register accessors imported as a CSS module so we don't
238+
# treat every object property access (e.g. `user.name`) as a
239+
# CSS class.
240+
self._parse_css_module_usage(content, used_css_classes)
221241

222242
# Parse components
223243
if rel_path.endswith((".tsx", ".jsx")):
@@ -450,6 +470,42 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No
450470
for cls in group.split():
451471
used_css_classes.add(cls)
452472

473+
def _parse_css_module_usage(self, content: str, used_css_classes: set[str]) -> None:
474+
"""Extract CSS-module class names consumed via object accessors.
475+
476+
The canonical Next.js/React pattern is::
477+
478+
import styles from './Card.module.css';
479+
<div className={styles.card}>...</div>
480+
481+
We first find any ``*.module.css`` imports (binding name ``styles``)
482+
so that accessor use ``styles.card`` registers ``card`` as a used
483+
class. Bracket access ``styles['card-hover']`` is also captured.
484+
Without this, every CSS-module class is falsely reported as orphaned
485+
and marked removable=True, risking deletion of live styles.
486+
"""
487+
# Map the local binding (e.g. ``styles``) to a CSS-module import so
488+
# only module accessors are treated as class names.
489+
module_bindings = {m.group(1) for m in _CSS_MODULE_IMPORT_PATTERN.finditer(content)}
490+
if not module_bindings:
491+
return
492+
for m in _CSS_MODULE_USAGE_PATTERN.finditer(content):
493+
groups = m.groups()
494+
# Bracket form `styles['card-hover']` -> groups = (binding, name).
495+
# Dot form `styles.card` -> groups = (None, None); the whole match
496+
# is "binding.attr". Distinguish by whether a bracket name exists.
497+
bracket_binding, bracket_name = groups
498+
if bracket_name is not None:
499+
if bracket_binding in module_bindings:
500+
used_css_classes.add(bracket_name)
501+
continue
502+
# Dot form `styles.card` -> the whole match is "binding.attr".
503+
full = m.group(0)
504+
binding, _, attr = full.partition(".")
505+
if binding in module_bindings and attr and attr[0] != "_":
506+
# Skip internal/private-ish accessors (e.g. styles.toString).
507+
used_css_classes.add(attr)
508+
453509
def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None:
454510
"""Extract React component definitions."""
455511
for m in _COMPONENT_PATTERN.finditer(content):

0 commit comments

Comments
 (0)