@@ -95,8 +95,20 @@ def unreferenced_components(self) -> list[Finding]:
9595)
9696
9797# import statements
98+ # Handles: import {Foo} from ..., import Foo from ..., import type {Foo} from ...,
99+ # import Default, {Named} from ..., import {type Foo} from ...
100+ # Groups: 1 = first named block content (e.g. "Foo, Bar"), 2 = default import name,
101+ # 3 = optional named block after comma default, 4 = module specifier
98102_IMPORT_PATTERN = re .compile (
99- r"import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['\"]([^'\"]+)['\"]" ,
103+ r"import\s+"
104+ r"(?:type\s+)?"
105+ r"(?:"
106+ r"\{([^}]+)\}" # group 1: named imports {Foo, type Bar}
107+ r"|"
108+ r"(\w+(?:\s+as\s+\w+)?)" # group 2: default import (Foo or Foo as Bar)
109+ r")"
110+ r"(?:\s*,\s*\{([^}]+)\})?" # group 3: optional named after default
111+ r"\s+from\s+['\"]([^'\"]+)['\"]" ,
100112)
101113
102114# Re-export forwarding: `export { A, B as C } from './mod'` and `export * from './mod'`.
@@ -140,9 +152,7 @@ def __init__(
140152 )
141153 self .include_spec = None
142154 if include_patterns :
143- self .include_spec = pathspec .PathSpec .from_lines (
144- "gitignore" , include_patterns
145- )
155+ self .include_spec = pathspec .PathSpec .from_lines ("gitignore" , include_patterns )
146156
147157 @staticmethod
148158 def _default_ignore_patterns () -> list [str ]:
@@ -223,9 +233,7 @@ def scan(self) -> ScanResult:
223233 # Resolve `export * from './mod'` specifiers to scanned files so that
224234 # every export forwarded by a barrel is treated as part of the public
225235 # API surface (never reported as removable).
226- file_set = {
227- str (f .relative_to (self .project_dir )).replace ("\\ " , "/" ) for f in all_files
228- }
236+ file_set = {str (f .relative_to (self .project_dir )).replace ("\\ " , "/" ) for f in all_files }
229237 star_reexported_files : set [str ] = set ()
230238 for barrel_file , module_spec in star_reexports :
231239 resolved = self ._resolve_relative_module (barrel_file , module_spec , file_set )
@@ -256,21 +264,13 @@ def _collect_files(self) -> list[Path]:
256264
257265 # Filter out ignored directories
258266 dirs [:] = [
259- d
260- for d in dirs
261- if not self .ignore_spec .match_file (
262- f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /"
263- )
267+ d for d in dirs if not self .ignore_spec .match_file (f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /" )
264268 ]
265269
266270 # Filter out non-included directories when include_spec is set
267271 if self .include_spec :
268272 dirs [:] = [
269- d
270- for d in dirs
271- if self .include_spec .match_file (
272- f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /"
273- )
273+ d for d in dirs if self .include_spec .match_file (f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /" )
274274 ]
275275
276276 for fname in filenames :
@@ -305,9 +305,7 @@ def _is_scannable_file(rel_path: str) -> bool:
305305 def _is_css_file (rel_path : str ) -> bool :
306306 return rel_path .endswith ((".css" , ".scss" , ".module.css" ))
307307
308- def _parse_exports (
309- self , content : str , rel_path : str , exports : dict [str , list [tuple [str , int ]]]
310- ) -> None :
308+ def _parse_exports (self , content : str , rel_path : str , exports : dict [str , list [tuple [str , int ]]]) -> None :
311309 """Extract export names from a file.
312310
313311 Handles both single-line forms::
@@ -343,25 +341,41 @@ def _parse_exports(
343341 if name and re .match (r"^[A-Za-z_$][\w$]*$" , name ):
344342 exports .setdefault (name , []).append ((rel_path , line_num ))
345343
346- def _parse_imports (
347- self , content : str , rel_path : str , imports : dict [str , set [str ]]
348- ) -> None :
349- """Extract import names from a file."""
350- for m in _IMPORT_PATTERN .finditer (content ):
351-
352- default_import = m .group (1 )
353- named_imports = m .group (2 )
354- # m.group(3) is the module specifier; imports are tracked by name only.
344+ def _parse_imports (self , content : str , rel_path : str , imports : dict [str , set [str ]]) -> None :
345+ """Extract import names from a file.
355346
356- if default_import :
357- imports .setdefault (default_import , set ()).add (rel_path )
358- if named_imports :
359- names = [n .strip ().split (" as " )[0 ].strip () for n in named_imports .split ("," )]
360- for name in names :
361- if name :
362- canonical = name [5 :].strip () if name .startswith ("type " ) else name
363- if canonical :
364- imports .setdefault (canonical , set ()).add (rel_path )
347+ Handles: named imports (group 1), default imports (group 2),
348+ and optional trailing named block (group 3, e.g. ``import React, { Foo }``).
349+ Named-block entries prefixed with ``type `` are stripped to the canonical name.
350+ """
351+ for m in _IMPORT_PATTERN .finditer (content ):
352+ named_block = m .group (1 ) # {Foo, Bar} content
353+ default_name = m .group (2 ) # React or type
354+ named_block2 = m .group (3 ) # optional second {Foo, Bar} after comma
355+
356+ # Process first named block (from direct {Foo} or import type {Foo})
357+ if named_block :
358+ for entry in named_block .split ("," ):
359+ name = entry .strip ()
360+ if not name :
361+ continue
362+ canonical = name [5 :].strip () if name .startswith ("type " ) else name
363+ if canonical :
364+ imports .setdefault (canonical , set ()).add (rel_path )
365+
366+ # Process default import name (but skip bare "type" keyword)
367+ if default_name and default_name != "type" :
368+ imports .setdefault (default_name , set ()).add (rel_path )
369+
370+ # Process optional named block after comma (Default, {Foo})
371+ if named_block2 :
372+ for entry in named_block2 .split ("," ):
373+ name = entry .strip ()
374+ if not name :
375+ continue
376+ canonical = name [5 :].strip () if name .startswith ("type " ) else name
377+ if canonical :
378+ imports .setdefault (canonical , set ()).add (rel_path )
365379
366380 def _parse_reexports (
367381 self ,
@@ -398,9 +412,7 @@ def _parse_reexports(
398412 star_reexports .append ((rel_path , module_path ))
399413
400414 @staticmethod
401- def _resolve_relative_module (
402- importer_rel : str , spec : str , file_set : set [str ]
403- ) -> str | None :
415+ def _resolve_relative_module (importer_rel : str , spec : str , file_set : set [str ]) -> str | None :
404416 """Resolve a relative module specifier to a scanned file's rel path.
405417
406418 Returns ``None`` for bare/package specifiers (e.g. ``'react'``) or when
@@ -423,10 +435,7 @@ def _resolve_relative_module(
423435 return candidate
424436 return None
425437
426-
427- def _parse_css_classes (
428- self , content : str , rel_path : str , css_classes : dict [str , list [tuple [str , int ]]]
429- ) -> None :
438+ def _parse_css_classes (self , content : str , rel_path : str , css_classes : dict [str , list [tuple [str , int ]]]) -> None :
430439 """Extract CSS class names defined in a stylesheet."""
431440 for i , line in enumerate (content .splitlines (), 1 ):
432441 for m in _CSS_CLASS_PATTERN .finditer (line ):
@@ -441,9 +450,7 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No
441450 for cls in group .split ():
442451 used_css_classes .add (cls )
443452
444- def _parse_components (
445- self , content : str , rel_path : str , components : dict [str , str ]
446- ) -> None :
453+ def _parse_components (self , content : str , rel_path : str , components : dict [str , str ]) -> None :
447454 """Extract React component definitions."""
448455 for m in _COMPONENT_PATTERN .finditer (content ):
449456 name = m .group (1 )
@@ -527,9 +534,7 @@ def _find_dead_routes(
527534 return
528535
529536 # Build set of all route paths referenced in links
530- link_pattern = re .compile (
531- r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']'
532- )
537+ link_pattern = re .compile (r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']' )
533538 referenced_routes : set [str ] = set ()
534539
535540 for filepath in all_files :
0 commit comments