Put the plugin API post-v1, and let the matrix count it that way
The register said two things about plugins. §7 had listed "Plugin API" as deferred since the first draft, in a bare row; §3.10 then specified it in 23 clauses that counted against coverage. Twenty-one of them had no implementation of any kind, and could not have: no crate loads anything at runtime. The coverage figure was measuring the contradiction. Decided 2026-09-19: §7 is right. §3.10 stays as the design of record, each of its clauses is marked "(post-v1)" on its defining line, and NFR-SEC-6 — which exists only for plugins — goes with them, as does D16. The traceability tool learns the marker. A deferred requirement is still defined, so a tag naming it is not an orphan, but it leaves the denominator and is listed in its own table rather than under "not yet tagged". The marker must sit on the definition line; a mention of "post-v1" in prose changes nothing, and where an ID is defined twice the deferral on either line wins. Both are tested. Coverage moves from 72.2% of 194 to 80.6% of 170 without a line of application code changing, which is the honest figure: it now measures what v1 owes.
This commit is contained in:
@@ -69,8 +69,14 @@ pub struct TraceEntry {
|
||||
/// 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 {
|
||||
@@ -90,6 +96,11 @@ pub struct Coverage {
|
||||
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*.
|
||||
@@ -103,15 +114,28 @@ pub struct Coverage {
|
||||
/// 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;
|
||||
}
|
||||
@@ -121,6 +145,9 @@ pub fn parse_defined_requirements(markdown: &str) -> DefinedRequirements {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -129,16 +156,33 @@ pub fn parse_defined_requirements(markdown: &str) -> DefinedRequirements {
|
||||
// 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 ids: BTreeSet<String> = ids.into_iter().filter(|id| is_requirement(id)).collect();
|
||||
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 }
|
||||
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
|
||||
@@ -316,7 +360,13 @@ pub fn compute_coverage(traced: &BTreeSet<String>, defined: &DefinedRequirements
|
||||
|
||||
let orphaned: Vec<String> = traced_reqs
|
||||
.iter()
|
||||
.filter(|id| !defined.ids.contains(**id))
|
||||
.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();
|
||||
|
||||
@@ -338,6 +388,7 @@ pub fn compute_coverage(traced: &BTreeSet<String>, defined: &DefinedRequirements
|
||||
},
|
||||
orphaned,
|
||||
untraced,
|
||||
deferred_tagged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +541,50 @@ This is discussed in FR-CAT-1 and also FR-CAT-1 again.
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user