radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 7a5f43748838a8663b65aaed0fa6a53bcfb6bf1f
parent 2e4cc1d6227b395b4761bd9127a38425ae7323ee
Author: triesap <tyson@radroots.org>
Date:   Wed,  4 Mar 2026 11:02:55 +0000

ci: add coverage contracts and workflow

Diffstat:
MCargo.toml | 3+++
AMakefile | 20++++++++++++++++++++
Acontract/coverage/POLICY.md | 32++++++++++++++++++++++++++++++++
Acontract/coverage/include.txt | 7+++++++
Acontract/coverage/thresholds.toml | 6++++++
Ascripts/ci/verify_coverage.py | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 1+
Msrc/main.rs | 1+
8 files changed, 251 insertions(+), 0 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -19,6 +19,9 @@ radroots-nostr = { path = "../../../../foundation/oss/rs/radroots/crates/nostr" radroots-runtime = { path = "../../../../foundation/oss/rs/radroots/crates/runtime" } radroots-trade = { path = "../../../../foundation/oss/rs/radroots/crates/trade" } +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + [dependencies] radroots-core = { workspace = true, features = ["std", "serde", "typeshare"] } radroots-events = { workspace = true, features = ["serde"] } diff --git a/Makefile b/Makefile @@ -0,0 +1,20 @@ +COVERAGE_OUTPUT_DIR := target/coverage +COVERAGE_SUMMARY := $(COVERAGE_OUTPUT_DIR)/summary.json +COVERAGE_LCOV := $(COVERAGE_OUTPUT_DIR)/lcov.info +COVERAGE_THRESHOLDS := contract/coverage/thresholds.toml +COVERAGE_INCLUDE := contract/coverage/include.txt + +.PHONY: coverage-report coverage-gate + +coverage-report: + mkdir -p $(COVERAGE_OUTPUT_DIR) + 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 $(COVERAGE_SUMMARY) + cargo +nightly llvm-cov report --lcov --output-path $(COVERAGE_LCOV) + cargo +nightly llvm-cov report --summary-only + @echo "coverage summary: $(COVERAGE_SUMMARY)" + @echo "coverage lcov: $(COVERAGE_LCOV)" + +coverage-gate: coverage-report + python3 scripts/ci/verify_coverage.py --thresholds $(COVERAGE_THRESHOLDS) --summary $(COVERAGE_SUMMARY) --lcov $(COVERAGE_LCOV) --include $(COVERAGE_INCLUDE) diff --git a/contract/coverage/POLICY.md b/contract/coverage/POLICY.md @@ -0,0 +1,32 @@ +# radrootsd coverage policy + +this policy defines the required rust coverage gate for this repository. + +## gate contract + +- executable lines coverage: 100.0 +- function coverage: 100.0 +- branch coverage: 100.0 +- region coverage: 100.0 +- branch records must be present in coverage data + +all thresholds are merge-blocking and release-blocking. + +## toolchain contract + +- use nightly rust for coverage runs +- use `cargo llvm-cov` with `--branch` +- export summary json and lcov artifacts per run +- evaluate gates from `contract/coverage/thresholds.toml` + +## enforcement contract + +- evaluate coverage for the repository crate, not only aggregated workspace totals +- fail hard when any required metric is below threshold, including regions +- fail hard when required branch records are missing +- fail hard when required covered files in `contract/coverage/include.txt` are absent from summary output + +## local and ci contract + +- local development may run report-only commands +- ci must run strict gate commands with default thresholds diff --git a/contract/coverage/include.txt b/contract/coverage/include.txt @@ -0,0 +1,7 @@ +# required source files that must appear in coverage summary output +src/app/config.rs +src/app/runtime.rs +src/core/nip46/session.rs +src/core/state.rs +src/lib.rs +src/main.rs diff --git a/contract/coverage/thresholds.toml b/contract/coverage/thresholds.toml @@ -0,0 +1,6 @@ +[coverage] +line_percent = 100.0 +function_percent = 100.0 +branch_percent = 100.0 +region_percent = 100.0 +require_branch_records = true diff --git a/scripts/ci/verify_coverage.py b/scripts/ci/verify_coverage.py @@ -0,0 +1,181 @@ +#!/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) + parser.add_argument("--include", required=False, 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"]), + "region_percent": float(coverage["region_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 read_covered_files(path: pathlib.Path) -> set[str]: + 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") + files = data[0].get("files") + if not isinstance(files, list): + raise ValueError("coverage summary missing files") + + repo_root = pathlib.Path.cwd().resolve() + covered: set[str] = set() + for entry in files: + if not isinstance(entry, dict): + continue + raw_name = entry.get("filename") + if not isinstance(raw_name, str): + continue + filename = pathlib.Path(raw_name) + if filename.is_absolute(): + try: + rel = filename.resolve().relative_to(repo_root) + covered.add(rel.as_posix()) + continue + except ValueError: + pass + covered.add(filename.as_posix()) + return covered + + +def read_required_files(path: pathlib.Path) -> set[str]: + required: set[str] = set() + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + required.add(pathlib.Path(line).as_posix()) + return required + + +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) + covered_files = read_covered_files(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"], + ), + ( + "regions", + metric_percent(totals, "regions"), + thresholds["region_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") + + if args.include is not None: + required_files = read_required_files(args.include) + missing = sorted(required_files - covered_files) + if missing: + errors.append( + "summary is missing required covered files: " + + ", ".join(missing) + ) + + print( + "coverage totals: " + f"lines={metric_percent(totals, 'lines'):.4f}% " + f"functions={metric_percent(totals, 'functions'):.4f}% " + f"branches={metric_percent(totals, 'branches'):.4f}% " + f"regions={metric_percent(totals, 'regions'):.4f}%" + ) + print(f"coverage files counted: {len(covered_files)}") + 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/src/lib.rs b/src/lib.rs @@ -1,4 +1,5 @@ #![forbid(unsafe_code)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] pub mod app; pub mod core; diff --git a/src/main.rs b/src/main.rs @@ -1,4 +1,5 @@ #![forbid(unsafe_code)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] use std::process::ExitCode;