use crate::spec_core::{Section, Verdict};
use crate::spec_knowledge::liveness::decision_liveness;
use crate::spec_knowledge::model::Liveness;
use crate::spec_knowledge::trace_ledger::{
latest_requirement_trace_records, read_requirement_trace_ledgers,
};
use serde::Serialize;
use std::path::Path;
pub const TRACEABILITY_SCHEMA_ID: &str = "agent-spec/intent-compiler/requirement-traceability-v1";
#[derive(Debug, Clone, Serialize)]
pub struct TraceabilityProjection {
pub schema: String,
pub id: String,
pub title: String,
pub governance: String,
pub source: String,
pub clauses: Vec<TraceabilityClause>,
pub specs: Vec<TraceabilitySpec>,
pub latest_run: Option<String>,
pub liveness: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TraceabilityClause {
pub id: String,
pub text: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TraceabilitySpec {
pub path: String,
pub name: String,
pub scenarios: Vec<TraceabilityScenario>,
pub latest_verdict: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TraceabilityScenario {
pub name: String,
pub test_selector: Option<String>,
pub latest_verdict: Option<String>,
}
fn verdict_str(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Pass => "pass",
Verdict::Fail => "fail",
Verdict::Skip => "skip",
Verdict::Uncertain => "uncertain",
Verdict::PendingReview => "pending_review",
}
}
pub fn build_traceability_projection(
knowledge_dir: &Path,
specs_dir: &Path,
trace_dir: &Path,
id: &str,
) -> Result<TraceabilityProjection, String> {
let wanted = id.trim().to_ascii_uppercase();
let graph = crate::spec_knowledge::build_requirement_graph(knowledge_dir);
let Some(node) = graph.node(&wanted) else {
return Err(format!(
"no requirement document under {} declares id {wanted}",
knowledge_dir.display()
));
};
let doc = crate::spec_knowledge::parse_requirement(&node.source_path)
.map_err(|e| format!("{}: {e}", node.source_path.display()))?;
let governance = match node.status {
Some(crate::spec_knowledge::DecisionStatus::Proposed) => "proposed",
Some(crate::spec_knowledge::DecisionStatus::Accepted) => "accepted",
Some(crate::spec_knowledge::DecisionStatus::Superseded) => "superseded",
Some(crate::spec_knowledge::DecisionStatus::Deprecated) => "deprecated",
Some(crate::spec_knowledge::DecisionStatus::Rejected) => "rejected",
None => "missing",
}
.to_string();
let clauses = node
.clauses
.iter()
.map(|clause| TraceabilityClause {
id: clause.id.clone().unwrap_or_default(),
text: clause.text.clone(),
})
.collect::<Vec<_>>();
let ledger = read_requirement_trace_ledgers(trace_dir);
let records = latest_requirement_trace_records(&ledger, &wanted);
let latest_run = records.first().map(|r| r.run_id.clone());
let index = crate::spec_knowledge::build_satisfies_index(specs_dir);
let mut spec_paths = index.get(&wanted).cloned().unwrap_or_default();
spec_paths.sort();
let mut specs = Vec::new();
let mut spec_verdicts = Vec::new();
for path in &spec_paths {
let parsed =
crate::spec_parser::parse_spec(path).map_err(|e| format!("{}: {e}", path.display()))?;
let spec_records: Vec<_> = records
.iter()
.filter(|r| r.spec_path.as_path() == path.as_path())
.collect();
let mut scenarios = Vec::new();
for section in &parsed.sections {
let Section::AcceptanceCriteria {
scenarios: list, ..
} = section
else {
continue;
};
for scenario in list {
let latest = spec_records
.iter()
.find(|r| r.scenario_name == scenario.name)
.map(|r| verdict_str(r.verdict).to_string());
scenarios.push(TraceabilityScenario {
name: scenario.name.clone(),
test_selector: scenario.test_selector.as_ref().map(|s| s.filter.clone()),
latest_verdict: latest,
});
}
}
let rollup = rollup_verdict(&spec_records);
if let Some(v) = rollup {
spec_verdicts.push(v);
}
specs.push(TraceabilitySpec {
path: path.to_string_lossy().into_owned(),
name: parsed.meta.name.clone(),
scenarios,
latest_verdict: rollup.map(|v| verdict_str(v).to_string()),
});
}
let liveness = match decision_liveness(doc.meta.liveness, &spec_verdicts) {
Liveness::Honored => "honored",
Liveness::Violated => "violated",
Liveness::Unproven => "unproven",
Liveness::Na => "na",
}
.to_string();
Ok(TraceabilityProjection {
schema: TRACEABILITY_SCHEMA_ID.to_string(),
id: wanted,
title: node.title.clone(),
governance,
source: node.source_path.to_string_lossy().into_owned(),
clauses,
specs,
latest_run,
liveness,
})
}
fn rollup_verdict(
records: &[&crate::spec_knowledge::trace_ledger::RequirementTraceRecord],
) -> Option<Verdict> {
if records.is_empty() {
return None;
}
if records.iter().any(|r| r.verdict == Verdict::Fail) {
return Some(Verdict::Fail);
}
if records.iter().all(|r| r.verdict == Verdict::Pass) {
return Some(Verdict::Pass);
}
Some(Verdict::Uncertain)
}
pub fn render_traceability_json(projection: &TraceabilityProjection) -> Result<String, String> {
let mut text = serde_json::to_string_pretty(projection).map_err(|e| e.to_string())?;
text.push('\n');
Ok(text)
}
pub fn format_traceability_text(projection: &TraceabilityProjection) -> String {
let mut out = format!(
"{}\n governance: {}\n liveness: {}\n",
projection.id, projection.governance, projection.liveness
);
for spec in &projection.specs {
out.push_str(&format!(
" spec {} ({}): {}\n",
spec.path,
spec.scenarios.len(),
spec.latest_verdict.as_deref().unwrap_or("no recorded run")
));
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::spec_knowledge::trace_ledger::{RequirementTraceLedger, RequirementTraceRecord};
use std::fs;
use std::path::PathBuf;
fn make_temp_tree(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("knowledge/requirements")).unwrap();
fs::create_dir_all(dir.join("specs")).unwrap();
fs::create_dir_all(dir.join("trace")).unwrap();
dir
}
fn write_fixture(dir: &Path) -> PathBuf {
let req = dir.join("knowledge/requirements/req-t.md");
fs::write(
&req,
"---\nkind: requirement\nid: REQ-TRACE-T\ntitle: \"Trace T\"\nstatus: accepted\nliveness: auto\ntags: []\n---\n\n# Trace T\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-TRACE-T-ONE] The system MUST hold the first obligation.\n\n## Scenarios\n\nScenario: holds\n Given a precondition\n When the action runs\n Then the outcome is observable\n",
)
.unwrap();
fs::write(
dir.join("specs/task-t.spec.md"),
"spec: task\nname: \"T\"\nsatisfies: [REQ-TRACE-T]\n---\n\n## Intent\n\nx\n\n## Boundaries\n\n### Allowed Changes\n- src/**\n\n## Completion Criteria\n\nScenario: holds\n Test: test_holds\n Given a precondition\n When the action runs\n Then the outcome is observable\n",
)
.unwrap();
let record = RequirementTraceRecord {
run_id: "run-1".to_string(),
requirement_id: "REQ-TRACE-T".to_string(),
requirement_source: req.clone(),
work_unit_id: "WU-REQ-TRACE-T".to_string(),
spec_path: dir.join("specs/task-t.spec.md"),
scenario_name: "holds".to_string(),
test_selector: Some("test_holds".to_string()),
code_targets: Vec::new(),
verdict: crate::spec_core::Verdict::Pass,
evidence: Vec::new(),
worktree_path: None,
branch: None,
vcs: None,
wiki_articles: Vec::new(),
timestamp: 100,
};
let ledger = RequirementTraceLedger {
version: 1,
records: vec![record],
diagnostics: Vec::new(),
};
fs::write(
dir.join("trace/run-1.json"),
serde_json::to_string_pretty(&ledger).unwrap(),
)
.unwrap();
req
}
#[test]
fn test_requirements_traceability_projection_is_byte_stable() {
let dir = make_temp_tree("traceability-stable");
write_fixture(&dir);
let first = build_traceability_projection(
&dir.join("knowledge"),
&dir.join("specs"),
&dir.join("trace"),
"REQ-TRACE-T",
)
.unwrap();
let second = build_traceability_projection(
&dir.join("knowledge"),
&dir.join("specs"),
&dir.join("trace"),
"REQ-TRACE-T",
)
.unwrap();
let a = render_traceability_json(&first).unwrap();
let b = render_traceability_json(&second).unwrap();
assert_eq!(
a, b,
"two runs over identical inputs must be byte-identical"
);
let value: serde_json::Value = serde_json::from_str(&a).unwrap();
assert_eq!(value["schema"], TRACEABILITY_SCHEMA_ID);
assert_eq!(value["governance"], "accepted");
assert_eq!(value["liveness"], "honored");
assert_eq!(value["latest_run"], "run-1");
assert_eq!(value["clauses"][0]["id"], "REQ-TRACE-T-ONE");
let scenario = &value["specs"][0]["scenarios"][0];
assert_eq!(scenario["name"], "holds");
assert_eq!(scenario["test_selector"], "test_holds");
assert_eq!(scenario["latest_verdict"], "pass");
let schema_file = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("docs/intent-compiler/schemas/requirement-traceability-v1.schema.json");
let schema_text = fs::read_to_string(&schema_file)
.unwrap_or_else(|e| panic!("{} must exist: {e}", schema_file.display()));
assert!(
schema_text.contains(TRACEABILITY_SCHEMA_ID),
"the published schema must carry the projection's $id"
);
for forbidden in ["actor", "authority", "approval", "policy"] {
assert!(
!schema_text.contains(&format!("\"{forbidden}\"")),
"schema must not define orchestrator field '{forbidden}'"
);
}
fs::remove_dir_all(dir).ok();
}
#[test]
fn test_requirements_traceability_rejects_unknown_id() {
let dir = make_temp_tree("traceability-unknown");
write_fixture(&dir);
let err = build_traceability_projection(
&dir.join("knowledge"),
&dir.join("specs"),
&dir.join("trace"),
"REQ-GHOST",
)
.unwrap_err();
assert!(err.contains("REQ-GHOST"), "{err}");
fs::remove_dir_all(dir).ok();
}
#[test]
fn test_requirements_traceability_keeps_knowledge_byte_identical() {
let dir = make_temp_tree("traceability-pure");
write_fixture(&dir);
let snapshot: Vec<(PathBuf, String)> = fs::read_dir(dir.join("knowledge/requirements"))
.unwrap()
.map(|e| e.unwrap().path())
.map(|p| (p.clone(), fs::read_to_string(&p).unwrap()))
.collect();
build_traceability_projection(
&dir.join("knowledge"),
&dir.join("specs"),
&dir.join("trace"),
"REQ-TRACE-T",
)
.unwrap();
for (path, content) in snapshot {
assert_eq!(
fs::read_to_string(&path).unwrap(),
content,
"traceability must not mutate {}",
path.display()
);
}
fs::remove_dir_all(dir).ok();
}
}