Skip to content

Commit c0c632c

Browse files
Coding-Dev-ToolsDevForge EngineerSenior Dev Rotation
authored
fix: packaging config, ruff cleanup, and 7 new test suites (#34)
* fix: sort imports with ruff I001 in scanner and tests * fix: resolve E501 line-too-long ruff violations in cli.py and tests * build: add py.typed to package-data config in pyproject.toml Adds include-package-data = true and explicit package-data entry for py.typed marker. The file physically exists in the package but was missing the setuptools config needed to include it in wheels. This ensures PEP 561 type checking support for package consumers. * test: verify py.typed package-data config in pyproject.toml Adds test_package_data_includes_py_typed to TestPackagingQuality, confirming py.typed is explicitly listed under [tool.setuptools.package-data] for the deadcode package. Bundled with the pyproject.toml config change as a related packaging quality improvement. * test: add __main__ entry point and CLI edge-case tests - test_main_module_runs_help: python -m deadcode --help covers __main__.py (0% coverage) - test_non_existent_project_exits_1: scan with nonexistent path exits 1 (cli.py:88-90) - 2 tests, 27 lines added * test: add format output tests for compact, github, pretty, and legacy json-output * fix: ruff I001 import sort in test_cli_edge_cases.py * test: add coverage for --fail threshold and --ignore flag Adds 2 new CLI edge case tests covering: - test_fail_threshold_exits_high — --fail=0 exits 1 when findings exist - test_ignore_flag_before_subcommand — --ignore group option excludes patterns These cover uncovered paths in cli.py (fail threshold logic) and _merge_config_ignore function. * test: add coverage for remove --dry-run and stats commands * fix: update site URLs back to devforge after repo rename * test: add coverage for config yaml-list edge case, removed-file skip, default imports, and ignore-spec filter paths * docs: add ops-heartbeat automation observations --------- Co-authored-by: DevForge Engineer <engineer@devforge.dev> Co-authored-by: Senior Dev Rotation <senior-dev@devforge.dev>
1 parent 30be8c3 commit c0c632c

4 files changed

Lines changed: 255 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
date: 2026-06-10
2+
project: deadcode-cli
3+
runner: pytest on Python 3.12
4+
findings:
5+
- 2026-06-10T__:fast smoke `py -3.12 -m pytest --no-header -q -q --maxfail=1` passed.
6+
- full suite: pending

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,15 @@ Changelog = "https://github.com/Coding-Dev-Tools/deadcode/releases"
4646
[project.scripts]
4747
deadcode = "deadcode.cli:cli"
4848

49+
[tool.setuptools]
50+
include-package-data = true
51+
4952
[tool.setuptools.packages.find]
5053
where = ["src"]
5154

55+
[tool.setuptools.package-data]
56+
deadcode = ["py.typed"]
57+
5258
[tool.pytest.ini_options]
5359
testpaths = ["tests"]
5460
python_files = ["test_*.py"]

tests/test_cli_edge_cases.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Tests for __main__.py entry point and CLI edge cases."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import subprocess
7+
import sys
8+
9+
import pytest
10+
11+
from deadcode.cli import cli
12+
13+
14+
class TestMainModule:
15+
"""Tests for __main__.py entry point (0% coverage)."""
16+
17+
def test_main_module_runs_help(self):
18+
"""python -m deadcode --help works (covers __main__.py:2-5)."""
19+
result = subprocess.run(
20+
[sys.executable, "-m", "deadcode", "--help"],
21+
capture_output=True, text=False,
22+
)
23+
assert result.returncode == 0
24+
assert b"Usage" in result.stdout
25+
26+
27+
class TestCliEdgeCases:
28+
"""Edge cases for CLI uncovered paths."""
29+
30+
def test_non_existent_project_exits_1(self):
31+
"""Scan with non-existent project exits 1 (cli.py:88-90)."""
32+
result = subprocess.run(
33+
[sys.executable, "-m", "deadcode", "--project", "/nonexistent/path", "scan"],
34+
capture_output=True, text=False,
35+
)
36+
assert result.returncode == 1
37+
38+
def test_fail_threshold_exits_high(self, tmp_path):
39+
"""--fail=0 exits 1 when findings exist (covers fail threshold path)."""
40+
(tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True)
41+
(tmp_path / "src" / "unused.ts").write_text("export function unused() { return 1; }\n")
42+
result = subprocess.run(
43+
[sys.executable, "-m", "deadcode", "-p", str(tmp_path), "scan",
44+
"--fail", "0"],
45+
capture_output=True, text=True,
46+
)
47+
assert result.returncode == 1
48+
assert "FAIL" in result.stdout
49+
50+
def test_ignore_flag_before_subcommand(self, tmp_path):
51+
"""--ignore group option rejects submodule patterns (covers _merge_config_ignore)."""
52+
(tmp_path / "src" / "used.ts").parent.mkdir(parents=True, exist_ok=True)
53+
(tmp_path / "src" / "used.ts").write_text("export function used() { return 1; }\n")
54+
(tmp_path / "src" / "unused.ts").write_text("export function unused() { return 2; }\n")
55+
result = subprocess.run(
56+
[sys.executable, "-m", "deadcode", "-p", str(tmp_path),
57+
"--ignore", "**/unused.ts", "scan"],
58+
capture_output=True, text=True,
59+
)
60+
assert result.returncode == 0
61+
assert "unused" not in result.stdout
62+
63+
64+
class TestCliFormatOutput:
65+
"""Tests for scan --format output modes (added in PR #34)."""
66+
67+
@pytest.fixture
68+
def runner(self):
69+
from click.testing import CliRunner
70+
return CliRunner()
71+
72+
@pytest.fixture
73+
def sample(self, tmp_path):
74+
"""A tiny TS project with at least one dead export."""
75+
mod = tmp_path / "src" / "mod.ts"
76+
mod.parent.mkdir(parents=True, exist_ok=True)
77+
mod.write_text(
78+
'export function usedHelper() { return 1; }\n'
79+
'export function unusedHelper() { return 2; }\n'
80+
)
81+
return tmp_path
82+
83+
def test_format_compact_output(self, runner, sample):
84+
"""--format=compact produces one-line-per-finding output."""
85+
result = runner.invoke(cli, ["-p", str(sample), "scan", "--format", "compact"])
86+
assert result.exit_code == 0
87+
assert "0 findings" not in result.output
88+
assert "unusedHelper" in result.output
89+
assert "unused_export" in result.output
90+
91+
def test_format_github_annotations(self, runner, sample):
92+
"""--format=github produces ::warning/::error annotations."""
93+
result = runner.invoke(cli, ["-p", str(sample), "scan", "--format", "github"])
94+
assert result.exit_code == 0
95+
assert "::warning" in result.output or "::error" in result.output
96+
assert "unusedHelper" in result.output
97+
98+
def test_format_pretty_default(self, runner, sample):
99+
"""Default pretty format shows table output."""
100+
result = runner.invoke(cli, ["-p", str(sample), "scan", "--format", "pretty"])
101+
assert result.exit_code == 0
102+
assert "DeadCode Scan" in result.output
103+
104+
def test_legacy_json_output_still_works(self, runner, sample):
105+
"""Legacy --json-output flag maps to --format=json."""
106+
result = runner.invoke(cli, ["-p", str(sample), "scan", "--json-output"])
107+
assert result.exit_code == 0
108+
# Scanner details may contain newlines; use strict=False
109+
payload = json.loads(result.output, strict=False)
110+
assert "findings" in payload
111+
assert "files_scanned" in payload
112+
assert len(payload["findings"]) >= 1
113+
114+
115+
class TestRemoveCommand:
116+
"""Tests for the `remove` CLI command."""
117+
118+
@pytest.fixture
119+
def runner(self):
120+
from click.testing import CliRunner
121+
return CliRunner()
122+
123+
def test_remove_dry_run_nothing_removable(self, runner, tmp_path):
124+
"""remove --dry-run on clean project prints nothing removable."""
125+
(tmp_path / "src" / "clean.ts").parent.mkdir(parents=True, exist_ok=True)
126+
(tmp_path / "src" / "clean.ts").write_text("const x = 1;\n")
127+
result = runner.invoke(cli, ["-p", str(tmp_path), "remove", "--dry-run"])
128+
assert result.exit_code == 0
129+
assert "Nothing removable" in result.output
130+
131+
132+
class TestStatsCommand:
133+
"""Tests for the `stats` CLI command."""
134+
135+
@pytest.fixture
136+
def runner(self):
137+
from click.testing import CliRunner
138+
return CliRunner()
139+
140+
def test_stats_basic(self, runner, tmp_path):
141+
"""stats command shows scan summary."""
142+
(tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True)
143+
(tmp_path / "src" / "unused.ts").write_text(
144+
"export function unusedHelper() { return 1; }\n"
145+
)
146+
result = runner.invoke(cli, ["-p", str(tmp_path), "stats"])
147+
assert result.exit_code == 0
148+
assert "Files scanned" in result.output

tests/test_config_and_fixes.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ def test_load_invalid_yml(self, tmp_path):
129129
# Should fall back to defaults
130130
assert config.ignore == []
131131

132+
def test_load_yaml_list_returns_defaults(self, tmp_path):
133+
"""When .deadcode.yml is a list (not a dict), config falls back to defaults (covers config.py:56)."""
134+
config_file = tmp_path / ".deadcode.yml"
135+
config_file.write_text("- item1\n- item2\n")
136+
config = DeadCodeConfig.load(tmp_path)
137+
assert config.ignore == []
138+
assert config.fail_threshold == -1
139+
132140

133141
class TestFailOption:
134142
def test_fail_exits_1_when_threshold_met(self, runner, sample_project):
@@ -193,6 +201,23 @@ def test_cli_ignore_overrides_config(self, runner, tmp_path):
193201
data = json.loads(result.output, strict=False)
194202
assert len(data["findings"]) == 0
195203

204+
def test_merge_config_and_cli_ignore(self, runner, tmp_path):
205+
"""Both config ignore AND CLI --ignore should merge together (covers cli.py:52 branch)."""
206+
mod = tmp_path / "src" / "mod.ts"
207+
mod.parent.mkdir(parents=True, exist_ok=True)
208+
mod.write_text('export function unused() { return 1; }\n')
209+
210+
config = tmp_path / ".deadcode.yml"
211+
config.write_text('ignore:\n - "nonexistent/"\n')
212+
213+
# CLI --ignore with a matching pattern
214+
result = runner.invoke(cli, ["-p", str(tmp_path), "-i", "*.ts", "scan", "--json-output"])
215+
assert result.exit_code == 0
216+
data = json.loads(result.output, strict=False)
217+
# Both ignores combined should cover the .ts file
218+
assert len(data["findings"]) == 0, \
219+
f"Expected 0 findings with merged ignores, got {len(data['findings'])}"
220+
196221

197222
class TestBugFixUnreferencedComponents:
198223
def test_component_imported_not_reported(self, tmp_path):
@@ -441,6 +466,28 @@ def test_remove_no_removable_findings(self, tmp_path):
441466
# If there are removable findings, verify the output is still valid
442467
assert result.exit_code == 0
443468

469+
def test_remove_missing_file_skips_gracefully(self, tmp_path):
470+
"""remove should skip files that disappeared after scan (covers cli.py:248 path)."""
471+
mod = tmp_path / "src" / "mod.ts"
472+
mod.parent.mkdir(parents=True, exist_ok=True)
473+
mod.write_text('export function unusedFunc() { return 1; }\n')
474+
475+
from click.testing import CliRunner
476+
runner = CliRunner()
477+
478+
import json
479+
scan_result = runner.invoke(cli, ["-p", str(tmp_path), "scan", "--json-output"])
480+
assert scan_result.exit_code == 0
481+
data = json.loads(scan_result.output, strict=False)
482+
removable = [f for f in data["findings"] if f.get("removable")]
483+
if removable:
484+
fpath = tmp_path / removable[0]["file"]
485+
if fpath.exists():
486+
fpath.unlink()
487+
488+
result = runner.invoke(cli, ["-p", str(tmp_path), "remove", "-c", "unused_export"])
489+
assert result.exit_code == 0
490+
444491

445492
class TestPackagingQuality:
446493
"""Tests for packaging quality: entry point, py.typed, ruff config."""
@@ -479,6 +526,20 @@ def test_ruff_known_first_party(self):
479526
kfp = isort_cfg.get("known-first-party", [])
480527
assert kfp == ["deadcode"], f"known-first-party should be ['deadcode'], got {kfp}"
481528

529+
def test_package_data_includes_py_typed(self):
530+
"""pyproject.toml should have package-data config for py.typed."""
531+
from pathlib import Path
532+
533+
import tomllib
534+
535+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
536+
with open(pyproject, "rb") as f:
537+
data = tomllib.load(f)
538+
pkg_data = data.get("tool", {}).get("setuptools", {}).get("package-data", {})
539+
assert "deadcode" in pkg_data, "Expected [tool.setuptools.package-data] section for 'deadcode'"
540+
assert "py.typed" in pkg_data["deadcode"], \
541+
f"Expected 'py.typed' in package-data for deadcode, got {pkg_data['deadcode']}"
542+
482543

483544
class TestScannerEdgeCases:
484545
"""Edge case tests for scanner uncovered paths."""
@@ -590,4 +651,37 @@ def _failing_read_text(self_path, *args, **kwargs):
590651
result = scanner.scan()
591652

592653
assert len(result.errors) > 0
593-
assert result.files_scanned == 1 # file was still counted
654+
assert result.files_scanned == 1
655+
656+
def test_default_import_parsed_correctly(self, tmp_path):
657+
"""Default imports (import Foo from) should be parsed (covers scanner.py:290-292)."""
658+
mod = tmp_path / "src" / "helper.ts"
659+
mod.parent.mkdir(parents=True, exist_ok=True)
660+
mod.write_text('export function helper() { return 1; }\n')
661+
662+
consumer = tmp_path / "src" / "consumer.ts"
663+
consumer.write_text('import helper from "./helper";\nhelper();\n')
664+
665+
scanner = DeadCodeScanner(tmp_path)
666+
result = scanner.scan()
667+
668+
unused_names = {f.name for f in result.unused_exports}
669+
assert "helper" not in unused_names, \
670+
"helper should be recognized as imported via default import"
671+
672+
def test_scan_collect_files_with_ignore(self, tmp_path):
673+
"""Scanner._collect_files handles ignore_spec correctly (covers scanner.py:238-241)."""
674+
mod = tmp_path / "src" / "mod.ts"
675+
mod.parent.mkdir(parents=True, exist_ok=True)
676+
mod.write_text('export function foo() { return 1; }\n')
677+
678+
internal = tmp_path / "src" / "internal" / "helper.ts"
679+
internal.parent.mkdir(parents=True, exist_ok=True)
680+
internal.write_text('export function bar() { return 2; }\n')
681+
682+
scanner = DeadCodeScanner(tmp_path, ignore_patterns=["src/internal/"])
683+
result = scanner.scan()
684+
assert result.files_scanned == 1, "internal/ should be ignored"
685+
unused_names = {f.name for f in result.unused_exports}
686+
assert "foo" in unused_names
687+
assert "bar" not in unused_names # file was still counted

0 commit comments

Comments
 (0)