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,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