@@ -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