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.
781 lines
29 KiB
Rust
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"]);
|
|
}
|
|
}
|