Skip to content
Open
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
28 changes: 28 additions & 0 deletions .github/workflows/cowork-auto-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Seeded by the repo-improver-rotation Cowork job into cowork/improve-* branches.
# Opens a PR automatically when such a branch is pushed (sandbox cannot reach
# the GitHub API directly; this runs server-side with the repo's GITHUB_TOKEN).
name: cowork-auto-pr
on:
push:
branches: ['cowork/improve-**']
permissions:
contents: read
pull-requests: write
jobs:
ensure-pr:
runs-on: ubuntu-latest
steps:
- name: Open PR for this branch if none exists
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq 'length')
if [ "$existing" = "0" ]; then
gh pr create --repo "$GITHUB_REPOSITORY" \
--head "$GITHUB_REF_NAME" \
--title "cowork-bot: automated improvements ($GITHUB_REF_NAME)" \
--body "Automated improvement PR from the Cowork repo-improver rotation (one coherent senior-dev improvement per run; see individual commit messages). Subsequent runs push additional commits to this PR rather than opening new ones."
else
echo "Open PR already exists for $GITHUB_REF_NAME — nothing to do."
fi
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,7 @@
"engines": {
"node": ">=16.0.0"
},
"preferGlobal": true
}
"publishConfig": {
"access": "public"
}
}
68 changes: 49 additions & 19 deletions src/deadcode/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ def unreferenced_components(self) -> list[Finding]:
re.MULTILINE,
)

# export { name }
# export { name } — may span multiple lines; [^}] matches newlines too
_EXPORT_LIST_PATTERN = re.compile(
r"export\s*\{([^}]+)\}",
re.DOTALL,
)

# React component: function Name or const Name = ...
Expand All @@ -86,9 +87,9 @@ def unreferenced_components(self) -> list[Finding]:
r"\.([a-zA-Z_][\w-]*(?::[\w-]+)*)\s*(?:\{|,|\[)",
)

# import statements
# import statements (handles default, named, type-only, and mixed forms)
_IMPORT_PATTERN = re.compile(
r"import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['\"]([^'\"]+)['\"]",
r"import\s+(?:type\s+)?(?:(\w+))?\s*,?\s*(?:\{([^}]+)\})?\s*from\s+['\"]([^'\"]+)['\"]",
)

# className="..." or className={...} in JSX
Expand Down Expand Up @@ -261,35 +262,64 @@ def _is_css_file(rel_path: str) -> bool:
def _parse_exports(
self, content: str, rel_path: str, exports: dict[str, list[tuple[str, int]]]
) -> None:
"""Extract export names from a file."""
"""Extract export names from a file.

Handles both single-line forms::

export function foo() {}
export const BAR = 1;

And multi-line export-list blocks::

export {
Foo,
Bar as Baz,
}
"""
# Named/typed exports: scan line-by-line to preserve line numbers cheaply.
for i, line in enumerate(content.splitlines(), 1):
# Named exports
for m in _EXPORT_PATTERN.finditer(line):
name = m.group(1)
exports.setdefault(name, []).append((rel_path, i))

# Export lists: export { Foo, Bar }
for m in _EXPORT_LIST_PATTERN.finditer(line):
names = [n.strip().split(" as ")[0].strip() for n in m.group(1).split(",")]
for name in names:
if name and re.match(r"^[A-Za-z_$][\w$]*$", name):
exports.setdefault(name, []).append((rel_path, i))
# Export-list blocks: applied to the full content so that multi-line
# blocks like ``export {\n Foo,\n Bar\n}`` are captured correctly.
# [^}] matches newlines, so re.DOTALL is added for clarity but [^}]
# already handles multi-line spans without it.
for m in _EXPORT_LIST_PATTERN.finditer(content):
# Determine the line number of the opening ``export {``.
line_num = content.count("\n", 0, m.start()) + 1
raw = m.group(1)
# Strip // comments so inline-annotated export lists still parse.
cleaned = "\n".join(line.split("//")[0] for line in raw.splitlines())
names = [n.strip().split(" as ")[0].strip() for n in cleaned.split(",")]
for name in names:
if name and re.match(r"^[A-Za-z_$][\w$]*$", name):
exports.setdefault(name, []).append((rel_path, line_num))

def _parse_imports(
self, content: str, rel_path: str, imports: dict[str, set[str]]
) -> None:
"""Extract import names from a file."""
"""Extract import names from a file.

Handles default, named, type-only, and mixed import forms:
import Foo from '...'
import { Foo, Bar } from '...'
import type { Foo } from '...'
import Foo, { Bar } from '...'
"""
for m in _IMPORT_PATTERN.finditer(content):
# Named imports: import { Foo, Bar } from '...'
if m.group(1):
names = [n.strip().split(" as ")[0].strip() for n in m.group(1).split(",")]
default_import = m.group(1)
named_imports = m.group(2)
module_path = m.group(3)

if default_import:
imports.setdefault(default_import, set()).add(rel_path)
if named_imports:
names = [n.strip().split(" as ")[0].strip() for n in named_imports.split(",")]
for name in names:
if name:
imports.setdefault(name, set()).add(rel_path)
# Default import: import Foo from '...'
elif m.group(2):
name = m.group(2)
imports.setdefault(name, set()).add(rel_path)

def _parse_css_classes(
self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]]
Expand Down
57 changes: 26 additions & 31 deletions tests/test_cli_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
from __future__ import annotations

import json
import subprocess
import sys

import pytest

Expand All @@ -14,51 +12,48 @@
class TestMainModule:
"""Tests for __main__.py entry point (0% coverage)."""

def test_main_module_runs_help(self):
@pytest.fixture
def runner(self):
from click.testing import CliRunner
return CliRunner()

def test_main_module_runs_help(self, runner):
"""python -m deadcode --help works (covers __main__.py:2-5)."""
result = subprocess.run(
[sys.executable, "-m", "deadcode", "--help"],
capture_output=True, text=False,
)
assert result.returncode == 0
assert b"Usage" in result.stdout
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Usage" in result.output


class TestCliEdgeCases:
"""Edge cases for CLI uncovered paths."""

def test_non_existent_project_exits_1(self):
@pytest.fixture
def runner(self):
from click.testing import CliRunner
return CliRunner()

def test_non_existent_project_exits_1(self, runner):
"""Scan with non-existent project exits 1 (cli.py:88-90)."""
result = subprocess.run(
[sys.executable, "-m", "deadcode", "--project", "/nonexistent/path", "scan"],
capture_output=True, text=False,
)
assert result.returncode == 1
result = runner.invoke(cli, ["--project", "/nonexistent/path", "scan"])
assert result.exit_code == 1

def test_fail_threshold_exits_high(self, tmp_path):
def test_fail_threshold_exits_high(self, runner, tmp_path):
"""--fail=0 exits 1 when findings exist (covers fail threshold path)."""
(tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True)
(tmp_path / "src" / "unused.ts").write_text("export function unused() { return 1; }\n")
result = subprocess.run(
[sys.executable, "-m", "deadcode", "-p", str(tmp_path), "scan",
"--fail", "0"],
capture_output=True, text=True,
)
assert result.returncode == 1
assert "FAIL" in result.stdout
result = runner.invoke(cli, ["-p", str(tmp_path), "scan", "--fail", "0"])
assert result.exit_code == 1
assert "FAIL" in result.output

def test_ignore_flag_before_subcommand(self, tmp_path):
def test_ignore_flag_before_subcommand(self, runner, tmp_path):
"""--ignore group option rejects submodule patterns (covers _merge_config_ignore)."""
(tmp_path / "src" / "used.ts").parent.mkdir(parents=True, exist_ok=True)
(tmp_path / "src" / "used.ts").write_text("export function used() { return 1; }\n")
(tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True)
(tmp_path / "src" / "unused.ts").write_text("export function unused() { return 2; }\n")
result = subprocess.run(
[sys.executable, "-m", "deadcode", "-p", str(tmp_path),
"--ignore", "**/unused.ts", "scan"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "unused" not in result.stdout
result = runner.invoke(cli, ["-p", str(tmp_path), "--ignore", "**/unused.ts", "scan"])
assert result.exit_code == 0
assert "unused" not in result.output


class TestCliFormatOutput:
Expand Down
130 changes: 130 additions & 0 deletions tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,33 @@ def test_used_exports_not_reported(self, tmp_path):
unused_names = {f.name for f in result.unused_exports}
assert "myFunc" not in unused_names

def test_type_import_counts_as_used(self, tmp_path):
"""import type { Foo } should mark Foo as used."""
mod = tmp_path / "mod.ts"
mod.write_text('export type Foo = string;\n')
app = tmp_path / "app.ts"
app.write_text('import type { Foo } from "./mod";\nconst x: Foo = "hello";\n')

scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()

unused_names = {f.name for f in result.unused_exports}
assert "Foo" not in unused_names

def test_mixed_default_and_named_import_counts_as_used(self, tmp_path):
"""import默认 + named should mark both as used."""
mod = tmp_path / "mod.ts"
mod.write_text('export function myFunc() { return 1; }\n')
app = tmp_path / "app.ts"
app.write_text('import Default, { myFunc } from "./mod";\nmyFunc();\n')

scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()

unused_names = {f.name for f in result.unused_exports}
assert "myFunc" not in unused_names
assert "Default" not in unused_names


class TestCSSParsing:
def test_orphaned_css_detection(self, tmp_path):
Expand Down Expand Up @@ -343,6 +370,109 @@ def test_main_module_entry_point(self, runner):
assert "stats" in result.stdout


class TestMultiLineExportList:
"""Tests for multi-line export { } blocks (scanner.py fix: apply list pattern to full content)."""

def test_multiline_export_list_detected(self, tmp_path):
"""export { Foo, Bar } split across lines should be detected as unused exports."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text(
"function Alpha() { return 1; }\n"
"function Beta() { return 2; }\n"
"export {\n"
" Alpha,\n"
" Beta,\n"
"}\n"
)

scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()

export_names = {f.name for f in result.unused_exports}
assert "Alpha" in export_names, "Multi-line export Alpha should be detected"
assert "Beta" in export_names, "Multi-line export Beta should be detected"

def test_multiline_export_list_used_not_reported(self, tmp_path):
"""Names from a multi-line export {} that are imported elsewhere should NOT be reported."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text(
"export function usedInApp() { return 1; }\n"
"export function alsoUnused() { return 2; }\n"
"export {\n"
" usedInApp,\n"
"}\n"
)
app = tmp_path / "src" / "app.ts"
app.write_text('import { usedInApp } from "./mod";\nusedInApp();\n')

scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()

export_names = {f.name for f in result.unused_exports}
# usedInApp appears in both an inline export and the export-list; it's imported so should be absent
assert "usedInApp" not in export_names, "usedInApp is imported — should not be reported"
assert "alsoUnused" in export_names, "alsoUnused is never imported — should be reported"

def test_multiline_export_list_with_aliases(self, tmp_path):
"""export { Foo as Bar } aliases: the local name Foo should be tracked, not the alias."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text(
"function InternalName() { return 1; }\n"
"export {\n"
" InternalName as PublicName,\n"
"}\n"
)

scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()

export_names = {f.name for f in result.unused_exports}
# The scanner tracks the local (pre-alias) name
assert "InternalName" in export_names
# The alias 'PublicName' should not appear as a spurious finding
assert "PublicName" not in export_names

def test_single_line_export_list_still_works(self, tmp_path):
"""Single-line export { Foo, Bar } should continue to work after the fix."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text(
"const alpha = 1;\n"
"const beta = 2;\n"
"export { alpha, beta };\n"
)

scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()

export_names = {f.name for f in result.unused_exports}
assert "alpha" in export_names
assert "beta" in export_names

def test_export_list_with_inline_comments(self, tmp_path):
"""Inline // comments inside export lists should not mask other exports."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text(
"function Alpha() { return 1; }\n"
"function Beta() { return 2; }\n"
"export {\n"
" Alpha, // kept for clarity\n"
" Beta,\n"
"}\n"
)

scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()

export_names = {f.name for f in result.unused_exports}
assert "Alpha" in export_names
assert "Beta" in export_names


class TestIncludePatterns:
"""Tests for the include_patterns scanner feature."""

Expand Down