Skip to content

Commit aa7aeb5

Browse files
authored
Merge pull request #645 from pulseengine/feat/req-238-result-trace
feat(trace): rivet trace-results — forward req→test-result trace (REQ-238 pt1, #547)
2 parents 57eefd5 + 6bd945d commit aa7aeb5

5 files changed

Lines changed: 429 additions & 2 deletions

File tree

artifacts/requirements.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7568,7 +7568,7 @@ artifacts:
75687568
- id: REQ-238
75697569
type: requirement
75707570
title: graphical req->test-result trace view
7571-
status: proposed
7571+
status: implemented
75727572
description: "A graphical/visual way to trace a requirement to its test results. #547. v0.23."
75737573
provenance:
75747574
created-by: ai-assisted

rivet-cli/src/main.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,25 @@ enum Command {
454454
id: String,
455455
},
456456

457+
/// Trace a requirement FORWARD to the test results that cover it (#547).
458+
/// Walks backlinks (what verifies it, possibly multi-hop through the ASPICE
459+
/// V) and reports each reached artifact with its latest test-result status,
460+
/// plus a roll-up verdict. Text or JSON — the JSON is what the `rivet serve`
461+
/// graphical trace view consumes.
462+
#[command(name = "trace-results")]
463+
TraceResults {
464+
/// Requirement (or any artifact) ID to trace forward from.
465+
id: String,
466+
467+
/// Maximum hops to walk (default: 4 — the ASPICE V depth).
468+
#[arg(long, default_value = "4")]
469+
depth: usize,
470+
471+
/// Output format: "text" (default) or "json".
472+
#[arg(short, long, default_value = "text")]
473+
format: String,
474+
},
475+
457476
/// Bundle an artifact and its link-graph closure as a single pasteable document
458477
Bundle {
459478
/// Root artifact ID
@@ -2370,6 +2389,7 @@ fn run(cli: Cli) -> Result<bool> {
23702389
// per-artifact traceability view; it renders the same as
23712390
// `validate --explain <id>` (which stays as an alias).
23722391
Command::Trace { id } => cmd_explain(&cli, id),
2392+
Command::TraceResults { id, depth, format } => cmd_trace_results(&cli, id, *depth, format),
23732393
Command::Bundle {
23742394
id,
23752395
depth,
@@ -7709,6 +7729,69 @@ fn cmd_check_verification_evidence(
77097729
Ok(missing.is_empty())
77107730
}
77117731

7732+
/// #547 (REQ-238): trace a requirement FORWARD to the test results that cover
7733+
/// it — the reverse of the authored `verifies` direction. Text tree or JSON
7734+
/// (the JSON is what the `rivet serve` graphical trace view consumes). Exits
7735+
/// non-zero only when a covering test recorded a FAILING result, so it is
7736+
/// usable as a per-requirement gate.
7737+
fn cmd_trace_results(cli: &Cli, id: &str, depth: usize, format: &str) -> Result<bool> {
7738+
use rivet_core::result_trace::{TraceVerdict, trace_test_results, verdict};
7739+
use rivet_core::results::{ResultStore, TestStatus};
7740+
validate_format(format, &["text", "json"])?;
7741+
let ctx = ProjectContext::load_full(cli)?;
7742+
ctx.warn_parse_error_skips(cli);
7743+
if !ctx.store.contains(id) {
7744+
anyhow::bail!("artifact '{id}' not found");
7745+
}
7746+
let empty = ResultStore::new();
7747+
let results = ctx.result_store.as_ref().unwrap_or(&empty);
7748+
let nodes = trace_test_results(id, &ctx.graph, results, depth);
7749+
let v = verdict(&nodes);
7750+
let ok = !matches!(v, TraceVerdict::Failing);
7751+
7752+
let badge = |s: Option<&TestStatus>| match s {
7753+
Some(TestStatus::Pass) => "pass",
7754+
Some(TestStatus::Fail) => "FAIL",
7755+
Some(TestStatus::Error) => "ERROR",
7756+
Some(TestStatus::Skip) => "skip",
7757+
Some(TestStatus::Blocked) => "blocked",
7758+
None => "·",
7759+
};
7760+
7761+
if format == "json" {
7762+
println!(
7763+
"{}",
7764+
serde_json::to_string_pretty(&serde_json::json!({
7765+
"command": "trace-results",
7766+
"root": id,
7767+
"verdict": v,
7768+
"nodes": nodes,
7769+
}))?
7770+
);
7771+
} else {
7772+
let verdict_str = match v {
7773+
TraceVerdict::Passing => "\u{2713} passing",
7774+
TraceVerdict::Failing => "\u{2717} failing",
7775+
TraceVerdict::NoEvidence => "\u{2014} no test evidence",
7776+
};
7777+
println!("Test-result trace for {id}: {verdict_str}");
7778+
if nodes.is_empty() {
7779+
println!(" (nothing traces to {id})");
7780+
}
7781+
for n in &nodes {
7782+
let indent = " ".repeat(n.distance);
7783+
println!(
7784+
"{indent}{} --{}--> {} [{}]",
7785+
n.artifact_id,
7786+
n.link_type,
7787+
n.via_target,
7788+
badge(n.status.as_ref())
7789+
);
7790+
}
7791+
}
7792+
Ok(ok)
7793+
}
7794+
77127795
/// #559: advance an artifact to `verified` when it has verifying evidence —
77137796
/// an incoming `verifies` link, OR a `// rivet: verifies <ID>` source marker.
77147797
/// Opt-in and auditable (no auto-advance); the artifact must be `implemented`.
@@ -15271,7 +15354,6 @@ impl ProjectContext {
1527115354
}
1527215355

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

rivet-cli/tests/cli_commands.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,75 @@ fn check_verification_evidence_flags_missing_named_test() {
786786
assert_eq!(missing, vec!["renamed_or_typod_test"]);
787787
}
788788

789+
/// #547 (REQ-238): `rivet trace-results <req>` walks FORWARD from a requirement
790+
/// to the test results that cover it (the reverse of the authored `verifies`
791+
/// direction) and rolls up a pass/fail verdict — the data behind the graphical
792+
/// dashboard trace view. Exits non-zero when a covering test failed.
793+
///
794+
/// rivet: verifies REQ-238
795+
#[test]
796+
fn trace_results_forward_from_requirement_to_test_outcome() {
797+
let tmp = tempfile::tempdir().expect("temp dir");
798+
let dir = tmp.path();
799+
let dirs = dir.to_str().unwrap();
800+
std::fs::create_dir_all(dir.join("artifacts")).unwrap();
801+
std::fs::create_dir_all(dir.join("results")).unwrap();
802+
std::fs::write(
803+
dir.join("rivet.yaml"),
804+
"project:\n name: p\n schemas: [common, dev]\n\
805+
sources:\n - path: artifacts\n format: generic-yaml\nresults: results\n",
806+
)
807+
.unwrap();
808+
std::fs::write(
809+
dir.join("artifacts/a.yaml"),
810+
"artifacts:\n \
811+
- id: REQ-1\n type: requirement\n title: r\n status: approved\n \
812+
- id: TEST-1\n type: test\n title: t\n status: approved\n \
813+
links:\n - type: verifies\n target: REQ-1\n",
814+
)
815+
.unwrap();
816+
let write_result = |status: &str| {
817+
std::fs::write(
818+
dir.join("results/run1.yaml"),
819+
format!(
820+
"run:\n id: run-1\n timestamp: \"2026-07-01T00:00:00Z\"\n\
821+
results:\n - artifact: TEST-1\n status: {status}\n"
822+
),
823+
)
824+
.unwrap();
825+
};
826+
827+
// Passing result → verdict passing, exit 0, TEST-1 reached with status.
828+
write_result("pass");
829+
let out = Command::new(rivet_bin())
830+
.args([
831+
"--project",
832+
dirs,
833+
"trace-results",
834+
"REQ-1",
835+
"--format",
836+
"json",
837+
])
838+
.output()
839+
.expect("trace-results");
840+
assert!(out.status.success());
841+
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
842+
assert_eq!(v["verdict"], "passing");
843+
assert_eq!(v["nodes"][0]["artifact_id"], "TEST-1");
844+
assert_eq!(v["nodes"][0]["status"], "pass");
845+
846+
// Failing result → exit non-zero (gate-usable).
847+
write_result("fail");
848+
let out2 = Command::new(rivet_bin())
849+
.args(["--project", dirs, "trace-results", "REQ-1"])
850+
.output()
851+
.expect("trace-results");
852+
assert!(
853+
!out2.status.success(),
854+
"a failing covering test must make trace-results exit non-zero"
855+
);
856+
}
857+
789858
/// #620 (REQ-241): `rivet validate` (default salsa path) and
790859
/// `rivet validate --direct` (library path) must produce IDENTICAL results
791860
/// on the same project. A user reported them disagreeing — one flagging

rivet-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pub mod ownership;
7474
pub mod query;
7575
pub mod remediation;
7676
pub mod reqif;
77+
pub mod result_trace;
7778
pub mod results;
7879
pub mod rivet_version;
7980
pub mod runs;

0 commit comments

Comments
 (0)