Files
DarkRoom/tools/traceability/src/lib.rs
T
dtourolle 9d1e31ffbb Fail CI when a key is bound but not in the gesture book, or listed but not bound
The gesture book is generated from GESTURE tags, so it could not describe a
gesture nobody tagged, but nothing made anyone tag one. The arrow keys, Enter,
P, X, U, Delete, F1 and F2 all worked in the grid with no line in the help
sheet, and a tag could name a key whose handler had gone.

Key handlers now compare one canonical string, Keys.chord(event) == "Ctrl+Z",
instead of reading event.text and the modifiers themselves. keys.slint folds
the key and its modifiers into that spelling, so the literal in the handler is
the whole binding and the checker reads exactly what the handler dispatches
on. Each handler carries a KEYMAP comment naming the gesture-book section its
keys belong to, and a tag's keys field names its keys between backticks.
gestures-check now fails when a handler binds a key no tag in that section
names, when a tag names a key no handler there binds, when any .slint file
other than keys.slint reads event.text, when a compared literal is not
canonical, and when keys.slint's named keys drift from the Rust list.

Spellings are normalised in one place, chord.rs: Ctrl+z, Control+Z and
LeftArrow all mean what the handler's "Ctrl+Z" and "Left" mean. Shift and Alt
count only for letters and named keys, because on the French layout every
digit needs shift and a 6 has to be a 6 however it was typed.

A Rust keymap that both dispatched and was read by the generator was the
alternative. It would have moved the handlers' decisions away from the Slint
state they depend on, and a window that forgot to install it would have had
no working keys at all.

The keys that were already bound and undocumented are now tagged.
2026-09-24 23:42:25 -04:00

780 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;
/// 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"]);
}
}