118 lines
3.7 KiB
Python
118 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Enforce non-regressing Go coverage against a checked-in baseline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
COVER_RE = re.compile(
|
|
r"^(?P<path>.+?):(?P<start>\d+)\.(?P<start_col>\d+),(?P<end>\d+)\.(?P<end_col>\d+)\s+(?P<stmts>\d+)\s+(?P<count>\d+)$"
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", default=".")
|
|
parser.add_argument("--coverprofile", required=True)
|
|
parser.add_argument("--min-total", type=float, default=0.0)
|
|
parser.add_argument("--baseline", required=True)
|
|
parser.add_argument("--summary-json", default="")
|
|
return parser.parse_args()
|
|
|
|
|
|
def normalize_path(raw: str, root_name: str) -> str:
|
|
marker = f"/{root_name}/"
|
|
if marker in raw:
|
|
return raw.split(marker, 1)[1]
|
|
path = Path(raw)
|
|
return path.as_posix()
|
|
|
|
|
|
def parse_coverprofile(path: Path, root_name: str) -> tuple[dict[str, tuple[int, int]], float]:
|
|
per_file: dict[str, list[int]] = defaultdict(lambda: [0, 0])
|
|
total_statements = 0
|
|
total_covered = 0
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if not line or line.startswith("mode:"):
|
|
continue
|
|
match = COVER_RE.match(line)
|
|
if not match:
|
|
continue
|
|
rel = normalize_path(match.group("path"), root_name)
|
|
stmts = int(match.group("stmts"))
|
|
count = int(match.group("count"))
|
|
per_file[rel][0] += stmts
|
|
total_statements += stmts
|
|
if count > 0:
|
|
per_file[rel][1] += stmts
|
|
total_covered += stmts
|
|
normalized = {k: (v[0], v[1]) for k, v in per_file.items()}
|
|
total_pct = 100.0 if total_statements == 0 else (total_covered * 100.0 / total_statements)
|
|
return normalized, total_pct
|
|
|
|
|
|
def load_baseline(path: Path) -> dict[str, float]:
|
|
baseline: dict[str, float] = {}
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
parts = line.split("\t")
|
|
if len(parts) < 2:
|
|
continue
|
|
rel = parts[0]
|
|
try:
|
|
pct = float(parts[1])
|
|
except ValueError:
|
|
continue
|
|
baseline[rel] = pct
|
|
return baseline
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
root = Path(args.root).resolve()
|
|
coverprofile = Path(args.coverprofile).resolve()
|
|
baseline = load_baseline(Path(args.baseline).resolve())
|
|
|
|
if not coverprofile.exists():
|
|
print(f"Coverage hygiene check failed: missing coverprofile {coverprofile}")
|
|
return 1
|
|
|
|
per_file, total_pct = parse_coverprofile(coverprofile, root.name)
|
|
violations: list[str] = []
|
|
for rel, floor in sorted(baseline.items()):
|
|
stmts, covered = per_file.get(rel, (0, 0))
|
|
pct = 100.0 if stmts == 0 else (covered * 100.0 / stmts)
|
|
if pct + 1e-9 < floor:
|
|
violations.append(f"{rel}: {pct:.2f}% < baseline {floor:.2f}%")
|
|
|
|
if total_pct + 1e-9 < args.min_total:
|
|
violations.append(f"total coverage {total_pct:.2f}% < floor {args.min_total:.2f}%")
|
|
|
|
summary = {
|
|
"total_percent": round(total_pct, 3),
|
|
"checked_files": len(baseline),
|
|
"violations": len(violations),
|
|
}
|
|
if args.summary_json:
|
|
Path(args.summary_json).write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
|
|
|
if violations:
|
|
print("Coverage hygiene check failed:")
|
|
for item in violations:
|
|
print(item)
|
|
return 1
|
|
|
|
print(f"Coverage hygiene checks: ok (total {total_pct:.2f}%)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|