Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7568,7 +7568,7 @@ artifacts:
- id: REQ-238
type: requirement
title: graphical req->test-result trace view
status: proposed
status: implemented
description: "A graphical/visual way to trace a requirement to its test results. #547. v0.23."
provenance:
created-by: ai-assisted
Expand Down
84 changes: 83 additions & 1 deletion rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,25 @@ enum Command {
id: String,
},

/// Trace a requirement FORWARD to the test results that cover it (#547).
/// Walks backlinks (what verifies it, possibly multi-hop through the ASPICE
/// V) and reports each reached artifact with its latest test-result status,
/// plus a roll-up verdict. Text or JSON — the JSON is what the `rivet serve`
/// graphical trace view consumes.
#[command(name = "trace-results")]
TraceResults {
/// Requirement (or any artifact) ID to trace forward from.
id: String,

/// Maximum hops to walk (default: 4 — the ASPICE V depth).
#[arg(long, default_value = "4")]
depth: usize,

/// Output format: "text" (default) or "json".
#[arg(short, long, default_value = "text")]
format: String,
},

/// Bundle an artifact and its link-graph closure as a single pasteable document
Bundle {
/// Root artifact ID
Expand Down Expand Up @@ -2370,6 +2389,7 @@ fn run(cli: Cli) -> Result<bool> {
// per-artifact traceability view; it renders the same as
// `validate --explain <id>` (which stays as an alias).
Command::Trace { id } => cmd_explain(&cli, id),
Command::TraceResults { id, depth, format } => cmd_trace_results(&cli, id, *depth, format),
Command::Bundle {
id,
depth,
Expand Down Expand Up @@ -7709,6 +7729,69 @@ fn cmd_check_verification_evidence(
Ok(missing.is_empty())
}

/// #547 (REQ-238): trace a requirement FORWARD to the test results that cover
/// it — the reverse of the authored `verifies` direction. Text tree or JSON
/// (the JSON is what the `rivet serve` graphical trace view consumes). Exits
/// non-zero only when a covering test recorded a FAILING result, so it is
/// usable as a per-requirement gate.
fn cmd_trace_results(cli: &Cli, id: &str, depth: usize, format: &str) -> Result<bool> {
use rivet_core::result_trace::{TraceVerdict, trace_test_results, verdict};
use rivet_core::results::{ResultStore, TestStatus};
validate_format(format, &["text", "json"])?;
let ctx = ProjectContext::load_full(cli)?;
ctx.warn_parse_error_skips(cli);
if !ctx.store.contains(id) {
anyhow::bail!("artifact '{id}' not found");
}
let empty = ResultStore::new();
let results = ctx.result_store.as_ref().unwrap_or(&empty);
let nodes = trace_test_results(id, &ctx.graph, results, depth);
let v = verdict(&nodes);
let ok = !matches!(v, TraceVerdict::Failing);

let badge = |s: Option<&TestStatus>| match s {
Some(TestStatus::Pass) => "pass",
Some(TestStatus::Fail) => "FAIL",
Some(TestStatus::Error) => "ERROR",
Some(TestStatus::Skip) => "skip",
Some(TestStatus::Blocked) => "blocked",
None => "·",
};

if format == "json" {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"command": "trace-results",
"root": id,
"verdict": v,
"nodes": nodes,
}))?
);
} else {
let verdict_str = match v {
TraceVerdict::Passing => "\u{2713} passing",
TraceVerdict::Failing => "\u{2717} failing",
TraceVerdict::NoEvidence => "\u{2014} no test evidence",
};
println!("Test-result trace for {id}: {verdict_str}");
if nodes.is_empty() {
println!(" (nothing traces to {id})");
}
for n in &nodes {
let indent = " ".repeat(n.distance);
println!(
"{indent}{} --{}--> {} [{}]",
n.artifact_id,
n.link_type,
n.via_target,
badge(n.status.as_ref())
);
}
}
Ok(ok)
}

/// #559: advance an artifact to `verified` when it has verifying evidence —
/// an incoming `verifies` link, OR a `// rivet: verifies <ID>` source marker.
/// Opt-in and auditable (no auto-advance); the artifact must be `implemented`.
Expand Down Expand Up @@ -15271,7 +15354,6 @@ impl ProjectContext {
}

/// Load project with artifacts, schema, link graph, documents, and test results.
#[allow(dead_code)]
fn load_full(cli: &Cli) -> Result<Self> {
let mut ctx = Self::load_with_docs(cli)?;

Expand Down
69 changes: 69 additions & 0 deletions rivet-cli/tests/cli_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,75 @@ fn check_verification_evidence_flags_missing_named_test() {
assert_eq!(missing, vec!["renamed_or_typod_test"]);
}

/// #547 (REQ-238): `rivet trace-results <req>` walks FORWARD from a requirement
/// to the test results that cover it (the reverse of the authored `verifies`
/// direction) and rolls up a pass/fail verdict — the data behind the graphical
/// dashboard trace view. Exits non-zero when a covering test failed.
///
/// rivet: verifies REQ-238
#[test]
fn trace_results_forward_from_requirement_to_test_outcome() {
let tmp = tempfile::tempdir().expect("temp dir");
let dir = tmp.path();
let dirs = dir.to_str().unwrap();
std::fs::create_dir_all(dir.join("artifacts")).unwrap();
std::fs::create_dir_all(dir.join("results")).unwrap();
std::fs::write(
dir.join("rivet.yaml"),
"project:\n name: p\n schemas: [common, dev]\n\
sources:\n - path: artifacts\n format: generic-yaml\nresults: results\n",
)
.unwrap();
std::fs::write(
dir.join("artifacts/a.yaml"),
"artifacts:\n \
- id: REQ-1\n type: requirement\n title: r\n status: approved\n \
- id: TEST-1\n type: test\n title: t\n status: approved\n \
links:\n - type: verifies\n target: REQ-1\n",
)
.unwrap();
let write_result = |status: &str| {
std::fs::write(
dir.join("results/run1.yaml"),
format!(
"run:\n id: run-1\n timestamp: \"2026-07-01T00:00:00Z\"\n\
results:\n - artifact: TEST-1\n status: {status}\n"
),
)
.unwrap();
};

// Passing result → verdict passing, exit 0, TEST-1 reached with status.
write_result("pass");
let out = Command::new(rivet_bin())
.args([
"--project",
dirs,
"trace-results",
"REQ-1",
"--format",
"json",
])
.output()
.expect("trace-results");
assert!(out.status.success());
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
assert_eq!(v["verdict"], "passing");
assert_eq!(v["nodes"][0]["artifact_id"], "TEST-1");
assert_eq!(v["nodes"][0]["status"], "pass");

// Failing result → exit non-zero (gate-usable).
write_result("fail");
let out2 = Command::new(rivet_bin())
.args(["--project", dirs, "trace-results", "REQ-1"])
.output()
.expect("trace-results");
assert!(
!out2.status.success(),
"a failing covering test must make trace-results exit non-zero"
);
}

/// #620 (REQ-241): `rivet validate` (default salsa path) and
/// `rivet validate --direct` (library path) must produce IDENTICAL results
/// on the same project. A user reported them disagreeing — one flagging
Expand Down
1 change: 1 addition & 0 deletions rivet-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub mod ownership;
pub mod query;
pub mod remediation;
pub mod reqif;
pub mod result_trace;
pub mod results;
pub mod rivet_version;
pub mod runs;
Expand Down
Loading
Loading