@@ -58,17 +58,24 @@ def unreferenced_components(self) -> list[Finding]:
5858
5959# ── Patterns ──────────────────────────────────────────────────────────
6060
61- # export const/let/var/function/class/type/interface/enum
61+ # export const/let/var/function/class/type/interface/enum.
62+ # Deliberately does NOT match `export default ...`: with `default` in the
63+ # alternation the capture group grabbed the keyword `function`/`class` as
64+ # the export name, flagging every default export as removable dead code.
65+ # Default exports are entry-point conventions and are skipped entirely.
6266_EXPORT_PATTERN = re .compile (
6367 r"^\s*export\s+"
64- r"(?:const|let|var|function|class|type|interface|enum|default )\s+"
68+ r"(?:const|let|var|function|class|type|interface|enum)\s+"
6569 r"([A-Za-z_$][\w$]*)" ,
6670 re .MULTILINE ,
6771)
6872
6973# export { name } — may span multiple lines; [^}] matches newlines too
7074_EXPORT_LIST_PATTERN = re .compile (
71- r"export\s*\{([^}]+)\}" ,
75+ # `(?!\s*from)` excludes re-export forwarding (`export { X } from '...'`),
76+ # which is handled by _REEXPORT_PATTERN as a *use* of the source module's
77+ # exports rather than a new local export definition.
78+ r"export\s*\{([^}]+)\}(?!\s*from)" ,
7279 re .DOTALL ,
7380)
7481
@@ -88,8 +95,29 @@ def unreferenced_components(self) -> list[Finding]:
8895)
8996
9097# 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
91102_IMPORT_PATTERN = re .compile (
92- 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+['\"]([^'\"]+)['\"]" ,
112+ )
113+
114+ # Re-export forwarding: `export { A, B as C } from './mod'` and `export * from './mod'`.
115+ # A barrel/index file that re-exports a symbol is *consuming* it from the source
116+ # module, so the source's export must not be flagged as unused. `[^}]*` matches
117+ # newlines (with re.DOTALL) for multi-line re-export blocks.
118+ _REEXPORT_PATTERN = re .compile (
119+ r"export\s*(?:\{([^}]*)\}|\*(?:\s+as\s+\w+)?)\s*from\s*['\"]([^'\"]+)['\"]" ,
120+ re .DOTALL ,
93121)
94122
95123# className="..." or className={...} in JSX
@@ -124,9 +152,7 @@ def __init__(
124152 )
125153 self .include_spec = None
126154 if include_patterns :
127- self .include_spec = pathspec .PathSpec .from_lines (
128- "gitignore" , include_patterns
129- )
155+ self .include_spec = pathspec .PathSpec .from_lines ("gitignore" , include_patterns )
130156
131157 @staticmethod
132158 def _default_ignore_patterns () -> list [str ]:
@@ -164,6 +190,7 @@ def scan(self) -> ScanResult:
164190 used_css_classes : set [str ] = set ()
165191 components : dict [str , str ] = {} # ComponentName -> file
166192 routes : list [tuple [str , str ]] = [] # (route_path, file)
193+ star_reexports : list [tuple [str , str ]] = [] # (barrel_file, module_spec)
167194
168195 for filepath in all_files :
169196 try :
@@ -180,6 +207,10 @@ def scan(self) -> ScanResult:
180207 # Parse imports
181208 self ._parse_imports (content , rel_path , imports )
182209
210+ # Parse re-exports (barrel/index forwarding) so re-exported symbols
211+ # are counted as used and not reported as removable dead code.
212+ self ._parse_reexports (content , rel_path , imports , star_reexports )
213+
183214 # Parse CSS classes (from .css/.scss/.module.css files)
184215 if self ._is_css_file (rel_path ):
185216 self ._parse_css_classes (content , rel_path , css_classes )
@@ -199,8 +230,18 @@ def scan(self) -> ScanResult:
199230
200231 # Phase 2: Detect dead code
201232
233+ # Resolve `export * from './mod'` specifiers to scanned files so that
234+ # every export forwarded by a barrel is treated as part of the public
235+ # API surface (never reported as removable).
236+ file_set = {str (f .relative_to (self .project_dir )).replace ("\\ " , "/" ) for f in all_files }
237+ star_reexported_files : set [str ] = set ()
238+ for barrel_file , module_spec in star_reexports :
239+ resolved = self ._resolve_relative_module (barrel_file , module_spec , file_set )
240+ if resolved :
241+ star_reexported_files .add (resolved )
242+
202243 # 2a. Unused exports
203- self ._find_unused_exports (exports , imports , result )
244+ self ._find_unused_exports (exports , imports , result , star_reexported_files )
204245
205246 # 2b. Dead routes
206247 self ._find_dead_routes (routes , all_files , result )
@@ -223,21 +264,13 @@ def _collect_files(self) -> list[Path]:
223264
224265 # Filter out ignored directories
225266 dirs [:] = [
226- d
227- for d in dirs
228- if not self .ignore_spec .match_file (
229- f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /"
230- )
267+ d for d in dirs if not self .ignore_spec .match_file (f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /" )
231268 ]
232269
233270 # Filter out non-included directories when include_spec is set
234271 if self .include_spec :
235272 dirs [:] = [
236- d
237- for d in dirs
238- if self .include_spec .match_file (
239- f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /"
240- )
273+ d for d in dirs if self .include_spec .match_file (f"{ rel_root } /{ d } /" if rel_root != "." else f"{ d } /" )
241274 ]
242275
243276 for fname in filenames :
@@ -272,9 +305,7 @@ def _is_scannable_file(rel_path: str) -> bool:
272305 def _is_css_file (rel_path : str ) -> bool :
273306 return rel_path .endswith ((".css" , ".scss" , ".module.css" ))
274307
275- def _parse_exports (
276- self , content : str , rel_path : str , exports : dict [str , list [tuple [str , int ]]]
277- ) -> None :
308+ def _parse_exports (self , content : str , rel_path : str , exports : dict [str , list [tuple [str , int ]]]) -> None :
278309 """Extract export names from a file.
279310
280311 Handles both single-line forms::
@@ -310,27 +341,101 @@ def _parse_exports(
310341 if name and re .match (r"^[A-Za-z_$][\w$]*$" , name ):
311342 exports .setdefault (name , []).append ((rel_path , line_num ))
312343
313- def _parse_imports (
314- self , content : str , rel_path : str , imports : dict [str , set [str ]]
315- ) -> None :
316- """Extract import names from a file."""
344+ def _parse_imports (self , content : str , rel_path : str , imports : dict [str , set [str ]]) -> None :
345+ """Extract import names from a file.
346+
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+ """
317351 for m in _IMPORT_PATTERN .finditer (content ):
318- # Named imports: import { Foo, Bar } from '...'
319- if m .group (1 ):
320- names = [
321- n .strip ().split (" as " )[0 ].strip () for n in m .group (1 ).split ("," )
322- ]
323- for name in names :
324- if name :
325- imports .setdefault (name , set ()).add (rel_path )
326- # Default import: import Foo from '...'
327- elif m .group (2 ):
328- name = m .group (2 )
329- imports .setdefault (name , set ()).add (rel_path )
330-
331- def _parse_css_classes (
332- self , content : str , rel_path : str , css_classes : dict [str , list [tuple [str , int ]]]
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 )
379+
380+ def _parse_reexports (
381+ self ,
382+ content : str ,
383+ rel_path : str ,
384+ imports : dict [str , set [str ]],
385+ star_reexports : list [tuple [str , str ]],
333386 ) -> None :
387+ """Record re-export forwarding so barrel/index files don't false-positive.
388+
389+ ``export { A, B as C } from './mod'`` consumes ``A`` and ``B`` from
390+ ``./mod``; the consumed (left-hand) names are registered as imports of
391+ this file so the source module's exports are not reported as unused.
392+ ``export * from './mod'`` forwards every export of ``./mod``; the
393+ (file, module) pair is recorded so those exports can be treated as used
394+ once ``./mod`` is resolved to a scanned file.
395+ """
396+ for m in _REEXPORT_PATTERN .finditer (content ):
397+ named = m .group (1 )
398+ module_path = m .group (2 )
399+ if named is not None :
400+ for entry in named .split ("," ):
401+ entry = entry .strip ()
402+ if not entry :
403+ continue
404+ # `A as B` re-exports A (the left-hand source name) as B.
405+ source = entry .split (" as " )[0 ].strip ()
406+ if source .startswith ("type " ):
407+ source = source [5 :].strip ()
408+ if source and re .match (r"^[A-Za-z_$][\w$]*$" , source ):
409+ imports .setdefault (source , set ()).add (rel_path )
410+ else :
411+ # `export * from './mod'` — resolved to a file in phase 2.
412+ star_reexports .append ((rel_path , module_path ))
413+
414+ @staticmethod
415+ def _resolve_relative_module (importer_rel : str , spec : str , file_set : set [str ]) -> str | None :
416+ """Resolve a relative module specifier to a scanned file's rel path.
417+
418+ Returns ``None`` for bare/package specifiers (e.g. ``'react'``) or when
419+ no matching scanned file exists. Tries the literal path, then common
420+ TS/JS extensions, then an ``index.*`` barrel inside a directory.
421+ """
422+ if not spec .startswith ("." ):
423+ return None
424+ base = os .path .dirname (importer_rel )
425+ target = os .path .normpath (os .path .join (base , spec )).replace ("\\ " , "/" )
426+ if target in file_set :
427+ return target
428+ exts = (".ts" , ".tsx" , ".js" , ".jsx" )
429+ for e in exts :
430+ if target + e in file_set :
431+ return target + e
432+ for e in exts :
433+ candidate = f"{ target } /index{ e } "
434+ if candidate in file_set :
435+ return candidate
436+ return None
437+
438+ def _parse_css_classes (self , content : str , rel_path : str , css_classes : dict [str , list [tuple [str , int ]]]) -> None :
334439 """Extract CSS class names defined in a stylesheet."""
335440 for i , line in enumerate (content .splitlines (), 1 ):
336441 for m in _CSS_CLASS_PATTERN .finditer (line ):
@@ -345,9 +450,7 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No
345450 for cls in group .split ():
346451 used_css_classes .add (cls )
347452
348- def _parse_components (
349- self , content : str , rel_path : str , components : dict [str , str ]
350- ) -> None :
453+ def _parse_components (self , content : str , rel_path : str , components : dict [str , str ]) -> None :
351454 """Extract React component definitions."""
352455 for m in _COMPONENT_PATTERN .finditer (content ):
353456 name = m .group (1 )
@@ -371,6 +474,7 @@ def _find_unused_exports(
371474 exports : dict [str , list [tuple [str , int ]]],
372475 imports : dict [str , set [str ]],
373476 result : ScanResult ,
477+ star_reexported_files : set [str ] | None = None ,
374478 ) -> None :
375479 """Find exports that are never imported elsewhere."""
376480 # Special names that are entry points or conventions
@@ -392,9 +496,16 @@ def _find_unused_exports(
392496 "generateStaticParams" ,
393497 }
394498
499+ star_reexported_files = star_reexported_files or set ()
500+
395501 for name , locations in exports .items ():
396502 if name in skip_names :
397503 continue
504+ # A symbol defined in a file that is `export *`-forwarded by a barrel
505+ # is part of the public API surface and must not be reported as
506+ # removable dead code.
507+ if any (loc_file in star_reexported_files for loc_file , _ in locations ):
508+ continue
398509 # If imported by at least one other file, it's used
399510 importers = imports .get (name , set ())
400511 exporter_files = {loc [0 ] for loc in locations }
@@ -423,9 +534,7 @@ def _find_dead_routes(
423534 return
424535
425536 # Build set of all route paths referenced in links
426- link_pattern = re .compile (
427- r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']'
428- )
537+ link_pattern = re .compile (r'(?:href|to|push|replace)\s*[=:]\s*["\'](/[^"\']*)["\']' )
429538 referenced_routes : set [str ] = set ()
430539
431540 for filepath in all_files :
0 commit comments