Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false

Expand Down Expand Up @@ -47,7 +47,7 @@ jobs:
contents: read

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false

Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/cowork-auto-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ jobs:
ensure-pr:
runs-on: ubuntu-latest
steps:
# gh pr create requires a local git checkout to diff head against base;
# without this step every run failed with "not a git repository" and no
# PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
- name: Check out the pushed branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Open PR for this branch if none exists
env:
GH_TOKEN: ${{ github.token }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Pages
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
id-token: write

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'; <div className={styles.card}>`) 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

Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.1",
"description": "Find unused/dead code in TypeScript, React, and Next.js projects. Scans for orphaned exports, dead routes, unreferenced components, and unused CSS module classes.",
"author": "Coding-Dev-Tools",
"license"
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Coding-Dev-Tools/deadcode.git"
Expand Down
47 changes: 47 additions & 0 deletions src/deadcode/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ def unreferenced_components(self) -> list[Finding]:
r'"([^"]+)"\s*:\s*\["([^"]+)"\]',
)

# CSS-module dot and literal bracket access, including valid JavaScript
# identifiers containing '$', and CSS class names beginning with '_'.
_CSS_MODULE_USAGE_PATTERN = re.compile(
r"""(?<![\w$])([\w$]+)(?:\s*\.\s*([\w$]+)|\s*\[\s*(['"])([\w$-]+)\3\s*\])""",
)

# 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 ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -219,6 +231,11 @@ def scan(self) -> ScanResult:
if rel_path.endswith((".tsx", ".jsx")):
self._parse_classname_usage(content, used_css_classes)

# CSS modules can also be consumed by plain TypeScript/JavaScript
# files, for example when exporting class-name maps to components.
if not self._is_css_file(rel_path):
self._parse_css_module_usage(content, used_css_classes)

# Parse components
if rel_path.endswith((".tsx", ".jsx")):
self._parse_components(content, rel_path, components)
Expand Down Expand Up @@ -281,6 +298,12 @@ def _collect_files(self) -> list[Path]:
continue

filepath = Path(root) / fname
# A source-file symlink may resolve outside the project. Such
# findings could otherwise make `remove` modify external files.
try:
filepath.resolve().relative_to(self.project_dir)
except (ValueError, OSError, RuntimeError):
continue
if self._is_scannable_file(rel_path):
files.append(filepath)

Expand Down Expand Up @@ -450,6 +473,30 @@ 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';
<div className={styles.card}>...</div>

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):
binding, dot_name, _, bracket_name = m.groups()
if binding in module_bindings:
used_css_classes.add(dot_name or bracket_name)

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):
Expand Down
50 changes: 50 additions & 0 deletions tests/test_css_module_boundaries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Protect live styles and the project boundary during a dead-code scan."""

import pytest

from deadcode.scanner import DeadCodeScanner


@pytest.mark.parametrize("extension", ["ts", "js", "tsx", "jsx"])
@pytest.mark.parametrize(
("binding", "accessor", "class_name"),
[
("styles", "._active", "_active"),
("styles", "[ 'card-hover' ]", "card-hover"),
("$styles", ".card", "card"),
],
)
def test_css_module_accessors_preserve_live_classes(tmp_path, extension, binding, accessor, class_name):
(tmp_path / "Card.module.css").write_text(f".{class_name} {{ color: red; }}\n.unused {{}}\n")
(tmp_path / f"consumer.{extension}").write_text(
f"import {binding} from './Card.module.css';\nconst selected = {binding}{accessor};\n"
)

result = DeadCodeScanner(tmp_path).scan()

assert class_name not in {finding.name for finding in result.orphaned_css}
assert "unused" in {finding.name for finding in result.orphaned_css}


def test_scanner_does_not_read_source_symlink_outside_project(tmp_path):
project = tmp_path / "project"
project.mkdir()
external = tmp_path / "external.ts"
external.write_text("export const externalLiveValue = 1;\n")
(project / "linked.ts").symlink_to(external)

result = DeadCodeScanner(project).scan()

assert result.files_scanned == 0
assert result.findings == []
assert external.read_text() == "export const externalLiveValue = 1;\n"


def test_scanner_still_reads_internal_source_symlink(tmp_path):
target = tmp_path / "target.ts"
target.write_text("export const internalValue = 1;\n")
(tmp_path / "linked.ts").symlink_to(target)

result = DeadCodeScanner(tmp_path).scan()

assert result.files_scanned == 2
Loading