diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 727057c..29590ce 100755 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -8,6 +8,32 @@ import json from pathlib import Path +def _normalize_report_path(path: str, cwd: Path) -> str: + """Return a stable repository-relative path for a coverage report entry.""" + + candidate = Path(path) + if candidate.is_absolute(): + try: + return candidate.relative_to(cwd).as_posix() + except ValueError: + return candidate.as_posix() + return candidate.as_posix() + + +def _production_files(root: Path, cwd: Path) -> set[str]: + """List production Python files that must appear in the coverage report.""" + + required: set[str] = set() + for path in root.rglob("*.py"): + if path.name == "__init__.py" or "__pycache__" in path.parts: + continue + try: + required.add(path.relative_to(cwd).as_posix()) + except ValueError: + required.add(path.as_posix()) + return required + + def main() -> int: """Check each production file against a minimum coverage percentage.""" @@ -19,24 +45,35 @@ def main() -> int: data = json.loads(Path(args.coverage_json).read_text(encoding="utf-8")) files = data.get("files") if isinstance(data, dict) else {} - violations: list[tuple[float, str]] = [] + cwd = Path.cwd().resolve() + root = Path(args.root) + root_path = (root if root.is_absolute() else cwd / root).resolve() + root_prefix = root_path.relative_to(cwd).as_posix() if root_path.is_relative_to(cwd) else root_path.as_posix() + covered_paths: set[str] = set() + violations: list[str] = [] + for path, payload in sorted(files.items()): - if not path.startswith(f"{args.root}/"): + normalized_path = _normalize_report_path(path, cwd) + if not normalized_path.startswith(f"{root_prefix}/"): continue summary = payload.get("summary") if isinstance(payload, dict) else {} percent = summary.get("percent_covered") if isinstance(summary, dict) else None if not isinstance(percent, (int, float)): + violations.append(f"{normalized_path}: coverage percent missing") continue + covered_paths.add(normalized_path) if float(percent) < args.threshold: - violations.append((float(percent), path)) + violations.append(f"{normalized_path}: {float(percent):.2f}% < {args.threshold:.2f}%") + + for path in sorted(_production_files(root_path, cwd) - covered_paths): + violations.append(f"{path}: missing from coverage report") if violations: - for percent, path in sorted(violations): - print(f"{path}: {percent:.2f}% < {args.threshold:.2f}%") + for violation in sorted(violations): + print(violation) return 1 return 0 if __name__ == "__main__": raise SystemExit(main()) - diff --git a/tests/test_check_coverage_contract.py b/tests/test_check_coverage_contract.py new file mode 100644 index 0000000..46c8ac1 --- /dev/null +++ b/tests/test_check_coverage_contract.py @@ -0,0 +1,83 @@ +"""Tests for Atlasbot's per-file coverage contract script.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check_coverage.py" + + +def _run_check(tmp_path: Path, coverage_payload: dict) -> subprocess.CompletedProcess[str]: + """Run the coverage script against a temporary Atlasbot source tree.""" + + coverage_path = tmp_path / "coverage.json" + coverage_path.write_text(json.dumps(coverage_payload), encoding="utf-8") + return subprocess.run( + [sys.executable, str(SCRIPT), str(coverage_path), "--root", "atlasbot", "--threshold", "95"], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + +def test_missing_source_file_fails_coverage_contract(tmp_path: Path) -> None: + """Every non-init production source file must appear in the coverage report.""" + + source_root = tmp_path / "atlasbot" + source_root.mkdir() + (source_root / "__init__.py").write_text("", encoding="utf-8") + (source_root / "covered.py").write_text("value = 1\n", encoding="utf-8") + (source_root / "missing.py").write_text("value = 2\n", encoding="utf-8") + + result = _run_check( + tmp_path, + {"files": {"atlasbot/covered.py": {"summary": {"percent_covered": 100.0}}}}, + ) + + assert result.returncode == 1 + assert "atlasbot/missing.py: missing from coverage report" in result.stdout + assert "atlasbot/__init__.py" not in result.stdout + + +def test_low_or_malformed_file_coverage_fails_contract(tmp_path: Path) -> None: + """Covered files still fail if their per-file percentage is bad or missing.""" + + source_root = tmp_path / "atlasbot" + source_root.mkdir() + (source_root / "low.py").write_text("value = 1\n", encoding="utf-8") + (source_root / "malformed.py").write_text("value = 2\n", encoding="utf-8") + + result = _run_check( + tmp_path, + { + "files": { + "atlasbot/low.py": {"summary": {"percent_covered": 94.9}}, + "atlasbot/malformed.py": {"summary": {}}, + } + }, + ) + + assert result.returncode == 1 + assert "atlasbot/low.py: 94.90% < 95.00%" in result.stdout + assert "atlasbot/malformed.py: coverage percent missing" in result.stdout + + +def test_complete_per_file_coverage_passes_contract(tmp_path: Path) -> None: + """The contract passes when every production file is present above threshold.""" + + source_root = tmp_path / "atlasbot" + source_root.mkdir() + (source_root / "covered.py").write_text("value = 1\n", encoding="utf-8") + + result = _run_check( + tmp_path, + {"files": {"atlasbot/covered.py": {"summary": {"percent_covered": 95.0}}}}, + ) + + assert result.returncode == 0 + assert result.stdout == ""