diff --git a/Jenkinsfile b/Jenkinsfile index 72b3c8e..996ee45 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -196,11 +196,11 @@ if [ "${install_rc}" -eq 0 ]; then -m pytest -ra -vv --durations=20 --junitxml "${JUNIT_XML}" tests_rc=$? python -c "import json; payload=json.load(open('build/coverage.json', encoding='utf-8')); percent=(payload.get('summary') or {}).get('percent_covered'); print(f'Coverage summary: {percent:.2f}%' if percent is not None else 'Coverage summary unavailable')" || true - if [ -f "${COVERAGE_JSON}" ] && [ -f scripts/check_coverage_contract.py ] && [ -f ci/coverage_contract.json ]; then - python scripts/check_coverage_contract.py "${COVERAGE_JSON}" ci/coverage_contract.json + if [ -f "${COVERAGE_JSON}" ] && [ -f scripts/check_coverage_contract.py ]; then + python scripts/check_coverage_contract.py "${COVERAGE_JSON}" --source-root ariadne --threshold "${COVERAGE_MIN}" coverage_contract_rc=$? else - echo "coverage contract check skipped: checker, contract, or coverage report missing" + echo "coverage contract check skipped: checker or coverage report missing" fi fi printf '%s\n' "${docs_rc}" > build/docs-naming.rc diff --git a/scripts/check_coverage_contract.py b/scripts/check_coverage_contract.py new file mode 100644 index 0000000..6c4d1f7 --- /dev/null +++ b/scripts/check_coverage_contract.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Enforce Ariadne's per-file source coverage contract.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def _source_files(root: Path) -> list[str]: + files: list[str] = [] + for path in sorted(root.rglob("*.py")): + if "__pycache__" in path.parts: + continue + files.append(path.as_posix()) + return files + + +def _coverage_percent(file_payload: object) -> float | None: + if not isinstance(file_payload, dict): + return None + summary = file_payload.get("summary") + if not isinstance(summary, dict): + return None + value = summary.get("percent_covered") + if isinstance(value, (int, float)): + return float(value) + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("coverage_json") + parser.add_argument("--source-root", default="ariadne") + parser.add_argument("--threshold", type=float, default=95.0) + args = parser.parse_args() + + coverage_path = Path(args.coverage_json) + source_root = Path(args.source_root) + payload = json.loads(coverage_path.read_text(encoding="utf-8")) + files = payload.get("files") if isinstance(payload, dict) else None + if not isinstance(files, dict): + print(f"{coverage_path}: missing files coverage map") + return 1 + + failures: list[str] = [] + for source_file in _source_files(source_root): + percent = _coverage_percent(files.get(source_file)) + if percent is None: + failures.append(f"{source_file}: missing from coverage report") + elif percent < args.threshold: + failures.append(f"{source_file}: {percent:.2f}% below {args.threshold:.2f}%") + + if failures: + print("coverage contract failed:") + for failure in failures: + print(f" - {failure}") + return 1 + + print(f"coverage contract passed: {len(_source_files(source_root))} files >= {args.threshold:.2f}%") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())