Files
DarkRoom/tools/traceability/src/lib.rs
T
dtourolle 2cde287440 Hold every verdict write to a reviewed list of user actions
FR-CULL-13 says evidence never writes a rating, flag, label or trash
membership, and nothing enforced it. tools/traceability/src/verdicts.rs
parses the shipped code with syn and enumerates every write: calls to
the catalog setters and trash recorders, SQL that assigns those columns,
sidecar Amendment::Judgement, and fields named rating/flag/label. Each
site must be in ALLOWED with a reason, as Input (inside a Slint on_*
closure, checked structurally), Relay (its callers are checked in
turn), Carried (a verdict made elsewhere: sidecar and XMP pulls, sync
merge, catalog mirrored to file, duplicates consolidation) or
NotAVerdict. Unlisted sites and stale entries both fail
`cargo test -p traceability`; `traces verdicts` prints the list.

syn and proc-macro2 were already in the lockfile as proc-macro
dependencies; this adds the edges, no new crate and no version change.
2026-09-27 07:20:38 -04:00

781 lines
29 KiB
Rust

//! 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.
//!
//! # And why the extractor is fussy about where a tag sits
//!
//! A third rule, learned the same way. `SOURCE_ROOTS` includes `tools`, so this
//! crate scans itself, and the fixtures below demonstrating tag extraction were
//! read as tags: R1 — cross-platform output within a bounded tolerance — was
//! reported implemented on the strength of two string literals in a unit test.
//!
//! 3. **A tag is a comment whose first word is `TRACES:`**, not a line in which
//! the string appears. See `tag_body`. It is a rule about position rather
//! than about string literals, because `schema.rs` writes six real tags
//! *inside* string literals — the SQL it embeds is commented with `--` — so
//! an extractor that refused string literals would lose more than it saved.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
pub mod chord;
pub mod gestures;
pub mod keymap;
pub mod manual;
pub mod verdicts;
/// 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 {
/// Requirements in scope: the denominator.
pub ids: BTreeSet<String>,
pub by_type: BTreeMap<String, usize>,
/// Requirements the register defines but marks `(post-v1)` on the line
/// that defines them. Still defined — a tag naming one is not an orphan —
/// but outside the denominator, and reported in their own table so that
/// leaving the count is visible rather than a way of hiding.
pub deferred: BTreeSet<String>,
}
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>,
/// Tagged in source although the register defers the requirement. Not
/// covered — a deferred clause is not in the denominator — and not an
/// orphan either; listed so the code that anticipates post-v1 work is
/// findable.
pub deferred_tagged: 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.
///
/// A definition line carrying [`DEFERRED_MARKER`] defines the requirement
/// as **deferred**: it exists, but it is outside the count. The marker sits
/// on the definition line and nowhere else, so a prose mention of "post-v1"
/// three paragraphs down changes nothing. Where the same ID is defined twice
/// — once in a summary table, once in prose — deferral on either line wins,
/// because the alternative is a clause that is deferred in one place and
/// counted in another.
pub fn parse_defined_requirements(markdown: &str) -> DefinedRequirements {
let mut ids = BTreeSet::new();
let mut deferred = BTreeSet::new();
for line in markdown.lines() {
let trimmed = line.trim_start();
let is_deferred = trimmed.contains(DEFERRED_MARKER);
// 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) {
if is_deferred {
deferred.insert(id.clone());
}
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) {
if is_deferred {
deferred.insert(id.clone());
}
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 deferred: BTreeSet<String> = deferred
.into_iter()
.filter(|id| is_requirement(id))
.collect();
let ids: BTreeSet<String> = ids
.into_iter()
.filter(|id| is_requirement(id) && !deferred.contains(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,
deferred,
}
}
/// The text that, on a definition line, takes a requirement out of the
/// denominator. Written `*(post-v1)*` after the bold declaration in
/// `requirements.md`; only the parenthesised part is matched, so the
/// emphasis around it is a matter of style.
pub const DEFERRED_MARKER: &str = "(post-v1)";
/// 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))
}
/// Comment openers a tag may be introduced by.
///
/// `--` is here for the SQL embedded in `schema.rs`, which carries real tags
/// inside Rust string literals; `#` for YAML; `*` for the continuation lines of
/// a block comment.
const COMMENT_OPENERS: &[&str] = &["///", "//!", "//", "<!--", "/*", "*", "--", "#"];
/// The body of a tag comment, if this line is one.
///
/// **A tag is a comment whose first word is `TRACES:`** — not any line in which
/// the string appears. The tool scans `tools/`, which is its own source, so
/// without this rule its unit-test fixtures are tags: a one-line literal in
/// `context_looks_forward_then_backward` had R1 reported as implemented, and
/// the register believed it. That is not a quirk of this crate. `build.rs`
/// emits a tag into generated code from a string literal, and any test
/// anywhere that exercises the extractor would do the same.
///
/// The rule does not merely exclude string literals — it cannot tell one from a
/// comment, and `schema.rs` proves it must not try, since its `-- TRACES:` tags
/// live inside string literals and are entirely real. What it asks instead is
/// where on the line the tag sits: a tag written to be read is the first thing
/// in its comment, and a tag quoted inside an expression never is.
fn tag_body(line: &str) -> Option<&str> {
let line = line.trim_start();
let after_opener = COMMENT_OPENERS.iter().find_map(|o| line.strip_prefix(o))?;
after_opener.trim_start().strip_prefix("TRACES:")
}
/// 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(tail) = tag_body(line) else {
continue;
};
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.
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) && !defined.deferred.contains(**id))
.map(|s| s.to_string())
.collect();
let deferred_tagged: Vec<String> = traced_reqs
.iter()
.filter(|id| defined.deferred.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,
deferred_tagged,
}
}
/// Source file extensions scanned for tags.
///
/// `.yaml` is here because a develop operation is now declared rather than
/// written: `core/dr-pipeline/ops/<id>.yaml` is the whole node, and the Rust
/// implementing it is generated into `OUT_DIR`, which is not scanned and could
/// not be linked to from the report if it were. Without this a node would have
/// nowhere to record the requirement it satisfies.
///
/// # What is deliberately absent, and why widening this is the wrong fix
///
/// `AndroidManifest.xml`, the Flatpak manifest, the Dockerfile and the CI
/// workflows all carry requirements — SAF grants, sandbox permissions, the API
/// levels NFR-COMPAT-1 names — and none of them can hold a tag. That reads like
/// a gap in this list. It is not one.
///
/// A tag proves that a tag exists, not that the file under it does the thing,
/// and a declarative manifest is the case where the difference bites hardest:
/// an intent filter can be deleted and the tag above it still says the
/// requirement is met. The convention already in the tree answers it better —
/// a Rust test `include_str!`s the file and asserts what must be in it, and
/// the tag goes on the test. That satisfies what CONTRIBUTING.md asks of any
/// tag, that it name something a test would fail without, which a comment in a
/// manifest never can.
///
/// So the list stays as it is, and a config file is tagged through the test
/// that reads it.
pub const SOURCE_SUFFIXES: &[&str] = &[".rs", ".slint", ".wgsl", ".yaml"];
/// 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 a_deferred_definition_leaves_the_denominator_but_stays_defined() {
// FR-PLG-1 is defined and marked post-v1 on its own line. It must
// not count, must not be "untagged", and a tag naming it must not be
// an orphan — it is a real ID, just not one v1 is measured against.
let md = "\
**FR-CAT-1 — Scan.** The app shall scan roots.
**FR-PLG-1 — Three plugin classes.** *(post-v1)* The app shall support three.
This paragraph says post-v1 about FR-CAT-1 and changes nothing.
";
let defined = parse_defined_requirements(md);
assert_eq!(defined.total(), 1);
assert!(defined.ids.contains("FR-CAT-1"));
assert!(!defined.ids.contains("FR-PLG-1"));
assert!(defined.deferred.contains("FR-PLG-1"));
let traced: BTreeSet<String> = ["FR-CAT-1", "FR-PLG-1"]
.iter()
.map(|s| s.to_string())
.collect();
let cov = compute_coverage(&traced, &defined);
assert_eq!((cov.covered, cov.total), (1, 1));
assert!(
cov.orphaned.is_empty(),
"a deferred ID is defined, not a typo"
);
assert!(cov.untraced.is_empty(), "a deferred ID is not owed a tag");
assert_eq!(cov.deferred_tagged, vec!["FR-PLG-1".to_string()]);
}
#[test]
fn deferral_on_either_definition_line_wins() {
// Defined in a summary table without the marker and in prose with it.
let md = "\
| R7 | Judge anywhere | criterion |
**R7 — Judge anywhere.** *(post-v1)*
";
let defined = parse_defined_requirements(md);
assert_eq!(defined.total(), 0);
assert!(defined.deferred.contains("R7"));
}
#[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() {
// **The ids here are UT and IT deliberately.** A multi-line literal is
// the one fixture shape `tag_body` cannot rule out: its lines begin
// with `///` in the file as well as in the string, so this really is a
// tag as far as any line-oriented reader can tell. Naming a
// requirement here would report it implemented by the traceability
// tool. UT and IT are excluded from coverage by `is_requirement`, so
// the fixture can be as tag-shaped as it likes and still cost nothing.
//
// The requirement id *shapes* are exercised by `darkroom_id_shapes_parse`
// below, which needs no `TRACES:` at all to do it.
let src = "\
/// TRACES: UT-001, UT-002 | IT-003
pub fn scan() {}
";
let traces = extract_from_text(src, "x.rs");
assert_eq!(traces.len(), 1);
assert_eq!(traces[0].requirements, vec!["UT-001", "UT-002", "IT-003"]);
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_quoted_tag_is_not_a_tag() {
// What made the tool report `R1` as implemented: a fixture in this very
// module, scanned along with everything else, because a line mentioning
// `TRACES:` was taken for a line carrying it.
let quoted = extract_from_text(
r#"let s = "// TRACES: FR-CAT-1"; // and a real one below"#,
"x.rs",
);
assert!(quoted.is_empty(), "a tag inside an expression is not a tag");
// Emitted into generated code, which is the `build.rs` case.
let emitted = extract_from_text(r#" "/// TRACES: FR-DEV-3a\n","#, "x.rs");
assert!(emitted.is_empty(), "a tag being written out is not a tag");
// Prose about the mechanism, which is what this crate's own doc
// comments are full of.
let prose = extract_from_text("/// Extract every `TRACES:` tag. FR-CAT-1", "x.rs");
assert!(prose.is_empty(), "a tag named in prose is not a tag");
// And the forms that must keep working: SQL inside a Rust literal,
// which is how `schema.rs` tags its migrations, and YAML.
let sql = extract_from_text(" -- TRACES: FR-CULL-8\n", "x.rs");
assert_eq!(sql[0].requirements, vec!["FR-CULL-8"]);
let yaml = extract_from_text("# TRACES: FR-DEV-3a\nid: exposure\n", "x.yaml");
assert_eq!(yaml[0].requirements, vec!["FR-DEV-3a"]);
}
#[test]
fn this_crates_own_fixtures_cannot_reach_the_register() {
// `SOURCE_ROOTS` includes `tools`, so this crate is scanned by the tool
// it implements and a fixture below is indistinguishable from a tag on
// the code above. `tag_body` closes every shape but one — a multi-line
// literal whose lines begin with a comment opener — and this closes
// that one, by asking where the tag is rather than what it looks like.
//
// Tagging the tool's real code is still allowed; two such tags were
// wrong on other grounds and were removed, but the rule here is only
// that a fixture may not name a requirement. Use a `UT-` or `IT-` id.
for (name, src) in [
("lib.rs", include_str!("lib.rs")),
("gestures.rs", include_str!("gestures.rs")),
("chord.rs", include_str!("chord.rs")),
("keymap.rs", include_str!("keymap.rs")),
("main.rs", include_str!("main.rs")),
] {
let fixtures_begin = src
.lines()
.position(|l| l.trim_start().starts_with("mod tests"))
.map_or(usize::MAX, |i| i + 1);
for entry in extract_from_text(src, name) {
let reqs: Vec<&String> = entry
.requirements
.iter()
.filter(|id| is_requirement(id))
.collect();
assert!(
entry.line < fixtures_begin || reqs.is_empty(),
"{name}:{} is a test fixture naming {reqs:?}, which the \
register would read as implemented. Use a UT- or IT- id.",
entry.line,
);
}
}
}
#[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"]);
}
}