diff --git a/CHANGELOG.md b/CHANGELOG.md index c44dabd..dd26d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 './mod'`, `export { type X } from './mod'`, and `export * from './mod'`) now mark the forwarded symbols as used, preventing false-positive `removable` findings that could delete live public API. +- CSS-module classes consumed via the object-accessor pattern + (`import styles from './x.module.css';
`) are now + treated as used. Previously every `*.module.css` class was falsely reported as + orphaned CSS and marked `removable=True`, risking deletion of live styles. Bracket + accessors (`styles['card-hover']`) are also recognized; non-module object access + (e.g. `util.foo`) is not over-matched. ### Changed diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index c1a5e1c..eaa2351 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -132,6 +132,21 @@ def unreferenced_components(self) -> list[Finding]: r'"([^"]+)"\s*:\s*\["([^"]+)"\]', ) +# CSS-module accessor usage, two forms:: +# - dot: `styles.card` -> capture group 1 empty, full = "styles.card" +# - bracket: `styles['card-hover']` / `styles["card"]` -> capture group 1 = name +# We deliberately match either form after a binding identifier so both +# `styles.card` and `styles['card-hover']` register the class as used. +_CSS_MODULE_USAGE_PATTERN = re.compile( + r"""\b\w+\.(?:_?[\w$]+)|(\w+)\[['"]([\w$-]+)['"]\]""", +) + +# Detect a CSS-module import so we know which source-file class accessors +# correspond to module classes (``import styles from './x.module.css'``). +_CSS_MODULE_IMPORT_PATTERN = re.compile( + r"""import\s+(?:type\s+)?(\w+)\s+from\s+['"]([^'"]*\.module\.css)['"]""", +) + # ── Scanner ─────────────────────────────────────────────────────────── @@ -218,6 +233,11 @@ def scan(self) -> ScanResult: # Parse className usage in TSX/JSX files if rel_path.endswith((".tsx", ".jsx")): self._parse_classname_usage(content, used_css_classes) + # CSS-module accessor usage (styles.card / styles['card']). + # Only register accessors imported as a CSS module so we don't + # treat every object property access (e.g. `user.name`) as a + # CSS class. + self._parse_css_module_usage(content, used_css_classes) # Parse components if rel_path.endswith((".tsx", ".jsx")): @@ -450,6 +470,42 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No for cls in group.split(): used_css_classes.add(cls) + def _parse_css_module_usage(self, content: str, used_css_classes: set[str]) -> None: + """Extract CSS-module class names consumed via object accessors. + + The canonical Next.js/React pattern is:: + + import styles from './Card.module.css'; +
...
+ + We first find any ``*.module.css`` imports (binding name ``styles``) + so that accessor use ``styles.card`` registers ``card`` as a used + class. Bracket access ``styles['card-hover']`` is also captured. + Without this, every CSS-module class is falsely reported as orphaned + and marked removable=True, risking deletion of live styles. + """ + # Map the local binding (e.g. ``styles``) to a CSS-module import so + # only module accessors are treated as class names. + module_bindings = {m.group(1) for m in _CSS_MODULE_IMPORT_PATTERN.finditer(content)} + if not module_bindings: + return + for m in _CSS_MODULE_USAGE_PATTERN.finditer(content): + groups = m.groups() + # Bracket form `styles['card-hover']` -> groups = (binding, name). + # Dot form `styles.card` -> groups = (None, None); the whole match + # is "binding.attr". Distinguish by whether a bracket name exists. + bracket_binding, bracket_name = groups + if bracket_name is not None: + if bracket_binding in module_bindings: + used_css_classes.add(bracket_name) + continue + # Dot form `styles.card` -> the whole match is "binding.attr". + full = m.group(0) + binding, _, attr = full.partition(".") + if binding in module_bindings and attr and attr[0] != "_": + # Skip internal/private-ish accessors (e.g. styles.toString). + used_css_classes.add(attr) + def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None: """Extract React component definitions.""" for m in _COMPONENT_PATTERN.finditer(content): diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 0ddfbbb..77db3d6 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -43,33 +43,24 @@ def sample_project(tmp_path): # src/components/UnusedWidget.tsx - never imported widget = tmp_path / "src" / "components" / "UnusedWidget.tsx" - widget.write_text( - "export function UnusedWidget() {\n return
Unused
;\n}\n" - ) + widget.write_text("export function UnusedWidget() {\n return
Unused
;\n}\n") # src/styles/main.css - with orphaned class css = tmp_path / "src" / "styles" / "main.css" css.parent.mkdir(parents=True, exist_ok=True) - css.write_text( - ".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n" - ) + css.write_text(".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n") # src/app/page.tsx - Next.js page (entry point) page = tmp_path / "src" / "app" / "page.tsx" page.parent.mkdir(parents=True, exist_ok=True) page.write_text( - 'import { Button } from "../components/Button";\n' - "export default function Page() {\n" - " return