@@ -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 ,
0 commit comments