mandible_core/audit.rs
1//! The `audit/<seed>.toml` manifest schema: one sampled tool's entry, its
2//! pre-tag suggestions, and its verdict — plus the load/save/parse helpers
3//! that let every reader and writer of the file agree on what's in it.
4//!
5//! **Why this lives in `mandible-core` rather than `xtask`,** which used to
6//! own the whole format: `xtask` is a binary crate, and `mandible` (the
7//! `--review` TUI) cannot depend on another binary, so a shared library is
8//! the only way for both to read and write byte-for-byte the same file
9//! rather than maintaining two serde structs that silently drift apart.
10//! This project has already been bitten by exactly that —
11//! `mandible/src/pipeline.rs`'s old `LoadedTool` was a field-for-field copy
12//! of `mandible_extract::ExtractionResult`, and the two drifted into
13//! computing different metrics (see `AGENTS.md`).
14//!
15//! **What stays in `xtask/src/audit.rs`, deliberately not moved here:**
16//! drawing the stratified sample and *computing* the K1/K2/K3 pre-tag
17//! suggestions. That needs `xtask`-only detectors (`status`, `existence`,
18//! `misattribution`) and a live extraction pass, and runs exactly once, at
19//! `xtask audit sample` time. `mandible --review` never recomputes a
20//! suggestion — it only ever reads the one already sitting in the file that
21//! `xtask audit sample` wrote, displays it, and lets the reviewer confirm or
22//! override it with the same `k1=`/`k2=`/`k3=` token syntax [`xtask audit
23//! review`] uses.
24
25use serde::{Deserialize, Serialize};
26use std::path::{Path, PathBuf};
27
28/// One entry in a verdict file: a sampled tool, its drawn stratum, and —
29/// once reviewed — a verdict plus an optional note. `verdict: None` is the
30/// "pending" state; every command that touches the file treats absence of a
31/// verdict as "not yet reviewed", never as an implicit skip.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Entry {
34 /// The tool name as found on `PATH` (or supplied via `--tools`).
35 pub tool: String,
36 /// The parse-status label this tool had when it was drawn — recorded at
37 /// draw time, not recomputed later, so a tool whose parse changes
38 /// between `sample` and `review` (a grammar fix landing mid-session)
39 /// still reports against the stratum it was actually drawn from.
40 pub stratum: String,
41 /// `"correct"` / `"incomplete"` / `"wrong"` / `"skip"`, or absent while
42 /// pending. Stored as a plain string (not an enum) so a hand-edited
43 /// verdict file with an unrecognized word fails loudly at the point of
44 /// use ([`parse_verdict_word`]) rather than silently at deserialization.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub verdict: Option<String>,
47 /// The reviewer's free-text note. Becomes an `[xfail]` `reason` for a
48 /// `wrong`/`incomplete` fixture (`xtask::audit::cmd_fixtures`).
49 #[serde(default, skip_serializing_if = "String::is_empty")]
50 pub note: String,
51 /// **K1 pre-tag**: the GCC-family single-dash-long-option parser defect
52 /// (`short.is_some() && long.is_none() && value_name.is_some()`).
53 /// Computed once, at sample time, by `xtask::audit::k1_signature`;
54 /// displayed and overridden here (`k1=true`/`k1=false` anywhere in a
55 /// verdict line or note, via [`extract_tag_override`]) exactly the same
56 /// way regardless of whether the reviewing tool is `xtask audit review`
57 /// or `mandible --review`. `Some(true)` when the tool's tree contains at
58 /// least one matching flag, `None` when it contains none — never
59 /// `Some(false)`, since there is no "confirmed not K1" state worth
60 /// asserting for a tool that never exhibited the shape at all.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub k1: Option<bool>,
63 /// **K2 pre-tag**: the existence detector's own tokenizer gap
64 /// (`xtask::existence`'s `line_start_words` only considered each line's
65 /// *first* token, so a multi-column or comma-separated
66 /// applet/subcommand list reported every column after the first as
67 /// "fabricated" even though it's right there in the raw text).
68 ///
69 /// **The gap itself is closed** — `existence::list_row_words` now reads
70 /// a whole list row, and the 359 fleet-wide fabrications this tag
71 /// existed to explain away are gone (spec §K2). Existing entries keep
72 /// their recorded tag, since a verdict is a record of what the reviewer
73 /// was shown; freshly sampled tools of the same shape simply produce no
74 /// fabrication to tag. Retained so an old manifest still round-trips,
75 /// and so a regression in the list-row rule shows up as this tag coming
76 /// back rather than as silent noise.
77 ///
78 /// Computed once, at sample time, by `xtask::audit::k2_signature`.
79 /// `Some(true)` when every subcommand-kind existence fabrication for
80 /// this tool is explained by the known tokenizer gap, `Some(false)`
81 /// when at least one is not (worth a real look), `None` when the tool
82 /// has no subcommand-kind fabrications to judge at all.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub k2: Option<bool>,
85 /// **K3 pre-tag**: "subcommand help was never fetched, so this node is
86 /// a bare stub." Two distinct causes produce it, both computed once at
87 /// sample time by `xtask::audit::k3_signature` from the same
88 /// single-pass snapshot K1/K2 use, and both should tag:
89 ///
90 /// - the attestation gate refused to probe a subcommand because its
91 /// name came from a native/cobra artifact rather than a recognized
92 /// `--help` heading (`git-lfs`: 36 nodes, 34 suspects, status
93 /// `suspicious`, every subcommand a cobra stub — and, unlike an
94 /// ordinary un-recursed node that just hasn't been fetched *yet*,
95 /// this shape is structurally permanent: the gate refuses it live,
96 /// in the TUI, exactly as it does here);
97 /// - the tool's subcommands simply carry no flags because their own
98 /// help was never fetched (`openssl`: 151 subcommands, zero flags
99 /// anywhere in the extracted tree, root included).
100 ///
101 /// Without this, a reviewer re-derives the same "still empty, still not
102 /// this tool's fault" verdict once per subcommand. `Some(true)` when the
103 /// tool's snapshot shows at least one of the two shapes, `None`
104 /// otherwise — the same "no `Some(false)`" convention as K1, since
105 /// there is nothing to assert-not for a tool that shows neither shape.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub k3: Option<bool>,
108 /// `Some(reason)` when this entry was force-included in the sample
109 /// outside the normal stratified draw (see `xtask::audit::cmd_sample`'s
110 /// `force_include` parameter). `None` for an entry drawn by the
111 /// ordinary stratified sample.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub include_reason: Option<String>,
114 /// `Some(event)` when this entry was drawn by `xtask audit spot-audit`
115 /// (spec §13.1b's sixth rule) to spot-check one specific mass-`ok`
116 /// promotion event named `event` — `None` for every other entry.
117 ///
118 /// **Why this cannot just reuse [`Self::include_reason`]/
119 /// [`FORCED_INCLUSION_STRATUM`]-style bucketing.** That mechanism
120 /// answers *why a tool bypassed the ordinary stratified draw*, and
121 /// tallies every such tool under one hardcoded label regardless of
122 /// reason — correct for its own purpose, but a spot-audit needs the
123 /// opposite property: *which promotion event* a tool's read is
124 /// evidence for, kept separate *per event*, since a promotion next
125 /// month must never blend into this month's numbers. `xtask::audit`'s
126 /// `effective_stratum` reads this field first and reports
127 /// `spot-audit:<event>` as its own row — one stratum per promotion,
128 /// never a single catch-all. An entry may carry both this field and
129 /// `include_reason` (the latter documents the draw itself: which event,
130 /// how many of the promoted set were available, the seed) — this field
131 /// alone decides the reported stratum.
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub spot_audit_event: Option<String>,
134 /// **Defect-family labels** for a `wrong`/`incomplete` verdict: which
135 /// *shapes* of defect this tool exhibits, drawn from the closed set in
136 /// [`DEFECT_FAMILIES`]. Empty for a `correct`/`skip` entry (there is no
137 /// defect to name), and — importantly — also empty for a
138 /// `wrong`/`incomplete` entry nobody could confidently classify. That
139 /// second case is [`Entry::is_unclassified`], and it is deliberately
140 /// representable: an honest "we do not know which family this is" is
141 /// worth far more than a fabricated label, because the whole purpose of
142 /// these labels is to calibrate a detector against them.
143 ///
144 /// Stored as plain strings rather than an enum for the same reason
145 /// [`Self::verdict`] is: a hand-edited manifest with an unrecognized
146 /// family fails loudly at the point of use
147 /// ([`Entry::validate_families`]) rather than silently at
148 /// deserialization, where the error would name a line number and not a
149 /// tool.
150 ///
151 /// **A family is a shape, never a tool.** spec §1's no-per-tool-logic
152 /// rule applies here exactly as it does to a parser: `tcpdump` is not a
153 /// family, `bundled-short-flag` is, and the tool name is data.
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub families: Vec<String>,
156 /// Provenance of [`Self::families`], and the reason that field is safe
157 /// to have in a tracked manifest at all.
158 ///
159 /// A verdict is a **human judgment**: a reviewer read the tool's real
160 /// output. A family label derived by a machine *reading that reviewer's
161 /// prose* is a strictly weaker claim, and this project's posture (spec
162 /// §13.1b's fifth rule: a name a reader could mistake for a stronger
163 /// claim is itself a defect) is that a weaker claim must be labelled as
164 /// one rather than left to be inferred.
165 ///
166 /// - `Some(true)` — derived by machine from the reviewer's note plus the
167 /// fixture evidence. **Not** a reviewer's own classification.
168 /// - `Some(false)` — the reviewer classified it themselves.
169 /// - `None` — no provenance recorded, which
170 /// [`Entry::validate_families`] rejects whenever `families` is
171 /// non-empty. Absence must never silently read as "a human said so":
172 /// a writer that forgets this field would otherwise launder a machine
173 /// reading into a human judgment, which is the single worst outcome
174 /// this schema can produce.
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub families_derived: Option<bool>,
177 /// A history of corrections applied to this entry's original verdict,
178 /// oldest first — **appended to, never used to overwrite [`Self::verdict`]
179 /// or [`Self::note`]**. Empty for the overwhelming majority of entries,
180 /// which is exactly why this is a `Vec` that serializes to nothing when
181 /// empty rather than a field every existing manifest would need
182 /// migrating to carry: an `audit/<seed>.toml` written before this field
183 /// existed deserializes with `amendments: vec![]`, identical in every
184 /// observable way to a freshly reviewed entry that has never been
185 /// amended. See [`Self::effective_verdict`]/[`Self::effective_note`] for
186 /// what a caller should actually read, and [`amend`] for how an entry
187 /// gets one of these appended.
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub amendments: Vec<Amendment>,
190}
191
192impl Entry {
193 /// The verdict every aggregate computation (accuracy tallies, the
194 /// wrong/incomplete listing, fixture generation) should read: the
195 /// `new_verdict` of the most recent [`Amendment`] if this entry has any,
196 /// else the original [`Self::verdict`] untouched. A verdict amendment
197 /// changes what the project believes about a tool without destroying
198 /// the record of what a reviewer originally wrote — see [`amend`]'s doc
199 /// comment for the full rationale.
200 pub fn effective_verdict(&self) -> Option<&str> {
201 match self.amendments.last() {
202 Some(a) => Some(a.new_verdict.as_str()),
203 None => self.verdict.as_deref(),
204 }
205 }
206
207 /// The note that belongs to [`Self::effective_verdict`]: the most
208 /// recent amendment's `new_note` if this entry has been amended, else
209 /// the original [`Self::note`]. Never a concatenation of both — an
210 /// amendment's `new_note` is a complete, self-contained note for the
211 /// corrected verdict (enforced by [`amend`]), not a delta on top of the
212 /// original.
213 pub fn effective_note(&self) -> &str {
214 match self.amendments.last() {
215 Some(a) => a.new_note.as_str(),
216 None => self.note.as_str(),
217 }
218 }
219
220 /// True when this entry's note is obligatory but missing or blank — a
221 /// `wrong`/`incomplete` verdict with nothing recorded about *what* was
222 /// wrong. Reads the *effective* verdict/note, so an amendment that
223 /// corrects a bare-note defect heals this the same way a plain
224 /// re-review would. See [`verdict_requires_note`].
225 pub fn missing_required_note(&self) -> bool {
226 self.effective_verdict().is_some_and(verdict_requires_note)
227 && self.effective_note().trim().is_empty()
228 }
229
230 /// True when a review session should still stop at this entry: no
231 /// verdict yet, or a verdict whose obligatory note never got written.
232 pub fn needs_attention(&self) -> bool {
233 self.verdict.is_none() || self.missing_required_note()
234 }
235
236 /// True when this entry is a judged defect — `wrong` or `incomplete`
237 /// under [`Self::effective_verdict`]. The population a family label is
238 /// *about*, and the population a detector is expected to fire on once
239 /// the label says it belongs to that detector's family.
240 pub fn is_judged_defect(&self) -> bool {
241 self.effective_verdict()
242 .is_some_and(|v| matches!(v, "wrong" | "incomplete"))
243 }
244
245 /// True when this entry is a judged non-defect — `correct` under
246 /// [`Self::effective_verdict`]. The population a detector must stay
247 /// **silent** on: a fire here is a false alarm against a human who read
248 /// the tool's real output and said the parse was right.
249 ///
250 /// `skip` is neither this nor [`Self::is_judged_defect`]. A skipped
251 /// entry carries no judgment about the parse at all (spec §13.1c
252 /// excludes it from the accuracy ratio for the same reason), so it can
253 /// neither confirm nor refute a detector and is excluded from
254 /// calibration entirely rather than silently counted as "good".
255 pub fn is_judged_correct(&self) -> bool {
256 self.effective_verdict() == Some("correct")
257 }
258
259 /// True when this entry is a judged defect that carries no family label
260 /// — the honest "nobody could tell which family this is" state. Counted
261 /// and printed rather than hidden, because an unclassified entry is a
262 /// known hole in a detector's calibration set, and a hole you can see is
263 /// not the same kind of problem as a hole papered over with a guess.
264 pub fn is_unclassified(&self) -> bool {
265 self.is_judged_defect() && self.families.is_empty()
266 }
267
268 /// True when this entry carries `family` among its labels.
269 pub fn has_family(&self, family: &str) -> bool {
270 self.families.iter().any(|f| f == family)
271 }
272
273 /// True when this judged defect (`wrong`/`incomplete`) is **entirely**
274 /// a display/rendering issue — the extraction itself is right, and
275 /// what the reviewer actually judged wrong is how `mandible --review`'s
276 /// TUI draws it (width, wrapping, a truncated bracket). Spec §13.1c
277 /// already draws this boundary for the audit's *scope* ("usage-section
278 /// formatting" is explicitly deferred); this is that same boundary
279 /// applied to the accuracy *denominator*: a finding this method returns
280 /// `true` for must be excluded from [`crate::audit`]'s accuracy
281 /// arithmetic (`xtask::audit::accuracy_over`) while remaining fully
282 /// visible everywhere else — [`Self::effective_note`], `xtask audit
283 /// report`'s stratum table and its own out-of-scope line, and every
284 /// fixture `xtask audit fixtures` writes.
285 ///
286 /// **Structural, not an assertion — this is the part of the task that
287 /// actually matters.** The tempting shortcut is "any entry that
288 /// mentions `display-only` in `families`," but that alone would let a
289 /// *mixed* defect — a genuine parse-shape family (`bundled-short-flag`,
290 /// `unparsed-flag`, …) with `display-only` tacked on beside it — escape
291 /// the denominator on the strength of one true-but-irrelevant label.
292 /// That is exactly the free-text-reason failure mode
293 /// `xtask::detector::Ground::BelowMemberThreshold` replaced this week:
294 /// an exclusion must be computed from a witness the author cannot
295 /// forge by writing a persuasive sentence, not claimed by assertion.
296 /// The witness here is cheaper than `Ground`'s (no arithmetic to
297 /// compute — a label set has no continuous "how much"), but the same
298 /// discipline applies in the one dimension available: `display-only`
299 /// must be this entry's **only** family. A tool with a real parse
300 /// defect can never also claim this exclusion just by naming
301 /// `display-only` as a second label, because a second label is exactly
302 /// what this check refuses. Composed with what [`Self::validate_families`]
303 /// already enforces — `display-only` must come from the closed
304 /// [`DEFECT_FAMILIES`] set, must carry [`Self::families_derived`]
305 /// provenance, and can only appear on a judged defect in the first
306 /// place — an entry cannot reach `true` here by hand-editing a stray
307 /// word into the manifest.
308 pub fn is_display_only(&self) -> bool {
309 self.is_judged_defect() && self.families.len() == 1 && self.families[0] == "display-only"
310 }
311
312 /// Check this entry's [`Self::families`]/[`Self::families_derived`] pair
313 /// for every way it could be a claim nobody can evaluate later:
314 ///
315 /// - a family word outside the closed [`DEFECT_FAMILIES`] set (a typo,
316 /// or an ad-hoc family invented in a hand edit and therefore invisible
317 /// to every reader that matches on the set);
318 /// - the same family listed twice (harmless to a matcher, but it makes a
319 /// per-family count wrong, and counts are what calibration reports);
320 /// - labels with no recorded provenance — see
321 /// [`Self::families_derived`] for why silence there is unacceptable;
322 /// - labels on a verdict that names no defect (`correct`/`skip`), which
323 /// would put a tool into a detector's expected-fires set on the
324 /// strength of a verdict that says nothing is wrong with it.
325 pub fn validate_families(&self) -> anyhow::Result<()> {
326 for (i, family) in self.families.iter().enumerate() {
327 if family_meaning(family).is_none() {
328 anyhow::bail!(
329 "{:?}: unrecognized defect family {family:?} — expected one of: {}",
330 self.tool,
331 family_names().join(", ")
332 );
333 }
334 if self.families[..i].contains(family) {
335 anyhow::bail!("{:?}: defect family {family:?} listed twice", self.tool);
336 }
337 }
338 if !self.families.is_empty() {
339 if self.families_derived.is_none() {
340 anyhow::bail!(
341 "{:?} carries family labels with no `families_derived` provenance — a machine \
342 reading of a reviewer's note must never be mistakable for the reviewer's own \
343 classification",
344 self.tool
345 );
346 }
347 if !self.is_judged_defect() {
348 anyhow::bail!(
349 "{:?} is {:?}, which names no defect, yet carries family labels {:?} — a \
350 family describes what is wrong, so labelling a non-defect would put this \
351 tool in a detector's expected-fires set on a verdict that says nothing is \
352 wrong with it",
353 self.tool,
354 self.effective_verdict().unwrap_or("pending"),
355 self.families,
356 );
357 }
358 }
359 Ok(())
360 }
361}
362
363/// One shape of defect, as a machine-readable label plus what it means.
364///
365/// **Derived from the seed-2 audit's own notes, not asserted in advance.**
366/// Every family here is backed by at least one reviewer note that describes
367/// that shape; nothing was added on the strength of "a parser could
368/// plausibly do this", because a family with no labelled member calibrates
369/// nothing and would only make the set look more complete than the evidence
370/// supports.
371pub struct DefectFamily {
372 /// The label as it appears in [`Entry::families`]. Kebab-case, names a
373 /// shape.
374 pub name: &'static str,
375 /// One line describing the shape — what a detector for this family
376 /// would have to recognize.
377 pub meaning: &'static str,
378}
379
380/// The closed set of defect families (see [`DefectFamily`]).
381///
382/// Ordered roughly by how directly each one is a *parser* defect: the first
383/// group is the grammar getting a flag's structure wrong, then recall gaps,
384/// then help text whose shape the grammar has no model for at all, and last
385/// the two families that are honest about **not** being extraction defects
386/// (`display-only`, `no-usable-help`). Keeping those last two in the same
387/// closed set is deliberate — a reviewer's `wrong` verdict on a tool with no
388/// help text is a real recorded judgment, and dropping it from the labelled
389/// set would quietly inflate every detector's apparent recall by removing
390/// tools it was never going to fire on for a reason that has nothing to do
391/// with the detector.
392pub const DEFECT_FAMILIES: &[DefectFamily] = &[
393 DefectFamily {
394 name: "bundled-short-flag",
395 meaning: "a bundle of boolean short flags (`[-abcXYZ]`) collapses into one flag `-a` \
396 carrying the rest as a value, instead of N separate flags",
397 },
398 DefectFamily {
399 name: "single-dash-long",
400 meaning: "a single-dash long option (`-help`, `-fdump-scos`) splits into a one-character \
401 short flag plus the remainder as a value name (the K1 pre-tag's shape)",
402 },
403 DefectFamily {
404 name: "repeated-char-flag",
405 meaning: "a repeated-character flag (`-vv`, `-dd`, `-kk`) is stored as its single-letter \
406 form carrying the repeat as a required value (`-v` + value `\"v\"`) rather than \
407 as the doubled flag itself — extracted, but as the wrong shape, not absent",
408 },
409 DefectFamily {
410 name: "brace-alternation-flag",
411 meaning: "a flag written as a brace alternation of its own spellings (`{-i|--input} \
412 <file>`, `{-v | --version}`) is dropped entirely or keeps a brace as its value",
413 },
414 DefectFamily {
415 name: "dropped-alias",
416 meaning: "one half of a documented short/long alias pair is missing from the extracted \
417 flag (`-p` kept, `--pid` dropped, or the reverse)",
418 },
419 DefectFamily {
420 name: "value-name-mangled",
421 meaning: "a flag's value spec is mis-captured: an alternative form, an alias spelling, or \
422 a second accepted type is swallowed into or dropped from `value_name`",
423 },
424 DefectFamily {
425 name: "missing-flag-description",
426 meaning: "flags are extracted but carry no description text, though the help text \
427 attaches one",
428 },
429 DefectFamily {
430 name: "section-header-bleed",
431 meaning: "text belonging to a section heading is absorbed into a flag, a description, or \
432 a node name",
433 },
434 // NOT ONE DEFECT, AND NO DETECTOR WAS BUILT. The name describes a
435 // *symptom* — "no flag came out" — not a shape, which is exactly the
436 // `value-name-mangled` failure mode (spec §13.1e) rather than the
437 // `brace-alternation-flag` one. Its five labelled tools are five
438 // unrelated dispositions, and three of them are not this family's work
439 // at all:
440 //
441 // cache_restore `{-i|--input} <input xml file>` brace-alternation-flag,
442 // ALREADY FIXED — fixture green
443 // xfs_io `[[-c|-C] cmd]...`, `[-adfinrRstVx]`
444 // brace-alternation-flag +
445 // bundled-short-flag,
446 // ALREADY FIXED — fixture green
447 // (its own note says "singe dash
448 // issue + missing -c and -C";
449 // the label, not the parse, is
450 // what is stale here)
451 // ip `OPTIONS := { -V[ersion] | ... }` unparsed-subcommand SHAPE D,
452 // already declared NOT BUILT and
453 // already excluded by witness in
454 // `xtask::commandtable`. Its
455 // OBJECT set and its OPTIONS set
456 // are one grammar; the survivors
457 // are additionally single-dash-long
458 // (`-V` + value `"ersion"`)
459 // sg_dd a second synopsis paragraph singleton: the usage block ends
460 // after a blank line at the blank line, losing
461 // `--progress` and `--verify` and
462 // nothing else
463 // pptpsetup `pptpsetup --create <TUNNEL> ...` singleton: a synopsis with no
464 // with no `usage:` label `usage:` anchor, so no usage
465 // block exists and no synopsis flag
466 // is ever extracted
467 //
468 // The two singletons share no witness token, so no one predicate reaches
469 // both, and each fix would flip exactly one fixture — "a tool, not a
470 // family". Worse, the pptpsetup shape cannot be claimed at all without
471 // breaking two boundaries: *any* predicate reading "the tool's own name
472 // leads a line carrying flag-shaped tokens, with no `usage:` at line
473 // start" also claims `vgck`, `vgextend` and `vgrename` (labelled
474 // verbatim-fallback — a cross-family fire) and `nfsidmap` (`nfsidmap:
475 // Usage: nfsidmap [-vh] ...`, judged **correct** by maintainer decision
476 // and explicitly not re-litigated — a false alarm). Anchoring the usage
477 // block on the tool's own name would hand all four of them a synopsis
478 // they do not have today, re-opening a signed-off `correct` verdict as a
479 // side effect of a different family's fix, with no fleet measurement
480 // available to bound the damage.
481 DefectFamily {
482 name: "unparsed-flag",
483 meaning: "flag spellings plainly present in the help text produce no flag at all — a \
484 partial recall gap, distinct from `verbatim-fallback`'s total one. A SYMPTOM, \
485 not a shape: its five labelled tools are five unrelated dispositions and no \
486 detector generalizes them; see the comment above",
487 },
488 // NOT ONE DEFECT. The seed-2 audit's 8 `unparsed-subcommand` tools —
489 // the largest family in the set — write their subcommand lists in four
490 // unrelated grammars, and only the first has been built and fixed:
491 //
492 // A dash-separated command table ar, gcc-ar, gcc-ar-13, FIXED
493 // (` commands:` + `d - desc`) aarch64-linux-gnu-{ar,gcc-ar}
494 // B inline label + continuation apt-ftparchive NOT BUILT
495 // (`Commands: packages binarypath`, first entry on the label's line)
496 // C repeated-prefix usage catalogue btrfs NOT BUILT
497 // (` btrfs balance start <path>`, no heading, two levels deep)
498 // D metavariable alternation set ip NOT BUILT
499 // (`where OBJECT := { address | addrlabel | ... }`)
500 //
501 // `xtask`'s `unparsed-command-table` detector claims shape A only and
502 // names B, C and D as declared exclusions with a witness line each (see
503 // `xtask/src/commandtable.rs`). A zero count for that detector therefore
504 // means shape A is repaired — it does NOT mean this family is done.
505 DefectFamily {
506 name: "unparsed-subcommand",
507 meaning: "subcommand names are plainly present in the help text but no child node is \
508 produced for them — four unrelated grammars share this label; see the comment \
509 above, only shape A (the dash-separated command table) is fixed",
510 },
511 DefectFamily {
512 name: "unparsed-positional",
513 meaning: "a positional operand in the usage line (`<destination>`, `pid`) is never \
514 extracted — the IR has nowhere to put it",
515 },
516 // NOT ONE DEFECT, AND NO DETECTOR WAS BUILT. This is the vaguest label
517 // in the manifest — its own `meaning` below is a list of five unrelated
518 // layouts, which is the tell — and reading the six labelled tools'
519 // raw help beside their trees confirms it. Two of the six are the same
520 // binary: `/usr/bin/mariadb-repair` and `/usr/bin/mariadbcheck` are both
521 // symlinks to `mariadb-check`, and their help differs only in the
522 // program name it prints. Six labels are therefore five tools, and the
523 // five are five shapes with no witness token in common:
524 //
525 // gcc `--help={common|optimizers|...}` ALREADY MODELED, and
526 // already deferred:
527 // `help_text::confession::match_flag_value_row` detects
528 // this exact row and caps the status at `incomplete`.
529 // Following it needs a new `exec::InertArgv` for the
530 // one-token `--help=common` plus its own §6 deliberation
531 // (WS5b). Nothing here is a grammar defect.
532 // mariadb- `Variables (--variable-name=value)` NOTHING TO EXTRACT.
533 // {repair, defaults table Every one of its 39
534 // check} rows restates an
535 // option already in the tree, and adds only a default
536 // *value*, which the IR has nowhere to put. Recall is
537 // complete. The table's one tree artifact is a phantom
538 // flag whose long name is the header ruler
539 // `---------------------------------`.
540 // qemu-arm64- `Argument | Env-variable | three-column table:
541 // static Description` columns column 2 is swallowed
542 // into the description
543 // on 21 of its 23 rows. Its other damage is
544 // `single-dash-long`, which has its own detector and fix.
545 // sg_dd a synopsis paragraph resumed singleton, and
546 // after a blank line, plus a already recorded
547 // `where:` KEY=VALUE operand table under `unparsed-flag`
548 // above — verified again
549 // here: `--progress`/`--verify` are the only losses.
550 // ssh-keygen one synopsis line per invocation the block IS read —
551 // variant, each reprinting the 40 flags come out of
552 // tool's own name it. What is unmodeled
553 // is the mode words (`-Y sign`, `-M generate`,
554 // `-Y find-principals`), subcommands in all but name.
555 //
556 // The mariadb residue is the only one two labels reach, and it is not a
557 // family: one binary under two names is the same evidence counted twice.
558 // It is also unbuildable today in both directions. There is no
559 // `must_not_contain_flags` contract field, so a fixture cannot state a
560 // *phantom* flag falsifiably (the same gap `must_contain_positionals`
561 // was added to close for operands). And the obvious predicate — a long
562 // option name made only of `-` characters — fires on `bzless`
563 // (`------> --help <------` parses as `--` + `----` carrying the value
564 // `>`), which is labelled `wrong-stream`: a cross-family fire, which no
565 // exclusion may excuse. Narrowing it to "the raw text carries a line of
566 // nothing but dashes" does clear `bzless`, and was scanned mechanically
567 // across all 81 corpus fixtures: **zero** of them carry such a line.
568 // One binary is a tool, not a family.
569 DefectFamily {
570 name: "unmodeled-help-shape",
571 meaning: "the help text is structured in a way the grammar has no model for at all \
572 (topic-partitioned `--help=<topic>` pages, a combinatorial synopsis that \
573 reprints the tool name per variant, `KEY=VALUE` operands, a settings/variables \
574 table that is not a flag list, multi-column layouts). A LABEL FOR FIVE \
575 UNRELATED LAYOUTS, not a shape: its six labels are five tools and five \
576 shapes, and no detector generalizes them; see the comment above",
577 },
578 DefectFamily {
579 name: "wrong-stream",
580 meaning: "the tool wrote its real help to one stream and a banner or decorator to the \
581 other, and the parser read the decorator — the whole tree is built from the \
582 wrong bytes",
583 },
584 DefectFamily {
585 name: "verbatim-fallback",
586 meaning: "help text was captured but no structure came out of it at all, so the tool \
587 falls back to verbatim display",
588 },
589 DefectFamily {
590 name: "display-only",
591 meaning: "the extraction is right and the defect is in how the TUI renders it (width, \
592 wrapping, a truncated bracket) — recorded as not-an-extraction-defect rather \
593 than dropped, so it cannot be mistaken for one",
594 },
595 DefectFamily {
596 name: "no-usable-help",
597 meaning: "the tool yields no help text to parse under the allowlisted probe argv (prints \
598 nothing, errors, opens a REPL, or emits something that is not help) — a \
599 property of the tool, not of the parser",
600 },
601];
602
603/// Every family name, in [`DEFECT_FAMILIES`] order.
604pub fn family_names() -> Vec<&'static str> {
605 DEFECT_FAMILIES.iter().map(|f| f.name).collect()
606}
607
608/// The one-line meaning of `name`, or `None` if it is not a recognized
609/// family. The membership test every reader of [`Entry::families`] should
610/// use, so an unrecognized word can never be silently treated as a family
611/// nobody has heard of.
612pub fn family_meaning(name: &str) -> Option<&'static str> {
613 DEFECT_FAMILIES
614 .iter()
615 .find(|f| f.name == name)
616 .map(|f| f.meaning)
617}
618
619/// Parse a family word to its canonical `'static` spelling, failing loudly
620/// (and naming the whole valid set) on anything else — the family
621/// counterpart of [`parse_verdict_word`], and shared for the same reason:
622/// every entry point that accepts a family must agree on what one is.
623pub fn parse_family(word: &str) -> anyhow::Result<&'static str> {
624 DEFECT_FAMILIES
625 .iter()
626 .find(|f| f.name == word)
627 .map(|f| f.name)
628 .ok_or_else(|| {
629 anyhow::anyhow!(
630 "unrecognized defect family {word:?} — expected one of: {}",
631 family_names().join(", ")
632 )
633 })
634}
635
636/// One recorded correction to an [`Entry`]'s verdict — the audit's amendment
637/// mechanism (see the module's own doc comment for why this exists: a
638/// reviewer error, once identified, must be fixable without either silently
639/// rewriting history or leaving a known-false record standing).
640///
641/// **Appended, never mutated once written**, and the `Entry` this lives on
642/// never has [`Entry::verdict`]/[`Entry::note`] overwritten by [`amend`]
643/// either — an amendment is additive by construction, so `git blame` and a
644/// plain read of the TOML both show the original verdict sitting right there
645/// next to the record of what it became and why, rather than requiring
646/// reconstruction from a diff.
647#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct Amendment {
649 /// The verdict this amendment supersedes: the entry's original
650 /// [`Entry::verdict`] for a first amendment, or the previous
651 /// amendment's `new_verdict` for a second — recorded explicitly (not
652 /// left to be inferred by walking the list) so each amendment reads as
653 /// a complete, self-contained "was X, became Y, because Z" statement on
654 /// its own.
655 pub previous_verdict: String,
656 /// The corrected verdict, in effect from this amendment forward.
657 pub new_verdict: String,
658 /// The note attached to `new_verdict`, required under the same rule
659 /// [`verdict_requires_note`] applies to an ordinary verdict — an
660 /// amendment to `wrong`/`incomplete` with nothing recorded about what
661 /// is actually wrong is exactly as useless as a bare initial verdict
662 /// would be. Stored separately from [`Entry::note`] so the original
663 /// note (which may itself be empty, e.g. a `correct` being amended
664 /// away) survives untouched as history.
665 #[serde(default, skip_serializing_if = "String::is_empty")]
666 pub new_note: String,
667 /// Why the original verdict was wrong and is being corrected. Always
668 /// required, regardless of what the new verdict is — an amendment with
669 /// no stated reason is exactly the kind of unauditable rewrite this
670 /// mechanism exists to prevent, the same precedent
671 /// [`Entry::include_reason`] already sets for force-inclusion.
672 pub reason: String,
673}
674
675/// Append an [`Amendment`] to `entry`, correcting its effective verdict
676/// without overwriting anything already on disk. Fails loudly, before
677/// touching `entry`, on every way an amendment could become an unauditable
678/// or incomplete record:
679///
680/// - `entry` has no verdict yet (nothing to amend — record an initial
681/// verdict first, this is not a shortcut around the ordinary review
682/// flow);
683/// - `reason` is blank (the whole point of this function over hand-editing
684/// the TOML directly);
685/// - `new_verdict` obliges a note ([`verdict_requires_note`]) and
686/// `new_note` is blank, the same obligation an ordinary verdict carries;
687/// - `new_verdict` is identical to the entry's current effective verdict
688/// (nothing is actually changing — that is an edit to the note, a
689/// different operation this function does not perform, not a verdict
690/// amendment).
691///
692/// `new_verdict` must already be a canonical word (run it through
693/// [`parse_verdict_word`] first, the same as every other entry point that
694/// accepts one) — this function does not parse `c`/`i`/`w`/`s` shorthand
695/// itself, so a caller's typo surfaces as a rejected value rather than a
696/// silently accepted wrong one.
697pub fn amend(
698 entry: &mut Entry,
699 new_verdict: &str,
700 new_note: String,
701 reason: String,
702) -> anyhow::Result<()> {
703 let Some(previous_verdict) = entry.effective_verdict().map(str::to_string) else {
704 anyhow::bail!(
705 "{:?} has no verdict yet — nothing to amend (record an initial verdict first, via \
706 `xtask audit review`/`ingest` or `mandible --review`)",
707 entry.tool
708 );
709 };
710 if reason.trim().is_empty() {
711 anyhow::bail!(
712 "amending {:?} needs a reason — an amendment with nothing recorded about why is \
713 exactly the unauditable change this mechanism exists to prevent",
714 entry.tool
715 );
716 }
717 if verdict_requires_note(new_verdict) && new_note.trim().is_empty() {
718 anyhow::bail!(
719 "amending {:?} to {new_verdict:?} needs a note — the same obligation an ordinary \
720 wrong/incomplete verdict carries, now aimed at the corrected value",
721 entry.tool
722 );
723 }
724 if previous_verdict == new_verdict {
725 anyhow::bail!(
726 "{:?} is already {new_verdict:?} (after any prior amendments) — nothing to amend",
727 entry.tool
728 );
729 }
730 entry.amendments.push(Amendment {
731 previous_verdict,
732 new_verdict: new_verdict.to_string(),
733 new_note,
734 reason,
735 });
736 Ok(())
737}
738
739/// The persisted state of one audit run: everything needed to resume, and
740/// nothing that would make two runs of `sample` with the same `--seed`
741/// disagree with each other.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct AuditFile {
744 /// The seed and sample size that produced this file.
745 pub meta: AuditMeta,
746 /// Every sampled tool, reviewed or not, in file order.
747 #[serde(default, rename = "entry")]
748 pub entries: Vec<Entry>,
749}
750
751/// [`AuditFile`]'s own metadata: the seed and requested sample size that
752/// produced it, re-asserted whenever the sample is (re)drawn so a stale
753/// `--sample`/`--seed` combination against an existing file is a loud
754/// error, never a silent merge.
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct AuditMeta {
757 /// The seed the stratified draw used.
758 pub seed: u64,
759 /// The total sample size requested at draw time.
760 pub sample_size: usize,
761}
762
763impl AuditFile {
764 /// Indices of entries with no verdict yet, in file order — the ordered
765 /// walk both `xtask audit review` and `mandible --review` follow, so an
766 /// interrupted session resumes at the same entry regardless of which of
767 /// the two last touched the file.
768 pub fn pending(&self) -> impl Iterator<Item = usize> + '_ {
769 self.entries
770 .iter()
771 .enumerate()
772 .filter(|(_, e)| e.verdict.is_none())
773 .map(|(i, _)| i)
774 }
775
776 /// Indices of entries a review session should still stop at, in file
777 /// order: everything [`Self::pending`] yields, plus anything already
778 /// judged `wrong`/`incomplete` whose note is missing or blank
779 /// ([`verdict_requires_note`]).
780 ///
781 /// The second half exists because such an entry is a *record* that is
782 /// incomplete even though a verdict was given. For accuracy arithmetic
783 /// it counts as judged and always did — the tool really was judged
784 /// wrong — but for the triage the audit exists to feed it is useless: it
785 /// names a tool and says nothing about what was wrong with it. Rather
786 /// than a separate repair command, the ordinary walk simply stops there
787 /// again, so a session that recorded bare verdicts before this rule
788 /// existed heals itself on the next run.
789 pub fn needing_attention(&self) -> impl Iterator<Item = usize> + '_ {
790 self.entries
791 .iter()
792 .enumerate()
793 .filter(|(_, e)| e.needs_attention())
794 .map(|(i, _)| i)
795 }
796
797 /// Run [`Entry::validate_families`] over every entry, reporting the
798 /// first failure. Called by every command that reads family labels
799 /// before it computes anything from them, so a hand edit that
800 /// mistypes a family or forgets its provenance fails at the top of the
801 /// run rather than quietly changing a confusion matrix.
802 pub fn validate_families(&self) -> anyhow::Result<()> {
803 for entry in &self.entries {
804 entry.validate_families()?;
805 }
806 Ok(())
807 }
808
809 /// Judged defects carrying no family label, in file order — the
810 /// `unclassified` population every calibration report prints. See
811 /// [`Entry::is_unclassified`].
812 pub fn unclassified(&self) -> impl Iterator<Item = &Entry> + '_ {
813 self.entries.iter().filter(|e| e.is_unclassified())
814 }
815}
816
817/// Whether a verdict word obliges the reviewer to write a note.
818///
819/// `wrong` and `incomplete` do: for those two the note *is* the finding, and
820/// the whole point of the audit is to hand a later fix something actionable.
821/// `correct` and `skip` do not — "it parsed correctly" is complete on its
822/// own, and forcing prose out of a reviewer who has nothing to add is how a
823/// review loop starts collecting "n/a".
824pub fn verdict_requires_note(verdict: &str) -> bool {
825 matches!(verdict, "wrong" | "incomplete")
826}
827
828/// The path a given `(dir, seed)` pair resolves to: `<dir>/<seed>.toml`.
829pub fn verdict_path(dir: &Path, seed: u64) -> PathBuf {
830 dir.join(format!("{seed}.toml"))
831}
832
833/// Read and parse `path` as an [`AuditFile`].
834pub fn load(path: &Path) -> anyhow::Result<AuditFile> {
835 let raw = std::fs::read_to_string(path).map_err(|e| {
836 anyhow::anyhow!(
837 "reading {}: {e} (run `xtask audit sample` first)",
838 path.display()
839 )
840 })?;
841 toml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))
842}
843
844/// Serialize and write `file` to `path`, creating its parent directory if
845/// needed. Called after **every** verdict by both `xtask audit review` and
846/// `mandible --review` — never batched — so a killed process leaves
847/// everything answered so far recorded and everything else still pending.
848pub fn save(path: &Path, file: &AuditFile) -> anyhow::Result<()> {
849 if let Some(parent) = path.parent() {
850 if !parent.as_os_str().is_empty() {
851 std::fs::create_dir_all(parent)
852 .map_err(|e| anyhow::anyhow!("creating {}: {e}", parent.display()))?;
853 }
854 }
855 let text = toml::to_string_pretty(file)
856 .map_err(|e| anyhow::anyhow!("serializing {}: {e}", path.display()))?;
857 std::fs::write(path, text).map_err(|e| anyhow::anyhow!("writing {}: {e}", path.display()))
858}
859
860/// Parse a verdict word (`c`/`correct`, `i`/`incomplete`, `w`/`wrong`,
861/// `s`/`skip`) to its canonical spelling. Shared by every entry point that
862/// accepts a verdict — typed live in `xtask audit review`, read from a
863/// verdicts file by `xtask audit ingest`, or chosen by a keypress in
864/// `mandible --review` — so none of them can disagree about what counts as
865/// a valid verdict.
866pub fn parse_verdict_word(word: &str) -> anyhow::Result<&'static str> {
867 match word {
868 "c" | "correct" => Ok("correct"),
869 "i" | "incomplete" => Ok("incomplete"),
870 "w" | "wrong" => Ok("wrong"),
871 "s" | "skip" => Ok("skip"),
872 other => anyhow::bail!(
873 "unrecognized verdict {other:?} — expected one of: c/correct, i/incomplete, w/wrong, s/skip"
874 ),
875 }
876}
877
878/// Pull any `k1=true`/`k1=false`/`k2=true`/`k2=false`/`k3=true`/`k3=false`
879/// (case-insensitive) token for `key` out of `text`, in place, returning the
880/// override it specified (if any). The token is removed from `text`
881/// regardless of position — a reviewer's note is free-form prose, not a
882/// fixed field order — so what remains is the plain note with no tag syntax
883/// left in it. Shared by every entry point that accepts a note, for the
884/// same reason [`parse_verdict_word`] is.
885pub fn extract_tag_override(text: &mut String, key: &str) -> Option<bool> {
886 let true_tok = format!("{key}=true");
887 let false_tok = format!("{key}=false");
888 let mut found = None;
889 let kept: Vec<&str> = text
890 .split_whitespace()
891 .filter(|tok| {
892 if tok.eq_ignore_ascii_case(&true_tok) {
893 found = Some(true);
894 false
895 } else if tok.eq_ignore_ascii_case(&false_tok) {
896 found = Some(false);
897 false
898 } else {
899 true
900 }
901 })
902 .collect();
903 *text = kept.join(" ");
904 found
905}
906
907/// Human-readable line for a pre-tag, shown to the reviewer before they
908/// record a verdict — the whole point of [`Entry::k1`]/[`Entry::k2`]/
909/// [`Entry::k3`] is that this line lets a reviewer confirm-or-override in
910/// one glance instead of re-deriving the same known defect per flag.
911pub fn tag_display(label: &str, tag: Option<bool>, override_syntax: &str) -> String {
912 match tag {
913 Some(true) => format!(
914 "{label}: suggested TRUE — leave as-is to confirm, or add `{override_syntax}=false` \
915 to your verdict to override"
916 ),
917 Some(false) => format!(
918 "{label}: suggested FALSE (fabrications present but not fully explained by the \
919 known class — worth a real look) — add `{override_syntax}=true` to override"
920 ),
921 None => format!("{label}: not flagged (nothing of this class detected)"),
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 fn entry(tool: &str, verdict: Option<&str>, note: &str) -> Entry {
930 Entry {
931 tool: tool.to_string(),
932 stratum: "ok".to_string(),
933 verdict: verdict.map(str::to_string),
934 note: note.to_string(),
935 k1: None,
936 k2: None,
937 k3: None,
938 include_reason: None,
939 spot_audit_event: None,
940 families: Vec::new(),
941 families_derived: None,
942 amendments: Vec::new(),
943 }
944 }
945
946 fn labelled(tool: &str, verdict: &str, families: &[&str]) -> Entry {
947 Entry {
948 families: families.iter().map(|f| f.to_string()).collect(),
949 families_derived: Some(true),
950 ..entry(tool, Some(verdict), "a real finding")
951 }
952 }
953
954 /// `wrong`/`incomplete` oblige a note; `correct`/`skip` do not. Forcing
955 /// prose out of a reviewer with nothing to add is how a review loop
956 /// starts collecting "n/a".
957 #[test]
958 fn only_wrong_and_incomplete_require_a_note() {
959 assert!(verdict_requires_note("wrong"));
960 assert!(verdict_requires_note("incomplete"));
961 assert!(!verdict_requires_note("correct"));
962 assert!(!verdict_requires_note("skip"));
963 }
964
965 #[test]
966 fn a_blank_or_whitespace_note_does_not_satisfy_the_obligation() {
967 assert!(entry("a", Some("wrong"), "").missing_required_note());
968 assert!(entry("a", Some("wrong"), " ").missing_required_note());
969 assert!(!entry("a", Some("wrong"), "descriptions off by one").missing_required_note());
970 assert!(!entry("a", Some("correct"), "").missing_required_note());
971 assert!(!entry("a", None, "").missing_required_note());
972 }
973
974 /// The self-healing property: three `wrong` verdicts were recorded with
975 /// no note before this rule existed, and the ordinary review walk must
976 /// stop at them again rather than needing a separate repair command.
977 #[test]
978 fn the_walk_revisits_a_verdict_whose_required_note_is_missing() {
979 let file = AuditFile {
980 meta: AuditMeta {
981 seed: 2,
982 sample_size: 4,
983 },
984 entries: vec![
985 entry("noted", Some("wrong"), "real finding"),
986 entry("bare", Some("wrong"), ""),
987 entry("fine", Some("correct"), ""),
988 entry("fresh", None, ""),
989 ],
990 };
991 // `pending` keeps its old meaning, so accuracy arithmetic that
992 // counts a bare `wrong` as judged is unaffected.
993 assert_eq!(file.pending().collect::<Vec<_>>(), vec![3]);
994 // The review walk stops at the bare verdict too.
995 assert_eq!(file.needing_attention().collect::<Vec<_>>(), vec![1, 3]);
996 }
997
998 #[test]
999 fn verdict_path_joins_seed_as_a_toml_filename() {
1000 assert_eq!(
1001 verdict_path(Path::new("audit"), 42),
1002 Path::new("audit/42.toml")
1003 );
1004 }
1005
1006 #[test]
1007 fn save_then_load_round_trips_every_field() {
1008 let tmp = tempfile::tempdir().unwrap();
1009 let path = verdict_path(tmp.path(), 7);
1010 let file = AuditFile {
1011 meta: AuditMeta {
1012 seed: 7,
1013 sample_size: 2,
1014 },
1015 entries: vec![
1016 Entry {
1017 tool: "openssl".to_string(),
1018 stratum: "suspicious".to_string(),
1019 verdict: Some("incomplete".to_string()),
1020 note: "subcommand help never fetched".to_string(),
1021 k1: None,
1022 k2: Some(false),
1023 k3: Some(true),
1024 include_reason: None,
1025 spot_audit_event: None,
1026 families: vec!["unparsed-subcommand".to_string()],
1027 families_derived: Some(true),
1028 amendments: vec![Amendment {
1029 previous_verdict: "incomplete".to_string(),
1030 new_verdict: "wrong".to_string(),
1031 new_note: "actually a genuine parser defect, not just unfetched help"
1032 .to_string(),
1033 reason: "re-read after a related tool surfaced the same shape".to_string(),
1034 }],
1035 },
1036 Entry {
1037 tool: "zoxide".to_string(),
1038 stratum: "ok".to_string(),
1039 verdict: None,
1040 note: String::new(),
1041 k1: None,
1042 k2: None,
1043 k3: None,
1044 include_reason: Some("unaudited promotion".to_string()),
1045 spot_audit_event: Some("bundled-short-flag-942890d".to_string()),
1046 families: Vec::new(),
1047 families_derived: None,
1048 amendments: Vec::new(),
1049 },
1050 ],
1051 };
1052 save(&path, &file).unwrap();
1053 let loaded = load(&path).unwrap();
1054 assert_eq!(loaded.meta.seed, 7);
1055 assert_eq!(loaded.meta.sample_size, 2);
1056 assert_eq!(loaded.entries.len(), 2);
1057 assert_eq!(loaded.entries[0].tool, "openssl");
1058 // The original verdict/note are untouched by the amendment: the
1059 // file still shows what the reviewer originally wrote.
1060 assert_eq!(loaded.entries[0].verdict.as_deref(), Some("incomplete"));
1061 assert_eq!(loaded.entries[0].note, "subcommand help never fetched");
1062 assert_eq!(loaded.entries[0].k3, Some(true));
1063 assert_eq!(loaded.entries[0].families, vec!["unparsed-subcommand"]);
1064 assert_eq!(loaded.entries[0].families_derived, Some(true));
1065 assert!(loaded.entries[1].families.is_empty());
1066 // ...while the amendment history carries the correction.
1067 assert_eq!(loaded.entries[0].amendments.len(), 1);
1068 assert_eq!(
1069 loaded.entries[0].amendments[0].previous_verdict,
1070 "incomplete"
1071 );
1072 assert_eq!(loaded.entries[0].amendments[0].new_verdict, "wrong");
1073 assert_eq!(loaded.entries[0].effective_verdict(), Some("wrong"));
1074 assert_eq!(loaded.entries[1].amendments.len(), 0);
1075 assert_eq!(
1076 loaded.entries[1].include_reason.as_deref(),
1077 Some("unaudited promotion")
1078 );
1079 assert_eq!(
1080 loaded.entries[1].spot_audit_event.as_deref(),
1081 Some("bundled-short-flag-942890d")
1082 );
1083 assert!(loaded.entries[0].spot_audit_event.is_none());
1084 assert_eq!(loaded.pending().collect::<Vec<_>>(), vec![1]);
1085 }
1086
1087 #[test]
1088 fn load_of_a_missing_file_names_the_sample_command() {
1089 let tmp = tempfile::tempdir().unwrap();
1090 let path = verdict_path(tmp.path(), 1);
1091 let err = load(&path).unwrap_err();
1092 assert!(err.to_string().contains("xtask audit sample"));
1093 }
1094
1095 #[test]
1096 fn parse_verdict_word_accepts_short_and_long_forms() {
1097 assert_eq!(parse_verdict_word("c").unwrap(), "correct");
1098 assert_eq!(parse_verdict_word("correct").unwrap(), "correct");
1099 assert_eq!(parse_verdict_word("i").unwrap(), "incomplete");
1100 assert_eq!(parse_verdict_word("incomplete").unwrap(), "incomplete");
1101 assert_eq!(parse_verdict_word("w").unwrap(), "wrong");
1102 assert_eq!(parse_verdict_word("wrong").unwrap(), "wrong");
1103 assert_eq!(parse_verdict_word("s").unwrap(), "skip");
1104 assert_eq!(parse_verdict_word("skip").unwrap(), "skip");
1105 assert!(parse_verdict_word("maybe").is_err());
1106 }
1107
1108 #[test]
1109 fn extract_tag_override_pulls_the_token_out_of_the_note() {
1110 let mut note =
1111 "the extra flags were genuinely wrong k1=false not the gcc defect".to_string();
1112 let k1 = extract_tag_override(&mut note, "k1");
1113 assert_eq!(k1, Some(false));
1114 assert_eq!(
1115 note, "the extra flags were genuinely wrong not the gcc defect",
1116 "the token is removed, the rest of the note survives untouched"
1117 );
1118 }
1119
1120 #[test]
1121 fn extract_tag_override_is_case_insensitive_and_absent_returns_none() {
1122 let mut note = "K1=TRUE looks like the known defect".to_string();
1123 assert_eq!(extract_tag_override(&mut note, "k1"), Some(true));
1124 assert_eq!(extract_tag_override(&mut note, "k2"), None);
1125 }
1126
1127 #[test]
1128 fn extract_tag_override_handles_three_keys_in_one_note() {
1129 let mut note = "k1=true k2=false k3=true mixed causes".to_string();
1130 assert_eq!(extract_tag_override(&mut note, "k1"), Some(true));
1131 assert_eq!(extract_tag_override(&mut note, "k2"), Some(false));
1132 assert_eq!(extract_tag_override(&mut note, "k3"), Some(true));
1133 assert_eq!(note, "mixed causes");
1134 }
1135
1136 #[test]
1137 fn tag_display_names_every_state() {
1138 assert!(tag_display("K3", Some(true), "k3").contains("suggested TRUE"));
1139 assert!(tag_display("K3", Some(false), "k3").contains("suggested FALSE"));
1140 assert!(tag_display("K3", None, "k3").contains("not flagged"));
1141 }
1142
1143 // ------------------------------------------------------------------
1144 // Amendment mechanism
1145 // ------------------------------------------------------------------
1146
1147 /// A manifest written before `amendments` existed — no `[[entry.
1148 /// amendments]]` block anywhere, exactly what every `audit/<seed>.toml`
1149 /// committed before this field was added looks like on disk — must
1150 /// still load, with every entry's `amendments` simply empty. This is
1151 /// the schema's whole backward-compatibility contract: an old file is
1152 /// not a migration, it's already valid.
1153 #[test]
1154 fn a_manifest_with_no_amendments_field_still_loads() {
1155 let tmp = tempfile::tempdir().unwrap();
1156 let path = verdict_path(tmp.path(), 99);
1157 let raw = r#"
1158[meta]
1159seed = 99
1160sample_size = 1
1161
1162[[entry]]
1163tool = "tmux"
1164stratum = "ok"
1165verdict = "correct"
1166k1 = true
1167"#;
1168 std::fs::write(&path, raw).unwrap();
1169 let loaded = load(&path).unwrap();
1170 assert_eq!(loaded.entries.len(), 1);
1171 assert!(loaded.entries[0].amendments.is_empty());
1172 assert_eq!(loaded.entries[0].effective_verdict(), Some("correct"));
1173 assert_eq!(loaded.entries[0].effective_note(), "");
1174 }
1175
1176 /// The ordinary case: a `correct` verdict amended to `wrong`, with a
1177 /// required reason and a required note on the new value (since `wrong`
1178 /// obliges one). `effective_verdict`/`effective_note` must report the
1179 /// amendment; `verdict`/`note` must report the original, untouched.
1180 #[test]
1181 fn amend_appends_history_without_touching_the_original_fields() {
1182 let mut e = entry("tmux", Some("correct"), "");
1183 amend(
1184 &mut e,
1185 "wrong",
1186 "bundled-short-flag collapse, same shape judged wrong elsewhere".to_string(),
1187 "reviewer missed the same defect confirmed on other tools in this review".to_string(),
1188 )
1189 .unwrap();
1190
1191 assert_eq!(e.verdict.as_deref(), Some("correct"), "original preserved");
1192 assert_eq!(e.note, "", "original note preserved");
1193 assert_eq!(e.effective_verdict(), Some("wrong"));
1194 assert_eq!(
1195 e.effective_note(),
1196 "bundled-short-flag collapse, same shape judged wrong elsewhere"
1197 );
1198 assert_eq!(e.amendments.len(), 1);
1199 assert_eq!(e.amendments[0].previous_verdict, "correct");
1200 assert_eq!(e.amendments[0].new_verdict, "wrong");
1201 assert!(!e.amendments[0].reason.is_empty());
1202 }
1203
1204 /// A blank or whitespace-only reason is refused — the required-reason
1205 /// rule this function exists to enforce, mirroring
1206 /// `verdict_requires_note`'s treatment of a blank note.
1207 #[test]
1208 fn amend_refuses_a_blank_reason() {
1209 let mut e = entry("tmux", Some("correct"), "");
1210 let err = amend(
1211 &mut e,
1212 "wrong",
1213 "a real finding".to_string(),
1214 " ".to_string(),
1215 )
1216 .unwrap_err();
1217 assert!(err.to_string().contains("reason"));
1218 assert!(
1219 e.amendments.is_empty(),
1220 "a rejected amendment leaves no trace"
1221 );
1222 }
1223
1224 /// Amending *to* `wrong`/`incomplete` still needs a note on the new
1225 /// value, exactly as an ordinary verdict would — the reason explains
1226 /// why the verdict changed, the note explains what is wrong, and they
1227 /// are not substitutes for each other.
1228 #[test]
1229 fn amend_refuses_a_wrong_verdict_with_no_new_note() {
1230 let mut e = entry("tmux", Some("correct"), "");
1231 let err = amend(&mut e, "wrong", "".to_string(), "a real reason".to_string()).unwrap_err();
1232 assert!(err.to_string().contains("note"));
1233 assert!(e.amendments.is_empty());
1234 }
1235
1236 /// `correct` and `skip` carry no note obligation, so amending *to*
1237 /// either needs no `new_note` — same asymmetry `verdict_requires_note`
1238 /// already encodes for an ordinary verdict.
1239 #[test]
1240 fn amend_to_correct_needs_no_note() {
1241 let mut e = entry("openssl", Some("wrong"), "flags missing");
1242 amend(
1243 &mut e,
1244 "correct",
1245 String::new(),
1246 "re-read against a later capture; the flags were there after all".to_string(),
1247 )
1248 .unwrap();
1249 assert_eq!(e.effective_verdict(), Some("correct"));
1250 assert_eq!(e.effective_note(), "");
1251 }
1252
1253 /// Amending an entry with no verdict yet is refused — this is not a
1254 /// backdoor around the ordinary review flow.
1255 #[test]
1256 fn amend_refuses_an_entry_with_no_verdict_yet() {
1257 let mut e = entry("tmux", None, "");
1258 let err = amend(&mut e, "wrong", "note".to_string(), "reason".to_string()).unwrap_err();
1259 assert!(err.to_string().contains("no verdict yet"));
1260 }
1261
1262 /// Amending to the same verdict the entry already effectively has is
1263 /// refused — nothing is actually changing, so recording an "amendment"
1264 /// would just be noise.
1265 #[test]
1266 fn amend_refuses_a_no_op_amendment() {
1267 let mut e = entry("tmux", Some("correct"), "");
1268 let err = amend(&mut e, "correct", String::new(), "reason".to_string()).unwrap_err();
1269 assert!(err.to_string().contains("already"));
1270 }
1271
1272 /// A second amendment chains onto the first: its `previous_verdict` is
1273 /// the first amendment's `new_verdict`, not the entry's original
1274 /// verdict, so the history reads as a true sequence of corrections.
1275 #[test]
1276 fn a_second_amendment_chains_onto_the_first() {
1277 let mut e = entry("tmux", Some("correct"), "");
1278 amend(
1279 &mut e,
1280 "wrong",
1281 "first finding".to_string(),
1282 "first reason".to_string(),
1283 )
1284 .unwrap();
1285 amend(
1286 &mut e,
1287 "incomplete",
1288 "actually just incomplete, not fully wrong".to_string(),
1289 "reconsidered after further review".to_string(),
1290 )
1291 .unwrap();
1292 assert_eq!(e.amendments.len(), 2);
1293 assert_eq!(e.amendments[0].previous_verdict, "correct");
1294 assert_eq!(e.amendments[0].new_verdict, "wrong");
1295 assert_eq!(e.amendments[1].previous_verdict, "wrong");
1296 assert_eq!(e.amendments[1].new_verdict, "incomplete");
1297 assert_eq!(e.effective_verdict(), Some("incomplete"));
1298 }
1299
1300 /// An amendment round-trips through `save`/`load` byte-for-byte in
1301 /// meaning: every field of the [`Amendment`] survives, and
1302 /// `effective_verdict`/`effective_note` on the reloaded entry agree
1303 /// with the in-memory value before it was written.
1304 #[test]
1305 fn an_amendment_round_trips_through_save_and_load() {
1306 let tmp = tempfile::tempdir().unwrap();
1307 let path = verdict_path(tmp.path(), 2);
1308 let mut e = entry("tmux", Some("correct"), "");
1309 amend(
1310 &mut e,
1311 "wrong",
1312 "bundled-short-flag collapse".to_string(),
1313 "reviewer inconsistency caught in reconciliation".to_string(),
1314 )
1315 .unwrap();
1316 let file = AuditFile {
1317 meta: AuditMeta {
1318 seed: 2,
1319 sample_size: 1,
1320 },
1321 entries: vec![e],
1322 };
1323 save(&path, &file).unwrap();
1324 let loaded = load(&path).unwrap();
1325 assert_eq!(loaded.entries[0].verdict.as_deref(), Some("correct"));
1326 assert_eq!(loaded.entries[0].effective_verdict(), Some("wrong"));
1327 assert_eq!(
1328 loaded.entries[0].amendments[0].reason,
1329 "reviewer inconsistency caught in reconciliation"
1330 );
1331 }
1332
1333 // ------------------------------------------------------------------
1334 // Defect-family labels
1335 // ------------------------------------------------------------------
1336
1337 /// A manifest written before `families` existed loads unchanged, with
1338 /// every entry simply unlabelled — the same backward-compatibility
1339 /// contract `amendments` carries, and the reason the seed-2 file could
1340 /// be backfilled incrementally rather than migrated wholesale.
1341 #[test]
1342 fn a_manifest_with_no_families_field_still_loads() {
1343 let tmp = tempfile::tempdir().unwrap();
1344 let path = verdict_path(tmp.path(), 98);
1345 std::fs::write(
1346 &path,
1347 "[meta]\nseed = 98\nsample_size = 1\n\n[[entry]]\ntool = \"tcpdump\"\nstratum = \
1348 \"ok\"\nverdict = \"wrong\"\nnote = \"single dash issue\"\n",
1349 )
1350 .unwrap();
1351 let loaded = load(&path).unwrap();
1352 assert!(loaded.entries[0].families.is_empty());
1353 assert_eq!(loaded.entries[0].families_derived, None);
1354 loaded.validate_families().unwrap();
1355 // ...and an unlabelled judged defect reads as unclassified, not as
1356 // "no defect family applies".
1357 assert!(loaded.entries[0].is_unclassified());
1358 }
1359
1360 #[test]
1361 fn every_family_name_is_kebab_case_and_unique() {
1362 let mut seen = Vec::new();
1363 for f in DEFECT_FAMILIES {
1364 assert!(
1365 f.name
1366 .chars()
1367 .all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()),
1368 "{:?} is not kebab-case",
1369 f.name
1370 );
1371 assert!(!f.meaning.trim().is_empty(), "{:?} has no meaning", f.name);
1372 assert!(!seen.contains(&f.name), "{:?} listed twice", f.name);
1373 seen.push(f.name);
1374 }
1375 }
1376
1377 #[test]
1378 fn parse_family_accepts_the_set_and_names_it_on_failure() {
1379 assert_eq!(
1380 parse_family("bundled-short-flag").unwrap(),
1381 "bundled-short-flag"
1382 );
1383 let err = parse_family("bundled_short_flag").unwrap_err().to_string();
1384 assert!(err.contains("unrecognized defect family"));
1385 assert!(
1386 err.contains("bundled-short-flag"),
1387 "the error must name the valid set, not just reject: {err}"
1388 );
1389 assert!(family_meaning("no-such-family").is_none());
1390 }
1391
1392 /// The claim-strength rule this field exists for: labels with no
1393 /// recorded provenance are refused outright, so a writer that forgets
1394 /// the field cannot launder a machine reading of a reviewer's prose
1395 /// into the reviewer's own classification.
1396 #[test]
1397 fn families_without_recorded_provenance_are_refused() {
1398 let mut e = entry("tcpdump", Some("wrong"), "single dash issue");
1399 e.families = vec!["bundled-short-flag".to_string()];
1400 let err = e.validate_families().unwrap_err().to_string();
1401 assert!(err.contains("families_derived"), "{err}");
1402
1403 e.families_derived = Some(true);
1404 e.validate_families().unwrap();
1405 }
1406
1407 #[test]
1408 fn an_unrecognized_or_duplicated_family_is_refused() {
1409 let mut e = labelled("tcpdump", "wrong", &["not-a-real-family"]);
1410 assert!(e.validate_families().is_err());
1411
1412 e.families = vec![
1413 "bundled-short-flag".to_string(),
1414 "bundled-short-flag".to_string(),
1415 ];
1416 let err = e.validate_families().unwrap_err().to_string();
1417 assert!(err.contains("twice"), "{err}");
1418 }
1419
1420 /// A family says what is *wrong*, so a verdict that names no defect
1421 /// cannot carry one — otherwise a `correct` tool would sit in a
1422 /// detector's expected-fires set on the strength of a verdict saying
1423 /// nothing is wrong with it.
1424 #[test]
1425 fn a_correct_verdict_may_not_carry_family_labels() {
1426 let e = labelled("tmux", "correct", &["bundled-short-flag"]);
1427 let err = e.validate_families().unwrap_err().to_string();
1428 assert!(err.contains("names no defect"), "{err}");
1429 }
1430
1431 /// Labels follow the *effective* verdict, so an amendment that turns a
1432 /// `correct` into a `wrong` makes that entry labellable — which is
1433 /// exactly how `tmux` entered the bundled-short-flag calibration set.
1434 #[test]
1435 fn an_amended_verdict_decides_whether_labels_are_allowed() {
1436 let mut e = labelled("tmux", "correct", &["bundled-short-flag"]);
1437 assert!(e.validate_families().is_err());
1438 e.verdict = Some("correct".to_string());
1439 amend(
1440 &mut e,
1441 "wrong",
1442 "bundled-short-flag collapse".to_string(),
1443 "reviewer inconsistency caught in reconciliation".to_string(),
1444 )
1445 .unwrap();
1446 e.validate_families().unwrap();
1447 assert!(e.is_judged_defect());
1448 assert!(!e.is_judged_correct());
1449 assert!(e.has_family("bundled-short-flag"));
1450 assert!(!e.is_unclassified());
1451 }
1452
1453 /// `skip` is neither good nor bad for calibration purposes: it records
1454 /// no judgment about the parse, so counting it as either side would put
1455 /// a number in a confusion matrix that no human ever supported.
1456 #[test]
1457 fn skip_is_neither_a_judged_defect_nor_a_judged_correct() {
1458 let e = entry("xzgrep", Some("skip"), "");
1459 assert!(!e.is_judged_defect());
1460 assert!(!e.is_judged_correct());
1461 assert!(!e.is_unclassified());
1462 }
1463
1464 /// task #28: a judged defect whose only family is `display-only` is
1465 /// out of `xtask::audit::accuracy_over`'s denominator, but nothing
1466 /// about the record itself changes — it is still `wrong`/`incomplete`,
1467 /// still carries its note, still `is_judged_defect()`.
1468 #[test]
1469 fn is_display_only_true_for_a_pure_display_only_verdict() {
1470 let e = labelled("pcre2-config", "wrong", &["display-only"]);
1471 assert!(e.is_display_only());
1472 assert!(e.is_judged_defect());
1473 assert!(!e.is_unclassified());
1474 }
1475
1476 /// The precedent this method follows —
1477 /// `xtask::detector::Ground::BelowMemberThreshold` — exists because a
1478 /// free-text exclusion reason can justify anything. Here the
1479 /// equivalent forgeable move is tacking `display-only` onto an entry
1480 /// that already carries a real parse-shape family: two true labels
1481 /// must not add up to an exclusion neither one earns alone.
1482 #[test]
1483 fn is_display_only_false_when_a_real_family_rides_along() {
1484 let e = labelled("tcpdump", "wrong", &["bundled-short-flag", "display-only"]);
1485 assert!(!e.is_display_only());
1486 }
1487
1488 /// `correct`/`skip`/pending entries are never display-only-excludable
1489 /// — there is no accuracy exclusion to grant an entry with no judged
1490 /// defect on it in the first place.
1491 #[test]
1492 fn is_display_only_false_off_a_judged_defect() {
1493 assert!(!labelled("tmux", "correct", &[]).is_display_only());
1494 assert!(!entry("xzgrep", Some("skip"), "").is_display_only());
1495 assert!(!entry("fresh", None, "").is_display_only());
1496 }
1497
1498 #[test]
1499 fn unclassified_lists_judged_defects_with_no_label() {
1500 let file = AuditFile {
1501 meta: AuditMeta {
1502 seed: 2,
1503 sample_size: 4,
1504 },
1505 entries: vec![
1506 labelled("tcpdump", "wrong", &["bundled-short-flag"]),
1507 entry("pptpsetup", Some("incomplete"), "bad parse"),
1508 entry("wall", Some("correct"), ""),
1509 entry("xzgrep", Some("skip"), ""),
1510 ],
1511 };
1512 file.validate_families().unwrap();
1513 let names: Vec<&str> = file.unclassified().map(|e| e.tool.as_str()).collect();
1514 assert_eq!(names, vec!["pptpsetup"]);
1515 }
1516}