//! 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, } /// What `requirements.md` defines — the coverage denominators. #[derive(Debug, Clone, Default)] pub struct DefinedRequirements { /// Requirements in scope: the denominator. pub ids: BTreeSet, pub by_type: BTreeMap, /// 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, } 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, /// Defined but never tagged anywhere. pub untraced: Vec, /// 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, } /// 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 = deferred .into_iter() .filter(|id| is_requirement(id)) .collect(); let ids: BTreeSet = ids .into_iter() .filter(|id| is_requirement(id) && !deferred.contains(id)) .collect(); let mut by_type: BTreeMap = 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 { 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] = &["///", "//!", "//", "