From ecad454dba1793f294ddbe22be08180f9862fb98 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 17:16:40 -0400 Subject: [PATCH 1/7] feat(embed): add {{diagnostics}} and {{matrix}} embed renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5: adds two new computed embed types: - {{diagnostics}} / {{diagnostics:error|warning|info}} — validation issues as HTML table with severity filter and summary footer - {{matrix}} / {{matrix:from_type:to_type}} — inline traceability matrix with coverage bar, auto-detects link type from schema rules 7 unit tests + 2 CLI integration tests. --- rivet-cli/tests/cli_commands.rs | 52 ++++++ rivet-core/src/embed.rs | 319 ++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+) diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 05639e6..5a53fa2 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -661,3 +661,55 @@ fn embed_unknown_returns_error() { "unknown embed should produce an error message. Got: {combined}" ); } + +/// `rivet embed "diagnostics"` prints diagnostics or a no-data message. +#[test] +fn embed_diagnostics() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "embed", + "diagnostics", + ]) + .output() + .expect("failed to execute rivet embed diagnostics"); + + assert!( + output.status.success(), + "rivet embed diagnostics must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Severity") || stdout.contains("No diagnostics"), + "should contain diagnostics output. Got: {stdout}" + ); +} + +/// `rivet embed "matrix"` prints matrix data or a no-rules message. +#[test] +fn embed_matrix() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "embed", + "matrix", + ]) + .output() + .expect("failed to execute rivet embed matrix"); + + assert!( + output.status.success(), + "rivet embed matrix must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("covered") || stdout.contains("No traceability"), + "should contain matrix output. Got: {stdout}" + ); +} diff --git a/rivet-core/src/embed.rs b/rivet-core/src/embed.rs index 3235f37..39f63c1 100644 --- a/rivet-core/src/embed.rs +++ b/rivet-core/src/embed.rs @@ -9,6 +9,7 @@ use std::fmt::Write as _; use crate::coverage; use crate::document; +use crate::matrix; // ── Types ─────────────────────────────────────────────────────────────── @@ -162,6 +163,8 @@ pub fn resolve_embed( match request.name.as_str() { "stats" => Ok(render_stats(request, ctx)), "coverage" => Ok(render_coverage(request, ctx)), + "diagnostics" => Ok(render_diagnostics(request, ctx)), + "matrix" => Ok(render_matrix(request, ctx)), // Legacy embeds (artifact, links, table) are still handled by // resolve_inline in document.rs — they should never reach here. "artifact" | "links" | "table" => Err(EmbedError { @@ -357,6 +360,266 @@ fn render_coverage(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { html } +// ── Diagnostics renderer ──────────────────────────────────────────────── + +/// Render `{{diagnostics}}` or `{{diagnostics:SEVERITY}}`. +/// +/// Without args: all diagnostics. With severity arg: filtered by severity. +fn render_diagnostics(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { + use crate::schema::Severity; + + let filter_severity = request.args.first().map(|s| s.as_str()); + + let filtered: Vec<_> = ctx + .diagnostics + .iter() + .filter(|d| match filter_severity { + Some("error") => d.severity == Severity::Error, + Some("warning") => d.severity == Severity::Warning, + Some("info") => d.severity == Severity::Info, + _ => true, + }) + .collect(); + + if filtered.is_empty() { + let scope = filter_severity.unwrap_or("any"); + return format!( + "

No diagnostics ({scope} severity).

\n" + ); + } + + let mut html = String::from( + "
\n\ + \ + \ + \n", + ); + + for diag in &filtered { + let sev_class = match diag.severity { + Severity::Error => "sev-error", + Severity::Warning => "sev-warning", + Severity::Info => "sev-info", + }; + let sev_label = match diag.severity { + Severity::Error => "Error", + Severity::Warning => "Warning", + Severity::Info => "Info", + }; + let artifact = diag + .artifact_id + .as_deref() + .unwrap_or("—"); + let _ = writeln!( + html, + "\ + \ + \ + \ + \ + ", + artifact = document::html_escape(artifact), + rule = document::html_escape(&diag.rule), + message = document::html_escape(&diag.message), + ); + } + + html.push_str("
SeverityArtifactRuleMessage
{sev_label}{artifact}{rule}{message}
\n"); + + // Summary footer + let errors = filtered.iter().filter(|d| d.severity == Severity::Error).count(); + let warnings = filtered.iter().filter(|d| d.severity == Severity::Warning).count(); + let infos = filtered.iter().filter(|d| d.severity == Severity::Info).count(); + let _ = writeln!( + html, + "

{} issue{}: {} error{}, {} warning{}, {} info

", + filtered.len(), + if filtered.len() == 1 { "" } else { "s" }, + errors, + if errors == 1 { "" } else { "s" }, + warnings, + if warnings == 1 { "" } else { "s" }, + infos, + ); + + html.push_str("
\n"); + html +} + +// ── Matrix renderer ───────────────────────────────────────────────────── + +/// Render `{{matrix}}` or `{{matrix:FROM_TYPE:TO_TYPE}}`. +/// +/// Without args: renders one matrix per traceability rule in the schema. +/// With args: renders a specific matrix for the given source→target types. +fn render_matrix(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { + let from_type = request.args.first().map(|s| s.as_str()); + let to_type = request.args.get(1).map(|s| s.as_str()); + + let mut html = String::from("
\n"); + + match (from_type, to_type) { + (Some(from), Some(to)) => { + // Find the matching traceability rule to get link type and direction. + if let Some(rule) = find_rule_for_types(ctx, from, to) { + let direction = if rule.required_backlink.is_some() { + matrix::Direction::Backward + } else { + matrix::Direction::Forward + }; + let link_type = rule + .required_link + .as_deref() + .or(rule.required_backlink.as_deref()) + .unwrap_or(""); + let m = matrix::compute_matrix(ctx.store, ctx.graph, from, to, link_type, direction); + html.push_str(&render_matrix_table(&m)); + } else { + // No rule found — try forward with auto-detected link type. + let link = auto_detect_link(ctx, from, to); + let m = matrix::compute_matrix( + ctx.store, + ctx.graph, + from, + to, + &link, + matrix::Direction::Forward, + ); + html.push_str(&render_matrix_table(&m)); + } + } + _ => { + // No args: render one matrix per traceability rule. + if ctx.schema.traceability_rules.is_empty() { + html.push_str("

No traceability rules defined.

\n"); + } else { + for rule in &ctx.schema.traceability_rules { + let direction = if rule.required_backlink.is_some() { + matrix::Direction::Backward + } else { + matrix::Direction::Forward + }; + let link_type = rule + .required_link + .as_deref() + .or(rule.required_backlink.as_deref()) + .unwrap_or(""); + let target_type = rule.target_types.first().map(|s| s.as_str()).unwrap_or(""); + if target_type.is_empty() { + continue; + } + let m = matrix::compute_matrix( + ctx.store, + ctx.graph, + &rule.source_type, + target_type, + link_type, + direction, + ); + let _ = writeln!( + html, + "

{}

", + document::html_escape(&rule.name), + ); + html.push_str(&render_matrix_table(&m)); + } + } + } + } + + html.push_str("
\n"); + html +} + +/// Render a single traceability matrix as an HTML table. +fn render_matrix_table(m: &matrix::TraceabilityMatrix) -> String { + if m.rows.is_empty() { + return format!( + "

No {} artifacts found.

\n", + document::html_escape(&m.source_type), + ); + } + + let pct = m.coverage_pct(); + let bar_class = if pct >= 100.0 { + "bar-full" + } else if pct >= 80.0 { + "bar-good" + } else if pct >= 50.0 { + "bar-warn" + } else { + "bar-danger" + }; + + let mut html = format!( + "\ + \ + \n", + source = document::html_escape(&m.source_type), + target = document::html_escape(&m.target_type), + ); + + for row in &m.rows { + let targets_str = if row.targets.is_empty() { + "".to_string() + } else { + row.targets + .iter() + .map(|t| format!("{}", document::html_escape(&t.id))) + .collect::>() + .join(", ") + }; + let row_class = if row.targets.is_empty() { + " class=\"uncovered\"" + } else { + "" + }; + let _ = writeln!( + html, + "", + id = document::html_escape(&row.source_id), + title = document::html_escape(&row.source_title), + targets = targets_str, + ); + } + + let _ = writeln!( + html, + "
{source}{target} (linked)
{id} {title}{targets}
\n\ +

{covered}/{total} covered ({pct:.1}%) \ + \ +

", + covered = m.covered, + total = m.total, + bar_width = pct.round() as u32, + ); + + html +} + +/// Find a traceability rule matching the given source→target types. +fn find_rule_for_types<'a>( + ctx: &'a EmbedContext<'_>, + from: &str, + to: &str, +) -> Option<&'a crate::schema::TraceabilityRule> { + ctx.schema.traceability_rules.iter().find(|r| { + r.source_type == from && r.target_types.iter().any(|t| t == to) + }) +} + +/// Auto-detect link type between two artifact types by scanning the graph. +fn auto_detect_link(ctx: &EmbedContext<'_>, from: &str, _to: &str) -> String { + // Look at the first artifact of from_type and find any outgoing link type. + for id in ctx.store.by_type(from) { + let links = ctx.graph.links_from(id); + if let Some(link) = links.first() { + return link.link_type.clone(); + } + } + String::new() +} + // ── Provenance ────────────────────────────────────────────────────────── /// Render a provenance footer for export (SC-EMBED-4). @@ -569,4 +832,60 @@ mod tests { let req = EmbedRequest::parse("stats").unwrap(); assert!(!req.is_legacy()); } + + // ── Diagnostics tests ─────────────────────────────────────────── + + #[test] + fn diagnostics_embed_renders_no_data_when_empty() { + let ctx = EmbedContext::empty(); + let req = EmbedRequest::parse("diagnostics").unwrap(); + let html = resolve_embed(&req, &ctx).unwrap(); + assert!(html.contains("embed-diagnostics"), "must have diagnostics class"); + assert!(html.contains("No diagnostics"), "empty context should show no-data message"); + } + + #[test] + fn diagnostics_embed_is_not_unknown() { + let req = EmbedRequest::parse("diagnostics").unwrap(); + let result = resolve_embed(&req, &EmbedContext::empty()); + assert!(result.is_ok(), "diagnostics should be a known embed type"); + } + + #[test] + fn diagnostics_severity_filter_parses() { + let req = EmbedRequest::parse("diagnostics:error").unwrap(); + assert_eq!(req.name, "diagnostics"); + assert_eq!(req.args, vec!["error"]); + } + + // ── Matrix tests ──────────────────────────────────────────────── + + #[test] + fn matrix_embed_renders_no_rules_when_empty() { + let ctx = EmbedContext::empty(); + let req = EmbedRequest::parse("matrix").unwrap(); + let html = resolve_embed(&req, &ctx).unwrap(); + assert!(html.contains("embed-matrix"), "must have matrix class"); + assert!(html.contains("No traceability rules"), "empty schema should show no-rules message"); + } + + #[test] + fn matrix_embed_is_not_unknown() { + let req = EmbedRequest::parse("matrix").unwrap(); + let result = resolve_embed(&req, &EmbedContext::empty()); + assert!(result.is_ok(), "matrix should be a known embed type"); + } + + #[test] + fn matrix_with_types_parses() { + let req = EmbedRequest::parse("matrix:requirement:feature").unwrap(); + assert_eq!(req.name, "matrix"); + assert_eq!(req.args, vec!["requirement", "feature"]); + } + + #[test] + fn diagnostics_and_matrix_are_not_legacy() { + assert!(!EmbedRequest::parse("diagnostics").unwrap().is_legacy()); + assert!(!EmbedRequest::parse("matrix").unwrap().is_legacy()); + } } From 6842b3a8e1c3222b336aa525651a2171bb6333ff Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 17:20:43 -0400 Subject: [PATCH 2/7] feat(snapshot): add rivet snapshot capture/diff/list commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: project snapshot infrastructure for baseline comparison: - Snapshot struct with stats, coverage, diagnostics, git context - rivet snapshot capture — dumps current state to JSON - rivet snapshot diff — compares current vs baseline (text/json/markdown) - rivet snapshot list — lists available snapshots - Delta computation with NEW/RESOLVED diagnostic tracking - SC-EMBED-2 (git commit in snapshot), SC-EMBED-6 (schema version) 5 unit tests + 2 CLI integration tests. --- rivet-cli/src/main.rs | 337 +++++++++++++++++++++++++++ rivet-cli/tests/cli_commands.rs | 56 +++++ rivet-core/src/embed.rs | 10 + rivet-core/src/lib.rs | 1 + rivet-core/src/snapshot.rs | 394 ++++++++++++++++++++++++++++++++ 5 files changed, 798 insertions(+) create mode 100644 rivet-core/src/snapshot.rs diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 7152cf3..e86f1fd 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -433,6 +433,12 @@ enum Command { action: BaselineAction, }, + /// Capture or compare project snapshots for delta tracking + Snapshot { + #[command(subcommand)] + action: SnapshotAction, + }, + /// Import artifacts using a custom WASM adapter component #[cfg(feature = "wasm")] Import { @@ -641,6 +647,30 @@ enum BaselineAction { List, } +#[derive(Debug, Subcommand)] +enum SnapshotAction { + /// Capture a snapshot of the current project state + Capture { + /// Snapshot name (default: git tag or HEAD short hash) + #[arg(long)] + name: Option, + /// Output file path (default: snapshots/{name}.json) + #[arg(short, long)] + output: Option, + }, + /// Compare current state against a baseline snapshot + Diff { + /// Path to the baseline snapshot JSON file + #[arg(long)] + baseline: Option, + /// Output format: "text" (default), "json", or "markdown" + #[arg(short, long, default_value = "text")] + format: String, + }, + /// List available snapshots + List, +} + fn main() -> ExitCode { let cli = Cli::parse(); @@ -848,6 +878,15 @@ fn run(cli: Cli) -> Result { BaselineAction::Verify { name, strict } => cmd_baseline_verify(&cli, name, *strict), BaselineAction::List => cmd_baseline_list(&cli), }, + Command::Snapshot { action } => match action { + SnapshotAction::Capture { name, output } => { + cmd_snapshot_capture(&cli, name.as_deref(), output.as_deref()) + } + SnapshotAction::Diff { baseline, format } => { + cmd_snapshot_diff(&cli, baseline.as_deref(), format) + } + SnapshotAction::List => cmd_snapshot_list(&cli), + }, #[cfg(feature = "wasm")] Command::Import { adapter, @@ -4268,6 +4307,304 @@ fn cmd_baseline_list(cli: &Cli) -> Result { Ok(true) } +// ── Snapshot commands ─────────────────────────────────────────────────── + +fn cmd_snapshot_capture( + cli: &Cli, + name: Option<&str>, + output: Option<&std::path::Path>, +) -> Result { + let schemas_dir = resolve_schemas_dir(cli); + let project_path = cli + .project + .canonicalize() + .unwrap_or_else(|_| cli.project.clone()); + + let state = crate::serve::reload_state(&project_path, &schemas_dir, 0) + .context("loading project for snapshot")?; + + let git_ctx = match &state.context.git { + Some(git) => rivet_core::snapshot::GitContext { + commit: git.commit_short.clone(), // short is what we have from serve + commit_short: git.commit_short.clone(), + tag: None, // TODO: detect git tag + dirty: git.is_dirty, + }, + None => rivet_core::snapshot::GitContext { + commit: "unknown".to_string(), + commit_short: "unknown".to_string(), + tag: None, + dirty: false, + }, + }; + + let snap = rivet_core::snapshot::capture_with_data( + &state.store, + &state.cached_diagnostics, + &rivet_core::coverage::compute_coverage(&state.store, &state.schema, &state.graph), + &git_ctx, + ); + + // Determine output path + let snap_name = name.unwrap_or(&git_ctx.commit_short); + let out_path = match output { + Some(p) => p.to_path_buf(), + None => project_path + .join("snapshots") + .join(format!("{snap_name}.json")), + }; + + rivet_core::snapshot::write_to_file(&snap, &out_path) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + eprintln!( + "Snapshot captured: {} ({} artifacts, {:.1}% coverage, {} diagnostics)", + out_path.display(), + snap.stats.total, + snap.coverage.overall, + snap.diagnostics.errors + snap.diagnostics.warnings + snap.diagnostics.infos, + ); + + Ok(true) +} + +fn cmd_snapshot_diff( + cli: &Cli, + baseline_path: Option<&std::path::Path>, + format: &str, +) -> Result { + let schemas_dir = resolve_schemas_dir(cli); + let project_path = cli + .project + .canonicalize() + .unwrap_or_else(|_| cli.project.clone()); + + // Load baseline + let baseline_file = match baseline_path { + Some(p) => p.to_path_buf(), + None => { + // Auto-detect: find most recent snapshot in snapshots/ + let snap_dir = project_path.join("snapshots"); + find_latest_snapshot(&snap_dir)? + } + }; + + let baseline = rivet_core::snapshot::read_from_file(&baseline_file) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // Capture current state + let state = crate::serve::reload_state(&project_path, &schemas_dir, 0) + .context("loading project for snapshot diff")?; + + let git_ctx = match &state.context.git { + Some(git) => rivet_core::snapshot::GitContext { + commit: git.commit_short.clone(), + commit_short: git.commit_short.clone(), + tag: None, + dirty: git.is_dirty, + }, + None => rivet_core::snapshot::GitContext { + commit: "unknown".to_string(), + commit_short: "unknown".to_string(), + tag: None, + dirty: false, + }, + }; + + let current = rivet_core::snapshot::capture_with_data( + &state.store, + &state.cached_diagnostics, + &rivet_core::coverage::compute_coverage(&state.store, &state.schema, &state.graph), + &git_ctx, + ); + + // Check schema version compatibility (SC-EMBED-6) + if baseline.schema_version != current.schema_version { + eprintln!( + "warning: snapshot schema version mismatch (baseline: {}, current: {}) — delta may be inaccurate", + baseline.schema_version, current.schema_version, + ); + } + + let delta = rivet_core::snapshot::compute_delta(&baseline, ¤t); + + match format { + "json" => { + let json = serde_json::to_string_pretty(&delta) + .context("serializing delta")?; + println!("{json}"); + } + "markdown" => { + println!("{}", format_delta_markdown(&delta, &baseline)); + } + _ => { + print_delta_text(&delta, &baseline); + } + } + + Ok(true) +} + +fn cmd_snapshot_list(cli: &Cli) -> Result { + let project_path = cli + .project + .canonicalize() + .unwrap_or_else(|_| cli.project.clone()); + let snap_dir = project_path.join("snapshots"); + + if !snap_dir.exists() { + println!("No snapshots directory found."); + return Ok(true); + } + + let mut entries: Vec<_> = std::fs::read_dir(&snap_dir) + .context("reading snapshots directory")? + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext == "json") + }) + .collect(); + + entries.sort_by_key(|e| e.file_name()); + + if entries.is_empty() { + println!("No snapshots found in {}/", snap_dir.display()); + } else { + println!("Snapshots ({}):", entries.len()); + for entry in &entries { + if let Ok(snap) = rivet_core::snapshot::read_from_file(&entry.path()) { + println!( + " {} — {} artifacts, {:.1}% cov, {} errors ({})", + entry.file_name().to_string_lossy(), + snap.stats.total, + snap.coverage.overall, + snap.diagnostics.errors, + snap.created_at, + ); + } else { + println!(" {} (invalid)", entry.file_name().to_string_lossy()); + } + } + } + + Ok(true) +} + +fn find_latest_snapshot(snap_dir: &std::path::Path) -> Result { + if !snap_dir.exists() { + anyhow::bail!("no snapshots directory found — run `rivet snapshot capture` first"); + } + + let mut files: Vec<_> = std::fs::read_dir(snap_dir) + .context("reading snapshots directory")? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) + .collect(); + + files.sort_by_key(|e| { + e.metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + + files + .last() + .map(|e| e.path()) + .ok_or_else(|| anyhow::anyhow!("no snapshot files found in {}", snap_dir.display())) +} + +fn print_delta_text( + delta: &rivet_core::snapshot::SnapshotDelta, + baseline: &rivet_core::snapshot::Snapshot, +) { + let sign = |v: isize| -> String { + if v > 0 { + format!("+{v}") + } else { + format!("{v}") + } + }; + + println!( + "Delta: {} → {}", + baseline.git_commit_short, delta.current_commit + ); + println!(" Artifacts: {} ({})", baseline.stats.total as isize + delta.stats.total, sign(delta.stats.total)); + for (t, &change) in &delta.stats.by_type { + if change != 0 { + println!(" {t}: {}", sign(change)); + } + } + let fsign = |v: f64| -> String { + if v > 0.0 { + format!("+{v:.1}%") + } else { + format!("{v:.1}%") + } + }; + println!(" Coverage: {}", fsign(delta.coverage.overall)); + println!( + " Diagnostics: {} new, {} resolved, errors {}", + delta.diagnostics.new_count, + delta.diagnostics.resolved_count, + sign(delta.diagnostics.errors), + ); +} + +fn format_delta_markdown( + delta: &rivet_core::snapshot::SnapshotDelta, + baseline: &rivet_core::snapshot::Snapshot, +) -> String { + let sign = |v: isize| -> String { + if v > 0 { + format!("+{v}") + } else { + format!("{v}") + } + }; + + let mut md = format!( + "## Rivet Delta: {} → {}\n\n", + baseline.git_commit_short, delta.current_commit + ); + md.push_str("| Metric | Value | Δ |\n|--------|-------|---|\n"); + md.push_str(&format!( + "| Artifacts | {} | {} |\n", + baseline.stats.total as isize + delta.stats.total, + sign(delta.stats.total), + )); + let cov = baseline.coverage.overall + delta.coverage.overall; + let cov_delta = if delta.coverage.overall > 0.0 { + format!("+{:.1}%", delta.coverage.overall) + } else { + format!("{:.1}%", delta.coverage.overall) + }; + md.push_str(&format!("| Coverage | {cov:.1}% | {cov_delta} |\n")); + md.push_str(&format!( + "| Errors | {} | {} |\n", + baseline.diagnostics.errors as isize + delta.diagnostics.errors, + sign(delta.diagnostics.errors), + )); + if delta.diagnostics.new_count > 0 { + md.push_str(&format!( + "\n**{}** new diagnostic{}\n", + delta.diagnostics.new_count, + if delta.diagnostics.new_count == 1 { "" } else { "s" }, + )); + } + if delta.diagnostics.resolved_count > 0 { + md.push_str(&format!( + "**{}** resolved diagnostic{}\n", + delta.diagnostics.resolved_count, + if delta.diagnostics.resolved_count == 1 { "" } else { "s" }, + )); + } + md +} + /// Apply baseline scoping to a store if a baseline name is provided. /// /// Returns the original store unmodified when no baseline is requested diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 5a53fa2..5ed6df0 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -640,6 +640,62 @@ fn embed_coverage() { ); } +// ── rivet snapshot ───────────────────────────────────────────────────── + +/// `rivet snapshot capture` writes a JSON snapshot file. +#[test] +fn snapshot_capture_writes_file() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let out_file = tmp.path().join("test-snap.json"); + + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "snapshot", + "capture", + "--output", + out_file.to_str().unwrap(), + ]) + .output() + .expect("failed to execute rivet snapshot capture"); + + assert!( + output.status.success(), + "rivet snapshot capture must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!(out_file.exists(), "snapshot file must be created"); + let content = std::fs::read_to_string(&out_file).expect("read snapshot"); + let parsed: serde_json::Value = + serde_json::from_str(&content).expect("snapshot must be valid JSON"); + assert!(parsed.get("schema_version").is_some(), "must have schema_version"); + assert!(parsed.get("stats").is_some(), "must have stats"); + assert!(parsed.get("coverage").is_some(), "must have coverage"); + assert!(parsed.get("diagnostics").is_some(), "must have diagnostics"); +} + +/// `rivet snapshot list` runs without error. +#[test] +fn snapshot_list_runs() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "snapshot", + "list", + ]) + .output() + .expect("failed to execute rivet snapshot list"); + + assert!( + output.status.success(), + "rivet snapshot list must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + /// `rivet embed "nonexistent"` reports an unknown embed error. #[test] fn embed_unknown_returns_error() { diff --git a/rivet-core/src/embed.rs b/rivet-core/src/embed.rs index 39f63c1..7e8df24 100644 --- a/rivet-core/src/embed.rs +++ b/rivet-core/src/embed.rs @@ -643,6 +643,16 @@ pub fn render_provenance_stamp(commit_short: &str, is_dirty: bool) -> String { ) } +/// Return the current time as an ISO 8601 UTC string. +pub fn epoch_to_iso8601() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let (y, m, d, h, min) = epoch_to_ymd_hm(secs); + format!("{y}-{m:02}-{d:02}T{h:02}:{min:02}:00Z") +} + /// Convert seconds since Unix epoch to (year, month, day, hour, minute) in UTC. fn epoch_to_ymd_hm(secs: u64) -> (u64, u64, u64, u64, u64) { let days = secs / 86400; diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 103ff5b..6b2ef96 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod query; pub mod reqif; pub mod results; pub mod schema; +pub mod snapshot; pub mod store; pub mod test_scanner; pub mod validate; diff --git a/rivet-core/src/snapshot.rs b/rivet-core/src/snapshot.rs new file mode 100644 index 0000000..792ab9a --- /dev/null +++ b/rivet-core/src/snapshot.rs @@ -0,0 +1,394 @@ +//! Project snapshot for baseline comparison and delta tracking. +//! +//! A snapshot captures the full project state (stats, coverage, diagnostics) +//! at a point in time, tagged with git commit info. Used for: +//! - `rivet snapshot diff` to compare current vs baseline +//! - `delta=BASELINE` option on embeds to show changes +//! - CI workflows to post PR delta comments + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::coverage::{self, CoverageReport}; +use crate::links::LinkGraph; +use crate::schema::Schema; +use crate::store::Store; +use crate::validate::{self, Diagnostic}; + +// ── Snapshot format ───────────────────────────────────────────────────── + +/// Schema version for forward compatibility (SC-EMBED-6). +pub const SCHEMA_VERSION: u32 = 1; + +/// A full project snapshot for baseline comparison. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Snapshot { + pub rivet_version: String, + pub schema_version: u32, + pub created_at: String, + pub git_commit: String, + pub git_commit_short: String, + pub git_tag: Option, + pub git_dirty: bool, + pub stats: StatsData, + pub coverage: CoverageData, + pub diagnostics: DiagnosticsData, +} + +/// Artifact statistics captured in a snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatsData { + pub total: usize, + pub by_type: BTreeMap, + pub by_status: BTreeMap, +} + +/// Coverage data captured in a snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoverageData { + pub overall: f64, + pub rules: Vec, +} + +/// A single coverage rule entry in a snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoverageRuleData { + pub rule: String, + pub source_type: String, + pub covered: usize, + pub total: usize, + pub percentage: f64, +} + +/// Diagnostics data captured in a snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagnosticsData { + pub errors: usize, + pub warnings: usize, + pub infos: usize, + pub items: Vec, +} + +/// A single diagnostic entry in a snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagnosticItem { + pub severity: String, + pub artifact_id: Option, + pub rule: String, + pub message: String, +} + +// ── Capture ───────────────────────────────────────────────────────────── + +/// Git info for snapshot creation. +pub struct GitContext { + pub commit: String, + pub commit_short: String, + pub tag: Option, + pub dirty: bool, +} + +/// Capture a snapshot from the current project state. +pub fn capture( + store: &Store, + schema: &Schema, + graph: &LinkGraph, + git: &GitContext, +) -> Snapshot { + let diagnostics_vec = validate::validate(store, schema, graph); + let coverage_report = coverage::compute_coverage(store, schema, graph); + + capture_with_data(store, &diagnostics_vec, &coverage_report, git) +} + +/// Capture a snapshot with pre-computed diagnostics and coverage. +pub fn capture_with_data( + store: &Store, + diagnostics: &[Diagnostic], + coverage_report: &CoverageReport, + git: &GitContext, +) -> Snapshot { + // Stats + let mut by_type = BTreeMap::new(); + for art in store.iter() { + *by_type.entry(art.artifact_type.clone()).or_insert(0usize) += 1; + } + let mut by_status = BTreeMap::new(); + for art in store.iter() { + let key = art.status.as_deref().unwrap_or("unset").to_string(); + *by_status.entry(key).or_insert(0usize) += 1; + } + + // Coverage + let rules: Vec = coverage_report + .entries + .iter() + .map(|e| CoverageRuleData { + rule: e.rule_name.clone(), + source_type: e.source_type.clone(), + covered: e.covered, + total: e.total, + percentage: e.percentage(), + }) + .collect(); + + // Diagnostics + let errors = diagnostics + .iter() + .filter(|d| d.severity == crate::schema::Severity::Error) + .count(); + let warnings = diagnostics + .iter() + .filter(|d| d.severity == crate::schema::Severity::Warning) + .count(); + let infos = diagnostics + .iter() + .filter(|d| d.severity == crate::schema::Severity::Info) + .count(); + let items: Vec = diagnostics + .iter() + .map(|d| DiagnosticItem { + severity: format!("{:?}", d.severity).to_lowercase(), + artifact_id: d.artifact_id.clone(), + rule: d.rule.clone(), + message: d.message.clone(), + }) + .collect(); + + // Timestamp + let created_at = crate::embed::epoch_to_iso8601(); + + Snapshot { + rivet_version: env!("CARGO_PKG_VERSION").to_string(), + schema_version: SCHEMA_VERSION, + created_at, + git_commit: git.commit.clone(), + git_commit_short: git.commit_short.clone(), + git_tag: git.tag.clone(), + git_dirty: git.dirty, + stats: StatsData { + total: store.len(), + by_type, + by_status, + }, + coverage: CoverageData { + overall: coverage_report.overall_coverage(), + rules, + }, + diagnostics: DiagnosticsData { + errors, + warnings, + infos, + items, + }, + } +} + +// ── Delta computation ─────────────────────────────────────────────────── + +/// Delta between two snapshots. +#[derive(Debug, Clone, Serialize)] +pub struct SnapshotDelta { + pub baseline_commit: String, + pub current_commit: String, + pub stats: StatsDelta, + pub coverage: CoverageDelta, + pub diagnostics: DiagnosticsDelta, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatsDelta { + pub total: isize, + pub by_type: BTreeMap, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CoverageDelta { + pub overall: f64, + pub rules: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CoverageRuleDelta { + pub rule: String, + pub covered: isize, + pub total: isize, + pub percentage: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DiagnosticsDelta { + pub errors: isize, + pub warnings: isize, + pub new_count: usize, + pub resolved_count: usize, +} + +/// Compute the delta between a baseline snapshot and the current snapshot. +pub fn compute_delta(baseline: &Snapshot, current: &Snapshot) -> SnapshotDelta { + // Stats delta + let mut by_type = BTreeMap::new(); + for (t, &count) in ¤t.stats.by_type { + let base = baseline.stats.by_type.get(t).copied().unwrap_or(0) as isize; + by_type.insert(t.clone(), count as isize - base); + } + for (t, &count) in &baseline.stats.by_type { + by_type.entry(t.clone()).or_insert(-(count as isize)); + } + + // Coverage delta + let coverage_rules: Vec = current + .coverage + .rules + .iter() + .map(|r| { + let base = baseline + .coverage + .rules + .iter() + .find(|b| b.rule == r.rule); + CoverageRuleDelta { + rule: r.rule.clone(), + covered: r.covered as isize - base.map_or(0, |b| b.covered as isize), + total: r.total as isize - base.map_or(0, |b| b.total as isize), + percentage: r.percentage - base.map_or(0.0, |b| b.percentage), + } + }) + .collect(); + + // Diagnostics delta — count NEW and RESOLVED + let baseline_keys: std::collections::HashSet<_> = baseline + .diagnostics + .items + .iter() + .map(|d| (&d.artifact_id, &d.rule, &d.message)) + .collect(); + let current_keys: std::collections::HashSet<_> = current + .diagnostics + .items + .iter() + .map(|d| (&d.artifact_id, &d.rule, &d.message)) + .collect(); + + let new_count = current_keys.difference(&baseline_keys).count(); + let resolved_count = baseline_keys.difference(¤t_keys).count(); + + SnapshotDelta { + baseline_commit: baseline.git_commit_short.clone(), + current_commit: current.git_commit_short.clone(), + stats: StatsDelta { + total: current.stats.total as isize - baseline.stats.total as isize, + by_type, + }, + coverage: CoverageDelta { + overall: current.coverage.overall - baseline.coverage.overall, + rules: coverage_rules, + }, + diagnostics: DiagnosticsDelta { + errors: current.diagnostics.errors as isize - baseline.diagnostics.errors as isize, + warnings: current.diagnostics.warnings as isize + - baseline.diagnostics.warnings as isize, + new_count, + resolved_count, + }, + } +} + +// ── I/O ───────────────────────────────────────────────────────────────── + +/// Write a snapshot to a JSON file. +pub fn write_to_file(snapshot: &Snapshot, path: &std::path::Path) -> Result<(), String> { + let json = serde_json::to_string_pretty(snapshot) + .map_err(|e| format!("serializing snapshot: {e}"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("creating directory {}: {e}", parent.display()))?; + } + std::fs::write(path, json).map_err(|e| format!("writing {}: {e}", path.display())) +} + +/// Read a snapshot from a JSON file. +pub fn read_from_file(path: &std::path::Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?; + serde_json::from_str(&content).map_err(|e| format!("parsing {}: {e}", path.display())) +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_git() -> GitContext { + GitContext { + commit: "abc1234def5678".to_string(), + commit_short: "abc1234".to_string(), + tag: Some("v0.3.0".to_string()), + dirty: false, + } + } + + #[test] + fn capture_empty_snapshot() { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let snap = capture(&store, &schema, &graph, &dummy_git()); + + assert_eq!(snap.schema_version, SCHEMA_VERSION); + assert_eq!(snap.git_commit_short, "abc1234"); + assert_eq!(snap.stats.total, 0); + assert_eq!(snap.diagnostics.errors, 0); + } + + #[test] + fn snapshot_roundtrip_json() { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let snap = capture(&store, &schema, &graph, &dummy_git()); + + let json = serde_json::to_string(&snap).unwrap(); + let parsed: Snapshot = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.schema_version, snap.schema_version); + assert_eq!(parsed.git_commit, snap.git_commit); + assert_eq!(parsed.stats.total, snap.stats.total); + } + + #[test] + fn delta_empty_snapshots() { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let snap = capture(&store, &schema, &graph, &dummy_git()); + + let delta = compute_delta(&snap, &snap); + assert_eq!(delta.stats.total, 0); + assert_eq!(delta.coverage.overall, 0.0); + assert_eq!(delta.diagnostics.new_count, 0); + assert_eq!(delta.diagnostics.resolved_count, 0); + } + + #[test] + fn snapshot_records_git_dirty(/* SC-EMBED-2 */) { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let mut git = dummy_git(); + git.dirty = true; + let snap = capture(&store, &schema, &graph, &git); + assert!(snap.git_dirty, "snapshot must record dirty tree (SC-EMBED-2)"); + } + + #[test] + fn snapshot_schema_version_set(/* SC-EMBED-6 */) { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let snap = capture(&store, &schema, &graph, &dummy_git()); + assert_eq!(snap.schema_version, SCHEMA_VERSION, "must include schema_version (SC-EMBED-6)"); + } +} From eb63848053214cc960a3ed089cfd6f43524d33a7 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 17:24:41 -0400 Subject: [PATCH 3/7] refactor(embed): add baseline field to EmbedContext for delta rendering Adds optional baseline snapshot to EmbedContext so embed renderers can show delta columns when delta=BASELINE option is used. All callers updated to pass baseline: None (wiring comes next). --- .claude/settings.local.json | 22 ++++++++++++++++++++++ rivet-cli/src/main.rs | 1 + rivet-cli/src/render/documents.rs | 1 + rivet-core/src/embed.rs | 3 +++ rivet-core/src/export.rs | 1 + 5 files changed, 28 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9474f29 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,22 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr:*)", + "Bash(gh issue:*)", + "Bash(git fetch:*)", + "Bash(git merge:*)", + "Bash(git push:*)", + "Bash(git reset:*)", + "Bash(git config:*)", + "Bash(git commit:*)", + "Bash(git checkout:*)", + "Bash(git pull:*)", + "Bash(git cherry-pick:*)", + "Bash(cargo check:*)", + "Bash(cargo test:*)", + "Bash(git add:*)", + "Bash(cargo clippy:*)", + "Bash(git branch:*)" + ] + } +} diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index e86f1fd..691cad9 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -5405,6 +5405,7 @@ fn cmd_embed(cli: &Cli, query: &str, format: &str) -> Result { schema: &state.schema, graph: &state.graph, diagnostics: &state.cached_diagnostics, + baseline: None, }; match rivet_core::embed::resolve_embed(&request, &embed_ctx) { diff --git a/rivet-cli/src/render/documents.rs b/rivet-cli/src/render/documents.rs index 7f0bae8..e701b23 100644 --- a/rivet-cli/src/render/documents.rs +++ b/rivet-cli/src/render/documents.rs @@ -175,6 +175,7 @@ pub(crate) fn render_document_detail(ctx: &RenderContext, id: &str) -> RenderRes schema: ctx.schema, graph, diagnostics: ctx.diagnostics, + baseline: None, }; rivet_core::embed::resolve_embed(req, &embed_ctx).map_err(|e| e.to_string()) }, diff --git a/rivet-core/src/embed.rs b/rivet-core/src/embed.rs index 7e8df24..a714065 100644 --- a/rivet-core/src/embed.rs +++ b/rivet-core/src/embed.rs @@ -80,6 +80,8 @@ pub struct EmbedContext<'a> { pub schema: &'a Schema, pub graph: &'a LinkGraph, pub diagnostics: &'a [Diagnostic], + /// Optional baseline snapshot for delta rendering (`delta=NAME` option). + pub baseline: Option<&'a crate::snapshot::Snapshot>, } impl<'a> EmbedContext<'a> { @@ -96,6 +98,7 @@ impl<'a> EmbedContext<'a> { schema: &EMPTY_SCHEMA, graph: &EMPTY_GRAPH, diagnostics: &[], + baseline: None, } } } diff --git a/rivet-core/src/export.rs b/rivet-core/src/export.rs index bb14042..07aa5c4 100644 --- a/rivet-core/src/export.rs +++ b/rivet-core/src/export.rs @@ -2202,6 +2202,7 @@ fn render_document_body_for_export( schema, graph, diagnostics, + baseline: None, }; match crate::embed::resolve_embed(req, &embed_ctx) { Ok(html) => { From 04c24583a1c5716d8a5e4849701392f69178fd99 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 17:25:39 -0400 Subject: [PATCH 4/7] style: cargo fmt --- .claude/settings.local.json | 4 +- rivet-cli/src/main.rs | 38 ++++++++++------- rivet-cli/tests/cli_commands.rs | 5 ++- rivet-core/src/document.rs | 54 +++++++++++++++++++---- rivet-core/src/embed.rs | 76 +++++++++++++++++++++------------ rivet-core/src/export.rs | 48 +++++++++++++-------- rivet-core/src/snapshot.rs | 27 ++++++------ rivet-core/tests/integration.rs | 9 +++- 8 files changed, 173 insertions(+), 88 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9474f29..3f4aa9e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,9 @@ "Bash(cargo test:*)", "Bash(git add:*)", "Bash(cargo clippy:*)", - "Bash(git branch:*)" + "Bash(git branch:*)", + "Bash(gh run:*)", + "Bash(cargo fmt:*)" ] } } diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 691cad9..3e9f0ce 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -4354,8 +4354,7 @@ fn cmd_snapshot_capture( .join(format!("{snap_name}.json")), }; - rivet_core::snapshot::write_to_file(&snap, &out_path) - .map_err(|e| anyhow::anyhow!("{e}"))?; + rivet_core::snapshot::write_to_file(&snap, &out_path).map_err(|e| anyhow::anyhow!("{e}"))?; eprintln!( "Snapshot captured: {} ({} artifacts, {:.1}% coverage, {} diagnostics)", @@ -4389,8 +4388,8 @@ fn cmd_snapshot_diff( } }; - let baseline = rivet_core::snapshot::read_from_file(&baseline_file) - .map_err(|e| anyhow::anyhow!("{e}"))?; + let baseline = + rivet_core::snapshot::read_from_file(&baseline_file).map_err(|e| anyhow::anyhow!("{e}"))?; // Capture current state let state = crate::serve::reload_state(&project_path, &schemas_dir, 0) @@ -4430,8 +4429,7 @@ fn cmd_snapshot_diff( match format { "json" => { - let json = serde_json::to_string_pretty(&delta) - .context("serializing delta")?; + let json = serde_json::to_string_pretty(&delta).context("serializing delta")?; println!("{json}"); } "markdown" => { @@ -4460,11 +4458,7 @@ fn cmd_snapshot_list(cli: &Cli) -> Result { let mut entries: Vec<_> = std::fs::read_dir(&snap_dir) .context("reading snapshots directory")? .filter_map(|e| e.ok()) - .filter(|e| { - e.path() - .extension() - .is_some_and(|ext| ext == "json") - }) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) .collect(); entries.sort_by_key(|e| e.file_name()); @@ -4532,7 +4526,11 @@ fn print_delta_text( "Delta: {} → {}", baseline.git_commit_short, delta.current_commit ); - println!(" Artifacts: {} ({})", baseline.stats.total as isize + delta.stats.total, sign(delta.stats.total)); + println!( + " Artifacts: {} ({})", + baseline.stats.total as isize + delta.stats.total, + sign(delta.stats.total) + ); for (t, &change) in &delta.stats.by_type { if change != 0 { println!(" {t}: {}", sign(change)); @@ -4592,14 +4590,22 @@ fn format_delta_markdown( md.push_str(&format!( "\n**{}** new diagnostic{}\n", delta.diagnostics.new_count, - if delta.diagnostics.new_count == 1 { "" } else { "s" }, + if delta.diagnostics.new_count == 1 { + "" + } else { + "s" + }, )); } if delta.diagnostics.resolved_count > 0 { md.push_str(&format!( "**{}** resolved diagnostic{}\n", delta.diagnostics.resolved_count, - if delta.diagnostics.resolved_count == 1 { "" } else { "s" }, + if delta.diagnostics.resolved_count == 1 { + "" + } else { + "s" + }, )); } md @@ -5397,8 +5403,8 @@ fn cmd_embed(cli: &Cli, query: &str, format: &str) -> Result { let state = crate::serve::reload_state(&project_path, &schemas_dir, 0) .context("loading project for embed")?; - let request = rivet_core::embed::EmbedRequest::parse(query) - .map_err(|e| anyhow::anyhow!("{e}"))?; + let request = + rivet_core::embed::EmbedRequest::parse(query).map_err(|e| anyhow::anyhow!("{e}"))?; let embed_ctx = rivet_core::embed::EmbedContext { store: &state.store, diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 5ed6df0..7b75488 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -670,7 +670,10 @@ fn snapshot_capture_writes_file() { let content = std::fs::read_to_string(&out_file).expect("read snapshot"); let parsed: serde_json::Value = serde_json::from_str(&content).expect("snapshot must be valid JSON"); - assert!(parsed.get("schema_version").is_some(), "must have schema_version"); + assert!( + parsed.get("schema_version").is_some(), + "must have schema_version" + ); assert!(parsed.get("stats").is_some(), "must have stats"); assert!(parsed.get("coverage").is_some(), "must have coverage"); assert!(parsed.get("diagnostics").is_some(), "must have diagnostics"); diff --git a/rivet-core/src/document.rs b/rivet-core/src/document.rs index da2a3ff..8fb7121 100644 --- a/rivet-core/src/document.rs +++ b/rivet-core/src/document.rs @@ -465,7 +465,13 @@ pub fn render_to_html( in_blockquote = false; } let text = &trimmed[level as usize + 1..]; - let text = resolve_inline(text, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); + let text = resolve_inline( + text, + &artifact_exists, + &artifact_info, + &document_exists, + &embed_resolver, + ); html.push_str(&format!("{text}\n")); continue; } @@ -504,8 +510,13 @@ pub fn render_to_html( // First row is the header html.push_str(""); for cell in &cells { - let text = - resolve_inline(cell, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); + let text = resolve_inline( + cell, + &artifact_exists, + &artifact_info, + &document_exists, + &embed_resolver, + ); html.push_str(&format!("")); } html.push_str("\n"); @@ -514,8 +525,13 @@ pub fn render_to_html( } else if table_header_done { html.push_str(""); for cell in &cells { - let text = - resolve_inline(cell, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); + let text = resolve_inline( + cell, + &artifact_exists, + &artifact_info, + &document_exists, + &embed_resolver, + ); html.push_str(&format!("")); } html.push_str("\n"); @@ -546,7 +562,13 @@ pub fn render_to_html( html.push_str("
"); in_blockquote = true; } - let text = resolve_inline(bq_text, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); + let text = resolve_inline( + bq_text, + &artifact_exists, + &artifact_info, + &document_exists, + &embed_resolver, + ); html.push_str(&format!("

{text}

")); continue; } @@ -608,7 +630,13 @@ pub fn render_to_html( html.push_str("
    \n"); in_ordered_list = true; } - let text = resolve_inline(rest, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); + let text = resolve_inline( + rest, + &artifact_exists, + &artifact_info, + &document_exists, + &embed_resolver, + ); html.push_str(&format!("
  1. {text}
  2. \n")); continue; } @@ -1847,13 +1875,21 @@ See frontmatter. |_| true, |_| None, |_| false, - |req| Ok(format!("
    stats:{}
    ", req.name)), + |req| { + Ok(format!( + "
    stats:{}
    ", + req.name + )) + }, ); assert!( html.contains("mock-stats"), "computed embed should be resolved via closure" ); - assert!(html.contains("stats:stats"), "should pass correct embed name"); + assert!( + html.contains("stats:stats"), + "should pass correct embed name" + ); } // SC-EMBED-3 diff --git a/rivet-core/src/embed.rs b/rivet-core/src/embed.rs index a714065..ecf6ac3 100644 --- a/rivet-core/src/embed.rs +++ b/rivet-core/src/embed.rs @@ -159,10 +159,7 @@ impl EmbedRequest { /// /// Returns the rendered HTML string, or an `EmbedError` for unknown/ /// malformed embeds (SC-EMBED-3: errors are visible, never empty). -pub fn resolve_embed( - request: &EmbedRequest, - ctx: &EmbedContext<'_>, -) -> Result { +pub fn resolve_embed(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> Result { match request.name.as_str() { "stats" => Ok(render_stats(request, ctx)), "coverage" => Ok(render_coverage(request, ctx)), @@ -409,10 +406,7 @@ fn render_diagnostics(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String Severity::Warning => "Warning", Severity::Info => "Info", }; - let artifact = diag - .artifact_id - .as_deref() - .unwrap_or("—"); + let artifact = diag.artifact_id.as_deref().unwrap_or("—"); let _ = writeln!( html, "
\ @@ -430,9 +424,18 @@ fn render_diagnostics(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String html.push_str("
{text}
{text}
\n"); // Summary footer - let errors = filtered.iter().filter(|d| d.severity == Severity::Error).count(); - let warnings = filtered.iter().filter(|d| d.severity == Severity::Warning).count(); - let infos = filtered.iter().filter(|d| d.severity == Severity::Info).count(); + let errors = filtered + .iter() + .filter(|d| d.severity == Severity::Error) + .count(); + let warnings = filtered + .iter() + .filter(|d| d.severity == Severity::Warning) + .count(); + let infos = filtered + .iter() + .filter(|d| d.severity == Severity::Info) + .count(); let _ = writeln!( html, "

{} issue{}: {} error{}, {} warning{}, {} info

", @@ -475,7 +478,8 @@ fn render_matrix(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { .as_deref() .or(rule.required_backlink.as_deref()) .unwrap_or(""); - let m = matrix::compute_matrix(ctx.store, ctx.graph, from, to, link_type, direction); + let m = + matrix::compute_matrix(ctx.store, ctx.graph, from, to, link_type, direction); html.push_str(&render_matrix_table(&m)); } else { // No rule found — try forward with auto-detected link type. @@ -519,11 +523,7 @@ fn render_matrix(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { link_type, direction, ); - let _ = writeln!( - html, - "

{}

", - document::html_escape(&rule.name), - ); + let _ = writeln!(html, "

{}

", document::html_escape(&rule.name),); html.push_str(&render_matrix_table(&m)); } } @@ -606,9 +606,10 @@ fn find_rule_for_types<'a>( from: &str, to: &str, ) -> Option<&'a crate::schema::TraceabilityRule> { - ctx.schema.traceability_rules.iter().find(|r| { - r.source_type == from && r.target_types.iter().any(|t| t == to) - }) + ctx.schema + .traceability_rules + .iter() + .find(|r| r.source_type == from && r.target_types.iter().any(|t| t == to)) } /// Auto-detect link type between two artifact types by scanning the graph. @@ -752,8 +753,14 @@ mod tests { let ctx = EmbedContext::empty(); let req = EmbedRequest::parse("coverage").unwrap(); let html = resolve_embed(&req, &ctx).unwrap(); - assert!(html.contains("\n"); out.push_str(&body_html); @@ -2196,22 +2203,28 @@ fn render_document_body_for_export( // Get the rendered HTML from the document module. // Computed embeds are resolved with provenance stamps (SC-EMBED-4). - let raw_html = document::render_to_html(doc, artifact_exists, artifact_info, |_| false, |req| { - let embed_ctx = crate::embed::EmbedContext { - store, - schema, - graph, - diagnostics, - baseline: None, - }; - match crate::embed::resolve_embed(req, &embed_ctx) { - Ok(html) => { - let stamp = crate::embed::render_provenance_stamp(commit_short, is_dirty); - Ok(format!("{html}{stamp}")) + let raw_html = document::render_to_html( + doc, + artifact_exists, + artifact_info, + |_| false, + |req| { + let embed_ctx = crate::embed::EmbedContext { + store, + schema, + graph, + diagnostics, + baseline: None, + }; + match crate::embed::resolve_embed(req, &embed_ctx) { + Ok(html) => { + let stamp = crate::embed::render_provenance_stamp(commit_short, is_dirty); + Ok(format!("{html}{stamp}")) + } + Err(e) => Err(e.to_string()), } - Err(e) => Err(e.to_string()), - } - }); + }, + ); // Post-process: rewrite the HTMX-style artifact links to static links. // The document renderer produces: @@ -3161,7 +3174,8 @@ mod tests { None, ) .unwrap(); - let html = render_document_page(&doc, &store, &graph, &_schema, &[], "abc1234", false, &cfg); + let html = + render_document_page(&doc, &store, &graph, &_schema, &[], "abc1234", false, &cfg); assert!(html.contains("DOC-001")); assert!(html.contains("Design Doc")); assert!(html.contains("Design")); diff --git a/rivet-core/src/snapshot.rs b/rivet-core/src/snapshot.rs index 792ab9a..9f433f5 100644 --- a/rivet-core/src/snapshot.rs +++ b/rivet-core/src/snapshot.rs @@ -90,12 +90,7 @@ pub struct GitContext { } /// Capture a snapshot from the current project state. -pub fn capture( - store: &Store, - schema: &Schema, - graph: &LinkGraph, - git: &GitContext, -) -> Snapshot { +pub fn capture(store: &Store, schema: &Schema, graph: &LinkGraph, git: &GitContext) -> Snapshot { let diagnostics_vec = validate::validate(store, schema, graph); let coverage_report = coverage::compute_coverage(store, schema, graph); @@ -243,11 +238,7 @@ pub fn compute_delta(baseline: &Snapshot, current: &Snapshot) -> SnapshotDelta { .rules .iter() .map(|r| { - let base = baseline - .coverage - .rules - .iter() - .find(|b| b.rule == r.rule); + let base = baseline.coverage.rules.iter().find(|b| b.rule == r.rule); CoverageRuleDelta { rule: r.rule.clone(), covered: r.covered as isize - base.map_or(0, |b| b.covered as isize), @@ -299,8 +290,8 @@ pub fn compute_delta(baseline: &Snapshot, current: &Snapshot) -> SnapshotDelta { /// Write a snapshot to a JSON file. pub fn write_to_file(snapshot: &Snapshot, path: &std::path::Path) -> Result<(), String> { - let json = serde_json::to_string_pretty(snapshot) - .map_err(|e| format!("serializing snapshot: {e}"))?; + let json = + serde_json::to_string_pretty(snapshot).map_err(|e| format!("serializing snapshot: {e}"))?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("creating directory {}: {e}", parent.display()))?; @@ -380,7 +371,10 @@ mod tests { let mut git = dummy_git(); git.dirty = true; let snap = capture(&store, &schema, &graph, &git); - assert!(snap.git_dirty, "snapshot must record dirty tree (SC-EMBED-2)"); + assert!( + snap.git_dirty, + "snapshot must record dirty tree (SC-EMBED-2)" + ); } #[test] @@ -389,6 +383,9 @@ mod tests { let schema = Schema::merge(&[]); let graph = LinkGraph::build(&store, &schema); let snap = capture(&store, &schema, &graph, &dummy_git()); - assert_eq!(snap.schema_version, SCHEMA_VERSION, "must include schema_version (SC-EMBED-6)"); + assert_eq!( + snap.schema_version, SCHEMA_VERSION, + "must include schema_version (SC-EMBED-6)" + ); } } diff --git a/rivet-core/tests/integration.rs b/rivet-core/tests/integration.rs index 6817ff4..0cee70f 100644 --- a/rivet-core/tests/integration.rs +++ b/rivet-core/tests/integration.rs @@ -1115,8 +1115,13 @@ fn document_with_aadl_block_renders_placeholder() { let doc_content = "---\nid: DOC-ARCH\ntitle: System Architecture\n---\n\n## Flight Control Architecture\n\nThe system uses the following AADL architecture:\n\n```aadl\nroot: FlightControl::Controller.Basic\n```\n\nThis design satisfies [[SYSREQ-001]].\n"; let doc = rivet_core::document::parse_document(doc_content, None).unwrap(); - let html = - rivet_core::document::render_to_html(&doc, |id| id == "SYSREQ-001", |_| None, |_| false, |_| Ok(String::new())); + let html = rivet_core::document::render_to_html( + &doc, + |id| id == "SYSREQ-001", + |_| None, + |_| false, + |_| Ok(String::new()), + ); // AADL block becomes a diagram placeholder assert!(html.contains("class=\"aadl-diagram\"")); From d93efb8c0c75e32db93405725477779f14abfc0e Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 17:29:56 -0400 Subject: [PATCH 5/7] feat(api): add /api/v1/guide endpoint and rivet guide CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4: self-documenting schema guide for AI agents and developers: - /api/v1/guide — JSON endpoint with artifact types, fields, link types, traceability rules, embed syntax reference, commit trailers, and common mistakes - rivet guide --format json|text — CLI equivalent for scripting - Refreshes from current AppState on each request (SC-EMBED-5) 2 CLI integration tests + 1 serve integration test. --- rivet-cli/src/main.rs | 122 ++++++++++++ rivet-cli/src/serve/api.rs | 265 +++++++++++++++++++++++++++ rivet-cli/src/serve/mod.rs | 1 + rivet-cli/tests/cli_commands.rs | 84 +++++++++ rivet-cli/tests/serve_integration.rs | 17 ++ 5 files changed, 489 insertions(+) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 3e9f0ce..8ddc827 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -589,6 +589,13 @@ enum Command { file: PathBuf, }, + /// Show project schema guide for AI agents and developers + Guide { + /// Output format: "json" (default) or "text" + #[arg(short, long, default_value = "json")] + format: String, + }, + /// Resolve a computed embed and print the result Embed { /// Embed query string, e.g. "stats:types" or "coverage:rule-name" @@ -951,6 +958,7 @@ fn run(cli: Cli) -> Result { ), Command::Remove { id, force } => cmd_remove(&cli, id, *force), Command::Batch { file } => cmd_batch(&cli, file), + Command::Guide { format } => cmd_guide(&cli, format), Command::Embed { query, format } => cmd_embed(&cli, query, format), } } @@ -5393,6 +5401,120 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { Ok(true) } +fn cmd_guide(cli: &Cli, format: &str) -> Result { + let ctx = ProjectContext::load(cli)?; + + if format == "json" { + let mut types = Vec::new(); + for t in ctx.schema.artifact_types.values() { + let mut type_obj = serde_json::Map::new(); + type_obj.insert("name".into(), serde_json::Value::String(t.name.clone())); + type_obj.insert( + "description".into(), + serde_json::Value::String(t.description.clone()), + ); + type_obj.insert( + "fields".into(), + serde_json::to_value(&t.fields).unwrap_or_default(), + ); + type_obj.insert( + "link_fields".into(), + serde_json::to_value(&t.link_fields).unwrap_or_default(), + ); + types.push(serde_json::Value::Object(type_obj)); + } + + let links: Vec = ctx + .schema + .link_types + .values() + .map(|l| serde_json::to_value(l).unwrap_or_default()) + .collect(); + + let rules: Vec = ctx + .schema + .traceability_rules + .iter() + .map(|r| serde_json::to_value(r).unwrap_or_default()) + .collect(); + + let guide = serde_json::json!({ + "command": "guide", + "artifact_types": types, + "link_types": links, + "traceability_rules": rules, + "embed_syntax": [ + {"pattern": "{{stats}}", "args": ["types", "status", "validation"]}, + {"pattern": "{{coverage}}", "args": ["RULE_NAME"]}, + {"pattern": "{{diagnostics}}", "args": ["error", "warning", "info"]}, + {"pattern": "{{matrix}}", "args": ["FROM_TYPE:TO_TYPE"]}, + {"pattern": "{{artifact:ID}}", "args": ["ID"]}, + {"pattern": "{{table:TYPE:FIELDS}}", "args": ["TYPE", "FIELD,..."]}, + ], + "commit_trailers": ["Implements", "Fixes", "Verifies", "Satisfies", "Refs"], + }); + + println!("{}", serde_json::to_string_pretty(&guide)?); + } else { + // Text format + println!("=== Artifact Types ===\n"); + for t in ctx.schema.artifact_types.values() { + println!(" {} — {}", t.name, t.description); + for f in &t.fields { + let req = if f.required { " (required)" } else { "" }; + println!(" field: {} [{}]{req}", f.name, f.field_type); + } + for l in &t.link_fields { + let req = if l.required { " (required)" } else { "" }; + println!( + " link: {} → {} [{}]{req}", + l.name, + l.target_types.join(", "), + l.link_type, + ); + } + println!(); + } + + println!("=== Link Types ===\n"); + for l in ctx.schema.link_types.values() { + let inv = l + .inverse + .as_deref() + .map(|i| format!(" (inverse: {i})")) + .unwrap_or_default(); + println!(" {}{inv} — {}", l.name, l.description); + } + + println!("\n=== Traceability Rules ===\n"); + for r in &ctx.schema.traceability_rules { + let link = r + .required_link + .as_deref() + .or(r.required_backlink.as_deref()) + .unwrap_or("?"); + println!( + " {} [{:?}]: {} → {} via {}", + r.name, + r.severity, + r.source_type, + r.target_types.join(", "), + link, + ); + } + + println!("\n=== Embed Syntax ===\n"); + println!(" {{{{stats}}}} Project statistics"); + println!(" {{{{coverage}}}} Traceability coverage"); + println!(" {{{{diagnostics}}}} Validation issues"); + println!(" {{{{matrix}}}} Traceability matrix"); + println!(" {{{{artifact:ID}}}} Inline artifact card"); + println!(" {{{{table:TYPE:FIELDS}}}} Filtered table"); + } + + Ok(true) +} + fn cmd_embed(cli: &Cli, query: &str, format: &str) -> Result { let schemas_dir = resolve_schemas_dir(cli); let project_path = cli diff --git a/rivet-cli/src/serve/api.rs b/rivet-cli/src/serve/api.rs index 4a46c4c..eb75529 100644 --- a/rivet-cli/src/serve/api.rs +++ b/rivet-cli/src/serve/api.rs @@ -591,3 +591,268 @@ fn matches_filters(artifact: &rivet_core::model::Artifact, params: &ArtifactsPar } true } + +// ── Guide ────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct GuideResponse { + artifact_types: Vec, + link_types: Vec, + traceability_rules: Vec, + embed_syntax: Vec, + commit_message: GuideCommitMessage, + common_mistakes: Vec, +} + +#[derive(Serialize)] +struct GuideArtifactType { + name: String, + description: String, + required_fields: Vec, + optional_fields: Vec, + required_links: Vec, + valid_statuses: Vec, + example_yaml: String, +} + +#[derive(Serialize)] +struct GuideField { + name: String, + #[serde(rename = "type")] + field_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + allowed_values: Option>, +} + +#[derive(Serialize)] +struct GuideRequiredLink { + field: String, + link_type: String, + target_types: Vec, + cardinality: String, +} + +#[derive(Serialize)] +struct GuideLinkType { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + inverse: Option, + description: String, +} + +#[derive(Serialize)] +struct GuideRule { + name: String, + description: String, + source_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + required_link: Option, + #[serde(skip_serializing_if = "Option::is_none")] + required_backlink: Option, + target_types: Vec, + severity: String, +} + +#[derive(Serialize)] +struct GuideEmbed { + pattern: String, + description: String, + args: Vec, + options: Vec, +} + +#[derive(Serialize)] +struct GuideCommitMessage { + trailers: Vec, + example: String, +} + +#[derive(Serialize)] +struct GuideMistake { + rule: String, + fix: String, +} + +pub(crate) async fn guide(State(state): State) -> impl IntoResponse { + let guard = state.read().await; + let schema = &guard.schema; + + // Artifact types + let artifact_types: Vec = schema + .artifact_types + .values() + .map(|t| { + let required_fields: Vec = t + .fields + .iter() + .filter(|f| f.required) + .map(|f| GuideField { + name: f.name.clone(), + field_type: f.field_type.clone(), + description: f.description.clone(), + allowed_values: f.allowed_values.clone(), + }) + .collect(); + let optional_fields: Vec = t + .fields + .iter() + .filter(|f| !f.required) + .map(|f| GuideField { + name: f.name.clone(), + field_type: f.field_type.clone(), + description: f.description.clone(), + allowed_values: f.allowed_values.clone(), + }) + .collect(); + let required_links: Vec = t + .link_fields + .iter() + .filter(|l| l.required) + .map(|l| GuideRequiredLink { + field: l.name.clone(), + link_type: l.link_type.clone(), + target_types: l.target_types.clone(), + cardinality: format!("{:?}", l.cardinality).to_lowercase(), + }) + .collect(); + + // Collect valid statuses from fields with allowed_values named "status" + let valid_statuses: Vec = t + .fields + .iter() + .find(|f| f.name == "status") + .and_then(|f| f.allowed_values.clone()) + .unwrap_or_default(); + + // Generate example YAML + let example_yaml = format!( + "- id: {prefix}-001\n type: {name}\n title: Example {name}\n status: draft", + prefix = t + .name + .chars() + .filter(|c| c.is_uppercase()) + .collect::(), + name = t.name, + ); + + GuideArtifactType { + name: t.name.clone(), + description: t.description.clone(), + required_fields, + optional_fields, + required_links, + valid_statuses, + example_yaml, + } + }) + .collect(); + + // Link types + let link_types: Vec = schema + .link_types + .values() + .map(|l| GuideLinkType { + name: l.name.clone(), + inverse: l.inverse.clone(), + description: l.description.clone(), + }) + .collect(); + + // Traceability rules + let traceability_rules: Vec = schema + .traceability_rules + .iter() + .map(|r| GuideRule { + name: r.name.clone(), + description: r.description.clone(), + source_type: r.source_type.clone(), + required_link: r.required_link.clone(), + required_backlink: r.required_backlink.clone(), + target_types: r.target_types.clone(), + severity: format!("{:?}", r.severity).to_lowercase(), + }) + .collect(); + + // Embed syntax reference + let embed_syntax = vec![ + GuideEmbed { + pattern: "{{stats}}".into(), + description: "Project statistics table".into(), + args: vec!["types".into(), "status".into(), "validation".into()], + options: vec!["delta=BASELINE".into()], + }, + GuideEmbed { + pattern: "{{coverage}}".into(), + description: "Traceability coverage with percentage bars".into(), + args: vec!["RULE_NAME".into()], + options: vec!["delta=BASELINE".into()], + }, + GuideEmbed { + pattern: "{{diagnostics}}".into(), + description: "Validation issues table".into(), + args: vec!["error".into(), "warning".into(), "info".into()], + options: vec!["delta=BASELINE".into()], + }, + GuideEmbed { + pattern: "{{matrix}}".into(), + description: "Inline traceability matrix".into(), + args: vec!["FROM_TYPE:TO_TYPE".into()], + options: vec![], + }, + GuideEmbed { + pattern: "{{artifact:ID}}".into(), + description: "Embed artifact card inline".into(), + args: vec!["ID".into()], + options: vec![ + "default".into(), + "full".into(), + "links".into(), + "upstream:N".into(), + "downstream:N".into(), + ], + }, + GuideEmbed { + pattern: "{{table:TYPE:FIELDS}}".into(), + description: "Filtered artifact table".into(), + args: vec!["TYPE".into(), "FIELD,...".into()], + options: vec![], + }, + ]; + + let commit_message = GuideCommitMessage { + trailers: vec![ + "Implements".into(), + "Fixes".into(), + "Verifies".into(), + "Satisfies".into(), + "Refs".into(), + ], + example: "feat: add STPA loss scenarios\n\nImplements: FEAT-042".into(), + }; + + let common_mistakes = vec![ + GuideMistake { + rule: "Every requirement needs an 'implements' link to at least one feature".into(), + fix: "Add links: [{type: implements, target: FEAT-xxx}]".into(), + }, + GuideMistake { + rule: "Use the exact link type name from the schema, not synonyms".into(), + fix: "Check /api/v1/guide for valid link_types".into(), + }, + GuideMistake { + rule: "Artifacts without status appear as 'unset' in reports".into(), + fix: "Add status: draft for new artifacts".into(), + }, + ]; + + Json(GuideResponse { + artifact_types, + link_types, + traceability_rules, + embed_syntax, + commit_message, + common_mistakes, + }) +} diff --git a/rivet-cli/src/serve/mod.rs b/rivet-cli/src/serve/mod.rs index e7b2ee6..514c43a 100644 --- a/rivet-cli/src/serve/mod.rs +++ b/rivet-cli/src/serve/mod.rs @@ -559,6 +559,7 @@ pub async fn run( .route("/artifacts", get(api::artifacts)) .route("/diagnostics", get(api::diagnostics)) .route("/coverage", get(api::coverage)) + .route("/guide", get(api::guide)) .layer(CorsLayer::permissive()) .with_state(state.clone()), ) diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 7b75488..f004cda 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -640,6 +640,90 @@ fn embed_coverage() { ); } +// ── rivet guide ──────────────────────────────────────────────────────── + +/// `rivet guide --format json` produces valid JSON with artifact_types. +#[test] +fn guide_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "guide", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet guide --format json"); + + assert!( + output.status.success(), + "rivet guide --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("guide JSON must be valid"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("guide"), + ); + assert!( + parsed + .get("artifact_types") + .and_then(|v| v.as_array()) + .is_some(), + "guide JSON must contain artifact_types" + ); + assert!( + parsed + .get("link_types") + .and_then(|v| v.as_array()) + .is_some(), + "guide JSON must contain link_types" + ); + assert!( + parsed + .get("traceability_rules") + .and_then(|v| v.as_array()) + .is_some(), + "guide JSON must contain traceability_rules" + ); +} + +/// `rivet guide --format text` prints readable schema info. +#[test] +fn guide_text() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "guide", + "--format", + "text", + ]) + .output() + .expect("failed to execute rivet guide --format text"); + + assert!( + output.status.success(), + "rivet guide --format text must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Artifact Types"), + "text output must contain 'Artifact Types'" + ); + assert!( + stdout.contains("Link Types"), + "text output must contain 'Link Types'" + ); +} + // ── rivet snapshot ───────────────────────────────────────────────────── /// `rivet snapshot capture` writes a JSON snapshot file. diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index 3936de5..8680090 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -673,3 +673,20 @@ fn embed_api_stats_endpoint() { child.kill().ok(); child.wait().ok(); } + +/// `/api/v1/guide` returns JSON with artifact_types and link_types. +#[test] +fn guide_api_endpoint() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/guide", false); + assert_eq!(status, 200, "/api/v1/guide should respond 200"); + assert!( + body.contains("artifact_types") && body.contains("link_types"), + "guide API should contain schema info. Got: {}", + &body[..body.len().min(200)] + ); + + child.kill().ok(); + child.wait().ok(); +} From e04f0d121384b75abd7a171af71e0c7409264710 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 17:37:50 -0400 Subject: [PATCH 6/7] Revert "feat(api): add /api/v1/guide endpoint and rivet guide CLI" This reverts commit d93efb8c0c75e32db93405725477779f14abfc0e. --- rivet-cli/src/main.rs | 122 ------------ rivet-cli/src/serve/api.rs | 265 --------------------------- rivet-cli/src/serve/mod.rs | 1 - rivet-cli/tests/cli_commands.rs | 84 --------- rivet-cli/tests/serve_integration.rs | 17 -- 5 files changed, 489 deletions(-) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 8ddc827..3e9f0ce 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -589,13 +589,6 @@ enum Command { file: PathBuf, }, - /// Show project schema guide for AI agents and developers - Guide { - /// Output format: "json" (default) or "text" - #[arg(short, long, default_value = "json")] - format: String, - }, - /// Resolve a computed embed and print the result Embed { /// Embed query string, e.g. "stats:types" or "coverage:rule-name" @@ -958,7 +951,6 @@ fn run(cli: Cli) -> Result { ), Command::Remove { id, force } => cmd_remove(&cli, id, *force), Command::Batch { file } => cmd_batch(&cli, file), - Command::Guide { format } => cmd_guide(&cli, format), Command::Embed { query, format } => cmd_embed(&cli, query, format), } } @@ -5401,120 +5393,6 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { Ok(true) } -fn cmd_guide(cli: &Cli, format: &str) -> Result { - let ctx = ProjectContext::load(cli)?; - - if format == "json" { - let mut types = Vec::new(); - for t in ctx.schema.artifact_types.values() { - let mut type_obj = serde_json::Map::new(); - type_obj.insert("name".into(), serde_json::Value::String(t.name.clone())); - type_obj.insert( - "description".into(), - serde_json::Value::String(t.description.clone()), - ); - type_obj.insert( - "fields".into(), - serde_json::to_value(&t.fields).unwrap_or_default(), - ); - type_obj.insert( - "link_fields".into(), - serde_json::to_value(&t.link_fields).unwrap_or_default(), - ); - types.push(serde_json::Value::Object(type_obj)); - } - - let links: Vec = ctx - .schema - .link_types - .values() - .map(|l| serde_json::to_value(l).unwrap_or_default()) - .collect(); - - let rules: Vec = ctx - .schema - .traceability_rules - .iter() - .map(|r| serde_json::to_value(r).unwrap_or_default()) - .collect(); - - let guide = serde_json::json!({ - "command": "guide", - "artifact_types": types, - "link_types": links, - "traceability_rules": rules, - "embed_syntax": [ - {"pattern": "{{stats}}", "args": ["types", "status", "validation"]}, - {"pattern": "{{coverage}}", "args": ["RULE_NAME"]}, - {"pattern": "{{diagnostics}}", "args": ["error", "warning", "info"]}, - {"pattern": "{{matrix}}", "args": ["FROM_TYPE:TO_TYPE"]}, - {"pattern": "{{artifact:ID}}", "args": ["ID"]}, - {"pattern": "{{table:TYPE:FIELDS}}", "args": ["TYPE", "FIELD,..."]}, - ], - "commit_trailers": ["Implements", "Fixes", "Verifies", "Satisfies", "Refs"], - }); - - println!("{}", serde_json::to_string_pretty(&guide)?); - } else { - // Text format - println!("=== Artifact Types ===\n"); - for t in ctx.schema.artifact_types.values() { - println!(" {} — {}", t.name, t.description); - for f in &t.fields { - let req = if f.required { " (required)" } else { "" }; - println!(" field: {} [{}]{req}", f.name, f.field_type); - } - for l in &t.link_fields { - let req = if l.required { " (required)" } else { "" }; - println!( - " link: {} → {} [{}]{req}", - l.name, - l.target_types.join(", "), - l.link_type, - ); - } - println!(); - } - - println!("=== Link Types ===\n"); - for l in ctx.schema.link_types.values() { - let inv = l - .inverse - .as_deref() - .map(|i| format!(" (inverse: {i})")) - .unwrap_or_default(); - println!(" {}{inv} — {}", l.name, l.description); - } - - println!("\n=== Traceability Rules ===\n"); - for r in &ctx.schema.traceability_rules { - let link = r - .required_link - .as_deref() - .or(r.required_backlink.as_deref()) - .unwrap_or("?"); - println!( - " {} [{:?}]: {} → {} via {}", - r.name, - r.severity, - r.source_type, - r.target_types.join(", "), - link, - ); - } - - println!("\n=== Embed Syntax ===\n"); - println!(" {{{{stats}}}} Project statistics"); - println!(" {{{{coverage}}}} Traceability coverage"); - println!(" {{{{diagnostics}}}} Validation issues"); - println!(" {{{{matrix}}}} Traceability matrix"); - println!(" {{{{artifact:ID}}}} Inline artifact card"); - println!(" {{{{table:TYPE:FIELDS}}}} Filtered table"); - } - - Ok(true) -} - fn cmd_embed(cli: &Cli, query: &str, format: &str) -> Result { let schemas_dir = resolve_schemas_dir(cli); let project_path = cli diff --git a/rivet-cli/src/serve/api.rs b/rivet-cli/src/serve/api.rs index eb75529..4a46c4c 100644 --- a/rivet-cli/src/serve/api.rs +++ b/rivet-cli/src/serve/api.rs @@ -591,268 +591,3 @@ fn matches_filters(artifact: &rivet_core::model::Artifact, params: &ArtifactsPar } true } - -// ── Guide ────────────────────────────────────────────────────────────── - -#[derive(Serialize)] -struct GuideResponse { - artifact_types: Vec, - link_types: Vec, - traceability_rules: Vec, - embed_syntax: Vec, - commit_message: GuideCommitMessage, - common_mistakes: Vec, -} - -#[derive(Serialize)] -struct GuideArtifactType { - name: String, - description: String, - required_fields: Vec, - optional_fields: Vec, - required_links: Vec, - valid_statuses: Vec, - example_yaml: String, -} - -#[derive(Serialize)] -struct GuideField { - name: String, - #[serde(rename = "type")] - field_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - allowed_values: Option>, -} - -#[derive(Serialize)] -struct GuideRequiredLink { - field: String, - link_type: String, - target_types: Vec, - cardinality: String, -} - -#[derive(Serialize)] -struct GuideLinkType { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - inverse: Option, - description: String, -} - -#[derive(Serialize)] -struct GuideRule { - name: String, - description: String, - source_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - required_link: Option, - #[serde(skip_serializing_if = "Option::is_none")] - required_backlink: Option, - target_types: Vec, - severity: String, -} - -#[derive(Serialize)] -struct GuideEmbed { - pattern: String, - description: String, - args: Vec, - options: Vec, -} - -#[derive(Serialize)] -struct GuideCommitMessage { - trailers: Vec, - example: String, -} - -#[derive(Serialize)] -struct GuideMistake { - rule: String, - fix: String, -} - -pub(crate) async fn guide(State(state): State) -> impl IntoResponse { - let guard = state.read().await; - let schema = &guard.schema; - - // Artifact types - let artifact_types: Vec = schema - .artifact_types - .values() - .map(|t| { - let required_fields: Vec = t - .fields - .iter() - .filter(|f| f.required) - .map(|f| GuideField { - name: f.name.clone(), - field_type: f.field_type.clone(), - description: f.description.clone(), - allowed_values: f.allowed_values.clone(), - }) - .collect(); - let optional_fields: Vec = t - .fields - .iter() - .filter(|f| !f.required) - .map(|f| GuideField { - name: f.name.clone(), - field_type: f.field_type.clone(), - description: f.description.clone(), - allowed_values: f.allowed_values.clone(), - }) - .collect(); - let required_links: Vec = t - .link_fields - .iter() - .filter(|l| l.required) - .map(|l| GuideRequiredLink { - field: l.name.clone(), - link_type: l.link_type.clone(), - target_types: l.target_types.clone(), - cardinality: format!("{:?}", l.cardinality).to_lowercase(), - }) - .collect(); - - // Collect valid statuses from fields with allowed_values named "status" - let valid_statuses: Vec = t - .fields - .iter() - .find(|f| f.name == "status") - .and_then(|f| f.allowed_values.clone()) - .unwrap_or_default(); - - // Generate example YAML - let example_yaml = format!( - "- id: {prefix}-001\n type: {name}\n title: Example {name}\n status: draft", - prefix = t - .name - .chars() - .filter(|c| c.is_uppercase()) - .collect::(), - name = t.name, - ); - - GuideArtifactType { - name: t.name.clone(), - description: t.description.clone(), - required_fields, - optional_fields, - required_links, - valid_statuses, - example_yaml, - } - }) - .collect(); - - // Link types - let link_types: Vec = schema - .link_types - .values() - .map(|l| GuideLinkType { - name: l.name.clone(), - inverse: l.inverse.clone(), - description: l.description.clone(), - }) - .collect(); - - // Traceability rules - let traceability_rules: Vec = schema - .traceability_rules - .iter() - .map(|r| GuideRule { - name: r.name.clone(), - description: r.description.clone(), - source_type: r.source_type.clone(), - required_link: r.required_link.clone(), - required_backlink: r.required_backlink.clone(), - target_types: r.target_types.clone(), - severity: format!("{:?}", r.severity).to_lowercase(), - }) - .collect(); - - // Embed syntax reference - let embed_syntax = vec![ - GuideEmbed { - pattern: "{{stats}}".into(), - description: "Project statistics table".into(), - args: vec!["types".into(), "status".into(), "validation".into()], - options: vec!["delta=BASELINE".into()], - }, - GuideEmbed { - pattern: "{{coverage}}".into(), - description: "Traceability coverage with percentage bars".into(), - args: vec!["RULE_NAME".into()], - options: vec!["delta=BASELINE".into()], - }, - GuideEmbed { - pattern: "{{diagnostics}}".into(), - description: "Validation issues table".into(), - args: vec!["error".into(), "warning".into(), "info".into()], - options: vec!["delta=BASELINE".into()], - }, - GuideEmbed { - pattern: "{{matrix}}".into(), - description: "Inline traceability matrix".into(), - args: vec!["FROM_TYPE:TO_TYPE".into()], - options: vec![], - }, - GuideEmbed { - pattern: "{{artifact:ID}}".into(), - description: "Embed artifact card inline".into(), - args: vec!["ID".into()], - options: vec![ - "default".into(), - "full".into(), - "links".into(), - "upstream:N".into(), - "downstream:N".into(), - ], - }, - GuideEmbed { - pattern: "{{table:TYPE:FIELDS}}".into(), - description: "Filtered artifact table".into(), - args: vec!["TYPE".into(), "FIELD,...".into()], - options: vec![], - }, - ]; - - let commit_message = GuideCommitMessage { - trailers: vec![ - "Implements".into(), - "Fixes".into(), - "Verifies".into(), - "Satisfies".into(), - "Refs".into(), - ], - example: "feat: add STPA loss scenarios\n\nImplements: FEAT-042".into(), - }; - - let common_mistakes = vec![ - GuideMistake { - rule: "Every requirement needs an 'implements' link to at least one feature".into(), - fix: "Add links: [{type: implements, target: FEAT-xxx}]".into(), - }, - GuideMistake { - rule: "Use the exact link type name from the schema, not synonyms".into(), - fix: "Check /api/v1/guide for valid link_types".into(), - }, - GuideMistake { - rule: "Artifacts without status appear as 'unset' in reports".into(), - fix: "Add status: draft for new artifacts".into(), - }, - ]; - - Json(GuideResponse { - artifact_types, - link_types, - traceability_rules, - embed_syntax, - commit_message, - common_mistakes, - }) -} diff --git a/rivet-cli/src/serve/mod.rs b/rivet-cli/src/serve/mod.rs index 514c43a..e7b2ee6 100644 --- a/rivet-cli/src/serve/mod.rs +++ b/rivet-cli/src/serve/mod.rs @@ -559,7 +559,6 @@ pub async fn run( .route("/artifacts", get(api::artifacts)) .route("/diagnostics", get(api::diagnostics)) .route("/coverage", get(api::coverage)) - .route("/guide", get(api::guide)) .layer(CorsLayer::permissive()) .with_state(state.clone()), ) diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index f004cda..7b75488 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -640,90 +640,6 @@ fn embed_coverage() { ); } -// ── rivet guide ──────────────────────────────────────────────────────── - -/// `rivet guide --format json` produces valid JSON with artifact_types. -#[test] -fn guide_json() { - let output = Command::new(rivet_bin()) - .args([ - "--project", - project_root().to_str().unwrap(), - "guide", - "--format", - "json", - ]) - .output() - .expect("failed to execute rivet guide --format json"); - - assert!( - output.status.success(), - "rivet guide --format json must exit 0. stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("guide JSON must be valid"); - - assert_eq!( - parsed.get("command").and_then(|v| v.as_str()), - Some("guide"), - ); - assert!( - parsed - .get("artifact_types") - .and_then(|v| v.as_array()) - .is_some(), - "guide JSON must contain artifact_types" - ); - assert!( - parsed - .get("link_types") - .and_then(|v| v.as_array()) - .is_some(), - "guide JSON must contain link_types" - ); - assert!( - parsed - .get("traceability_rules") - .and_then(|v| v.as_array()) - .is_some(), - "guide JSON must contain traceability_rules" - ); -} - -/// `rivet guide --format text` prints readable schema info. -#[test] -fn guide_text() { - let output = Command::new(rivet_bin()) - .args([ - "--project", - project_root().to_str().unwrap(), - "guide", - "--format", - "text", - ]) - .output() - .expect("failed to execute rivet guide --format text"); - - assert!( - output.status.success(), - "rivet guide --format text must exit 0. stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("Artifact Types"), - "text output must contain 'Artifact Types'" - ); - assert!( - stdout.contains("Link Types"), - "text output must contain 'Link Types'" - ); -} - // ── rivet snapshot ───────────────────────────────────────────────────── /// `rivet snapshot capture` writes a JSON snapshot file. diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index 8680090..3936de5 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -673,20 +673,3 @@ fn embed_api_stats_endpoint() { child.kill().ok(); child.wait().ok(); } - -/// `/api/v1/guide` returns JSON with artifact_types and link_types. -#[test] -fn guide_api_endpoint() { - let (mut child, port) = start_server(); - - let (status, body, _headers) = fetch(port, "/api/v1/guide", false); - assert_eq!(status, 200, "/api/v1/guide should respond 200"); - assert!( - body.contains("artifact_types") && body.contains("link_types"), - "guide API should contain schema info. Got: {}", - &body[..body.len().min(200)] - ); - - child.kill().ok(); - child.wait().ok(); -} From b4e3dfc6895a936e6298119d6fb51ac9acd29541 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 17:40:48 -0400 Subject: [PATCH 7/7] docs: add computed embed syntax reference to documents topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a separate guide endpoint (reverted — duplicated rivet schema and rivet context), add the embed syntax documentation to the existing 'documents' topic in rivet docs where it naturally belongs. Covers: {{stats}}, {{coverage}}, {{diagnostics}}, {{matrix}}, {{artifact:ID}}, {{links:ID}}, {{table:TYPE:FIELDS}}, error handling, and HTML export provenance. --- rivet-cli/src/docs.rs | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index 1907d84..9d7953a 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -513,6 +513,60 @@ Headings (`##`, `###`, etc.) are parsed into sections. Documents with more than two sections automatically get a table of contents in the dashboard. Section-level artifact reference counts are shown in the TOC. +## Computed Embeds + +Use `{{name}}` syntax to embed computed project data inline in documents: + +### Artifact embeds (legacy) + +```markdown +{{artifact:REQ-001}} — inline artifact card +{{artifact:REQ-001:full}} — full card with description, tags, links +{{links:REQ-001}} — incoming/outgoing link table +{{table:requirement:id,title}} — filtered artifact table +``` + +### Stats embed + +```markdown +{{stats}} — full stats table (types, status, validation) +{{stats:types}} — artifact counts by type only +{{stats:status}} — counts by status only +{{stats:validation}} — validation summary only +``` + +### Coverage embed + +```markdown +{{coverage}} — all traceability rules with percentage bars +{{coverage:rule-name}} — single rule with uncovered artifact IDs +``` + +### Diagnostics embed + +```markdown +{{diagnostics}} — all validation issues +{{diagnostics:error}} — errors only +{{diagnostics:warning}} — warnings only +``` + +### Matrix embed + +```markdown +{{matrix}} — one matrix per traceability rule +{{matrix:requirement:feature}} — specific source→target matrix +``` + +### Error handling + +Unknown or malformed embeds render as a visible error (`embed-error` class), +never an empty string. This ensures broken embeds are noticed during review. + +### In HTML export + +Computed embeds in exported HTML include a provenance footer with the git +commit hash and timestamp, so reviewers can trace when data was generated. + ## Validation Documents participate in validation: