commit da8e7f701cb3b19e48bec10c01a11211427cb55b
parent 419a2a52be47451fb7d92da20653797cab56e0b1
Author: triesap <tyson@radroots.org>
Date: Tue, 3 Mar 2026 18:38:00 +0000
build: add deterministic coverage gate scripts
Diffstat:
5 files changed, 169 insertions(+), 0 deletions(-)
diff --git a/scripts/ci/verify_coverage.py b/scripts/ci/verify_coverage.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import sys
+import tomllib
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="verify coverage thresholds")
+ parser.add_argument("--thresholds", required=True, type=pathlib.Path)
+ parser.add_argument("--summary", required=True, type=pathlib.Path)
+ parser.add_argument("--lcov", required=True, type=pathlib.Path)
+ return parser.parse_args()
+
+
+def read_thresholds(path: pathlib.Path) -> dict[str, float | bool]:
+ payload = tomllib.loads(path.read_text(encoding="utf-8"))
+ coverage = payload.get("coverage")
+ if not isinstance(coverage, dict):
+ raise ValueError("missing [coverage] table")
+ return {
+ "line_percent": float(coverage["line_percent"]),
+ "function_percent": float(coverage["function_percent"]),
+ "branch_percent": float(coverage["branch_percent"]),
+ "require_branch_records": bool(coverage["require_branch_records"]),
+ }
+
+
+def read_totals(path: pathlib.Path) -> dict[str, object]:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ data = payload.get("data")
+ if not isinstance(data, list) or not data:
+ raise ValueError("coverage summary missing data entries")
+ totals = data[0].get("totals")
+ if not isinstance(totals, dict):
+ raise ValueError("coverage summary missing totals")
+ return totals
+
+
+def metric_percent(totals: dict[str, object], metric: str) -> float:
+ metric_obj = totals.get(metric)
+ if not isinstance(metric_obj, dict):
+ raise ValueError(f"missing totals metric: {metric}")
+ percent = metric_obj.get("percent")
+ if not isinstance(percent, (int, float)):
+ raise ValueError(f"missing percent for metric: {metric}")
+ return float(percent)
+
+
+def metric_count(totals: dict[str, object], metric: str) -> int:
+ metric_obj = totals.get(metric)
+ if not isinstance(metric_obj, dict):
+ raise ValueError(f"missing totals metric: {metric}")
+ count = metric_obj.get("count")
+ if not isinstance(count, int):
+ raise ValueError(f"missing count for metric: {metric}")
+ return count
+
+
+def lcov_branch_record_count(path: pathlib.Path) -> int:
+ total = 0
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
+ if not raw_line.startswith("BRF:"):
+ continue
+ value = raw_line[4:].strip()
+ if not value:
+ continue
+ total += int(value)
+ return total
+
+
+def main() -> int:
+ args = parse_args()
+ thresholds = read_thresholds(args.thresholds)
+ totals = read_totals(args.summary)
+
+ checks = [
+ ("lines", metric_percent(totals, "lines"), thresholds["line_percent"]),
+ (
+ "functions",
+ metric_percent(totals, "functions"),
+ thresholds["function_percent"],
+ ),
+ (
+ "branches",
+ metric_percent(totals, "branches"),
+ thresholds["branch_percent"],
+ ),
+ ]
+
+ errors: list[str] = []
+ for name, actual, required in checks:
+ if actual < float(required):
+ errors.append(f"{name} coverage {actual:.4f}% is below {required:.4f}%")
+
+ if thresholds["require_branch_records"]:
+ branch_total = metric_count(totals, "branches")
+ if branch_total <= 0:
+ errors.append("summary has no branch records")
+ lcov_branches = lcov_branch_record_count(args.lcov)
+ if lcov_branches <= 0:
+ errors.append("lcov report has no branch records")
+
+ print(
+ "coverage totals: "
+ f"lines={metric_percent(totals, 'lines'):.4f}% "
+ f"functions={metric_percent(totals, 'functions'):.4f}% "
+ f"branches={metric_percent(totals, 'branches'):.4f}%"
+ )
+ if errors:
+ for error in errors:
+ print(f"coverage gate error: {error}", file=sys.stderr)
+ return 1
+ print("coverage gate passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/just/coverage_gate.sh b/scripts/just/coverage_gate.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "${script_dir}/../.." && pwd)"
+summary_path="${repo_root}/target/coverage/summary.json"
+lcov_path="${repo_root}/target/coverage/lcov.info"
+thresholds_path="${repo_root}/contract/coverage/thresholds.toml"
+
+"${script_dir}/coverage_report.sh"
+
+python3 "${repo_root}/scripts/ci/verify_coverage.py" \
+ --thresholds "${thresholds_path}" \
+ --summary "${summary_path}" \
+ --lcov "${lcov_path}"
diff --git a/scripts/just/coverage_report.sh b/scripts/just/coverage_report.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "${script_dir}/../.." && pwd)"
+output_dir="${repo_root}/target/coverage"
+summary_path="${output_dir}/summary.json"
+lcov_path="${output_dir}/lcov.info"
+
+mkdir -p "${output_dir}"
+cd "${repo_root}"
+
+cargo +nightly llvm-cov clean --workspace
+cargo +nightly llvm-cov --workspace --all-features --branch --no-report
+cargo +nightly llvm-cov report --json --summary-only --output-path "${summary_path}"
+cargo +nightly llvm-cov report --lcov --output-path "${lcov_path}"
+cargo +nightly llvm-cov report --summary-only
+
+echo "coverage summary: ${summary_path}"
+echo "coverage lcov: ${lcov_path}"
diff --git a/scripts/just/dev.sh b/scripts/just/dev.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+log_dir="${1:?missing log dir}"
+
+RADROOTS_LOG_DIR="${log_dir}" cargo run -- --config config.dev.toml --identity identity.json --allow-generate-identity
diff --git a/scripts/just/log_reset.sh b/scripts/just/log_reset.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+log_dir="${1:?missing log dir}"
+rm -rf "${log_dir}"/*