From 7fd4352719fb9c022de494a5fca0a320f55dbcdf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 12:46:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(coverage):=20rivet=20coverage=20--matrix?= =?UTF-8?q?=20=E2=80=94=20V&V=20matrix=20from=20repo-status=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the V&V coverage matrix view to `rivet coverage`: reads `repo-status` artifacts (schema `vv-coverage` from PR #232) and renders a per-repo × per-technique matrix in text, json, markdown, or html. Sub-issue 2 of #188. Sub-issue 1 (the schema) shipped in #232; the cross-repo aggregator (sub-issue 3) is still out of scope here per the prior triage decomposition because the agent's GitHub access is restricted to `pulseengine/rivet` only. ## Surface - New flag `--matrix` on the existing `Coverage` subcommand. Mutually exclusive with `--tests` at the clap layer. - `--format` accepts `text` (default), `json`, `markdown`, `html` when `--matrix` is set; the original `text|json` contract for non-matrix coverage is preserved. ## Cell semantics | State | Glyph | JSON | Meaning | |----------|-------|-------------|----------------------------------------| | absent | · | "absent" | Technique not in `techniques-applied`. | | applied | ○ | "applied" | Applied but not gated in CI. | | gated | ● | "gated" | In `techniques-gated-in-ci`. | Columns are the sorted union of `techniques-applied` ∪ `techniques-gated-in-ci` across all rows, so the matrix only shows techniques at least one repo cares about. ## Output - text: fixed-width table with the legend on top. - markdown: pipe table; pastes verbatim into a PR body or wiki. - html: `
` fragment with `cell-{absent,applied,gated}` classes for downstream styling, with `&` and friends escaped. - json: structured `{command: "coverage-matrix", columns, repos[]}` envelope. Each repo carries its raw lists plus a precomputed `cells[]` so consumers don't have to recompute set membership. ## Verification - 7 new integration tests in `rivet-cli/tests/cli_commands.rs`: markdown / html / json / text-default rendering, invalid-format diagnostic, `--matrix` × `--tests` clap conflict, empty-project graceful render across all four formats. - `cargo test -p rivet-cli` — full suite green (432 tests). - `cargo test -p rivet-core --lib` — 896 pass. - `cargo clippy -p rivet-cli --all-targets -- -D warnings` — clean. - `cargo fmt --all -- --check` — clean. - `rivet validate` diagnostics unchanged from origin/main (pre-existing 6 errors in the spar-external fixture, untouched here). ## Docs - New `coverage-matrix` topic in `rivet docs` documenting the surface, cell semantics, authoring `repo-status`, and the four output formats. - New `schema/vv-coverage` topic exposing the schema YAML directly. Refs: #188 Refs: #184 Implements: REQ-007 Co-authored-by: Claude --- rivet-cli/src/docs.rs | 102 +++++++++ rivet-cli/src/main.rs | 310 ++++++++++++++++++++++++- rivet-cli/tests/cli_commands.rs | 387 ++++++++++++++++++++++++++++++++ 3 files changed, 797 insertions(+), 2 deletions(-) diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index 68b8744..19627d2 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -254,6 +254,18 @@ const TOPICS: &[DocTopic] = &[ category: "Reference", content: SCHEMA_MIGRATE_DOC, }, + DocTopic { + slug: "coverage-matrix", + title: "rivet coverage --matrix — V&V coverage view from repo-status artifacts", + category: "Reference", + content: COVERAGE_MATRIX_DOC, + }, + DocTopic { + slug: "schema/vv-coverage", + title: "V&V coverage schema (repo-status type)", + category: "Schemas", + content: embedded::SCHEMA_VV_COVERAGE, + }, ]; // ── Embedded documentation ────────────────────────────────────────────── @@ -2707,3 +2719,93 @@ classes (mirrors `git rebase --interactive`'s pick / edit / drop): validate` first to convince yourself the migrated tree is healthy. - If you need to redo a migration: `--abort` and start over. "#; + +const COVERAGE_MATRIX_DOC: &str = r#"# rivet coverage --matrix + +The V&V coverage view: a per-repo × per-technique matrix of which +verification techniques the project applies and which subset is gated +in CI. Reads `repo-status` artifacts (schema `vv-coverage`, see +`rivet docs schema/vv-coverage`) from the local project. + +This is sub-issue 2 of [rivet#188](https://github.com/pulseengine/rivet/issues/188). +Sub-issue 1 (the schema) shipped in PR #232; sub-issue 3 (cross-repo +aggregator) is still open. + +## Quick start + +```sh +# Pretty-print the matrix in the terminal. +rivet coverage --matrix + +# Markdown for pasting into a PR description or wiki. +rivet coverage --matrix --format markdown + +# HTML fragment for embedding in a dashboard. +rivet coverage --matrix --format html > matrix.html + +# Structured JSON for downstream tooling. +rivet coverage --matrix --format json | jq '.repos[].repo' +``` + +## What gets rendered + +One row per `repo-status` artifact, sorted by `repo`. Columns are the +sorted union of `techniques-applied` ∪ `techniques-gated-in-ci` +across every row, so the matrix only contains techniques at least one +repo cares about. + +Each cell is one of: + +| State | Glyph | JSON | Meaning | +|-------|-------|------------|------------------------------------------| +| absent | `·` | `"absent"` | Technique not in `techniques-applied`. | +| applied | `○` | `"applied"` | Applied but not gated in CI. | +| gated | `●` | `"gated"` | Applied **and** gated in CI. | + +## Authoring `repo-status` + +```yaml +- id: RS-RIVET + type: repo-status + title: rivet + status: valid + fields: + repo: pulseengine/rivet + techniques-applied: [proptest, miri, kani, mutation] + techniques-gated-in-ci: [proptest, miri] + notes: Reference V&V repo for the pulseengine workspace. +``` + +`repo` is the canonical `owner/name` form and is the join key the +cross-repo aggregator (sub-issue 3) will use. Techniques are open-ended +strings; see `rivet docs schema/vv-coverage` for the recommended set +(verus, kani, rocq, lean, proptest, loom, miri, fuzz, mutation, …). + +## Output formats + +- **`text`** (default) — fixed-width table with the legend on top. + Designed for terminal eyeballing. +- **`markdown`** — pipe table with a separator row. Pastes verbatim into + a PR body or `docs/` page. +- **`html`** — `
` fragment with `cell-{absent,applied,gated}` + classes you can style from the dashboard's CSS. The matrix is escaped + for safe inline embedding. +- **`json`** — structured `{command, columns, repos[]}` envelope. Each + repo carries its raw `techniques_applied`/`techniques_gated_in_ci` + lists plus a precomputed `cells[]` list (one entry per column) so + consumers don't have to recompute set membership. + +## Exit codes + +`rivet coverage --matrix` is a report, not a gate — it always exits +`0` when invocation succeeds. CI gating belongs in the cross-repo +aggregator (sub-issue 3) where policy lives. + +## Limitations + +- Single-repo only. Aggregating across pulseengine repos is sub-issue 3. +- `--filter` and `--fail-under` apply to the traceability coverage + view, not the matrix; combining them with `--matrix` is silently + ignored. +- `--matrix` and `--tests` are mutually exclusive. +"#; diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index c955cba..de9d75d 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -374,7 +374,9 @@ enum Command { #[arg(long)] filter: Option, - /// Output format: "text" (default) or "json" + /// Output format. Default modes accept "text" or "json"; with + /// `--matrix` the V&V coverage view also accepts "markdown" and + /// "html". #[arg(short, long, default_value = "text")] format: String, @@ -393,6 +395,12 @@ enum Command { /// Scope coverage to a named baseline (cumulative) #[arg(long)] baseline: Option, + + /// Render the V&V coverage matrix from `repo-status` artifacts + /// (rivet#188). Combine with `--format` to choose text, json, + /// markdown, or html output. Mutually exclusive with `--tests`. + #[arg(long, conflicts_with = "tests")] + matrix: bool, }, /// Generate a traceability matrix @@ -1629,8 +1637,11 @@ fn run(cli: Cli) -> Result { tests, scan_paths, baseline, + matrix, } => { - if *tests { + if *matrix { + cmd_coverage_matrix(&cli, format, baseline.as_deref()) + } else if *tests { cmd_coverage_tests(&cli, format, scan_paths) } else { cmd_coverage( @@ -5390,6 +5401,301 @@ fn cmd_coverage_tests(cli: &Cli, format: &str, scan_paths: &[PathBuf]) -> Result Ok(true) } +// ── V&V coverage matrix (rivet#188 sub-issue 2) ──────────────────────── +// +// Reads `repo-status` artifacts (schema `vv-coverage`, sub-issue 1, PR #232) +// from the local project and renders a per-repo × per-technique matrix in +// text, json, markdown, or html. Cell values: +// +// - "absent" — technique not in `techniques-applied` +// - "applied" — in `techniques-applied`, not in `techniques-gated-in-ci` +// - "gated" — in `techniques-gated-in-ci` (implies applied) +// +// JSON uses those exact strings; the human-readable formats use a small +// glyph legend documented in the rendered output. + +#[derive(Debug)] +struct RepoStatusRow { + id: String, + repo: String, + applied: Vec, + gated: Vec, + notes: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MatrixCell { + Absent, + Applied, + Gated, +} + +impl MatrixCell { + fn for_row(row: &RepoStatusRow, technique: &str) -> Self { + if row.gated.iter().any(|t| t == technique) { + Self::Gated + } else if row.applied.iter().any(|t| t == technique) { + Self::Applied + } else { + Self::Absent + } + } + + fn json_str(self) -> &'static str { + match self { + Self::Absent => "absent", + Self::Applied => "applied", + Self::Gated => "gated", + } + } + + fn glyph(self) -> &'static str { + match self { + Self::Absent => "·", + Self::Applied => "○", + Self::Gated => "●", + } + } +} + +const MATRIX_LEGEND: &str = "legend: · absent ○ applied (not CI-gated) ● applied + CI-gated"; + +fn read_string_list( + fields: &std::collections::BTreeMap, + key: &str, +) -> Vec { + fields + .get(key) + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn collect_repo_status_rows(store: &Store) -> Vec { + let mut rows: Vec = store + .iter() + .filter(|a| a.artifact_type == "repo-status") + .map(|a| { + let repo = a + .fields + .get("repo") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .unwrap_or_else(|| a.id.to_string()); + let applied = read_string_list(&a.fields, "techniques-applied"); + let gated = read_string_list(&a.fields, "techniques-gated-in-ci"); + let notes = a + .fields + .get("notes") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + RepoStatusRow { + id: a.id.to_string(), + repo, + applied, + gated, + notes, + } + }) + .collect(); + rows.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.id.cmp(&b.id))); + rows +} + +fn matrix_columns(rows: &[RepoStatusRow]) -> Vec { + let mut cols: Vec = rows + .iter() + .flat_map(|r| r.applied.iter().chain(r.gated.iter()).cloned()) + .collect(); + cols.sort(); + cols.dedup(); + cols +} + +fn render_matrix_text(rows: &[RepoStatusRow], cols: &[String]) { + println!("V&V coverage matrix"); + println!("{}", MATRIX_LEGEND); + println!(); + + if rows.is_empty() { + println!("(no `repo-status` artifacts found)"); + return; + } + + let repo_w = rows + .iter() + .map(|r| r.repo.chars().count()) + .max() + .unwrap_or(4) + .max(4); + let col_w = cols + .iter() + .map(|c| c.chars().count()) + .max() + .unwrap_or(0) + .max(1); + + // Header. + print!(" {: String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn render_matrix_html(rows: &[RepoStatusRow], cols: &[String]) { + println!("
"); + println!("

V&V coverage matrix

"); + println!("

{}

", html_escape(MATRIX_LEGEND)); + + if rows.is_empty() { + println!("

No repo-status artifacts found.

"); + println!("
"); + return; + } + + println!("
"); + print!(" "); + for c in cols { + print!("", html_escape(c)); + } + println!(""); + println!(" "); + for row in rows { + print!( + " ", + html_escape(&row.repo) + ); + for c in cols { + let cell = MatrixCell::for_row(row, c); + print!( + "", + cell.json_str(), + cell.glyph() + ); + } + println!(""); + } + println!(" "); + println!("
repo{}
{}{}
"); + println!("
"); +} + +fn render_matrix_json(rows: &[RepoStatusRow], cols: &[String]) { + let repos_json: Vec = rows + .iter() + .map(|r| { + let cells: Vec = cols + .iter() + .map(|c| { + let cell = MatrixCell::for_row(r, c); + serde_json::json!({ + "technique": c, + "status": cell.json_str(), + }) + }) + .collect(); + serde_json::json!({ + "id": r.id, + "repo": r.repo, + "techniques_applied": r.applied, + "techniques_gated_in_ci": r.gated, + "notes": r.notes, + "cells": cells, + }) + }) + .collect(); + + let output = serde_json::json!({ + "command": "coverage-matrix", + "columns": cols, + "repos": repos_json, + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); +} + +/// Render the V&V coverage matrix from `repo-status` artifacts. +fn cmd_coverage_matrix(cli: &Cli, format: &str, baseline_name: Option<&str>) -> Result { + validate_format(format, &["text", "json", "markdown", "html"])?; + let ctx = ProjectContext::load(cli)?; + let store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); + + let rows = collect_repo_status_rows(&store); + let cols = matrix_columns(&rows); + + match format { + "json" => render_matrix_json(&rows, &cols), + "markdown" => render_matrix_markdown(&rows, &cols), + "html" => render_matrix_html(&rows, &cols), + _ => render_matrix_text(&rows, &cols), + } + + Ok(true) +} + /// Generate a traceability matrix. fn cmd_matrix( cli: &Cli, diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index e2ed877..ac1d979 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -1561,6 +1561,393 @@ fn coverage_without_fail_under_is_report_only() { ); } +// ── rivet coverage --matrix (rivet#188 sub-issue 2) ──────────────────── + +/// Build a tmpdir project that loads the embedded `vv-coverage` schema +/// and ships three `repo-status` artifacts covering the legend's three +/// states: gated, applied-not-gated, and absent. +fn vv_coverage_project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().expect("create temp dir"); + let dir = tmp.path(); + + std::fs::write( + dir.join("rivet.yaml"), + "project:\n name: vv-coverage-test\n version: \"0.1.0\"\n \ + schemas: [common, vv-coverage]\nsources:\n - path: artifacts\n \ + format: generic-yaml\n", + ) + .expect("write rivet.yaml"); + + let artifacts_dir = dir.join("artifacts"); + std::fs::create_dir_all(&artifacts_dir).expect("artifacts dir"); + + std::fs::write( + artifacts_dir.join("repo-status.yaml"), + "artifacts:\n - id: RS-RIVET\n type: repo-status\n \ + title: rivet\n status: valid\n fields:\n \ + repo: pulseengine/rivet\n \ + techniques-applied: [proptest, miri, kani]\n \ + techniques-gated-in-ci: [proptest, miri]\n \ + notes: Reference repo for the V&V matrix\n \ + - id: RS-LOOM\n type: repo-status\n title: loom\n \ + status: valid\n fields:\n repo: pulseengine/loom\n \ + techniques-applied: [proptest, kani]\n \ + techniques-gated-in-ci: [proptest]\n \ + - id: RS-GALE\n type: repo-status\n title: gale\n \ + status: draft\n fields:\n repo: pulseengine/gale\n \ + techniques-applied: [kani]\n", + ) + .expect("write repo-status fixture"); + + tmp +} + +/// `--matrix --format markdown` renders a pipe-table with the legend, +/// every repo on its own row, and the union of techniques as columns. +#[test] +fn coverage_matrix_markdown() { + let tmp = vv_coverage_project(); + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "coverage", + "--matrix", + "--format", + "markdown", + ]) + .output() + .expect("coverage --matrix --format markdown"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "must exit 0. stdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Heading + legend. + assert!( + stdout.contains("# V&V coverage matrix"), + "heading. {stdout}" + ); + assert!(stdout.contains("legend:"), "legend present. {stdout}"); + // Header row + separator (markdown table). + assert!(stdout.contains("| repo |"), "table header. {stdout}"); + assert!(stdout.contains("|---|"), "table separator. {stdout}"); + // All three repos appear in their own rows. + for repo in ["pulseengine/rivet", "pulseengine/loom", "pulseengine/gale"] { + assert!( + stdout.contains(&format!("| {} |", repo)), + "row for {repo}. {stdout}" + ); + } + // Columns are the union (sorted) of `techniques-applied` across rows. + for col in ["proptest", "miri", "kani"] { + assert!(stdout.contains(col), "column {col}. {stdout}"); + } + // Glyph legend in cells: gated > applied > absent. + assert!(stdout.contains('●'), "gated glyph. {stdout}"); + assert!(stdout.contains('○'), "applied glyph. {stdout}"); + assert!(stdout.contains('·'), "absent glyph. {stdout}"); +} + +/// `--matrix --format html` emits a `` with one tbody row per +/// repo and `cell-{absent,applied,gated}` classes for downstream styling. +#[test] +fn coverage_matrix_html() { + let tmp = vv_coverage_project(); + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "coverage", + "--matrix", + "--format", + "html", + ]) + .output() + .expect("coverage --matrix --format html"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "must exit 0. stdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!(stdout.contains(""), "html table. {stdout}"); + assert!(stdout.contains(""), "html thead. {stdout}"); + assert!(stdout.contains(""), "html tbody. {stdout}"); + assert!( + stdout.contains(""), + "row header for rivet. {stdout}" + ); + // `&` in headings escaped, classes wired up. + assert!(stdout.contains("V&V"), "& escaped. {stdout}"); + assert!(stdout.contains("cell-gated"), "gated cell class. {stdout}"); + assert!( + stdout.contains("cell-applied"), + "applied cell class. {stdout}" + ); + assert!( + stdout.contains("cell-absent"), + "absent cell class. {stdout}" + ); +} + +/// `--matrix --format json` is the structured envelope: `command`, +/// `columns`, and `repos[]` with per-cell `{technique, status}` records. +#[test] +fn coverage_matrix_json() { + let tmp = vv_coverage_project(); + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "coverage", + "--matrix", + "--format", + "json", + ]) + .output() + .expect("coverage --matrix --format json"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "must exit 0. stdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("matrix JSON must parse"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("coverage-matrix"), + "command tag. {stdout}" + ); + + let columns = parsed + .get("columns") + .and_then(|v| v.as_array()) + .expect("columns array"); + let column_names: Vec<&str> = columns.iter().filter_map(|v| v.as_str()).collect(); + // Sorted union of techniques across all rows. + assert_eq!( + column_names, + vec!["kani", "miri", "proptest"], + "columns. {stdout}" + ); + + let repos = parsed + .get("repos") + .and_then(|v| v.as_array()) + .expect("repos array"); + assert_eq!(repos.len(), 3, "three repo-status rows. {stdout}"); + + // Find rivet row, assert the three cell statuses are correct. + let rivet = repos + .iter() + .find(|r| r.get("repo").and_then(|v| v.as_str()) == Some("pulseengine/rivet")) + .expect("rivet row"); + let cells = rivet + .get("cells") + .and_then(|v| v.as_array()) + .expect("rivet cells"); + let by_technique: std::collections::BTreeMap<&str, &str> = cells + .iter() + .filter_map(|c| { + let t = c.get("technique")?.as_str()?; + let s = c.get("status")?.as_str()?; + Some((t, s)) + }) + .collect(); + assert_eq!(by_technique.get("proptest"), Some(&"gated")); + assert_eq!(by_technique.get("miri"), Some(&"gated")); + assert_eq!(by_technique.get("kani"), Some(&"applied")); + + // gale only applies kani — proptest and miri must be absent. + let gale = repos + .iter() + .find(|r| r.get("repo").and_then(|v| v.as_str()) == Some("pulseengine/gale")) + .expect("gale row"); + let gale_cells = gale + .get("cells") + .and_then(|v| v.as_array()) + .expect("gale cells"); + let gale_by: std::collections::BTreeMap<&str, &str> = gale_cells + .iter() + .filter_map(|c| { + let t = c.get("technique")?.as_str()?; + let s = c.get("status")?.as_str()?; + Some((t, s)) + }) + .collect(); + assert_eq!(gale_by.get("kani"), Some(&"applied")); + assert_eq!(gale_by.get("proptest"), Some(&"absent")); + assert_eq!(gale_by.get("miri"), Some(&"absent")); +} + +/// `--matrix --format text` (default text mode) renders the legend and a +/// fixed-width table that's easy to eyeball in a terminal. +#[test] +fn coverage_matrix_text_default() { + let tmp = vv_coverage_project(); + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "coverage", + "--matrix", + ]) + .output() + .expect("coverage --matrix"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "must exit 0. stdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!(stdout.contains("V&V coverage matrix"), "title. {stdout}"); + assert!(stdout.contains("legend:"), "legend. {stdout}"); + assert!(stdout.contains("pulseengine/rivet"), "rivet row. {stdout}"); + assert!(stdout.contains("●"), "gated glyph. {stdout}"); +} + +/// Unknown `--format` values for `--matrix` fail with a helpful error +/// listing the four valid options. +#[test] +fn coverage_matrix_invalid_format_fails() { + let tmp = vv_coverage_project(); + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "coverage", + "--matrix", + "--format", + "csv", + ]) + .output() + .expect("coverage --matrix --format csv"); + + assert!( + !out.status.success(), + "invalid format must fail. stdout: {}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("invalid format"), "diagnostic. {stderr}"); + for fmt in ["text", "json", "markdown", "html"] { + assert!(stderr.contains(fmt), "lists '{fmt}'. {stderr}"); + } +} + +/// `--matrix` and `--tests` are mutually exclusive at the clap layer so +/// users can't accidentally combine the V&V matrix with the +/// test-marker scanner. +#[test] +fn coverage_matrix_conflicts_with_tests_flag() { + let tmp = vv_coverage_project(); + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "coverage", + "--matrix", + "--tests", + ]) + .output() + .expect("coverage --matrix --tests"); + + assert!( + !out.status.success(), + "matrix + tests must conflict. stdout: {}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.to_lowercase().contains("conflict") || stderr.contains("cannot be used"), + "clap conflict diagnostic. {stderr}" + ); +} + +/// Empty project — no `repo-status` artifacts — still renders cleanly +/// in every format and exits 0 (the matrix is a report, not a gate). +#[test] +fn coverage_matrix_empty_project() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let dir = tmp.path(); + std::fs::write( + dir.join("rivet.yaml"), + "project:\n name: empty\n version: \"0.1.0\"\n \ + schemas: [common, vv-coverage]\nsources:\n - path: artifacts\n \ + format: generic-yaml\n", + ) + .expect("rivet.yaml"); + std::fs::create_dir_all(dir.join("artifacts")).expect("artifacts"); + + for (fmt, marker) in [ + ("text", "no `repo-status` artifacts"), + ("markdown", "No `repo-status`"), + ("html", "No repo-status"), + ] { + let out = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "coverage", + "--matrix", + "--format", + fmt, + ]) + .output() + .expect("coverage --matrix on empty project"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "empty {fmt} must exit 0. stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains(marker), + "{fmt}: empty marker '{marker}' missing. {stdout}" + ); + } + + // JSON: empty repos array, command tag still set. + let out = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "coverage", + "--matrix", + "--format", + "json", + ]) + .output() + .expect("coverage --matrix --format json on empty project"); + let stdout = String::from_utf8_lossy(&out.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("empty matrix JSON parses"); + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("coverage-matrix") + ); + assert!( + parsed + .get("repos") + .and_then(|v| v.as_array()) + .map(|a| a.is_empty()) + == Some(true), + "repos empty. {stdout}" + ); +} + /// `rivet stats --format json` exposes diagnostic counts so consumers /// don't need a second `rivet validate --format json` call just to /// get the severity breakdown.
pulseengine/rivet