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}