@@ -132,6 +132,27 @@ 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+ # DOM classList API: `el.classList.add('foo')` / `toggle('bar')` / `remove(...)`
145+ # / `contains('baz')`. Dynamic class names referenced here are real uses.
146+ _CLASSLIST_PATTERN = re .compile (
147+ r"""classList\.(?:add|remove|toggle|contains)\(\s*['"]([\w$-]+)['"]""" ,
148+ )
149+
150+ # Detect a CSS-module import so we know which source-file class accessors
151+ # correspond to module classes (``import styles from './x.module.css'``).
152+ _CSS_MODULE_IMPORT_PATTERN = re .compile (
153+ r"""import\s+(?:type\s+)?(\w+)\s+from\s+['"]([^'"]*\.module\.css)['"]""" ,
154+ )
155+
135156
136157# ── Scanner ───────────────────────────────────────────────────────────
137158
@@ -218,6 +239,11 @@ def scan(self) -> ScanResult:
218239 # Parse className usage in TSX/JSX files
219240 if rel_path .endswith ((".tsx" , ".jsx" )):
220241 self ._parse_classname_usage (content , used_css_classes )
242+ # CSS-module accessor usage (styles.card / styles['card']).
243+ # Only register accessors imported as a CSS module so we don't
244+ # treat every object property access (e.g. `user.name`) as a
245+ # CSS class.
246+ self ._parse_css_module_usage (content , used_css_classes )
221247
222248 # Parse components
223249 if rel_path .endswith ((".tsx" , ".jsx" )):
@@ -450,6 +476,42 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No
450476 for cls in group .split ():
451477 used_css_classes .add (cls )
452478
479+ def _parse_css_module_usage (self , content : str , used_css_classes : set [str ]) -> None :
480+ """Extract CSS-module class names consumed via object accessors.
481+
482+ The canonical Next.js/React pattern is::
483+
484+ import styles from './Card.module.css';
485+ <div className={styles.card}>...</div>
486+
487+ We first find any ``*.module.css`` imports (binding name ``styles``)
488+ so that accessor use ``styles.card`` registers ``card`` as a used
489+ class. Bracket access ``styles['card-hover']`` is also captured.
490+ Without this, every CSS-module class is falsely reported as orphaned
491+ and marked removable=True, risking deletion of live styles.
492+ """
493+ # Map the local binding (e.g. ``styles``) to a CSS-module import so
494+ # only module accessors are treated as class names.
495+ module_bindings = {m .group (1 ) for m in _CSS_MODULE_IMPORT_PATTERN .finditer (content )}
496+ if not module_bindings :
497+ return
498+ for m in _CSS_MODULE_USAGE_PATTERN .finditer (content ):
499+ groups = m .groups ()
500+ # Bracket form `styles['card-hover']` -> groups = (binding, name).
501+ # Dot form `styles.card` -> groups = (None, None); the whole match
502+ # is "binding.attr". Distinguish by whether a bracket name exists.
503+ bracket_binding , bracket_name = groups
504+ if bracket_name is not None :
505+ if bracket_binding in module_bindings :
506+ used_css_classes .add (bracket_name )
507+ continue
508+ # Dot form `styles.card` -> the whole match is "binding.attr".
509+ full = m .group (0 )
510+ binding , _ , attr = full .partition ("." )
511+ if binding in module_bindings and attr and attr [0 ] != "_" :
512+ # Skip internal/private-ish accessors (e.g. styles.toString).
513+ used_css_classes .add (attr )
514+
453515 def _parse_components (self , content : str , rel_path : str , components : dict [str , str ]) -> None :
454516 """Extract React component definitions."""
455517 for m in _COMPONENT_PATTERN .finditer (content ):
0 commit comments