Skip to main content

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}