Files
gaokao-volunteer-system/scripts/check_coverage_gate.py
2026-06-18 14:07:26 +08:00

136 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Fail CI when coverage gates are not met.
Single source of truth for the coverage gate.
CI workflow, dev-verify, and codecov all read this file (or
the same OVERALL_MIN / CORE_MIN constants) so the gate no
longer diverges across surfaces.
"""
from __future__ import annotations
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
# Mirrors `tests/test_coverage_gate_core.py` thresholds.
OVERALL_MIN = 0.80
CORE_MIN = 1.00
CORE_FILES = (
"skills/gaokao-spec-checker/scripts/spec_checker_v2.py",
"skills/gaokao-college-advisor/scripts/gaokao_visual_report.py",
)
CORE_FILE_KEYS = tuple(
path.removeprefix("skills/")
for path in CORE_FILES
)
IGNORED_PATH_PREFIXES = (
"tests/",
"admin/tests/",
"docs/",
)
IGNORED_PATH_PARTS = (
"/tests/",
"/docs/plans/",
"/fixtures/",
)
PATH_PREFIXES_TO_TRIM = (
"skills/",
)
def _normalize_path(filename: str) -> str:
normalized = filename.replace("\\", "/")
for prefix in PATH_PREFIXES_TO_TRIM:
if normalized.startswith(prefix):
return normalized[len(prefix) :]
return normalized
def _format_percent(value: float) -> str:
return f"{value * 100:.2f}%"
def _should_ignore(filename: str) -> bool:
normalized = _normalize_path(filename)
if normalized.startswith(IGNORED_PATH_PREFIXES):
return True
return any(part in normalized for part in IGNORED_PATH_PARTS)
def _line_count(cls: ET.Element) -> int:
return len(cls.findall("./lines/line"))
def main(argv: list[str]) -> int:
coverage_path = Path(argv[1]) if len(argv) > 1 else Path("coverage.xml")
if not coverage_path.exists():
print(f"coverage gate failed: missing file {coverage_path}", file=sys.stderr)
return 2
root = ET.parse(coverage_path).getroot()
classes = root.findall(".//class")
application_classes = [
cls for cls in classes if not _should_ignore(cls.attrib.get("filename", ""))
]
if not application_classes:
print("coverage gate failed: no application coverage entries", file=sys.stderr)
return 2
class_by_filename = {
_normalize_path(cls.attrib.get("filename", "")): float(cls.attrib["line-rate"])
for cls in application_classes
}
lines_by_filename = {
_normalize_path(cls.attrib.get("filename", "")): _line_count(cls)
for cls in application_classes
}
total_lines = sum(lines_by_filename.values())
covered_lines = sum(
class_by_filename[name] * lines_by_filename[name]
for name in class_by_filename
)
overall = covered_lines / total_lines if total_lines else 0.0
missing = [name for name in CORE_FILE_KEYS if name not in class_by_filename]
if missing:
print("coverage gate failed: missing core coverage entries:", file=sys.stderr)
for name in missing:
print(f" - {name}", file=sys.stderr)
return 2
core_lines = 0
core_covered_lines = 0.0
for name in CORE_FILE_KEYS:
line_count = lines_by_filename[name]
core_lines += line_count
core_covered_lines += class_by_filename[name] * line_count
core = core_covered_lines / core_lines if core_lines else 0.0
failures: list[str] = []
if overall < OVERALL_MIN:
failures.append(
f"overall coverage {_format_percent(overall)} < required {_format_percent(OVERALL_MIN)}"
)
if core < CORE_MIN:
failures.append(
f"core coverage {_format_percent(core)} < required {_format_percent(CORE_MIN)}"
)
print(
"coverage gate summary: "
f"overall={_format_percent(overall)}, core={_format_percent(core)}"
)
if failures:
print("coverage gate failed:", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))