//! Traceability gate and matrix generator. //! //! ```text //! traces report # write docs/traceability.md //! traces json # machine-readable, to stdout //! traces check # gate: non-zero exit on failure //! ``` //! //! The gate fails hard on a *misconfigured run* — zero requirements parsed, or //! zero source files scanned — rather than reporting a plausible-looking 0%. //! A gate that cannot distinguish "nothing is tagged" from "I read nothing" is //! how JellyTau's reported 158% went unnoticed for months. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use traceability::*; /// Directories scanned for tags. /// /// `platform` belongs here as much as the rest: it is where the display-server /// and secret-store integrations live, so leaving it out made every /// FR-PLAT-\* and NFR-PORT-\* tag in `dr-plat` invisible and understated the /// matrix by exactly the requirements the platform layer exists to satisfy. const SOURCE_ROOTS: &[&str] = &["core", "ui", "platform", "apps", "tools"]; /// Minimum coverage the gate accepts. /// /// 0 today: the requirements register is written but the code that implements /// it barely exists. Ratchet upward as tags land; never reset downward. /// Structural failures below are unconditional and do not depend on this. const MIN_COVERAGE: f64 = 0.0; fn main() -> Result<()> { let base = repo_root()?; let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into()); let req_path = base.join("docs/requirements.md"); let markdown = std::fs::read_to_string(&req_path) .with_context(|| format!("reading {}", req_path.display()))?; let defined = parse_defined_requirements(&markdown); let files = collect_sources(&base, SOURCE_ROOTS); let mut entries = Vec::new(); for file in &files { let text = std::fs::read_to_string(file).unwrap_or_default(); let rel = file .strip_prefix(&base) .unwrap_or(file) .to_string_lossy() .to_string(); entries.extend(extract_from_text(&text, &rel)); } let traced: BTreeSet = entries .iter() .flat_map(|e| e.requirements.iter().cloned()) .collect(); let coverage = compute_coverage(&traced, &defined); match mode.as_str() { "json" => println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "filesScanned": files.len(), "tagsFound": entries.len(), "defined": defined.total(), "coverage": coverage, }))? ), "check" => { print_summary(&files, &entries, &defined, &coverage); gate(&files, &defined, &coverage)?; println!("\ntraceability gate: PASS"); } _ => { let out = base.join("docs/traceability.md"); let md = render(&files, &entries, &defined, &coverage); std::fs::write(&out, md)?; print_summary(&files, &entries, &defined, &coverage); println!("\nwrote {}", out.display()); } } Ok(()) } /// Structural checks that fail regardless of the coverage threshold. fn gate(files: &[PathBuf], defined: &DefinedRequirements, cov: &Coverage) -> Result<()> { // A run that parsed nothing is misconfigured, not passing. if defined.total() == 0 { bail!("no requirements parsed from docs/requirements.md — misconfigured, not 0% coverage"); } if files.is_empty() { bail!("no source files scanned — misconfigured, not 0% coverage"); } // Arithmetic invariant. If this ever trips, the numerator has stopped // being an intersection — the exact JellyTau defect. if cov.percent > 100.0 { bail!( "coverage {:.1}% exceeds 100% — numerator is not an intersection of traced and defined", cov.percent ); } if !cov.orphaned.is_empty() { bail!( "{} orphan tag(s) naming requirements that do not exist: {}", cov.orphaned.len(), cov.orphaned.join(", ") ); } if cov.percent < MIN_COVERAGE { bail!( "coverage {:.1}% is below the {:.1}% threshold", cov.percent, MIN_COVERAGE ); } Ok(()) } fn print_summary( files: &[PathBuf], entries: &[TraceEntry], defined: &DefinedRequirements, cov: &Coverage, ) { println!("files scanned {}", files.len()); println!("tags found {}", entries.len()); println!("requirements {}", defined.total()); println!( "coverage {:.1}% ({}/{})", cov.percent, cov.covered, cov.total ); if !cov.orphaned.is_empty() { println!("orphan tags {}", cov.orphaned.join(", ")); } } fn render( files: &[PathBuf], entries: &[TraceEntry], defined: &DefinedRequirements, cov: &Coverage, ) -> String { let mut m = String::new(); m.push_str("# Requirements traceability matrix\n\n"); m.push_str("\n"); m.push_str("\n\n"); m.push_str( "Denominators are parsed from [`requirements.md`](requirements.md) at run time, \ never hardcoded. Coverage is the intersection of tagged and defined IDs over \ defined IDs, so it cannot exceed 100%.\n\n", ); m.push_str("## Summary\n\n| Metric | Value |\n|---|---|\n"); m.push_str(&format!("| Source files scanned | {} |\n", files.len())); m.push_str(&format!("| TRACES tags found | {} |\n", entries.len())); m.push_str(&format!("| Requirements defined | {} |\n", defined.total())); m.push_str(&format!("| Requirements covered | {} |\n", cov.covered)); m.push_str(&format!( "| **Coverage** | **{:.1}%** ({}/{}) |\n\n", cov.percent, cov.covered, cov.total )); m.push_str("### By type\n\n| Type | Covered | Defined |\n|---|---|---|\n"); let mut covered_by_type: BTreeMap<&str, usize> = BTreeMap::new(); for id in &defined.ids { if entries.iter().any(|e| e.requirements.contains(id)) { *covered_by_type.entry(type_of(id)).or_insert(0) += 1; } } for (ty, count) in &defined.by_type { m.push_str(&format!( "| {} | {} | {} |\n", ty, covered_by_type.get(ty.as_str()).copied().unwrap_or(0), count )); } m.push('\n'); m.push_str("## Orphan tags\n\n"); m.push_str( "A tag naming an ID `requirements.md` does not define — what renumbering produces, \ and what a typo produces.\n\n", ); if cov.orphaned.is_empty() { m.push_str("_None._\n\n"); } else { for id in &cov.orphaned { m.push_str(&format!("- `{id}`\n")); } m.push('\n'); } m.push_str("## Tagged requirements\n\n| ID | Tagged in |\n|---|---|\n"); let mut by_req: BTreeMap<&String, Vec<&TraceEntry>> = BTreeMap::new(); for e in entries { for r in &e.requirements { if defined.ids.contains(r) { by_req.entry(r).or_default().push(e); } } } for (id, es) in &by_req { let mut locs: Vec = es .iter() .map(|e| format!("[`{}:{}`](../{}#L{})", e.file, e.line, e.file, e.line)) .collect(); locs.sort(); locs.dedup(); m.push_str(&format!("| {} | {} |\n", id, locs.join(", "))); } m.push('\n'); m.push_str("## Not yet tagged\n\n"); m.push_str(&format!( "{} of {} requirements have no implementation tag. Expected while the \ codebase is young; each should gain one as it is built.\n\n", cov.untraced.len(), defined.total() )); if !cov.untraced.is_empty() { m.push_str("
Show untagged requirements\n\n"); for id in &cov.untraced { m.push_str(&format!("- {id}\n")); } m.push_str("\n
\n"); } m } /// The repo root, found by walking up from the executable's manifest dir. fn repo_root() -> Result { let mut dir = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf(); while !dir.join("docs/requirements.md").exists() { if !dir.pop() { bail!("could not locate repo root (no docs/requirements.md above the tool)"); } } Ok(dir) }