diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a533aae..96b8f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,12 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run rivet docs check run: cargo run --release -p rivet-cli -- docs check + # Subcommand-coverage gate — warn-only initially so the existing + # inventory of uncovered subcommands (variant, baseline, snapshot, + # runs, pipelines, templates, close-gaps) doesn't break the build. + # Flip to `--strict` once those gaps are filled. + - name: Subcommand-coverage gate (warn-only) + run: cargo run --release -p rivet-cli -- docs check --coverage # ── Tests ───────────────────────────────────────────────────────────── test: diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index 68b8744..a729be5 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -254,8 +254,28 @@ const TOPICS: &[DocTopic] = &[ category: "Reference", content: SCHEMA_MIGRATE_DOC, }, + DocTopic { + slug: "docs-coverage", + title: "rivet docs check --coverage — subcommand-coverage gate", + category: "Reference", + content: DOCS_COVERAGE_DOC, + }, ]; +/// Return all registered topic slugs in declaration order. +/// +/// Used by the subcommand-coverage gate to cross-reference clap subcommand +/// paths against documented topics. +pub fn topic_slugs() -> Vec<&'static str> { + TOPICS.iter().map(|t| t.slug).collect() +} + +/// True iff a topic with this slug is registered. +#[allow(dead_code)] +pub fn has_topic(slug: &str) -> bool { + TOPICS.iter().any(|t| t.slug == slug) +} + // ── Embedded documentation ────────────────────────────────────────────── const ARTIFACT_FORMAT_DOC: &str = r#"# Artifact YAML Format @@ -472,11 +492,18 @@ rivet schema migrate TGT Plan + apply preset migration with snapshot ## Documentation Commands ``` -rivet docs List available documentation topics -rivet docs TOPIC Show a specific topic -rivet docs --grep PATTERN Search across all documentation +rivet docs List available documentation topics +rivet docs TOPIC Show a specific topic +rivet docs --grep PATTERN Search across all documentation +rivet docs check Run doc-vs-reality invariants +rivet docs check --coverage Subcommand-coverage gate (warn) +rivet docs check --coverage --strict Fail-on-uncovered (CI gate) ``` +`rivet docs check --coverage` walks the live clap CLI tree and asserts +every subcommand has an embedded `rivet docs ` entry. See +`rivet docs docs-coverage` for the matching rules and the allow-list. + ## Scaffolding ``` @@ -2707,3 +2734,88 @@ 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 DOCS_COVERAGE_DOC: &str = r#"# rivet docs check --coverage + +The subcommand-coverage gate walks the live clap CLI tree and asserts that +every subcommand path has a documented topic in the embedded `rivet docs` +registry. It complements the existing `SubcommandReferences` invariant — +which catches docs referencing non-existent subcommands — by checking the +reverse direction: every subcommand that EXISTS should be DOCUMENTED. + +## Quick start + +``` +rivet docs check --coverage # warn-only report +rivet docs check --coverage --strict # fail-on-uncovered (CI gate) +rivet docs check --coverage --format json +``` + +The default warn-only mode always exits 0 so the gate can land in CI as a +visibility step before the obvious gaps are filled. Once the inventory is +clean, flip `--strict` on to make uncovered subcommands fail the build. + +## Coverage rules + +A subcommand path X (e.g. `schema/show`) is covered if any of: + +1. A topic with the same slug exists (`rivet docs schema-show` — slashes + become dashes for slug lookup). +2. The path itself is a top-level subcommand whose name has a topic + (e.g. `mcp` is covered by the `mcp` topic). +3. The parent subcommand has a topic (e.g. all `schema/*` paths are + covered by the `schema` parent — currently mapped to `cli`). +4. The subcommand is in the explicit allow-list (built-in commands like + `help` that are inherently undocumented). + +The mapping is intentionally generous: a single `cli` reference topic +covers every nested action under `schema`, `baseline`, `snapshot`, etc., +provided the parent subcommand itself has a section in `rivet docs cli`. + +## Report format + +Plain text: + +``` +rivet docs check --coverage + + Top-level subcommands: + ✓ init (doc: quickstart) + ✓ validate (doc: cli) + ✗ variant MISSING DOC + ... + + Coverage: 42/55 (76%) + Uncovered: variant, baseline, snapshot, runs, pipelines, templates, + close-gaps, mcp +``` + +JSON output (`--format json`) matches the `docs check` envelope and lists +each subcommand path with `covered: bool` plus the topic slug that +provides coverage (when applicable). + +## CI integration + +```yaml +- name: Subcommand-coverage gate + run: cargo run --release -p rivet-cli -- docs check --coverage + # add --strict once the obvious gaps are filled +``` + +The CI step is idempotent: it re-derives the subcommand tree from clap's +runtime metadata, so adding a new subcommand without a doc topic will fail +the gate immediately under `--strict`. + +## Allow-list + +The following clap subcommands are exempt from the gate because they +ship no user-facing documentation surface: + +| Subcommand | Reason | +|---------------------|--------------------------------------------------------------| +| `help` | clap-builtin help renderer | +| `commit-msg-check` | Internal pre-commit hook — usage is documented by the hook | + +Add new exemptions only when there's a real reason — the goal of the gate +is to surface gaps, not paper over them. +"#; diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index c955cba..ea7127f 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -530,6 +530,17 @@ enum Command { /// (check only) apply auto-fixes for fixable violations in place #[arg(long)] fix: bool, + + /// (check only) walk the clap subcommand tree and report which + /// subcommands have a documented topic in the embedded docs + /// registry. + #[arg(long)] + coverage: bool, + + /// (check --coverage only) exit non-zero if any subcommand is + /// uncovered. Default is warn-only. + #[arg(long)] + strict: bool, }, /// Print the 10-step oracle-gated quickstart (alias for `rivet docs quickstart`). @@ -1539,11 +1550,21 @@ fn run(cli: Cli) -> Result { format, context, fix, + coverage, + strict, } = &cli.command { if matches!(topic.as_deref(), Some("check")) { + if *coverage { + return cmd_docs_coverage(format, *strict); + } return cmd_docs_check(&cli, format, *fix); } + // Allow `rivet docs --coverage` (no `check` topic) as a shorthand — + // the coverage gate doesn't depend on any other doc-check state. + if *coverage { + return cmd_docs_coverage(format, *strict); + } return cmd_docs(topic.as_deref(), *list, grep.as_deref(), format, *context); } if let Command::Quickstart { format } = &cli.command { @@ -7226,6 +7247,325 @@ fn render_docs_check_json(report: &rivet_core::doc_check::CheckReport) -> String out } +// ── Subcommand-coverage gate ──────────────────────────────────────────── +// +// `rivet docs check --coverage` walks the live clap CLI tree and asserts +// every subcommand path has a topic in the embedded docs registry. This +// is the inverse of the existing `SubcommandReferences` invariant, which +// flags markdown referencing non-existent subcommands — here we flag +// existing subcommands without documentation. +// +// See `rivet docs docs-coverage` for the full design. Quick rules: +// * A subcommand is covered if its slug (or a parent slug) matches a +// registered topic, OR if the parent's name appears as a topic. +// * Built-ins like `help` are exempt via `COVERAGE_ALLOWLIST`. +// * Default is warn-only (exit 0); `--strict` makes uncovered fail. + +/// Top-level subcommands whose docs are inherently unnecessary or are +/// surfaced via a different channel. Keep this list short — adding an +/// entry must be justified. +const COVERAGE_ALLOWLIST: &[&str] = &[ + // clap-builtin help renderer; user help is the topic itself. + "help", + // Pre-commit hook helper — usage is documented by the hook, not as a + // standalone topic. + "commit-msg-check", +]; + +/// Manual subcommand-to-topic map. Used when a single umbrella topic +/// (typically `cli`) documents a family of subcommands that don't each +/// have their own dedicated topic. +/// +/// Keys are top-level clap subcommand names; values are the topic slug +/// that documents them. Entries here must correspond to a real topic in +/// `docs::TOPICS` — if the topic disappears, the gate will surface every +/// affected subcommand as uncovered. +const COVERAGE_TOPIC_MAP: &[(&str, &str)] = &[ + // The `cli` topic is the canonical CLI reference and documents most + // top-level commands that don't have a dedicated topic. + ("init", "cli"), + ("validate", "cli"), + ("get", "cli"), + ("list", "cli"), + ("stats", "cli"), + ("coverage", "cli"), + ("matrix", "cli"), + ("stpa", "cli"), + ("diff", "cli"), + ("export", "cli"), + ("schema", "cli"), + ("docs", "cli"), + ("quickstart", "quickstart"), + ("context", "cli"), + ("commits", "commit-traceability"), + ("serve", "cli"), + ("sync", "cross-repo"), + ("lock", "cross-repo"), + ("externals", "cross-repo"), + ("impact", "impact"), + ("import-results", "cli"), + ("next-id", "mutation"), + ("add", "mutation"), + ("link", "mutation"), + ("unlink", "mutation"), + ("modify", "mutation"), + ("remove", "mutation"), + ("batch", "mutation"), + ("embed", "embed-syntax"), + ("query", "cli"), + ("stamp", "cli"), + ("lsp", "cli"), + ("mcp", "mcp"), + ("check", "cli"), + ("import", "needs-json"), +]; + +/// One row in the coverage report: a single subcommand path. +#[derive(Debug, Clone)] +struct CoverageRow { + /// Subcommand path joined by `/` — e.g. `"schema/show"`. + path: String, + /// Depth in the tree (0 = top-level). + depth: usize, + /// Topic slug that provides coverage, or `None` when uncovered. + covered_by: Option, + /// True when the path is exempt via `COVERAGE_ALLOWLIST`. + allow_listed: bool, +} + +impl CoverageRow { + fn is_covered(&self) -> bool { + self.covered_by.is_some() || self.allow_listed + } +} + +/// Slugify a subcommand path for topic lookup. `schema/show` -> `schema-show`. +fn coverage_slug(path: &str) -> String { + path.replace('/', "-") +} + +/// Walk a clap `Command` and collect every subcommand path. Internal +/// `help` synthetic command is included so the allow-list can decide +/// whether to drop it. +fn collect_subcommand_paths(root: &clap::Command) -> Vec<(String, usize)> { + let mut out = Vec::new(); + for sub in root.get_subcommands() { + walk_subcommand(sub, "", 0, &mut out); + } + out +} + +fn walk_subcommand( + cmd: &clap::Command, + parent_path: &str, + depth: usize, + out: &mut Vec<(String, usize)>, +) { + let name = cmd.get_name(); + let path = if parent_path.is_empty() { + name.to_string() + } else { + format!("{parent_path}/{name}") + }; + out.push((path.clone(), depth)); + for child in cmd.get_subcommands() { + walk_subcommand(child, &path, depth + 1, out); + } +} + +/// Compute the coverage rows for a given clap tree and topic-slug set. +/// Pulled into its own function so the unit tests can exercise it without +/// shelling out to the full CLI. +fn compute_coverage_rows( + root: &clap::Command, + topic_slugs: &std::collections::BTreeSet, + allow_list: &[&str], + topic_map: &[(&str, &str)], +) -> Vec { + let paths = collect_subcommand_paths(root); + let mut rows = Vec::with_capacity(paths.len()); + for (path, depth) in paths { + // Exempt clap-builtin synthetic subcommands first so `help` etc. + // never show up as gaps. + let top = path.split('/').next().unwrap_or(&path); + let allow_listed = allow_list.contains(&top) || allow_list.contains(&path.as_str()); + + let covered_by = if allow_listed { + None + } else { + resolve_coverage(&path, top, topic_slugs, topic_map) + }; + + rows.push(CoverageRow { + path, + depth, + covered_by, + allow_listed, + }); + } + rows +} + +/// Resolve the covering topic slug for a single subcommand path, applying +/// the matching rules in priority order: +/// +/// 1. Exact slug match using `/` as the separator (e.g. `schema/show` → +/// a `schema/show` topic; matches the natural docs::TOPICS slug shape). +/// 2. Exact slug match using `-` as the separator (e.g. `schema-show`). +/// 3. Parent-walk: drop the last segment and retry both separators. +/// 4. Manual top-level mapping via `COVERAGE_TOPIC_MAP` — used when a +/// single umbrella topic (typically `cli`) documents a family. +fn resolve_coverage( + path: &str, + top: &str, + topic_slugs: &std::collections::BTreeSet, + topic_map: &[(&str, &str)], +) -> Option { + // 1. & 2. Exact slug match. + if topic_slugs.contains(path) { + return Some(path.to_string()); + } + let dashed = coverage_slug(path); + if topic_slugs.contains(&dashed) { + return Some(dashed); + } + + // 3. Parent walk. + let mut cur = path; + while let Some(idx) = cur.rfind('/') { + cur = &cur[..idx]; + if topic_slugs.contains(cur) { + return Some(cur.to_string()); + } + let slug = coverage_slug(cur); + if topic_slugs.contains(&slug) { + return Some(slug); + } + } + + // 4. Manual umbrella mapping on the top-level name. + for (name, slug) in topic_map { + if *name == top && topic_slugs.contains(*slug) { + return Some((*slug).to_string()); + } + } + None +} + +/// Run `rivet docs check --coverage` — assert every subcommand path has +/// an embedded doc topic. +fn cmd_docs_coverage(format: &str, strict: bool) -> Result { + use clap::CommandFactory; + use std::collections::BTreeSet; + + validate_format(format, &["text", "json"])?; + + let root = Cli::command(); + let topic_slugs: BTreeSet = docs::topic_slugs().into_iter().map(String::from).collect(); + let rows = compute_coverage_rows(&root, &topic_slugs, COVERAGE_ALLOWLIST, COVERAGE_TOPIC_MAP); + + let total = rows.iter().filter(|r| !r.allow_listed).count(); + // Covered = paths that resolve to a topic (and thus aren't allow-listed). + let covered = rows + .iter() + .filter(|r| r.covered_by.is_some() && !r.allow_listed) + .count(); + let uncovered: Vec<&CoverageRow> = rows.iter().filter(|r| !r.is_covered()).collect(); + + match format { + "json" => print!("{}", render_coverage_json(&rows, total, covered)), + _ => print!("{}", render_coverage_text(&rows, total, covered)), + } + + let pass = uncovered.is_empty(); + if !pass && !strict { + eprintln!( + "rivet docs check --coverage: {} subcommand(s) uncovered (warn-only; use --strict to fail)", + uncovered.len() + ); + return Ok(true); + } + Ok(pass) +} + +fn render_coverage_text(rows: &[CoverageRow], total: usize, covered: usize) -> String { + use std::fmt::Write as _; + let mut s = String::new(); + let _ = writeln!(s, "rivet docs check --coverage\n"); + + // Group by top-level so the report reads top-down. + let mut last_top = ""; + for row in rows { + let top = row.path.split('/').next().unwrap_or(&row.path); + if top != last_top { + if !last_top.is_empty() { + let _ = writeln!(s); + } + last_top = top; + } + let indent = " ".repeat(row.depth + 1); + let mark = if row.allow_listed { + "·" + } else if row.is_covered() { + "✓" + } else { + "✗" + }; + let detail = if row.allow_listed { + "(allow-listed)".to_string() + } else if let Some(slug) = &row.covered_by { + format!("(doc: {slug})") + } else { + "MISSING DOC".to_string() + }; + let _ = writeln!(s, "{indent}{mark} {} {}", row.path, detail); + } + + let pct = covered + .saturating_mul(100) + .checked_div(total) + .unwrap_or(100); + let _ = writeln!(s, "\nCoverage: {covered}/{total} ({pct}%)"); + + let uncovered: Vec<&CoverageRow> = rows.iter().filter(|r| !r.is_covered()).collect(); + if !uncovered.is_empty() { + let names: Vec = uncovered.iter().map(|r| r.path.clone()).collect(); + let _ = writeln!(s, "Uncovered: {}", names.join(", ")); + } + s +} + +fn render_coverage_json(rows: &[CoverageRow], total: usize, covered: usize) -> String { + let items: Vec = rows + .iter() + .map(|r| { + serde_json::json!({ + "path": r.path, + "depth": r.depth, + "covered": r.is_covered(), + "covered_by": r.covered_by, + "allow_listed": r.allow_listed, + }) + }) + .collect(); + let uncovered: Vec<&str> = rows + .iter() + .filter(|r| !r.is_covered()) + .map(|r| r.path.as_str()) + .collect(); + let payload = serde_json::json!({ + "command": "docs-coverage", + "status": if uncovered.is_empty() { "pass" } else { "fail" }, + "total": total, + "covered": covered, + "uncovered": uncovered, + "subcommands": items, + }); + let mut out = serde_json::to_string_pretty(&payload).unwrap_or_default(); + out.push('\n'); + out +} + /// Introspect loaded schemas. fn cmd_schema(cli: &Cli, action: &SchemaAction) -> Result { // `list-json` / `get-json` don't need the project schema graph — @@ -13064,3 +13404,130 @@ mod stats_tests { } } } + +// ──────────────────────────────────────────────────────────────────────── +// Subcommand-coverage gate tests +// ──────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod coverage_gate_tests { + use super::*; + use std::collections::BTreeSet; + + fn slugs(names: &[&str]) -> BTreeSet { + names.iter().map(|s| (*s).to_string()).collect() + } + + /// A small fake clap tree: one parent (`fruit`) with two children + /// (`apple`, `banana`) — exactly the kind of fixture the + /// implementation sketch in the task asked for. + fn fake_tree() -> clap::Command { + clap::Command::new("rivet").subcommand( + clap::Command::new("fruit") + .subcommand(clap::Command::new("apple")) + .subcommand(clap::Command::new("banana")), + ) + } + + #[test] + fn collect_paths_walks_tree() { + let cmd = fake_tree(); + let mut paths = collect_subcommand_paths(&cmd); + paths.sort(); + assert_eq!( + paths, + vec![ + ("fruit".to_string(), 0), + ("fruit/apple".to_string(), 1), + ("fruit/banana".to_string(), 1), + ] + ); + } + + #[test] + fn parent_topic_covers_all_leaves() { + let cmd = fake_tree(); + let topics = slugs(&["fruit"]); + let rows = compute_coverage_rows(&cmd, &topics, &[], &[]); + // All three rows resolved via the `fruit` topic — the parent + // walk is what catches the leaves. + assert_eq!(rows.len(), 3); + for r in &rows { + assert!(r.is_covered(), "row {r:?} should be covered by fruit"); + assert_eq!(r.covered_by.as_deref(), Some("fruit")); + } + } + + #[test] + fn exact_leaf_topic_wins_over_parent() { + let cmd = fake_tree(); + // Both `fruit` AND `fruit-apple` exist — the leaf-specific + // topic should win. + let topics = slugs(&["fruit", "fruit-apple"]); + let rows = compute_coverage_rows(&cmd, &topics, &[], &[]); + let apple = rows.iter().find(|r| r.path == "fruit/apple").unwrap(); + assert_eq!(apple.covered_by.as_deref(), Some("fruit-apple")); + let banana = rows.iter().find(|r| r.path == "fruit/banana").unwrap(); + assert_eq!(banana.covered_by.as_deref(), Some("fruit")); + } + + #[test] + fn missing_topic_is_uncovered() { + let cmd = fake_tree(); + let topics = slugs(&["something-else"]); + let rows = compute_coverage_rows(&cmd, &topics, &[], &[]); + for r in &rows { + assert!(!r.is_covered(), "row {r:?} should be uncovered"); + assert!(r.covered_by.is_none()); + } + } + + #[test] + fn allow_list_exempts_path() { + let cmd = fake_tree(); + let topics = BTreeSet::new(); + let rows = compute_coverage_rows(&cmd, &topics, &["fruit"], &[]); + for r in &rows { + assert!( + r.allow_listed, + "fruit subtree must be allow-listed; got {r:?}" + ); + assert!(r.is_covered(), "allow-listed must read as covered"); + assert!(r.covered_by.is_none(), "no covering topic for allow-listed"); + } + } + + #[test] + fn topic_map_provides_umbrella_coverage() { + let cmd = fake_tree(); + let topics = slugs(&["cli"]); + // `fruit` -> `cli` umbrella mapping. + let map = &[("fruit", "cli")]; + let rows = compute_coverage_rows(&cmd, &topics, &[], map); + for r in &rows { + assert_eq!(r.covered_by.as_deref(), Some("cli")); + } + } + + #[test] + fn coverage_slug_replaces_slashes() { + assert_eq!(coverage_slug("schema/show"), "schema-show"); + assert_eq!(coverage_slug("variant"), "variant"); + assert_eq!(coverage_slug("a/b/c"), "a-b-c"); + } + + /// Sanity-check the real CLI tree: every `(top, slug)` pair in the + /// production map must point at a topic that actually exists. If + /// somebody removes a topic, the gate would silently regress every + /// command in that family back to "uncovered" — catch it here. + #[test] + fn production_topic_map_references_real_topics() { + let topics: BTreeSet = docs::topic_slugs().into_iter().map(String::from).collect(); + for (name, slug) in COVERAGE_TOPIC_MAP { + assert!( + topics.contains(*slug), + "COVERAGE_TOPIC_MAP entry ({name}, {slug}) points at a non-existent topic" + ); + } + } +} diff --git a/rivet-cli/tests/docs_coverage.rs b/rivet-cli/tests/docs_coverage.rs new file mode 100644 index 0000000..7b585b1 --- /dev/null +++ b/rivet-cli/tests/docs_coverage.rs @@ -0,0 +1,210 @@ +// SAFETY-REVIEW (SCRC Phase 1, DD-058): Integration test / bench code. +// Tests legitimately use unwrap/expect/panic/assert-indexing patterns +// because a test failure should panic with a clear stack. Blanket-allow +// the Phase 1 restriction lints at crate scope; real risk analysis for +// these lints is carried by production code. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::wildcard_enum_match_arm, + clippy::match_wildcard_for_single_variants, + clippy::panic, + clippy::todo, + clippy::unimplemented, + clippy::dbg_macro, + clippy::print_stdout, + clippy::print_stderr +)] + +//! Integration tests for `rivet docs check --coverage` — the +//! subcommand-coverage gate that walks the clap CLI tree and asserts +//! every subcommand path is documented in the embedded `rivet docs` +//! registry. +//! +//! These tests exercise the SHAPE of the report (column markers, summary +//! line, exit codes) rather than asserting specific uncovered names, so +//! the gate keeps passing as docs are filled in for previously-uncovered +//! subcommands. + +use std::process::Command; + +fn rivet_bin() -> std::path::PathBuf { + if let Ok(bin) = std::env::var("CARGO_BIN_EXE_rivet") { + return std::path::PathBuf::from(bin); + } + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest.parent().expect("workspace root"); + workspace_root.join("target").join("debug").join("rivet") +} + +/// `rivet docs check --coverage` succeeds (exit 0) by default — warn-only +/// mode is the default contract so the gate can land in CI without +/// breaking on the existing inventory of uncovered commands. +#[test] +fn coverage_warn_only_exits_zero() { + let output = Command::new(rivet_bin()) + .args(["docs", "check", "--coverage"]) + .output() + .expect("failed to execute rivet docs check --coverage"); + + assert!( + output.status.success(), + "warn-only mode must exit 0; stderr: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + // Header line. + assert!( + stdout.contains("rivet docs check --coverage"), + "expected header, got:\n{stdout}" + ); + // Summary line shape: `Coverage: / (%)`. + assert!( + stdout.contains("Coverage:"), + "expected coverage summary, got:\n{stdout}" + ); + + // The gate MUST list every top-level subcommand we ship — not just + // the uncovered ones. Pick a handful of stable ones as a sanity + // check. + for name in ["init", "validate", "list", "schema", "docs", "mcp"] { + assert!( + stdout.contains(name), + "expected '{name}' in coverage output, got:\n{stdout}" + ); + } +} + +/// `--strict` exits non-zero whenever the inventory has any uncovered +/// path. With the current TOPICS registry we know there are at least a +/// few uncovered commands (variant, baseline, snapshot, runs, pipelines, +/// templates, close-gaps), so strict mode must currently fail. Once +/// those gaps are filled the test still holds: if NOTHING is uncovered, +/// strict exits 0, but then `expected_uncovered_count >= 1` is the only +/// place we assert non-zero — re-flip when the world catches up. +#[test] +fn coverage_strict_fails_when_uncovered_present() { + let output = Command::new(rivet_bin()) + .args(["docs", "check", "--coverage", "--strict"]) + .output() + .expect("failed to execute rivet docs check --coverage --strict"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Either the inventory is fully covered (status passes, no + // "Uncovered:" line) or strict mode has flagged something. Both are + // acceptable shapes — the gate is correct in either case. + let has_uncovered = stdout.contains("Uncovered:"); + if has_uncovered { + assert!( + !output.status.success(), + "strict mode must exit non-zero when uncovered are listed; got success with stdout:\n{stdout}" + ); + } else { + assert!( + output.status.success(), + "strict mode must exit 0 when no uncovered listed; got failure with stdout:\n{stdout}" + ); + } +} + +/// JSON output is machine-readable and follows the standard envelope +/// (`command`, `status`, `total`, `covered`, `uncovered`, `subcommands`). +#[test] +fn coverage_json_envelope() { + let output = Command::new(rivet_bin()) + .args(["docs", "check", "--coverage", "--format", "json"]) + .output() + .expect("failed to execute rivet docs check --coverage --format json"); + + assert!(output.status.success(), "warn-only json must exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + + let val: serde_json::Value = serde_json::from_str(&stdout).expect("output must be valid JSON"); + assert_eq!(val["command"], "docs-coverage"); + assert!(val["status"] == "pass" || val["status"] == "fail"); + assert!(val["total"].is_number()); + assert!(val["covered"].is_number()); + assert!(val["uncovered"].is_array()); + + let subs = val["subcommands"] + .as_array() + .expect("subcommands must be array"); + assert!(!subs.is_empty(), "subcommands must be non-empty"); + + // Every entry has the advertised fields. + for s in subs { + assert!(s["path"].is_string()); + assert!(s["depth"].is_number()); + assert!(s["covered"].is_boolean()); + assert!(s["allow_listed"].is_boolean()); + } + + // Stable shape: at least the top-level docs, validate, list paths + // appear in the subcommand list. + let paths: Vec<&str> = subs.iter().filter_map(|v| v["path"].as_str()).collect(); + for required in ["docs", "validate", "list"] { + assert!( + paths.contains(&required), + "expected path '{required}' in {paths:?}" + ); + } +} + +/// The allow-list applies: `commit-msg-check` is exempt and must not be +/// reported as uncovered. +#[test] +fn coverage_allowlist_excludes_internal_helpers() { + let output = Command::new(rivet_bin()) + .args(["docs", "check", "--coverage", "--format", "json"]) + .output() + .expect("failed to execute rivet docs check --coverage --format json"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + let val: serde_json::Value = serde_json::from_str(&stdout).expect("output must be valid JSON"); + let subs = val["subcommands"].as_array().expect("subcommands array"); + + let cmc = subs + .iter() + .find(|s| s["path"].as_str() == Some("commit-msg-check")) + .expect("commit-msg-check must be in the subcommand list"); + assert_eq!( + cmc["allow_listed"], true, + "commit-msg-check must be allow-listed; got {cmc}" + ); + + let uncovered = val["uncovered"].as_array().expect("uncovered array"); + let names: Vec<&str> = uncovered.iter().filter_map(|v| v.as_str()).collect(); + assert!( + !names.contains(&"commit-msg-check"), + "commit-msg-check must not be in the uncovered list; got {names:?}" + ); +} + +/// Backward compatibility: `rivet docs check` with no flags still runs +/// the existing doc-vs-reality invariants (no coverage report). +#[test] +fn docs_check_without_coverage_unchanged() { + let output = Command::new(rivet_bin()) + .args(["docs", "check"]) + .output() + .expect("failed to execute rivet docs check"); + + let stdout = String::from_utf8_lossy(&output.stdout); + // Doc-check banner, NOT the coverage banner. + assert!( + stdout.contains("doc-check:"), + "expected doc-check banner, got:\n{stdout}" + ); + assert!( + !stdout.contains("rivet docs check --coverage"), + "no-flags mode must not emit coverage report; got:\n{stdout}" + ); +}