@@ -429,6 +429,10 @@
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/{name}/requirements"), &query)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let reqs: Vec<Requirement> = resp
.get("requirements")
.and_then(|v| serde_json::from_value(v.clone()).ok())
@@ -472,6 +476,10 @@
let resp: serde_json::Value = client
.get_with_query(&format!("/{org}/standards"), &query)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let stds = resp
.get("standards")
.and_then(|v| v.as_array())
@@ -529,9 +537,15 @@
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let req: RequirementDetail = client
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements/{req_id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let req: RequirementDetail = serde_json::from_value(resp)
.map_err(|e| format!("unexpected requirement response shape: {e}"))?;
output::header(&format!(
"Requirement {} — {}",
req.requirement_id.as_deref().unwrap_or("?"),
@@ -568,6 +582,11 @@
let client = Client::from_config()?;
let std: serde_json::Value = client.get(&format!("/{org}/standards/{std_id}")).await?;
if output::is_json() {
output::print_json(&std);
return Ok(());
}
output::header(&format!(
"Standard {} — {}",
std["requirement_id"].as_str().unwrap_or("?"),
@@ -716,6 +735,10 @@
"/{organization}/standards/{std_id}/applicabilities"
))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let repos = resp
.get("repositories")
.and_then(|v| v.as_array())
@@ -1238,7 +1261,11 @@
Ok(body) => {
// 2xx with status=ok — coverage gate passed.
if body["status"] == "ok" {
output::success("Standards strict coverage gate passed");
if output::is_json() {
output::print_json(&serde_json::json!({"passed": true, "uncovered": []}));
} else {
output::success("Standards strict coverage gate passed");
}
Ok(())
} else {
// Unexpected envelope.
@@ -1253,8 +1280,17 @@
let body: serde_json::Value = serde_json::from_str(&message).map_err(|_| {
format!("standards gate failed with 422 but body was not JSON: {message}")
})?;
print_uncovered(&body);
let count = body["uncovered"].as_array().map(|a| a.len()).unwrap_or(0);
if output::is_json() {
// Machine payload on stdout, then preserve the non-zero exit.
let uncovered = body
.get("uncovered")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
output::print_json(&serde_json::json!({"passed": false, "uncovered": uncovered}));
} else {
print_uncovered(&body);
}
Err(format!("{count} uncovered mandatory standard(s)").into())
}
Err(other) => Err(Box::new(other)),
@@ -1282,8 +1318,121 @@
.collect();
output::print_table(&["STANDARD", "TITLE", "REPO", "STATUS"], &rows);
println!();
}
/// Coverage tallies derived from the traceability matrix.
#[derive(Debug, Default, PartialEq)]
struct StatusCounts {
covered: usize,
partial: usize,
uncovered: usize,
no_tests: usize,
total: usize,
}
/// One requirement that trips the coverage gate.
#[derive(Debug, PartialEq)]
struct FailingReq {
requirement_id: String,
title: String,
coverage_status: String,
}
/// Machine-readable rollup of requirement coverage. Drives both the `--json`
/// payload and the pass/fail gate, so the two can never disagree.
struct StatusSummary {
counts: StatusCounts,
/// Requirements that trip the gate: uncovered always, plus partial/no_tests
/// under `--strict`.
failing: Vec<FailingReq>,
strict: bool,
}
impl StatusSummary {
/// The gate passes exactly when nothing is failing — this is the process
/// exit contract, identical in human and JSON modes.
fn passed(&self) -> bool {
self.failing.is_empty()
}
fn to_json(&self, org: &str, name: &str) -> serde_json::Value {
serde_json::json!({
"repo": format!("{org}/{name}"),
"strict": self.strict,
"passed": self.passed(),
"counts": {
"covered": self.counts.covered,
"partial": self.counts.partial,
"uncovered": self.counts.uncovered,
"no_tests": self.counts.no_tests,
"total": self.counts.total,
},
"failing": self
.failing
.iter()
.map(|f| {
serde_json::json!({
"requirement_id": f.requirement_id,
"title": f.title,
"coverage_status": f.coverage_status,
})
})
.collect::<Vec<_>>(),
})
}
}
/// Roll up matrix entries into counts + the failing list. Pure (no I/O) so the
/// gate logic is unit-testable without a server.
fn summarize_status(entries: &[serde_json::Value], strict: bool) -> StatusSummary {
let mut counts = StatusCounts {
total: entries.len(),
..Default::default()
};
let mut failing = Vec::new();
for entry in entries {
let status = entry["coverage_status"].as_str().unwrap_or("unknown");
match status {
"covered" => counts.covered += 1,
"partial" => counts.partial += 1,
"uncovered" => counts.uncovered += 1,
"no_tests" => counts.no_tests += 1,
_ => {}
}
let is_failing =
status == "uncovered" || (strict && (status == "partial" || status == "no_tests"));
if is_failing {
failing.push(FailingReq {
requirement_id: entry["requirement"]["requirement_id"]
.as_str()
.unwrap_or("?")
.to_string(),
title: entry["requirement"]["title"]
.as_str()
.unwrap_or("?")
.to_string(),
coverage_status: status.to_string(),
});
}
}
StatusSummary {
counts,
failing,
strict,
}
}
/// The gate's process-exit contract: `Ok` when passing, otherwise an `Err`
/// whose message names the uncovered count (matching the historical wording).
/// Shared by the human and JSON paths so both exit identically.
fn gate_result(summary: &StatusSummary) -> Result<(), Box<dyn std::error::Error>> {
if summary.passed() {
Ok(())
} else {
Err(format!("{} uncovered requirement(s)", summary.counts.uncovered).into())
}
}
async fn status_requirements(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(args.repo.as_deref())?;
@@ -1297,62 +1446,40 @@
.cloned()
.unwrap_or_default();
let summary = summarize_status(&entries, args.strict);
if output::is_json() {
// Machine payload on stdout, then preserve the gate's exit contract so
// `set -e` scripts still fail on uncovered coverage.
output::print_json(&summary.to_json(&org, &name));
return gate_result(&summary);
}
if entries.is_empty() {
output::warn("No requirements found. Create requirements first.");
return Ok(());
}
let mut covered = 0usize;
let mut partial = 0usize;
let mut uncovered = 0usize;
let mut no_tests = 0usize;
for entry in &entries {
match entry["coverage_status"].as_str().unwrap_or("unknown") {
"covered" => covered += 1,
"partial" => partial += 1,
"uncovered" => uncovered += 1,
"no_tests" => no_tests += 1,
_ => {}
}
}
let total = entries.len();
output::header("Requirement Coverage Status");
println!(" Covered: {} ✓", covered);
println!(" Partial: {} ◐", partial);
println!(" Uncovered: {} ✗", uncovered);
println!(" No tests: {} ○", no_tests);
println!(" Total: {}", total);
println!(" Covered: {} ✓", summary.counts.covered);
println!(" Partial: {} ◐", summary.counts.partial);
println!(" Uncovered: {} ✗", summary.counts.uncovered);
println!(" No tests: {} ○", summary.counts.no_tests);
println!(" Total: {}", summary.counts.total);
println!();
if summary.passed() {
output::success("Requirement coverage check passed");
let fail = if args.strict {
uncovered > 0 || partial > 0 || no_tests > 0
Ok(())
} else {
uncovered > 0
};
if fail {
output::error("Requirement coverage check FAILED");
println!();
for entry in &entries {
let status = entry["coverage_status"].as_str().unwrap_or("");
let req_id = entry["requirement"]["requirement_id"]
.as_str()
.unwrap_or("?");
let title = entry["requirement"]["title"].as_str().unwrap_or("?");
let should_list = status == "uncovered"
|| (args.strict && (status == "partial" || status == "no_tests"));
if should_list {
output::detail(req_id, &format!("{} — {}", title, status));
}
for f in &summary.failing {
output::detail(
&f.requirement_id,
&format!("{} — {}", f.title, f.coverage_status),
);
}
Err(format!("{} uncovered requirement(s)", uncovered).into())
} else {
output::success("Requirement coverage check passed");
Ok(())
gate_result(&summary)
}
}
@@ -1854,6 +1981,102 @@
let mut inputs = base_inputs("REQ-X", "t");
inputs.effective_date = Some("2026-01-01");
assert!(any_standards_flags(&inputs));
}
fn matrix_entry(id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"coverage_status": status,
"requirement": {"requirement_id": id, "title": format!("title {id}")}
})
}
#[test]
fn summarize_status_counts_all_categories() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "covered"),
matrix_entry("REQ-C", "partial"),
matrix_entry("REQ-D", "uncovered"),
matrix_entry("REQ-E", "no_tests"),
];
let s = summarize_status(&entries, false);
assert_eq!(
s.counts,
StatusCounts {
covered: 2,
partial: 1,
uncovered: 1,
no_tests: 1,
total: 5,
}
);
}
#[test]
fn summarize_status_non_strict_fails_only_on_uncovered() {
let entries = vec![
matrix_entry("REQ-A", "partial"),
matrix_entry("REQ-B", "no_tests"),
matrix_entry("REQ-C", "uncovered"),
];
let s = summarize_status(&entries, false);
assert!(!s.passed());
assert_eq!(s.failing.len(), 1);
assert_eq!(s.failing[0].requirement_id, "REQ-C");
assert_eq!(s.failing[0].coverage_status, "uncovered");
}
#[test]
fn summarize_status_non_strict_passes_with_partials() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "partial"),
];
let s = summarize_status(&entries, false);
assert!(s.passed());
assert!(s.failing.is_empty());
}
#[test]
fn summarize_status_strict_fails_on_partial_and_no_tests() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "partial"),
matrix_entry("REQ-C", "no_tests"),
];
let s = summarize_status(&entries, true);
assert!(!s.passed());
let ids: Vec<&str> = s
.failing
.iter()
.map(|f| f.requirement_id.as_str())
.collect();
assert_eq!(ids, vec!["REQ-B", "REQ-C"]);
}
#[test]
fn summarize_status_empty_passes_in_both_modes() {
assert!(summarize_status(&[], false).passed());
let s = summarize_status(&[], true);
assert!(s.passed());
assert_eq!(s.counts.total, 0);
}
#[test]
fn status_summary_to_json_shape() {
let entries = vec![
matrix_entry("REQ-A", "covered"),
matrix_entry("REQ-B", "uncovered"),
];
let v = summarize_status(&entries, false).to_json("test-org", "test-repo");
assert_eq!(v["repo"], "test-org/test-repo");
assert_eq!(v["strict"], false);
assert_eq!(v["passed"], false);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["uncovered"], 1);
assert_eq!(v["counts"]["total"], 2);
assert_eq!(v["failing"][0]["requirement_id"], "REQ-B");
assert_eq!(v["failing"][0]["coverage_status"], "uncovered");
}
// The create() routing rules — these are unit tests on validation