diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 042ebc6..50994b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -47,7 +47,7 @@ jobs: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: persist-credentials: false diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml index 91690e6..b27f04e 100644 --- a/.github/workflows/cowork-auto-pr.yml +++ b/.github/workflows/cowork-auto-pr.yml @@ -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 }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e64318a..31993c2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 87d5a2a..e098b68 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: persist-credentials: false 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/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6ac8caf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "deadcode-cli", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deadcode-cli", + "version": "0.1.1", + "license": "MIT", + "bin": { + "deadcode": "cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + } + } +} diff --git a/package.json b/package.json index 560949a..4492ddd 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index c1a5e1c..47f8cf3 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -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"""(? 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) @@ -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) @@ -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'; +
...
+ + 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): diff --git a/tests/test_css_module_boundaries.py b/tests/test_css_module_boundaries.py new file mode 100644 index 0000000..7a874df --- /dev/null +++ b/tests/test_css_module_boundaries.py @@ -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 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