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 considers each line's
65    /// *first* token, so a multi-column or comma-separated
66    /// applet/subcommand list reports every column after the first as
67    /// "fabricated" even though it's right there in the raw text).
68    /// Computed once, at sample time, by `xtask::audit::k2_signature`.
69    /// `Some(true)` when every subcommand-kind existence fabrication for
70    /// this tool is explained by the known tokenizer gap, `Some(false)`
71    /// when at least one is not (worth a real look), `None` when the tool
72    /// has no subcommand-kind fabrications to judge at all.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub k2: Option<bool>,
75    /// **K3 pre-tag**: "subcommand help was never fetched, so this node is
76    /// a bare stub." Two distinct causes produce it, both computed once at
77    /// sample time by `xtask::audit::k3_signature` from the same
78    /// single-pass snapshot K1/K2 use, and both should tag:
79    ///
80    /// - the attestation gate refused to probe a subcommand because its
81    ///   name came from a native/cobra artifact rather than a recognized
82    ///   `--help` heading (`git-lfs`: 36 nodes, 34 suspects, status
83    ///   `suspicious`, every subcommand a cobra stub — and, unlike an
84    ///   ordinary un-recursed node that just hasn't been fetched *yet*,
85    ///   this shape is structurally permanent: the gate refuses it live,
86    ///   in the TUI, exactly as it does here);
87    /// - the tool's subcommands simply carry no flags because their own
88    ///   help was never fetched (`openssl`: 151 subcommands, zero flags
89    ///   anywhere in the extracted tree, root included).
90    ///
91    /// Without this, a reviewer re-derives the same "still empty, still not
92    /// this tool's fault" verdict once per subcommand. `Some(true)` when the
93    /// tool's snapshot shows at least one of the two shapes, `None`
94    /// otherwise — the same "no `Some(false)`" convention as K1, since
95    /// there is nothing to assert-not for a tool that shows neither shape.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub k3: Option<bool>,
98    /// `Some(reason)` when this entry was force-included in the sample
99    /// outside the normal stratified draw (see `xtask::audit::cmd_sample`'s
100    /// `force_include` parameter). `None` for an entry drawn by the
101    /// ordinary stratified sample.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub include_reason: Option<String>,
104}
105
106impl Entry {
107    /// True when this entry's note is obligatory but missing or blank — a
108    /// `wrong`/`incomplete` verdict with nothing recorded about *what* was
109    /// wrong. See [`verdict_requires_note`].
110    pub fn missing_required_note(&self) -> bool {
111        self.verdict.as_deref().is_some_and(verdict_requires_note) && self.note.trim().is_empty()
112    }
113
114    /// True when a review session should still stop at this entry: no
115    /// verdict yet, or a verdict whose obligatory note never got written.
116    pub fn needs_attention(&self) -> bool {
117        self.verdict.is_none() || self.missing_required_note()
118    }
119}
120
121/// The persisted state of one audit run: everything needed to resume, and
122/// nothing that would make two runs of `sample` with the same `--seed`
123/// disagree with each other.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AuditFile {
126    /// The seed and sample size that produced this file.
127    pub meta: AuditMeta,
128    /// Every sampled tool, reviewed or not, in file order.
129    #[serde(default, rename = "entry")]
130    pub entries: Vec<Entry>,
131}
132
133/// [`AuditFile`]'s own metadata: the seed and requested sample size that
134/// produced it, re-asserted whenever the sample is (re)drawn so a stale
135/// `--sample`/`--seed` combination against an existing file is a loud
136/// error, never a silent merge.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct AuditMeta {
139    /// The seed the stratified draw used.
140    pub seed: u64,
141    /// The total sample size requested at draw time.
142    pub sample_size: usize,
143}
144
145impl AuditFile {
146    /// Indices of entries with no verdict yet, in file order — the ordered
147    /// walk both `xtask audit review` and `mandible --review` follow, so an
148    /// interrupted session resumes at the same entry regardless of which of
149    /// the two last touched the file.
150    pub fn pending(&self) -> impl Iterator<Item = usize> + '_ {
151        self.entries
152            .iter()
153            .enumerate()
154            .filter(|(_, e)| e.verdict.is_none())
155            .map(|(i, _)| i)
156    }
157
158    /// Indices of entries a review session should still stop at, in file
159    /// order: everything [`Self::pending`] yields, plus anything already
160    /// judged `wrong`/`incomplete` whose note is missing or blank
161    /// ([`verdict_requires_note`]).
162    ///
163    /// The second half exists because such an entry is a *record* that is
164    /// incomplete even though a verdict was given. For accuracy arithmetic
165    /// it counts as judged and always did — the tool really was judged
166    /// wrong — but for the triage the audit exists to feed it is useless: it
167    /// names a tool and says nothing about what was wrong with it. Rather
168    /// than a separate repair command, the ordinary walk simply stops there
169    /// again, so a session that recorded bare verdicts before this rule
170    /// existed heals itself on the next run.
171    pub fn needing_attention(&self) -> impl Iterator<Item = usize> + '_ {
172        self.entries
173            .iter()
174            .enumerate()
175            .filter(|(_, e)| e.needs_attention())
176            .map(|(i, _)| i)
177    }
178}
179
180/// Whether a verdict word obliges the reviewer to write a note.
181///
182/// `wrong` and `incomplete` do: for those two the note *is* the finding, and
183/// the whole point of the audit is to hand a later fix something actionable.
184/// `correct` and `skip` do not — "it parsed correctly" is complete on its
185/// own, and forcing prose out of a reviewer who has nothing to add is how a
186/// review loop starts collecting "n/a".
187pub fn verdict_requires_note(verdict: &str) -> bool {
188    matches!(verdict, "wrong" | "incomplete")
189}
190
191/// The path a given `(dir, seed)` pair resolves to: `<dir>/<seed>.toml`.
192pub fn verdict_path(dir: &Path, seed: u64) -> PathBuf {
193    dir.join(format!("{seed}.toml"))
194}
195
196/// Read and parse `path` as an [`AuditFile`].
197pub fn load(path: &Path) -> anyhow::Result<AuditFile> {
198    let raw = std::fs::read_to_string(path).map_err(|e| {
199        anyhow::anyhow!(
200            "reading {}: {e} (run `xtask audit sample` first)",
201            path.display()
202        )
203    })?;
204    toml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))
205}
206
207/// Serialize and write `file` to `path`, creating its parent directory if
208/// needed. Called after **every** verdict by both `xtask audit review` and
209/// `mandible --review` — never batched — so a killed process leaves
210/// everything answered so far recorded and everything else still pending.
211pub fn save(path: &Path, file: &AuditFile) -> anyhow::Result<()> {
212    if let Some(parent) = path.parent() {
213        if !parent.as_os_str().is_empty() {
214            std::fs::create_dir_all(parent)
215                .map_err(|e| anyhow::anyhow!("creating {}: {e}", parent.display()))?;
216        }
217    }
218    let text = toml::to_string_pretty(file)
219        .map_err(|e| anyhow::anyhow!("serializing {}: {e}", path.display()))?;
220    std::fs::write(path, text).map_err(|e| anyhow::anyhow!("writing {}: {e}", path.display()))
221}
222
223/// Parse a verdict word (`c`/`correct`, `i`/`incomplete`, `w`/`wrong`,
224/// `s`/`skip`) to its canonical spelling. Shared by every entry point that
225/// accepts a verdict — typed live in `xtask audit review`, read from a
226/// verdicts file by `xtask audit ingest`, or chosen by a keypress in
227/// `mandible --review` — so none of them can disagree about what counts as
228/// a valid verdict.
229pub fn parse_verdict_word(word: &str) -> anyhow::Result<&'static str> {
230    match word {
231        "c" | "correct" => Ok("correct"),
232        "i" | "incomplete" => Ok("incomplete"),
233        "w" | "wrong" => Ok("wrong"),
234        "s" | "skip" => Ok("skip"),
235        other => anyhow::bail!(
236            "unrecognized verdict {other:?} — expected one of: c/correct, i/incomplete, w/wrong, s/skip"
237        ),
238    }
239}
240
241/// Pull any `k1=true`/`k1=false`/`k2=true`/`k2=false`/`k3=true`/`k3=false`
242/// (case-insensitive) token for `key` out of `text`, in place, returning the
243/// override it specified (if any). The token is removed from `text`
244/// regardless of position — a reviewer's note is free-form prose, not a
245/// fixed field order — so what remains is the plain note with no tag syntax
246/// left in it. Shared by every entry point that accepts a note, for the
247/// same reason [`parse_verdict_word`] is.
248pub fn extract_tag_override(text: &mut String, key: &str) -> Option<bool> {
249    let true_tok = format!("{key}=true");
250    let false_tok = format!("{key}=false");
251    let mut found = None;
252    let kept: Vec<&str> = text
253        .split_whitespace()
254        .filter(|tok| {
255            if tok.eq_ignore_ascii_case(&true_tok) {
256                found = Some(true);
257                false
258            } else if tok.eq_ignore_ascii_case(&false_tok) {
259                found = Some(false);
260                false
261            } else {
262                true
263            }
264        })
265        .collect();
266    *text = kept.join(" ");
267    found
268}
269
270/// Human-readable line for a pre-tag, shown to the reviewer before they
271/// record a verdict — the whole point of [`Entry::k1`]/[`Entry::k2`]/
272/// [`Entry::k3`] is that this line lets a reviewer confirm-or-override in
273/// one glance instead of re-deriving the same known defect per flag.
274pub fn tag_display(label: &str, tag: Option<bool>, override_syntax: &str) -> String {
275    match tag {
276        Some(true) => format!(
277            "{label}: suggested TRUE — leave as-is to confirm, or add `{override_syntax}=false` \
278             to your verdict to override"
279        ),
280        Some(false) => format!(
281            "{label}: suggested FALSE (fabrications present but not fully explained by the \
282             known class — worth a real look) — add `{override_syntax}=true` to override"
283        ),
284        None => format!("{label}: not flagged (nothing of this class detected)"),
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn entry(tool: &str, verdict: Option<&str>, note: &str) -> Entry {
293        Entry {
294            tool: tool.to_string(),
295            stratum: "ok".to_string(),
296            verdict: verdict.map(str::to_string),
297            note: note.to_string(),
298            k1: None,
299            k2: None,
300            k3: None,
301            include_reason: None,
302        }
303    }
304
305    /// `wrong`/`incomplete` oblige a note; `correct`/`skip` do not. Forcing
306    /// prose out of a reviewer with nothing to add is how a review loop
307    /// starts collecting "n/a".
308    #[test]
309    fn only_wrong_and_incomplete_require_a_note() {
310        assert!(verdict_requires_note("wrong"));
311        assert!(verdict_requires_note("incomplete"));
312        assert!(!verdict_requires_note("correct"));
313        assert!(!verdict_requires_note("skip"));
314    }
315
316    #[test]
317    fn a_blank_or_whitespace_note_does_not_satisfy_the_obligation() {
318        assert!(entry("a", Some("wrong"), "").missing_required_note());
319        assert!(entry("a", Some("wrong"), "   ").missing_required_note());
320        assert!(!entry("a", Some("wrong"), "descriptions off by one").missing_required_note());
321        assert!(!entry("a", Some("correct"), "").missing_required_note());
322        assert!(!entry("a", None, "").missing_required_note());
323    }
324
325    /// The self-healing property: three `wrong` verdicts were recorded with
326    /// no note before this rule existed, and the ordinary review walk must
327    /// stop at them again rather than needing a separate repair command.
328    #[test]
329    fn the_walk_revisits_a_verdict_whose_required_note_is_missing() {
330        let file = AuditFile {
331            meta: AuditMeta {
332                seed: 2,
333                sample_size: 4,
334            },
335            entries: vec![
336                entry("noted", Some("wrong"), "real finding"),
337                entry("bare", Some("wrong"), ""),
338                entry("fine", Some("correct"), ""),
339                entry("fresh", None, ""),
340            ],
341        };
342        // `pending` keeps its old meaning, so accuracy arithmetic that
343        // counts a bare `wrong` as judged is unaffected.
344        assert_eq!(file.pending().collect::<Vec<_>>(), vec![3]);
345        // The review walk stops at the bare verdict too.
346        assert_eq!(file.needing_attention().collect::<Vec<_>>(), vec![1, 3]);
347    }
348
349    #[test]
350    fn verdict_path_joins_seed_as_a_toml_filename() {
351        assert_eq!(
352            verdict_path(Path::new("audit"), 42),
353            Path::new("audit/42.toml")
354        );
355    }
356
357    #[test]
358    fn save_then_load_round_trips_every_field() {
359        let tmp = tempfile::tempdir().unwrap();
360        let path = verdict_path(tmp.path(), 7);
361        let file = AuditFile {
362            meta: AuditMeta {
363                seed: 7,
364                sample_size: 2,
365            },
366            entries: vec![
367                Entry {
368                    tool: "openssl".to_string(),
369                    stratum: "suspicious".to_string(),
370                    verdict: Some("incomplete".to_string()),
371                    note: "subcommand help never fetched".to_string(),
372                    k1: None,
373                    k2: Some(false),
374                    k3: Some(true),
375                    include_reason: None,
376                },
377                Entry {
378                    tool: "zoxide".to_string(),
379                    stratum: "ok".to_string(),
380                    verdict: None,
381                    note: String::new(),
382                    k1: None,
383                    k2: None,
384                    k3: None,
385                    include_reason: Some("unaudited promotion".to_string()),
386                },
387            ],
388        };
389        save(&path, &file).unwrap();
390        let loaded = load(&path).unwrap();
391        assert_eq!(loaded.meta.seed, 7);
392        assert_eq!(loaded.meta.sample_size, 2);
393        assert_eq!(loaded.entries.len(), 2);
394        assert_eq!(loaded.entries[0].tool, "openssl");
395        assert_eq!(loaded.entries[0].verdict.as_deref(), Some("incomplete"));
396        assert_eq!(loaded.entries[0].k3, Some(true));
397        assert_eq!(
398            loaded.entries[1].include_reason.as_deref(),
399            Some("unaudited promotion")
400        );
401        assert_eq!(loaded.pending().collect::<Vec<_>>(), vec![1]);
402    }
403
404    #[test]
405    fn load_of_a_missing_file_names_the_sample_command() {
406        let tmp = tempfile::tempdir().unwrap();
407        let path = verdict_path(tmp.path(), 1);
408        let err = load(&path).unwrap_err();
409        assert!(err.to_string().contains("xtask audit sample"));
410    }
411
412    #[test]
413    fn parse_verdict_word_accepts_short_and_long_forms() {
414        assert_eq!(parse_verdict_word("c").unwrap(), "correct");
415        assert_eq!(parse_verdict_word("correct").unwrap(), "correct");
416        assert_eq!(parse_verdict_word("i").unwrap(), "incomplete");
417        assert_eq!(parse_verdict_word("incomplete").unwrap(), "incomplete");
418        assert_eq!(parse_verdict_word("w").unwrap(), "wrong");
419        assert_eq!(parse_verdict_word("wrong").unwrap(), "wrong");
420        assert_eq!(parse_verdict_word("s").unwrap(), "skip");
421        assert_eq!(parse_verdict_word("skip").unwrap(), "skip");
422        assert!(parse_verdict_word("maybe").is_err());
423    }
424
425    #[test]
426    fn extract_tag_override_pulls_the_token_out_of_the_note() {
427        let mut note =
428            "the extra flags were genuinely wrong k1=false not the gcc defect".to_string();
429        let k1 = extract_tag_override(&mut note, "k1");
430        assert_eq!(k1, Some(false));
431        assert_eq!(
432            note, "the extra flags were genuinely wrong not the gcc defect",
433            "the token is removed, the rest of the note survives untouched"
434        );
435    }
436
437    #[test]
438    fn extract_tag_override_is_case_insensitive_and_absent_returns_none() {
439        let mut note = "K1=TRUE looks like the known defect".to_string();
440        assert_eq!(extract_tag_override(&mut note, "k1"), Some(true));
441        assert_eq!(extract_tag_override(&mut note, "k2"), None);
442    }
443
444    #[test]
445    fn extract_tag_override_handles_three_keys_in_one_note() {
446        let mut note = "k1=true k2=false k3=true mixed causes".to_string();
447        assert_eq!(extract_tag_override(&mut note, "k1"), Some(true));
448        assert_eq!(extract_tag_override(&mut note, "k2"), Some(false));
449        assert_eq!(extract_tag_override(&mut note, "k3"), Some(true));
450        assert_eq!(note, "mixed causes");
451    }
452
453    #[test]
454    fn tag_display_names_every_state() {
455        assert!(tag_display("K3", Some(true), "k3").contains("suggested TRUE"));
456        assert!(tag_display("K3", Some(false), "k3").contains("suggested FALSE"));
457        assert!(tag_display("K3", None, "k3").contains("not flagged"));
458    }
459}