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:
2026-08-09 08:01:32 +02:00
parent 82a5e21ec6
commit 0f202fd3f9
19 changed files with 1913 additions and 22 deletions
+20
View File
@@ -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
+541
View File
@@ -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"]);
}
}
+244
View File
@@ -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)
}