Add requirements traceability gate and Gitea pipelines
Ports JellyTau's traceability tooling to Rust, carrying across the bug it was repaired for. That gate divided a traced count by frozen literal denominators; the requirements file outgrew them and it reported 158% coverage, so it could never fail its own threshold. Two rules, both enforced by the extractor's own tests: - denominators parsed from docs/requirements.md at run time - coverage is |traced ∩ defined| / |defined|, never a raw traced count The gate additionally fails hard on a misconfigured run — zero requirements parsed or zero files scanned — rather than reporting a plausible 0%, and on any orphan tag naming a requirement that does not exist. Adapted for DarkRoom: IDs are FR-CAT-1 / NFR-P13 / FR-DEV-3a shapes rather than JellyTau's fixed three digits, and decisions (D), spikes (S), milestone items (M) and test ids remain taggable while being excluded from the denominator — counting them inflated it by 25. Also adds dr-sync: the RemoteBackend trait and capability model, so the Nextcloud connector is one implementation rather than the only shape the engine understands. No mature Nextcloud crate exists (reqwest_dav is too thin), so the connector will be hand-rolled over reqwest per D7. Gitea workflows follow the same style: containerised, commented with the reasoning, desktop and Android on every push, plus a CI check that no core/ crate depends on the UI toolkit (ARCH §6.5a). Coverage today: 13.3% (19/143). 50 tests passing.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "traceability"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "traceability"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "traces"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,541 @@
|
||||
//! Requirements traceability for DarkRoom.
|
||||
//!
|
||||
//! Extracts `TRACES:` tags from source, counts what `requirements.md` actually
|
||||
//! defines, and reports coverage as the intersection of the two.
|
||||
//!
|
||||
//! # Why the arithmetic is written this way
|
||||
//!
|
||||
//! Adapted from the JellyTau tooling, including the bug it was repaired for.
|
||||
//! That gate divided a traced count by *frozen literal* denominators; the
|
||||
//! requirements file grew past them, and it reported **158% coverage**. A gate
|
||||
//! reporting over 100% cannot fail its own threshold, so it silently stopped
|
||||
//! being a gate at all.
|
||||
//!
|
||||
//! Two rules follow, and both are enforced by tests here:
|
||||
//!
|
||||
//! 1. **Denominators are parsed from `requirements.md` at run time.** Never
|
||||
//! hardcoded, never cached.
|
||||
//! 2. **Coverage is `|traced ∩ defined| / |defined|`.** Using the raw traced
|
||||
//! count as the numerator is precisely what lets a ratio exceed 100%, since
|
||||
//! a tag naming a deleted requirement would count as covered. Such tags are
|
||||
//! reported as orphans instead.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Requirement ID prefixes that participate in coverage.
|
||||
///
|
||||
/// Test identifiers (UT, IT) are a separate taxonomy: they are *evidence* for
|
||||
/// requirements, not requirements themselves. Counting them would inflate both
|
||||
/// numerator and denominator, and flagging them as orphans would bury real
|
||||
/// typos in noise.
|
||||
pub const REQUIREMENT_TYPES: &[&str] = &["FR", "NFR", "R"];
|
||||
|
||||
/// Prefixes recognised in tags but deliberately excluded from coverage.
|
||||
///
|
||||
/// `D` are decisions, `S` spikes, `M` milestone items, `AC` acceptance
|
||||
/// criteria, `UT`/`IT` tests. All are legitimate things to tag against, and
|
||||
/// none is a requirement — counting them would inflate the denominator by 25
|
||||
/// and make coverage look worse than it is, which is the same class of defect
|
||||
/// as JellyTau's inflated ratio, just in the other direction.
|
||||
pub const NON_REQUIREMENT_TYPES: &[&str] = &["UT", "IT", "AC", "M", "S", "D"];
|
||||
|
||||
/// One `TRACES:` tag found in source.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct TraceEntry {
|
||||
pub file: String,
|
||||
pub line: usize,
|
||||
pub context: String,
|
||||
pub requirements: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `requirements.md` defines — the coverage denominators.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DefinedRequirements {
|
||||
pub ids: BTreeSet<String>,
|
||||
pub by_type: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl DefinedRequirements {
|
||||
pub fn total(&self) -> usize {
|
||||
self.ids.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The coverage result.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct Coverage {
|
||||
pub covered: usize,
|
||||
pub total: usize,
|
||||
pub percent: f64,
|
||||
/// Tagged in source but absent from `requirements.md` — a typo, or a
|
||||
/// requirement that was renumbered or deleted. Never counted as covered.
|
||||
pub orphaned: Vec<String>,
|
||||
/// Defined but never tagged anywhere.
|
||||
pub untraced: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse the requirement IDs a markdown document *defines*.
|
||||
///
|
||||
/// A requirement is defined by a bolded heading-style declaration
|
||||
/// (`**FR-CAT-1 — …**`) or as the leading cell of a table row (`| R1 | … |`).
|
||||
/// Both forms appear in DarkRoom's requirements.md.
|
||||
///
|
||||
/// Deliberately *not* a bare scan for anything matching the ID shape: that
|
||||
/// counts cross-references in prose and in "relates to" columns as
|
||||
/// definitions, inflating the denominator. IDs are deduplicated because a
|
||||
/// requirement may legitimately appear in both a definition and a summary
|
||||
/// table.
|
||||
pub fn parse_defined_requirements(markdown: &str) -> DefinedRequirements {
|
||||
let mut ids = BTreeSet::new();
|
||||
|
||||
for line in markdown.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
// Form 1: a bolded definition, e.g. `**FR-CAT-1 — Scan.**`
|
||||
if let Some(rest) = trimmed.strip_prefix("**") {
|
||||
if let Some(id) = leading_id(rest) {
|
||||
ids.insert(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Form 2: leading table cell, e.g. `| **R1** | … |` or `| R1 | … |`
|
||||
if let Some(rest) = trimmed.strip_prefix('|') {
|
||||
let cell = rest.trim().trim_start_matches("**");
|
||||
if let Some(id) = leading_id(cell) {
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only requirement types enter the register. Decisions, spikes, milestone
|
||||
// items and test ids are all taggable, but none is a requirement, and
|
||||
// counting them would inflate the denominator.
|
||||
let ids: BTreeSet<String> = ids.into_iter().filter(|id| is_requirement(id)).collect();
|
||||
|
||||
let mut by_type: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for id in &ids {
|
||||
*by_type.entry(type_of(id).to_string()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
DefinedRequirements { ids, by_type }
|
||||
}
|
||||
|
||||
/// Extract a requirement ID anchored at the start of `s`.
|
||||
///
|
||||
/// Accepts `FR-CAT-1`, `NFR-P13`, `R1`, `FR-DEV-3a` — DarkRoom uses
|
||||
/// alphanumeric segments and an optional trailing letter, not the fixed
|
||||
/// three-digit form JellyTau assumed.
|
||||
fn leading_id(s: &str) -> Option<String> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.is_empty() || !bytes[0].is_ascii_uppercase() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut end = 0;
|
||||
let mut seen_digit = false;
|
||||
for (i, c) in s.char_indices() {
|
||||
match c {
|
||||
'A'..='Z' | '0'..='9' | '-' => {
|
||||
if c.is_ascii_digit() {
|
||||
seen_digit = true;
|
||||
}
|
||||
end = i + c.len_utf8();
|
||||
}
|
||||
'a'..='z' if seen_digit => {
|
||||
// Trailing variant letter, e.g. FR-DEV-3a.
|
||||
end = i + c.len_utf8();
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
if end == 0 || !seen_digit {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = s[..end].trim_end_matches('-').to_string();
|
||||
// Must have a recognised prefix, or arbitrary capitalised words match.
|
||||
let ty = type_of(&id);
|
||||
if REQUIREMENT_TYPES.contains(&ty) || NON_REQUIREMENT_TYPES.contains(&ty) {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The type prefix of an ID: `FR-CAT-1` → `FR`, `R1` → `R`.
|
||||
pub fn type_of(id: &str) -> &str {
|
||||
let end = id
|
||||
.find(|c: char| !c.is_ascii_uppercase())
|
||||
.unwrap_or(id.len());
|
||||
&id[..end]
|
||||
}
|
||||
|
||||
/// Whether an ID participates in coverage.
|
||||
pub fn is_requirement(id: &str) -> bool {
|
||||
REQUIREMENT_TYPES.contains(&type_of(id))
|
||||
}
|
||||
|
||||
/// Extract every `TRACES:` tag from a source file's text.
|
||||
pub fn extract_from_text(text: &str, path: &str) -> Vec<TraceEntry> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = Vec::new();
|
||||
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
let Some(pos) = line.find("TRACES:") else {
|
||||
continue;
|
||||
};
|
||||
let tail = &line[pos + "TRACES:".len()..];
|
||||
let ids = parse_ids(tail);
|
||||
if ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(TraceEntry {
|
||||
file: path.to_string(),
|
||||
line: idx + 1,
|
||||
context: find_context(&lines, idx),
|
||||
requirements: ids,
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the ID list from a tag body: `FR-CAT-1, FR-CAT-2 | NFR-P1`.
|
||||
///
|
||||
/// The pipe groups types for readability; both separators are treated alike.
|
||||
fn parse_ids(s: &str) -> Vec<String> {
|
||||
s.split(['|', ','])
|
||||
.filter_map(|part| leading_id(part.trim()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The nearest preceding declaration, for the report's context column.
|
||||
fn find_context(lines: &[&str], from: usize) -> String {
|
||||
const MARKERS: &[&str] = &[
|
||||
"pub fn ",
|
||||
"fn ",
|
||||
"pub struct ",
|
||||
"struct ",
|
||||
"pub enum ",
|
||||
"enum ",
|
||||
"impl ",
|
||||
"pub trait ",
|
||||
"trait ",
|
||||
"#[test]",
|
||||
"component ",
|
||||
"export ",
|
||||
];
|
||||
|
||||
// Look forward first — a doc comment precedes what it documents.
|
||||
for line in lines.iter().skip(from + 1).take(6) {
|
||||
if MARKERS.iter().any(|m| line.contains(m)) {
|
||||
return trim_body(line);
|
||||
}
|
||||
}
|
||||
// Then backward, for tags placed inside a body.
|
||||
for i in (from.saturating_sub(6)..from).rev() {
|
||||
if MARKERS.iter().any(|m| lines[i].contains(m)) {
|
||||
return lines[i].trim().trim_end_matches('{').trim().to_string();
|
||||
}
|
||||
}
|
||||
"—".to_string()
|
||||
}
|
||||
|
||||
/// Strip a trailing body opener so the context reads as a signature.
|
||||
///
|
||||
/// Handles both `fn f() {` and `fn f() {}` — the latter is why this is a
|
||||
/// helper rather than a single `trim_end_matches('{')`.
|
||||
fn trim_body(line: &str) -> String {
|
||||
line.trim()
|
||||
.trim_end_matches("{}")
|
||||
.trim_end()
|
||||
.trim_end_matches('{')
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Compute coverage as the intersection of traced and defined IDs.
|
||||
///
|
||||
/// This signature is the fix for the 158% bug: `defined` is required, so there
|
||||
/// is nowhere for a frozen denominator to hide.
|
||||
/// TRACES: NFR-OPS-1
|
||||
pub fn compute_coverage(traced: &BTreeSet<String>, defined: &DefinedRequirements) -> Coverage {
|
||||
let traced_reqs: BTreeSet<&String> = traced.iter().filter(|id| is_requirement(id)).collect();
|
||||
|
||||
let covered: Vec<&String> = traced_reqs
|
||||
.iter()
|
||||
.filter(|id| defined.ids.contains(**id))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let orphaned: Vec<String> = traced_reqs
|
||||
.iter()
|
||||
.filter(|id| !defined.ids.contains(**id))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let untraced: Vec<String> = defined
|
||||
.ids
|
||||
.iter()
|
||||
.filter(|id| !traced.contains(*id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let total = defined.total();
|
||||
Coverage {
|
||||
covered: covered.len(),
|
||||
total,
|
||||
percent: if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(covered.len() as f64 / total as f64) * 100.0
|
||||
},
|
||||
orphaned,
|
||||
untraced,
|
||||
}
|
||||
}
|
||||
|
||||
/// Source file extensions scanned for tags.
|
||||
pub const SOURCE_SUFFIXES: &[&str] = &[".rs", ".slint", ".wgsl"];
|
||||
|
||||
/// Directories never scanned.
|
||||
const EXCLUDED: &[&str] = &["target", "target-android", ".git", "node_modules", "temp"];
|
||||
|
||||
/// Walk `roots` under `base`, returning every source file.
|
||||
pub fn collect_sources(base: &Path, roots: &[&str]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for root in roots {
|
||||
let dir = base.join(root);
|
||||
if dir.exists() {
|
||||
walk(&dir, &mut out);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
|
||||
if EXCLUDED.iter().any(|e| *e == name) {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
walk(&path, out);
|
||||
} else if SOURCE_SUFFIXES.iter().any(|s| name.ends_with(s)) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- the 158% regression ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn coverage_never_exceeds_one_hundred_percent() {
|
||||
// The JellyTau failure, reproduced: more tags than the register
|
||||
// defines. Every extra tag must land in `orphaned`, never inflate
|
||||
// the numerator.
|
||||
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
|
||||
let traced: BTreeSet<String> = ["FR-CAT-1", "FR-CAT-2", "FR-CAT-3", "FR-CAT-4"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let cov = compute_coverage(&traced, &defined);
|
||||
assert_eq!(cov.covered, 1);
|
||||
assert_eq!(cov.total, 1);
|
||||
assert_eq!(cov.percent, 100.0);
|
||||
assert!(cov.percent <= 100.0, "coverage must never exceed 100%");
|
||||
assert_eq!(cov.orphaned, vec!["FR-CAT-2", "FR-CAT-3", "FR-CAT-4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_tags_are_reported_not_counted() {
|
||||
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
|
||||
let traced: BTreeSet<String> = ["FR-CAT-1", "FR-TYPO-9"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let cov = compute_coverage(&traced, &defined);
|
||||
assert_eq!(cov.covered, 1);
|
||||
assert_eq!(cov.orphaned, vec!["FR-TYPO-9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_register_is_zero_not_a_division_by_zero() {
|
||||
let defined = parse_defined_requirements("");
|
||||
let traced = BTreeSet::new();
|
||||
let cov = compute_coverage(&traced, &defined);
|
||||
assert_eq!(cov.percent, 0.0);
|
||||
assert_eq!(cov.total, 0);
|
||||
}
|
||||
|
||||
// ---- denominator parsing ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn definitions_come_from_declarations_not_cross_references() {
|
||||
// Only the first line defines FR-CAT-1. The prose reference and the
|
||||
// "relates to" cell must not each count as another definition.
|
||||
let md = "\
|
||||
**FR-CAT-1 — Scan.** The app shall scan roots.
|
||||
|
||||
This is discussed in FR-CAT-1 and also FR-CAT-1 again.
|
||||
|
||||
| Spike | Answers | Relates to |
|
||||
|---|---|---|
|
||||
| S1 | whether it works | FR-CAT-1 |
|
||||
";
|
||||
let defined = parse_defined_requirements(md);
|
||||
// FR-CAT-1 once, despite three mentions. S1 is a spike, not a
|
||||
// requirement, so it does not enter the register at all.
|
||||
assert_eq!(defined.total(), 1);
|
||||
assert!(defined.ids.contains("FR-CAT-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_and_spikes_are_not_requirements() {
|
||||
// D and S are taggable but are not requirements; counting them would
|
||||
// inflate the denominator and understate real coverage.
|
||||
let md = "\
|
||||
**FR-CAT-1 — Scan.**
|
||||
| D1 | Language | Rust |
|
||||
| S1 | Zero-copy spike | proves ARCH 6.1 |
|
||||
";
|
||||
let defined = parse_defined_requirements(md);
|
||||
assert_eq!(defined.total(), 1, "only FR-CAT-1 is a requirement");
|
||||
assert!(defined.ids.contains("FR-CAT-1"));
|
||||
assert!(!defined.ids.contains("D1"));
|
||||
assert!(!defined.ids.contains("S1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagging_a_decision_neither_covers_nor_orphans() {
|
||||
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
|
||||
let traced: BTreeSet<String> = ["FR-CAT-1", "D1"].iter().map(|s| s.to_string()).collect();
|
||||
let cov = compute_coverage(&traced, &defined);
|
||||
assert_eq!(cov.covered, 1);
|
||||
assert!(cov.orphaned.is_empty(), "D1 is a valid tag, not an orphan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_row_ids_are_definitions() {
|
||||
let md = "| **R1** | Cross-platform | Same core on both |\n\
|
||||
| R2 | Efficient display | 60fps |\n";
|
||||
let defined = parse_defined_requirements(md);
|
||||
assert!(defined.ids.contains("R1"));
|
||||
assert!(defined.ids.contains("R2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darkroom_id_shapes_parse() {
|
||||
// Not JellyTau's fixed three-digit form.
|
||||
assert_eq!(leading_id("FR-CAT-1 — Scan").as_deref(), Some("FR-CAT-1"));
|
||||
assert_eq!(
|
||||
leading_id("NFR-P13 — Next image").as_deref(),
|
||||
Some("NFR-P13")
|
||||
);
|
||||
assert_eq!(
|
||||
leading_id("FR-DEV-3a — Descriptors").as_deref(),
|
||||
Some("FR-DEV-3a")
|
||||
);
|
||||
assert_eq!(leading_id("R1 | Cross-platform").as_deref(), Some("R1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_is_not_an_id() {
|
||||
assert_eq!(leading_id("The app shall scan"), None);
|
||||
assert_eq!(leading_id("GPU results never"), None);
|
||||
// A recognised prefix with no digits is not an ID either.
|
||||
assert_eq!(leading_id("FR without a number"), None);
|
||||
}
|
||||
|
||||
// ---- tag extraction -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extracts_tags_with_both_separators() {
|
||||
let src = "\
|
||||
/// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1
|
||||
pub fn scan() {}
|
||||
";
|
||||
let traces = extract_from_text(src, "x.rs");
|
||||
assert_eq!(traces.len(), 1);
|
||||
assert_eq!(
|
||||
traces[0].requirements,
|
||||
vec!["FR-CAT-1", "FR-CAT-2", "NFR-P1"]
|
||||
);
|
||||
assert_eq!(traces[0].line, 1);
|
||||
assert_eq!(traces[0].context, "pub fn scan()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_looks_forward_then_backward() {
|
||||
// Doc comments precede their item.
|
||||
let fwd = extract_from_text("// TRACES: R1\npub struct Catalog;", "x.rs");
|
||||
assert_eq!(fwd[0].context, "pub struct Catalog;");
|
||||
|
||||
// A tag inside a body refers to the enclosing item.
|
||||
let back = extract_from_text("pub fn render() {\n // TRACES: R1\n}", "x.rs");
|
||||
assert_eq!(back[0].context, "pub fn render()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_with_no_ids_is_ignored() {
|
||||
let traces = extract_from_text("// TRACES: see the design doc\n", "x.rs");
|
||||
assert!(traces.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ids_are_extracted_but_not_counted_as_requirements() {
|
||||
let traces = extract_from_text("// TRACES: FR-CAT-1 | UT-001\nfn f(){}", "x.rs");
|
||||
assert_eq!(traces[0].requirements, vec!["FR-CAT-1", "UT-001"]);
|
||||
|
||||
// UT is a separate taxonomy: evidence, not a requirement.
|
||||
assert!(is_requirement("FR-CAT-1"));
|
||||
assert!(!is_requirement("UT-001"));
|
||||
|
||||
// So it neither covers nor orphans.
|
||||
let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n");
|
||||
let traced: BTreeSet<String> = ["FR-CAT-1", "UT-001"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let cov = compute_coverage(&traced, &defined);
|
||||
assert_eq!(cov.covered, 1);
|
||||
assert!(
|
||||
cov.orphaned.is_empty(),
|
||||
"UT must not be reported as orphaned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_prefixes_split_correctly() {
|
||||
assert_eq!(type_of("FR-CAT-1"), "FR");
|
||||
assert_eq!(type_of("NFR-P13"), "NFR");
|
||||
assert_eq!(type_of("R1"), "R");
|
||||
assert_eq!(type_of("UT-001"), "UT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untraced_requirements_are_listed() {
|
||||
let defined = parse_defined_requirements("**FR-A-1 — One.**\n**FR-B-2 — Two.**\n");
|
||||
let traced: BTreeSet<String> = ["FR-A-1"].iter().map(|s| s.to_string()).collect();
|
||||
let cov = compute_coverage(&traced, &defined);
|
||||
assert_eq!(cov.untraced, vec!["FR-B-2"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! 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.
|
||||
const SOURCE_ROOTS: &[&str] = &["core", "ui", "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<String> = 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("<!-- GENERATED FILE — do not edit by hand. -->\n");
|
||||
m.push_str("<!-- Regenerate: cargo run -p traceability -- report -->\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<String> = 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("<details><summary>Show untagged requirements</summary>\n\n");
|
||||
for id in &cov.untraced {
|
||||
m.push_str(&format!("- {id}\n"));
|
||||
}
|
||||
m.push_str("\n</details>\n");
|
||||
}
|
||||
|
||||
m
|
||||
}
|
||||
|
||||
/// The repo root, found by walking up from the executable's manifest dir.
|
||||
fn repo_root() -> Result<PathBuf> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user