The register said two things about plugins. §7 had listed "Plugin API" as deferred since the first draft, in a bare row; §3.10 then specified it in 23 clauses that counted against coverage. Twenty-one of them had no implementation of any kind, and could not have: no crate loads anything at runtime. The coverage figure was measuring the contradiction. Decided 2026-09-19: §7 is right. §3.10 stays as the design of record, each of its clauses is marked "(post-v1)" on its defining line, and NFR-SEC-6 — which exists only for plugins — goes with them, as does D16. The traceability tool learns the marker. A deferred requirement is still defined, so a tag naming it is not an orphan, but it leaves the denominator and is listed in its own table rather than under "not yet tagged". The marker must sit on the definition line; a mention of "post-v1" in prose changes nothing, and where an ID is defined twice the deferral on either line wins. Both are tested. Coverage moves from 72.2% of 194 to 80.6% of 170 without a line of application code changing, which is the honest figure: it now measures what v1 owes.
389 lines
14 KiB
Rust
389 lines
14 KiB
Rust
//! 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
|
|
//! traces gestures # write docs/gestures.md and the app's gesture table
|
|
//! traces gestures-check # gate: non-zero exit if either has drifted
|
|
//! ```
|
|
//!
|
|
//! The gesture half scans a different tag out of the same files — see
|
|
//! [`traceability::gestures`] for what it is and why it lives here.
|
|
//!
|
|
//! 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;
|
|
|
|
/// Where the extracted gesture vocabulary is written.
|
|
///
|
|
/// Two artefacts from one scan: the document a person reads, and the table the
|
|
/// application draws its help sheet from. Both committed, both gated, so
|
|
/// neither can quietly stop describing the code.
|
|
const GESTURE_DOC: &str = "docs/gestures.md";
|
|
const GESTURE_TABLE: &str = "ui/dr-ui/src/gesture_book.rs";
|
|
|
|
/// Directories scanned for gestures.
|
|
///
|
|
/// Narrower than `SOURCE_ROOTS`, and not merely as an optimisation. A gesture
|
|
/// is something a *user* performs, so it can only be declared where there is an
|
|
/// interface to perform it on: `ui` and the two application shells. `core` has
|
|
/// no pointer and no finger.
|
|
///
|
|
/// Excluding `tools` is what stops this scanner reading its own documentation.
|
|
/// The tag has to appear in this crate — in the doc comment that teaches the
|
|
/// format, and in the fixtures that test the parser — and every one of those
|
|
/// appearances was being extracted as a broken gesture. A scanner that indexes
|
|
/// its own examples is a scanner nobody can document.
|
|
const GESTURE_ROOTS: &[&str] = &["ui", "apps"];
|
|
|
|
fn main() -> Result<()> {
|
|
let base = repo_root()?;
|
|
let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into());
|
|
|
|
if mode.starts_with("gestures") {
|
|
return run_gestures(&base, mode == "gestures-check");
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
/// Scan the gesture vocabulary, and either write it or check it has not moved.
|
|
///
|
|
/// Writing and checking share every step but the last, so they are one function
|
|
/// with a flag rather than two that could come to disagree about what the
|
|
/// artefact should contain — which is the only way a gate like this fails
|
|
/// dishonestly.
|
|
fn run_gestures(base: &Path, check: bool) -> Result<()> {
|
|
let files = collect_sources(base, GESTURE_ROOTS);
|
|
if files.is_empty() {
|
|
bail!("no source files scanned — misconfigured, not zero gestures");
|
|
}
|
|
|
|
let mut found = Vec::new();
|
|
let mut problems = 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();
|
|
// Never the table this command writes. It quotes the tag in its own
|
|
// header, so scanning it fed every generated gesture back in as a
|
|
// malformed one — and a generator that consumes its own output cannot
|
|
// converge.
|
|
if rel == GESTURE_TABLE {
|
|
continue;
|
|
}
|
|
let (g, p) = gestures::extract_from_text(&text, &rel);
|
|
found.extend(g);
|
|
problems.extend(p);
|
|
}
|
|
|
|
println!("files scanned {}", files.len());
|
|
println!("gestures found {}", found.len());
|
|
println!("places {}", gestures::by_section(&found).len());
|
|
|
|
if !problems.is_empty() {
|
|
for p in &problems {
|
|
println!(" {p}");
|
|
}
|
|
bail!("{} malformed gesture tag(s)", problems.len());
|
|
}
|
|
|
|
let doc = gestures::render_markdown(&found);
|
|
let table = gestures::render_rust(&found);
|
|
|
|
if check {
|
|
// Read back rather than trusting a timestamp: the artefacts are
|
|
// committed, and what matters is whether the file in the tree says what
|
|
// the source says, however it got there.
|
|
let mut stale = Vec::new();
|
|
for (path, want) in [(GESTURE_DOC, &doc), (GESTURE_TABLE, &table)] {
|
|
let have = std::fs::read_to_string(base.join(path)).unwrap_or_default();
|
|
if have != *want {
|
|
stale.push(path);
|
|
}
|
|
}
|
|
if !stale.is_empty() {
|
|
bail!(
|
|
"{} is stale — run: cargo run -p traceability -- gestures",
|
|
stale.join(" and ")
|
|
);
|
|
}
|
|
println!("\ngesture gate: PASS");
|
|
return Ok(());
|
|
}
|
|
|
|
std::fs::write(base.join(GESTURE_DOC), &doc)?;
|
|
std::fs::write(base.join(GESTURE_TABLE), &table)?;
|
|
println!("\nwrote {GESTURE_DOC} and {GESTURE_TABLE}");
|
|
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());
|
|
if !defined.deferred.is_empty() {
|
|
println!("deferred {} (post-v1)", defined.deferred.len());
|
|
}
|
|
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%. A requirement whose definition line carries \
|
|
`(post-v1)` is defined but deferred: outside the denominator, listed below, and \
|
|
never an orphan.\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 deferred (post-v1) | {} |\n",
|
|
defined.deferred.len()
|
|
));
|
|
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("## Deferred (post-v1)\n\n");
|
|
m.push_str(
|
|
"Defined in `requirements.md` and marked `(post-v1)` on the defining line. Not in \
|
|
the denominator; a tag naming one is recorded here rather than counted.\n\n",
|
|
);
|
|
if defined.deferred.is_empty() {
|
|
m.push_str("_None._\n\n");
|
|
} else {
|
|
for id in &defined.deferred {
|
|
if cov.deferred_tagged.contains(id) {
|
|
let mut locs: Vec<String> = entries
|
|
.iter()
|
|
.filter(|e| e.requirements.contains(id))
|
|
.map(|e| format!("[`{}:{}`](../{}#L{})", e.file, e.line, e.file, e.line))
|
|
.collect();
|
|
locs.sort();
|
|
locs.dedup();
|
|
m.push_str(&format!("- {id} — tagged in {}\n", locs.join(", ")));
|
|
} else {
|
|
m.push_str(&format!("- {id}\n"));
|
|
}
|
|
}
|
|
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)
|
|
}
|