diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a53295..bb6af3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ ## [Unreleased] +### Fixed + +- **REQ-110 / REQ-111 — coverage "totals" no longer masquerade as artifact + counts.** The dashboard overview rendered `{covered} / {total} artifacts + covered` and the `coverage --format json` `overall` object exposed + `total`/`covered`, but both aggregate *per-rule* denominators — an artifact + satisfying N traceability rules is counted N times — which is a different + cardinality from the distinct-artifact `total` the `stats` command reports + under the same key. The numbers are unchanged (per the relabel decision); + the label is now honest: the HTML reads "coverage checks" and the JSON + `overall` exposes `checks_covered` / `checks_total` (the ambiguous + `total`/`covered` keys are removed from the `overall` object — a JSON + consumer change). Per-rule `entries[]` keep `covered`/`total`, which are + correct at rule scope. ## [0.14.0] — 2026-05-30 Theme: **agent-actionable validation + self-contained export**. Two diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index a12c761..e247088 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -6099,17 +6099,21 @@ fn cmd_coverage( }) }) .collect(); - let total: usize = report.entries.iter().map(|e| e.total).sum(); - let covered: usize = report.entries.iter().map(|e| e.covered).sum(); + // REQ-111: these aggregate per-RULE denominators (an artifact satisfying + // N rules is counted N times) — NOT the distinct-artifact `total` that + // `stats` JSON reports. Emit under `checks_*` keys so the same key name + // `total` never carries two different cardinalities across commands. + let checks_total: usize = report.entries.iter().map(|e| e.total).sum(); + let checks_covered: usize = report.entries.iter().map(|e| e.covered).sum(); let external_boundary: usize = report.entries.iter().map(|e| e.external_boundary).sum(); let overall_pct = (report.overall_coverage() * 10.0).round() / 10.0; let mut output = serde_json::json!({ "command": "coverage", "rules": rules_json, "overall": { - "covered": covered, + "checks_covered": checks_covered, "external_boundary": external_boundary, - "total": total, + "checks_total": checks_total, "percentage": overall_pct, }, }); diff --git a/rivet-cli/src/render/stats.rs b/rivet-cli/src/render/stats.rs index 7e85283..7c28026 100644 --- a/rivet-cli/src/render/stats.rs +++ b/rivet-cli/src/render/stats.rs @@ -234,8 +234,12 @@ pub(crate) fn render_stats(ctx: &RenderContext) -> String { } else { "#c62828" }; - let total_covered: usize = cov_report.entries.iter().map(|e| e.covered).sum(); - let total_items: usize = cov_report.entries.iter().map(|e| e.total).sum(); + // REQ-110: these are per-RULE check sums (an artifact in N rules counts + // N times), NOT distinct artifacts. The store-artifact total lives on the + // "Artifacts" stat card. Label them "coverage checks" so the two + // different "totals" are not conflated. + let checks_covered: usize = cov_report.entries.iter().map(|e| e.covered).sum(); + let checks_total: usize = cov_report.entries.iter().map(|e| e.total).sum(); let cov_delta = bl.map_or(String::new(), |s| { delta_pct_badge(overall, s.coverage.overall) }); @@ -249,7 +253,7 @@ pub(crate) fn render_stats(ctx: &RenderContext) -> String {
\ \
\ - {total_covered} / {total_items} artifacts covered across {} rules\ + {checks_covered} / {checks_total} coverage checks across {} rules\
\ \ \ diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 5f43729..cfde622 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -1722,6 +1722,35 @@ fn coverage_json() { parsed.get("rules").and_then(|v| v.as_array()).is_some(), "coverage JSON must contain 'rules' array" ); + + // REQ-111: the overall aggregate sums per-rule denominators (an artifact in + // N rules counts N times), a different cardinality from `stats` JSON's + // distinct-artifact `total`. It must be exposed under disambiguated + // `checks_*` keys and must NOT reuse the bare `total`/`covered` names that + // would collide semantically with the stats command. + let overall = &parsed["overall"]; + assert!( + overall + .get("checks_total") + .and_then(|v| v.as_u64()) + .is_some(), + "coverage overall must expose 'checks_total'" + ); + assert!( + overall + .get("checks_covered") + .and_then(|v| v.as_u64()) + .is_some(), + "coverage overall must expose 'checks_covered'" + ); + assert!( + overall.get("total").is_none(), + "coverage overall must NOT use the ambiguous key 'total' (REQ-111)" + ); + assert!( + overall.get("covered").is_none(), + "coverage overall must NOT use the ambiguous key 'covered' (REQ-111)" + ); } // ── rivet matrix ───────────────────────────────────────────────────────