ci(atlasbot): fail coverage when source files are missing
This commit is contained in:
parent
51b9fd20e9
commit
0b10dcd897
@ -8,6 +8,32 @@ import json
|
|||||||
from pathlib import Path
|
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:
|
def main() -> int:
|
||||||
"""Check each production file against a minimum coverage percentage."""
|
"""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"))
|
data = json.loads(Path(args.coverage_json).read_text(encoding="utf-8"))
|
||||||
files = data.get("files") if isinstance(data, dict) else {}
|
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()):
|
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
|
continue
|
||||||
summary = payload.get("summary") if isinstance(payload, dict) else {}
|
summary = payload.get("summary") if isinstance(payload, dict) else {}
|
||||||
percent = summary.get("percent_covered") if isinstance(summary, dict) else None
|
percent = summary.get("percent_covered") if isinstance(summary, dict) else None
|
||||||
if not isinstance(percent, (int, float)):
|
if not isinstance(percent, (int, float)):
|
||||||
|
violations.append(f"{normalized_path}: coverage percent missing")
|
||||||
continue
|
continue
|
||||||
|
covered_paths.add(normalized_path)
|
||||||
if float(percent) < args.threshold:
|
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:
|
if violations:
|
||||||
for percent, path in sorted(violations):
|
for violation in sorted(violations):
|
||||||
print(f"{path}: {percent:.2f}% < {args.threshold:.2f}%")
|
print(violation)
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|||||||
83
tests/test_check_coverage_contract.py
Normal file
83
tests/test_check_coverage_contract.py
Normal file
@ -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 == ""
|
||||||
Loading…
x
Reference in New Issue
Block a user