Skip to content

Commit 1800931

Browse files
author
cowork-bot
committed
cowork-bot: fix import parsing in scanner — handle import type {Foo}, mixed default+named imports, and correct group-index reversal
- Rewrote _IMPORT_PATTERN regex to handle: import type {Foo}, import Default, {Named}, import {type Foo}, and import Foo as Bar forms - Fixed _parse_imports group-number reversal (group 1 = named imports block, group 2 = default) - Strips 'type ' prefix from named import entries in both named-block positions - All 113 existing tests pass; ruff clean
1 parent 8387e6c commit 1800931

1 file changed

Lines changed: 56 additions & 51 deletions

File tree

src/deadcode/scanner.py

Lines changed: 56 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)