Skip to main content

supercode_harness/
audit.rs

1//! Corpus coverage audit.
2//!
3//! Walks a directory of session logs, parses every line through the typed
4//! [`crate::schema`], and reports — with real counts — exactly what we model,
5//! what we model-but-drop on normalization, and what we don't model at all.
6//! This is the machine that turns "what's missing?" into an enumerated answer
7//! rather than a guess.
8//!
9//! ```no_run
10//! use std::path::Path;
11//! use supercode_harness::audit::{audit_dir, Corpus};
12//!
13//! let report = audit_dir(Path::new("/home/me/.codex/sessions"), Corpus::Codex, None);
14//! report.print();
15//! ```
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde_json::Value;
21
22use crate::schema::{claude_code::*, codex::*, raw_block_tag, ContentBlock};
23use crate::session::{opencode_file_image_part, pi_content_has_unknown_image_shape};
24
25/// Which corpus a directory holds.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Corpus {
28    /// `~/.claude/projects`
29    ClaudeCode,
30    /// `~/.codex/sessions`
31    Codex,
32    /// `~/.pi/agent/sessions`
33    Pi,
34    /// `~/.local/share/opencode` (envelope-form fixtures/corpus — see
35    /// `docs/interop/opencode-pi-spec.md` §1.2/§4.1).
36    OpenCode,
37    /// `~/.grok/sessions` (`chat_history.jsonl` files only; companion
38    /// `updates.jsonl` streams are live protocol events, not transcripts).
39    Grok,
40    /// `~/.gemini/tmp/<project>/chats` Gemini CLI JSONL transcripts.
41    Gemini,
42    /// Goose's `sessions/sessions.db` native store.
43    Goose,
44}
45
46/// How a given discriminant is handled by the loader.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
48pub enum Coverage {
49    /// Parsed and normalized into the canonical conversation.
50    Normalized,
51    /// PARITY-12/PARITY-13 (P012/P013): parsed and its content/provenance IS
52    /// captured by the loader — into `Session.meta` (Codex `session_meta`'s
53    /// id/cwd/model/base_instructions, `turn_context`'s model), replay
54    /// semantics (`thread_rolled_back` actually removes the rolled-back
55    /// turns, `exited_review_mode`'s `review_output.overall_explanation`
56    /// becomes a message with `review_output.findings` AND
57    /// `overall_correctness`/`overall_confidence_score` (N4) captured onto
58    /// that message's metadata (D4/N4), `thread_goal_updated`'s
59    /// `goal.objective` becomes a message with `goal.status`/
60    /// `goal.tokenBudget` captured onto that message's metadata too (D4),
61    /// `agent_message` can become a message when its text has no
62    /// `response_item` twin) — just not 1:1 into a `ChatMessage` the way
63    /// `Normalized` records are.
64    /// This is the "retained, not dropped" bucket the two audits were
65    /// missing: before this variant existed, every one of these landed in
66    /// `Dropped` indistinguishably from truly-inert UI noise (`token_count`,
67    /// `task_started`, …), which is exactly the false "silently dropped"
68    /// signal both items' dev/03 ACs flag.
69    ///
70    /// `response_item/reasoning` also belongs here (D5, PARITY-12): its
71    /// `summary` text, raw `content` chain-of-thought text when non-null
72    /// (N2 — previously dropped despite this very label claiming otherwise;
73    /// `content` is `null` on the vast majority of real turns, so this was
74    /// easy to miss until fixtures carried the key at all), and the
75    /// `encrypted_content` presence flag (N1: only a genuinely non-null
76    /// value counts — `serde_json` returns `Some(&Value::Null)` for a
77    /// present-but-null key, which is what EVERY real rollout's reasoning
78    /// item carries per upstream `codex-rs/protocol/src/models.rs:970-983`,
79    /// so a naive `.is_some()` false-flagged every reasoning item as
80    /// "encrypted" on real data) are captured by `Session::from_codex_str`
81    /// onto the *next* assistant `ChatMessage`'s metadata (`reasoning`/
82    /// `reasoning_content`/`reasoning_encrypted`) — see `audit_codex_item`'s
83    /// `Reasoning` arm. When no following assistant turn exists to attach to
84    /// (a non-assistant item interrupts, or the reasoning is dangling at
85    /// EOF — an aborted-turn shape, N3), it is flushed as its own synthesized
86    /// `[reasoning] (turn ended without a reply)` message instead of being
87    /// silently discarded, so this label stays honest for that shape too.
88    /// It isn't `Normalized` (no canonical "reasoning" `ChatMessage`), but it
89    /// is provably not a blind drop either.
90    ///
91    /// DISCLOSURE: every metadata key mentioned above (`review_findings`,
92    /// `review_overall_correctness`, `review_overall_confidence_score`,
93    /// `goal_status`, `goal_token_budget`, `reasoning`, `reasoning_content`,
94    /// `reasoning_encrypted`) is LOADER-CAPTURE ONLY. It survives the
95    /// verbatim Codex→Codex diagonal (raw bytes, untouched) and reads back
96    /// out of the native supercode format, but it does NOT survive a
97    /// cross-format writer or the `--session-id` re-serialized diagonal:
98    /// `ChatMessage.metadata` is never serialized (`message.rs:50-55`) and no
99    /// writer reads it back out. Don't misread `Retained` here as
100    /// cross-format-durable — it means "captured in-process", not "written
101    /// back out".
102    Retained,
103    /// Parsed and understood, but intentionally dropped (e.g. `token_count`,
104    /// `task_started`/`task_complete`, UI echoes of content already captured
105    /// elsewhere as `Normalized`/`Retained`). See `event_msg_coverage`'s doc
106    /// comment for the few real, currently-unrecovered exceptions (D1) —
107    /// e.g. `patch_apply_end`'s `changes[path].unified_diff` — where
108    /// `Dropped` means genuine, asserted content loss, not "duplicated
109    /// elsewhere".
110    Dropped,
111    /// Not modeled at all — falls into an `Unknown` typed bucket.
112    Unmodeled,
113}
114
115impl Coverage {
116    fn symbol(self) -> &'static str {
117        match self {
118            Coverage::Normalized => "✅ normalized",
119            Coverage::Retained => "◆ retained  ",
120            Coverage::Dropped => "➖ dropped   ",
121            Coverage::Unmodeled => "❌ UNMODELED ",
122        }
123    }
124}
125
126/// A tally for one discriminant value.
127#[derive(Debug, Clone, Default)]
128#[non_exhaustive]
129pub struct Tally {
130    /// How many times it occurred.
131    pub count: u64,
132    /// Field keys seen in `extra` (fields we didn't model), with counts.
133    pub unmodeled_fields: BTreeMap<String, u64>,
134}
135
136/// The full audit result.
137#[derive(Debug, Default)]
138#[non_exhaustive]
139pub struct Report {
140    /// Which corpus this is.
141    pub corpus: Option<&'static str>,
142    /// Files scanned.
143    pub files: u64,
144    /// Lines parsed.
145    pub lines: u64,
146    /// Lines that failed to deserialize even into the typed schema.
147    pub parse_errors: u64,
148    /// Per record/payload discriminant: (coverage, tally). Keyed by a readable
149    /// path like `response_item/custom_tool_call`.
150    pub records: BTreeMap<String, (Coverage, Tally)>,
151    /// Content block discriminants seen, with counts SPLIT by the coverage
152    /// each instance actually got (N1, Fable-5 review). Keyed by
153    /// `(tag, coverage)` rather than `tag` alone: D5 made `image` coverage
154    /// PER-INSTANCE (a `base64`/`url` source is `Normalized`, a Files-API/
155    /// `file` source is `Dropped`), so a single `tag -> (Coverage, count)`
156    /// entry — last-write-wins on `Coverage` — silently collapsed a mixed
157    /// corpus's genuinely-`Dropped` instances into whatever coverage the
158    /// LAST-seen instance of that tag happened to have, over- or
159    /// under-claiming fidelity depending on file order. Splitting the bucket
160    /// keeps every instance's actual coverage and never collapses counts.
161    pub blocks: BTreeMap<(String, Coverage), u64>,
162    /// Tool names seen, with counts.
163    pub tools: BTreeMap<String, u64>,
164    /// Structural notes discovered while scanning (e.g. sidechain lines).
165    pub notes: BTreeMap<String, u64>,
166}
167
168impl Report {
169    fn bump(&mut self, key: String, cov: Coverage, extra: &crate::schema::ExtraFields) {
170        let entry = self.records.entry(key).or_insert((cov, Tally::default()));
171        entry.0 = cov;
172        entry.1.count += 1;
173        for k in extra.keys() {
174            *entry.1.unmodeled_fields.entry(k.clone()).or_insert(0) += 1;
175        }
176    }
177
178    fn bump_block(&mut self, block: &ContentBlock, raw: &Value) {
179        let (tag, cov) = match block.tag() {
180            Some(t) => (t.to_string(), block_coverage(block)),
181            None => (
182                raw_block_tag(raw).unwrap_or_else(|| "<no-type>".into()),
183                Coverage::Unmodeled,
184            ),
185        };
186        // N1: bucket on (tag, coverage), not tag alone — see the `blocks`
187        // field doc. Each instance is counted under its OWN actual coverage
188        // instead of one shared, last-write-wins `Coverage` per tag.
189        *self.blocks.entry((tag, cov)).or_insert(0) += 1;
190    }
191
192    fn note(&mut self, key: &str) {
193        *self.notes.entry(key.to_string()).or_insert(0) += 1;
194    }
195
196    /// Serialize the report as structured JSON (for CI/dashboards).
197    pub fn to_json(&self) -> serde_json::Value {
198        let cov = |c: Coverage| match c {
199            Coverage::Normalized => "normalized",
200            Coverage::Retained => "retained",
201            Coverage::Dropped => "dropped",
202            Coverage::Unmodeled => "unmodeled",
203        };
204        let records: serde_json::Map<String, serde_json::Value> = self
205            .records
206            .iter()
207            .map(|(k, (c, t))| {
208                (
209                    k.clone(),
210                    serde_json::json!({
211                        "coverage": cov(*c),
212                        "count": t.count,
213                        "unmodeled_fields": t.unmodeled_fields.keys().collect::<Vec<_>>(),
214                    }),
215                )
216            })
217            .collect();
218        // N1: a tag can now have MULTIPLE coverage buckets (e.g. `image` ->
219        // Normalized:1, Dropped:1 on a mixed corpus), so each tag maps to a
220        // list of `{coverage, count}` entries rather than a single one.
221        let mut blocks_by_tag: BTreeMap<&str, Vec<serde_json::Value>> = BTreeMap::new();
222        for ((tag, c), n) in &self.blocks {
223            blocks_by_tag
224                .entry(tag.as_str())
225                .or_default()
226                .push(serde_json::json!({"coverage": cov(*c), "count": n}));
227        }
228        let blocks: serde_json::Map<String, serde_json::Value> = blocks_by_tag
229            .into_iter()
230            .map(|(k, v)| (k.to_string(), serde_json::Value::Array(v)))
231            .collect();
232        serde_json::json!({
233            "corpus": self.corpus,
234            "files": self.files,
235            "lines": self.lines,
236            "parse_errors": self.parse_errors,
237            "records": records,
238            "blocks": blocks,
239            "tools": self.tools,
240            "notes": self.notes,
241        })
242    }
243
244    /// Print a human-readable report to stdout.
245    pub fn print(&self) {
246        println!("# Coverage audit: {}", self.corpus.unwrap_or("?"));
247        println!(
248            "files={} lines={} parse_errors={}\n",
249            self.files, self.lines, self.parse_errors
250        );
251
252        println!("## Records (discriminant → coverage, count, unmodeled fields)");
253        for (key, (cov, tally)) in &self.records {
254            print!("  {}  {:<40} {:>9}", cov.symbol(), key, tally.count);
255            if !tally.unmodeled_fields.is_empty() {
256                let mut fields: Vec<_> = tally.unmodeled_fields.keys().cloned().collect();
257                fields.sort();
258                print!("   unmodeled fields: {}", fields.join(", "));
259            }
260            println!();
261        }
262
263        if !self.blocks.is_empty() {
264            println!("\n## Content blocks");
265            // N1: one row per (tag, coverage) bucket — a tag with mixed
266            // coverage (e.g. `image` seen both Normalized and Dropped) now
267            // prints as two distinct, honestly-counted rows instead of one
268            // row whose coverage was whichever instance was seen last.
269            for ((tag, cov), count) in &self.blocks {
270                println!("  {}  {:<28} {:>9}", cov.symbol(), tag, count);
271            }
272        }
273
274        if !self.notes.is_empty() {
275            println!("\n## Structural notes");
276            for (k, v) in &self.notes {
277                println!("  {k}: {v}");
278            }
279        }
280
281        if !self.tools.is_empty() {
282            println!("\n## Tools observed (top 30 by frequency)");
283            let mut tools: Vec<_> = self.tools.iter().collect();
284            tools.sort_by(|a, b| b.1.cmp(a.1));
285            for (name, count) in tools.into_iter().take(30) {
286                println!("  {count:>9}  {name}");
287            }
288        }
289
290        println!("\n## Summary of gaps (UNMODELED or dropped, non-UI)");
291        for (key, (cov, tally)) in &self.records {
292            if *cov == Coverage::Unmodeled {
293                println!("  ❌ {key} ({} occurrences) — not modeled", tally.count);
294            }
295        }
296        for ((tag, cov), count) in &self.blocks {
297            if *cov == Coverage::Unmodeled {
298                println!("  ❌ content block `{tag}` ({count}) — not modeled");
299            }
300        }
301    }
302}
303
304fn block_coverage(block: &ContentBlock) -> Coverage {
305    match block {
306        ContentBlock::Text { .. }
307        | ContentBlock::InputText { .. }
308        | ContentBlock::OutputText { .. }
309        | ContentBlock::ToolUse { .. }
310        | ContentBlock::ToolResult { .. } => Coverage::Normalized,
311        // D5 (Fable-5 review, confirmed): `image` used to be blanket-marked
312        // `Normalized` regardless of its `source` shape, but
313        // `claude_image_block_to_part` (session.rs) only actually converts
314        // `base64`/`url` sources into a replayable `content_parts` image —
315        // anything else (a Files-API `{"source":{"type":"file",...}}`
316        // reference, most commonly) is NOT carried through; the loader now
317        // emits a bracketed marker so the record survives (see
318        // `UNCONVERTIBLE_IMAGE_MARKER`), but the actual image content is
319        // still lost, so this must not claim full fidelity. Codex's
320        // `input_image` has no `source` sub-object (a bare, always-
321        // convertible `image_url` string via `codex_extract_images`) and
322        // `fallback` (folded into a text marker) are both still genuinely
323        // `Normalized`.
324        // N3 (Fable-5 review, ticket, fixed inline since it's the same
325        // `Image{source}` inspection N1 already touches): a well-typed but
326        // EMPTY `base64`/`url` source — e.g. `{"type":"base64","data":""}`
327        // — used to blanket-audit as `Normalized` just like a genuinely
328        // convertible one, but `claude_image_block_to_part` (session.rs)
329        // treats it as UNCONVERTIBLE (its own non-empty `mime`/`data`/`url`
330        // check returns `None`, same `UNCONVERTIBLE_IMAGE_MARKER` fallback
331        // path as a Files-API reference) — audit and loader must agree.
332        ContentBlock::Image { source } => image_source_coverage(source),
333        ContentBlock::InputImage { .. } | ContentBlock::Fallback { .. } => Coverage::Normalized,
334        // Provider-private reasoning: retained verbatim in
335        // (skip-serialized) `ChatMessage` metadata (`push_claude_assistant`)
336        // so a same-model continuation can replay it, but it has no slot in
337        // the canonical replayable conversation itself — "understood, not
338        // silently lost" rather than "normalized into the conversation".
339        ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => Coverage::Dropped,
340        ContentBlock::Unknown => Coverage::Unmodeled, // future blocks
341    }
342}
343
344/// Score a Claude `image` block's `source` object — factored out of
345/// `block_coverage`'s `Image` arm (D5/N3 discipline: `base64`/`url` with a
346/// non-empty payload is `Normalized`, anything else is `Dropped`) so PARITY-11
347/// can reuse the EXACT same test for an `image` block nested inside a
348/// `tool_result`'s own `content` array, not just a top-level one.
349fn image_source_coverage(source: &Value) -> Coverage {
350    match source.get("type").and_then(Value::as_str) {
351        Some("base64") => {
352            let mime = source
353                .get("media_type")
354                .and_then(Value::as_str)
355                .unwrap_or("");
356            let data = source.get("data").and_then(Value::as_str).unwrap_or("");
357            if mime.is_empty() || data.is_empty() {
358                Coverage::Dropped
359            } else {
360                Coverage::Normalized
361            }
362        }
363        Some("url") => {
364            let url = source.get("url").and_then(Value::as_str).unwrap_or("");
365            if url.is_empty() {
366                Coverage::Dropped
367            } else {
368                Coverage::Normalized
369            }
370        }
371        _ => Coverage::Dropped,
372    }
373}
374
375/// PARITY-11 (nested images, skeptic-confirmed on a real session): a Claude
376/// `tool_result` block's OWN `content` array can carry `image` blocks — the
377/// everyday "Read a PNG / screenshot tool output" shape. `block_coverage`
378/// blanket-labels the enclosing `tool_result` `Normalized` (true for its text
379/// portion), which used to be the ONLY signal `audit` gave — so a session
380/// whose `tool_result` held nothing but a dropped image still reported zero
381/// `image` blocks and a clean `tool_result: Normalized` line, i.e. coverage
382/// said "retained" while the loader silently dropped the bytes. This censuses
383/// each nested `image` block individually, under its own `tool_result/image`
384/// discriminant, scored with the SAME [`image_source_coverage`] test
385/// `session.rs`'s `extract_tool_result_content` uses to decide whether it
386/// actually captures the block into `content_parts` — so a genuinely
387/// unconvertible nested image (Files-API reference, empty payload, …) shows
388/// up here as `Dropped`, not folded invisibly into the outer `Normalized`
389/// tally.
390fn audit_nested_tool_result_images(content: &Value, report: &mut Report) {
391    let Some(items) = content.as_array() else {
392        return;
393    };
394    for item in items {
395        if item.get("type").and_then(Value::as_str) != Some("image") {
396            continue;
397        }
398        let cov = image_source_coverage(item.get("source").unwrap_or(&Value::Null));
399        *report
400            .blocks
401            .entry(("tool_result/image".to_string(), cov))
402            .or_insert(0) += 1;
403    }
404}
405
406/// Audit a directory. `limit` caps the number of files scanned (None = all).
407///
408/// `Corpus::OpenCode` (PARITY-4) is special-cased: a real OpenCode data root
409/// (`~/.local/share/opencode`) holds no `.jsonl` files at all — sessions live
410/// in `opencode*.db` (current installs) or a JSON-file tree (legacy). When
411/// [`crate::session::detect_opencode_storage_surface`] resolves `dir` to the
412/// SQLite surface, this routes through
413/// [`crate::session::opencode_sqlite_corpus_envelope_text`] (up to `limit`
414/// SESSIONS, not files — `report.files` counts sessions scanned in that
415/// case) instead of the `jsonl_files` walk below, so a real store actually
416/// gets audited rather than silently reporting zero files/lines. A directory
417/// with no detected SQLite surface (e.g. a fixture dir of committed
418/// envelope-form `.jsonl` files, or a not-yet-implemented legacy JSON tree)
419/// falls back to the original file-walk unchanged.
420pub fn audit_dir(dir: &Path, corpus: Corpus, limit: Option<usize>) -> Report {
421    let mut report = Report {
422        corpus: Some(match corpus {
423            Corpus::ClaudeCode => "claude-code",
424            Corpus::Codex => "codex",
425            Corpus::Pi => "pi",
426            Corpus::OpenCode => "opencode",
427            Corpus::Grok => "grok",
428            Corpus::Gemini => "gemini",
429            Corpus::Goose => "goose",
430        }),
431        ..Default::default()
432    };
433
434    if corpus == Corpus::OpenCode {
435        if let Some((crate::session::OpenCodeStorageSurface::Sqlite, db_path)) =
436            crate::session::detect_opencode_storage_surface(dir)
437        {
438            return audit_opencode_sqlite(&db_path, limit, report);
439        }
440    }
441    if corpus == Corpus::Goose {
442        return audit_goose(dir, limit, report);
443    }
444
445    let mut files = jsonl_files(dir);
446    if corpus == Corpus::Grok {
447        files.retain(|path| {
448            path.file_name().and_then(|name| name.to_str()) == Some("chat_history.jsonl")
449        });
450    }
451    let files = match limit {
452        Some(n) => &files[..files.len().min(n)],
453        None => &files[..],
454    };
455
456    for path in files {
457        report.files += 1;
458        let Ok(text) = std::fs::read_to_string(path) else {
459            continue;
460        };
461        for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
462            report.lines += 1;
463            match corpus {
464                Corpus::Codex => audit_codex_line(line, &mut report),
465                Corpus::ClaudeCode => audit_claude_line(line, &mut report),
466                Corpus::Pi => audit_pi_line(line, &mut report),
467                Corpus::OpenCode => audit_opencode_line(line, &mut report),
468                Corpus::Grok => audit_grok_line(line, &mut report),
469                Corpus::Gemini => audit_gemini_line(line, &mut report),
470                Corpus::Goose => unreachable!("Goose is audited through its SQLite store"),
471            }
472        }
473    }
474    report
475}
476
477fn audit_goose(root: &Path, limit: Option<usize>, mut report: Report) -> Report {
478    let catalog = crate::HarnessCatalog::new();
479    let discovery = catalog.discover(&crate::DiscoveryQuery {
480        harnesses: vec![crate::HarnessId::from(crate::HarnessId::GOOSE)],
481        homes: crate::HarnessHomes {
482            goose: root.to_path_buf(),
483            ..crate::HarnessHomes::default()
484        },
485        limit,
486        ..crate::DiscoveryQuery::default()
487    });
488    let descriptors = match discovery {
489        Ok(descriptors) => descriptors,
490        Err(error) => {
491            report.note(&format!("Goose discovery failed: {error}"));
492            return report;
493        }
494    };
495    let extra = EMPTY_EXTRA.get_or_init(Default::default);
496    for descriptor in descriptors {
497        let session = match catalog.load(&descriptor.locator) {
498            Ok(session) => session,
499            Err(error) => {
500                report.note(&format!(
501                    "Goose session {} failed to load: {error}",
502                    descriptor.locator.session_id
503                ));
504                continue;
505            }
506        };
507        report.files += 1;
508        for message in session.messages {
509            report.lines += 1;
510            report.bump(
511                format!("message/{:?}", message.role).to_lowercase(),
512                Coverage::Normalized,
513                extra,
514            );
515            for call in message.tool_calls() {
516                *report.tools.entry(call.function.name.clone()).or_insert(0) += 1;
517            }
518        }
519    }
520    report
521}
522
523/// The `Corpus::OpenCode` + SQLite branch of [`audit_dir`] (PARITY-4): reads
524/// every session's `session`/`message`/`part`/`todo` records out of
525/// `db_path` as envelope lines
526/// ([`crate::session::opencode_sqlite_corpus_envelope_text`]) and scores each
527/// one exactly like a line from a committed envelope-form fixture
528/// (`audit_opencode_line` — same classifier, same coverage buckets, so a
529/// SQLite corpus and a JSON-tree/fixture corpus are held to the identical
530/// bar). `report.files` counts SESSIONS scanned (the natural unit for a
531/// single-DB corpus), not `.jsonl` files. A store that fails to open (bad
532/// path, corrupt DB, wrong schema) does not panic or silently return an
533/// empty report — the failure is recorded in `report.notes` so it is visible
534/// in both the text and `--json` renderings.
535fn audit_opencode_sqlite(db_path: &Path, limit: Option<usize>, mut report: Report) -> Report {
536    match crate::session::opencode_sqlite_corpus_envelope_text(db_path, limit) {
537        Ok(text) => {
538            for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
539                report.lines += 1;
540                // A `session` envelope line is one-per-scanned-session — use
541                // it to derive `report.files` (sessions, not `.jsonl` files)
542                // without a second SQL pass.
543                if let Ok(v) = serde_json::from_str::<Value>(line) {
544                    if v.get("key")
545                        .and_then(Value::as_array)
546                        .and_then(|k| k.first())
547                        .and_then(Value::as_str)
548                        == Some("session")
549                    {
550                        report.files += 1;
551                    }
552                }
553                audit_opencode_line(line, &mut report);
554            }
555        }
556        Err(e) => {
557            report.note(&format!("opencode_sqlite_error: {e}"));
558        }
559    }
560    report
561}
562
563fn audit_codex_line(line: &str, report: &mut Report) {
564    let raw: Value = match serde_json::from_str(line) {
565        Ok(v) => v,
566        Err(_) => {
567            report.parse_errors += 1;
568            return;
569        }
570    };
571    let parsed: Result<CodexLine, _> = serde_json::from_str(line);
572    let Ok(parsed) = parsed else {
573        report.parse_errors += 1;
574        return;
575    };
576
577    match &parsed.record {
578        CodexRecord::Unknown => {
579            let tag = raw
580                .get("type")
581                .and_then(Value::as_str)
582                .unwrap_or("<no-type>");
583            report.bump(
584                format!("<line>/{tag}"),
585                Coverage::Unmodeled,
586                &Default::default(),
587            );
588        }
589        CodexRecord::ResponseItem { payload } => audit_codex_item(payload, &raw, report),
590        CodexRecord::EventMsg { payload } => {
591            let sub = payload.kind.clone().unwrap_or_else(|| "?".into());
592            report.bump(
593                format!("event_msg/{sub}"),
594                event_msg_coverage(&sub),
595                &payload.extra,
596            );
597        }
598        // PARITY-13 (P013): `session_meta` isn't discarded — `from_codex_str`
599        // (via `capture_codex_session_meta`) threads its id/cwd/model/
600        // base_instructions/lineage fields onto `Session.meta`, and the whole
601        // record is kept verbatim in `meta.codex_headers` so a same-format
602        // re-export (`to_codex_jsonl`) replays it byte-for-byte. `Dropped`
603        // read as a silent, untracked loss; it's retained, just not folded
604        // into a `ChatMessage`.
605        CodexRecord::SessionMeta { payload } => {
606            report.bump("session_meta".into(), Coverage::Retained, &payload.extra);
607        }
608        // Same reasoning as `SessionMeta` above: `turn_context`'s `model` is
609        // threaded onto `Session.meta.model` (first occurrence) and the
610        // record itself is kept verbatim in `meta.codex_headers` (P013).
611        CodexRecord::TurnContext { payload } => {
612            report.bump("turn_context".into(), Coverage::Retained, &payload.extra);
613        }
614        CodexRecord::Compacted { .. } => {
615            // replacement_history now replaces prior turns on load.
616            report.bump(
617                "compacted".into(),
618                Coverage::Normalized,
619                &Default::default(),
620            );
621        }
622    }
623}
624
625/// PARITY-12/PARITY-13 (P012/P013): `event_msg` coverage, mirroring EXACTLY
626/// the subset of `payload.type` values `Session::from_codex_str` special-cases
627/// (see its `Some("event_msg") if payload.get("type") == Some(...)` arms) —
628/// keep these two lists in lockstep; a subtype added there without a match
629/// here regresses to a false `Dropped` again.
630///
631/// - `agent_message`: real assistant narration with no `response_item`
632///   counterpart becomes a message (the sole source of truth in some
633///   collaboration/multi-agent sessions); a duplicate of an already-normalized
634///   `response_item/message` is skipped as a no-op. Either way the loader
635///   parses and acts on it — not a blind, untracked drop.
636/// - `thread_rolled_back`: directly mutates the canonical conversation
637///   (removes the rolled-back turns) — collaboration/undo provenance that's
638///   applied, not discarded.
639/// - `thread_goal_updated`: its `goal.objective` becomes a synthesized
640///   system message when present; `goal.status`/`goal.tokenBudget` (when
641///   present) are captured onto that message's metadata too (D4) —
642///   `goal.tokensUsed`/`timeUsedSeconds`/timestamps are still real, minor
643///   residue, not claimed as retained.
644/// - `exited_review_mode`: its `review_output.overall_explanation` becomes a
645///   synthesized assistant message; `review_output.findings` (verbatim JSON:
646///   `title`/`body`/`confidence_score`/`priority`/`code_location`) AND the
647///   review verdict itself, `overall_correctness`/`overall_confidence_score`
648///   (N4 — previously neither captured nor disclosed, unlike the
649///   `thread_goal_updated` arm above which already disclosed its own
650///   residue), are captured onto that message's metadata too (D4/N4) — the
651///   only place review-mode findings/verdict live.
652///
653/// `token_count`, `task_started`/`task_complete`, `user_message` (a
654/// duplicate of the already-normalized `response_item/message[user]`),
655/// `entered_review_mode` has no canonical chat turn, but PARITY-13's portable
656/// Codex provenance envelope now retains its exact target/hint record across
657/// every foreign-format hop, so it is `Retained` rather than an untracked
658/// drop. Other UI-only echoes with no unique replayable content stay
659/// `Dropped` honestly.
660///
661/// D1 correction — this used to also claim `exec_command_begin`/`end` and
662/// `mcp_tool_call_begin`/`patch_apply_begin` were safe to drop because
663/// "already captured via the paired `response_item/function_call*`". That
664/// framing was FALSE for what those events would carry if they were ever
665/// actually present: verified against upstream `openai/codex`'s
666/// `codex-rs/rollout/src/policy.rs` `should_persist_event_msg`, all four of
667/// `EventMsg::ExecCommandBegin`, `EventMsg::ExecCommandEnd`,
668/// `EventMsg::McpToolCallBegin`, and `EventMsg::PatchApplyBegin` hit that
669/// function's `=> false` arm — **codex never writes these event kinds to a
670/// real rollout file at all.** So in a genuine `~/.codex/sessions` corpus
671/// this isn't "content safely captured elsewhere"; it's a branch that is
672/// simply never reached. `Dropped` below is defensive (a hand-edited or
673/// legacy-schema file could still carry one, and the typed schema should
674/// keep parsing it rather than falling into `Unmodeled`), not a claim that
675/// real sessions lose this content on every turn.
676///
677/// `patch_apply_end` and `mcp_tool_call_end`, by contrast, ARE persisted by
678/// real Codex (`should_persist_event_msg` `=> true` for both) — and here the
679/// old "already captured" framing is mostly right but not entirely: their
680/// short `stdout`/`result` text does duplicate the paired
681/// `response_item/function_call_output` or
682/// `response_item/custom_tool_call_output`. `patch_apply_end`'s
683/// `changes[path]`'s `unified_diff` is NOT duplicated there — the paired
684/// `function_call_output` only carries the apply summary text, never the
685/// diff body — and the loader does not capture it, so this is genuine,
686/// currently-real content loss on cross-format export. (N5: the diff body's
687/// raw hunk TEXT does have a counterpart — the paired `response_item/
688/// function_call.arguments` for the preceding `apply_patch` call carries the
689/// same added/removed lines in its own `*** Begin Patch` format, since
690/// that's literally what was applied. What's genuinely unique to
691/// `unified_diff` and absent from `function_call.arguments` is its
692/// standard-diff framing — the `--- a/<path>`/`+++ b/<path>`/`@@ …@@` header
693/// lines `apply_patch`'s custom patch format never emits. The dev/02 test
694/// below keys its residue assertion on those header lines specifically, not
695/// on the shared hunk body, so it proves the part that's actually
696/// unrecovered rather than merely re-finding text that was never at risk.)
697/// `Dropped` is the honest label for it, not "already captured" — and
698/// `parity12_cross_format_export_retains_tool_outputs_as_transcript_content`
699/// (dev/02, `crates/cli/tests/codex_fidelity_cli.rs`) now asserts this
700/// residue explicitly instead of staying silent about it.
701fn event_msg_coverage(sub: &str) -> Coverage {
702    match sub {
703        "agent_message"
704        | "thread_rolled_back"
705        | "thread_goal_updated"
706        | "entered_review_mode"
707        | "exited_review_mode" => Coverage::Retained,
708        _ => Coverage::Dropped,
709    }
710}
711
712fn audit_codex_item(item: &ResponseItem, raw: &Value, report: &mut Report) {
713    let raw_payload = raw.get("payload").cloned().unwrap_or(Value::Null);
714    let cov = if item.is_normalized() {
715        Coverage::Normalized
716    } else if matches!(item, ResponseItem::Reasoning { .. }) {
717        // D5/N1/N2/N3: `Session::from_codex_str` captures `summary` text,
718        // the raw `content` chain-of-thought text when genuinely present
719        // (N2), and a correctly-computed `encrypted_content` presence flag
720        // (N1: only a non-null value counts, not merely a present-but-null
721        // key) onto the NEXT assistant `ChatMessage`'s metadata
722        // (`reasoning`/`reasoning_content`/`reasoning_encrypted`) — or, when
723        // there is no following assistant turn to attach to, flushes it as
724        // its own synthesized message instead of discarding it (N3). Not a
725        // blind drop. The opaque `encrypted_content` blob itself isn't
726        // replayed cross-model, so this is an honest `Retained`, not
727        // `Normalized` (there's no 1:1 canonical "reasoning" `ChatMessage`).
728        // See `Coverage::Retained`'s doc comment for the LOADER-CAPTURE-ONLY
729        // disclosure that applies to all of this.
730        Coverage::Retained
731    } else {
732        // custom_tool_call, web_search_call, tool_search_*, image_generation,
733        // and any future Unknown — all not yet normalized.
734        Coverage::Unmodeled
735    };
736
737    let tag = item.tag().map(str::to_string).unwrap_or_else(|| {
738        raw_payload
739            .get("type")
740            .and_then(Value::as_str)
741            .unwrap_or("<no-type>")
742            .to_string()
743    });
744
745    match item {
746        ResponseItem::Message {
747            content,
748            extra,
749            role,
750        } => {
751            report.bump(format!("response_item/message[{role}]"), cov, extra);
752            audit_blocks(content, &raw_payload, report);
753        }
754        ResponseItem::FunctionCall { name, extra, .. } => {
755            *report.tools.entry(name.clone()).or_insert(0) += 1;
756            report.bump("response_item/function_call".into(), cov, extra);
757        }
758        ResponseItem::FunctionCallOutput { extra, .. } => {
759            report.bump("response_item/function_call_output".into(), cov, extra);
760        }
761        ResponseItem::CustomToolCall { name, extra, .. } => {
762            if let Some(n) = name {
763                *report.tools.entry(n.clone()).or_insert(0) += 1;
764            }
765            report.bump("response_item/custom_tool_call".into(), cov, extra);
766        }
767        other => {
768            let extra = item_extra(other);
769            report.bump(format!("response_item/{tag}"), cov, extra);
770        }
771    }
772}
773
774fn item_extra(item: &ResponseItem) -> &crate::schema::ExtraFields {
775    match item {
776        ResponseItem::CustomToolCallOutput { extra, .. }
777        | ResponseItem::Reasoning { extra }
778        | ResponseItem::WebSearchCall { extra }
779        | ResponseItem::ToolSearchCall { extra }
780        | ResponseItem::ToolSearchOutput { extra }
781        | ResponseItem::ImageGenerationCall { extra } => extra,
782        _ => EMPTY_EXTRA.get_or_init(Default::default),
783    }
784}
785
786static EMPTY_EXTRA: std::sync::OnceLock<crate::schema::ExtraFields> = std::sync::OnceLock::new();
787
788fn audit_claude_line(line: &str, report: &mut Report) {
789    let raw: Value = match serde_json::from_str(line) {
790        Ok(v) => v,
791        Err(_) => {
792            report.parse_errors += 1;
793            return;
794        }
795    };
796    let parsed: Result<ClaudeRecord, _> = serde_json::from_str(line);
797    let Ok(parsed) = parsed else {
798        report.parse_errors += 1;
799        return;
800    };
801
802    match &parsed {
803        ClaudeRecord::Unknown => {
804            let tag = raw
805                .get("type")
806                .and_then(Value::as_str)
807                .unwrap_or("<no-type>");
808            report.bump(
809                format!("<line>/{tag}"),
810                Coverage::Unmodeled,
811                &Default::default(),
812            );
813        }
814        ClaudeRecord::User { message, meta } | ClaudeRecord::Assistant { message, meta } => {
815            let role = match &parsed {
816                ClaudeRecord::Assistant { .. } => "assistant",
817                _ => "user",
818            };
819            report.bump(role.to_string(), Coverage::Normalized, &meta.extra);
820            if meta.is_sidechain {
821                report.note("sidechain (subagent) lines — flattened, not separated");
822            }
823            match &message.content {
824                MessageContent::Text(_) => {
825                    report.bump_block(
826                        &ContentBlock::Text {
827                            text: String::new(),
828                        },
829                        &Value::Null,
830                    );
831                }
832                MessageContent::Blocks(blocks) => {
833                    let raw_blocks = raw
834                        .get("message")
835                        .and_then(|m| m.get("content"))
836                        .cloned()
837                        .unwrap_or(Value::Null);
838                    audit_blocks(blocks, &Value::Null, report);
839                    let _ = raw_blocks;
840                    // capture tool names
841                    for b in blocks {
842                        if let ContentBlock::ToolUse { name, .. } = b {
843                            *report.tools.entry(name.clone()).or_insert(0) += 1;
844                        }
845                    }
846                }
847            }
848        }
849        ClaudeRecord::System { subtype, extra } => {
850            let sub = subtype.clone().unwrap_or_else(|| "?".into());
851            // Content-bearing system subtypes are now folded into the conversation.
852            let cov = match sub.as_str() {
853                "scheduled_task_fire" | "local_command" | "away_summary" => Coverage::Normalized,
854                _ => Coverage::Dropped,
855            };
856            report.bump(format!("system/{sub}"), cov, extra);
857        }
858        other => {
859            let tag = other.tag().unwrap_or("?");
860            let cov = match tag {
861                // Metadata/UI we deliberately skip.
862                "permission-mode" | "mode" | "last-prompt" | "queue-operation" | "ai-title"
863                | "pr-link" | "frame-link" | "agent-name" | "worktree-state" => Coverage::Dropped,
864                // Content-bearing attachment subtypes are now folded into the
865                // conversation (regenerable ones are still skipped).
866                "attachment" => Coverage::Normalized,
867                // PARITY-10: captured into `Session::meta.lineage` on load
868                // (`capture_claude_meta` in `session.rs`) and re-emitted
869                // verbatim by the Claude Code writer — no longer silently
870                // dropped, even though (like `attachment`) it has no slot in
871                // the OpenAI-shaped canonical message conversation itself.
872                "fork-context-ref" => Coverage::Normalized,
873                // These still carry real content/structure we don't yet use:
874                // file-history-snapshot / file-history-delta (undo state),
875                // started/result (subagent task lifecycle).
876                _ => Coverage::Unmodeled,
877            };
878            report.bump(tag.to_string(), cov, record_extra(other));
879        }
880    }
881}
882
883fn record_extra(rec: &ClaudeRecord) -> &crate::schema::ExtraFields {
884    match rec {
885        ClaudeRecord::Attachment { extra }
886        | ClaudeRecord::FileHistorySnapshot { extra }
887        | ClaudeRecord::FileHistoryDelta { extra }
888        | ClaudeRecord::AiTitle { extra }
889        | ClaudeRecord::PermissionMode { extra }
890        | ClaudeRecord::Mode { extra }
891        | ClaudeRecord::LastPrompt { extra }
892        | ClaudeRecord::QueueOperation { extra }
893        | ClaudeRecord::PrLink { extra }
894        | ClaudeRecord::FrameLink { extra }
895        | ClaudeRecord::AgentName { extra }
896        | ClaudeRecord::Started { extra }
897        | ClaudeRecord::Result { extra }
898        | ClaudeRecord::WorktreeState { extra }
899        | ClaudeRecord::ForkContextRef { extra } => extra,
900        _ => EMPTY_EXTRA.get_or_init(Default::default),
901    }
902}
903
904fn audit_blocks(blocks: &[ContentBlock], raw_payload: &Value, report: &mut Report) {
905    let raw_blocks = raw_payload.get("content").and_then(Value::as_array);
906    for (i, b) in blocks.iter().enumerate() {
907        let raw = raw_blocks
908            .and_then(|arr| arr.get(i))
909            .cloned()
910            .unwrap_or(Value::Null);
911        report.bump_block(b, &raw);
912        // PARITY-11: census any `image` block nested inside this
913        // `tool_result`'s own `content` array separately — see
914        // `audit_nested_tool_result_images`'s doc comment.
915        if let ContentBlock::ToolResult { content, .. } = b {
916            audit_nested_tool_result_images(content, report);
917        }
918    }
919}
920
921/// Audit one line of a pi session file (`docs/interop/opencode-pi-spec.md`
922/// §1.1/§4.1, `pi-fields.md`). Unlike the Claude/Codex auditors this walks
923/// raw [`Value`]s rather than a typed `crate::schema` module — Wave A scopes
924/// the typed-schema mirror to a later pass; the tally/Unknown-bucket
925/// machinery this function drives is the same [`Report`] used everywhere
926/// else, so the coverage guard test reads identically.
927///
928/// `message.role` is tallied as a **second-level discriminant** under its own
929/// `message/…` keys, with an `message/UnknownRole:<role>` bucket for any role
930/// outside pi's five modeled ones — pi's `message.role` is an OPEN,
931/// extension-mergeable union (§1.1 S6), so a role the loader doesn't
932/// recognize must surface here as a scored `Unmodeled` entry, not vanish.
933///
934/// A second, orthogonal second-level bucket — `message/UnknownImageShape` —
935/// covers FIX #2: `user`/`toolResult`/`custom` content can carry an
936/// `ImageContent` block whose `{mimeType, data}` shape is an unverified guess
937/// (`pi-fields.md` never enumerates `ImageContent`'s own fields). A block
938/// that doesn't match that shape must score `Unmodeled` here too, instead of
939/// letting the loader silently synthesize an empty/corrupt `image_url` part.
940fn audit_pi_line(line: &str, report: &mut Report) {
941    let raw: Value = match serde_json::from_str(line) {
942        Ok(v) => v,
943        Err(_) => {
944            report.parse_errors += 1;
945            return;
946        }
947    };
948    let extra = EMPTY_EXTRA.get_or_init(Default::default);
949    let Some(ty) = raw.get("type").and_then(Value::as_str) else {
950        report.bump("<line>/<no-type>".to_string(), Coverage::Unmodeled, extra);
951        return;
952    };
953    match ty {
954        "session" => report.bump("session".to_string(), Coverage::Normalized, extra),
955        "message" => {
956            let message = raw.get("message");
957            let role = message.and_then(|m| m.get("role")).and_then(Value::as_str);
958            // FIX #2: `user`/`toolResult`/`custom` all carry the shared
959            // `(TextContent|ImageContent)[]` content union (`pi-fields.md`
960            // §3a/§3c/§3e) — an `ImageContent` block that doesn't match the
961            // loader's assumed (and unverified) `{mimeType, data}` shape
962            // must score as `message/UnknownImageShape`, never silently
963            // `Normalized`, mirroring `UnknownRole`'s "surface it, don't
964            // vanish" rule exactly.
965            let content = message.and_then(|m| m.get("content"));
966            let unknown_image = matches!(role, Some("user") | Some("toolResult") | Some("custom"))
967                && pi_content_has_unknown_image_shape(content);
968            match role {
969                _ if unknown_image => report.bump(
970                    "message/UnknownImageShape".to_string(),
971                    Coverage::Unmodeled,
972                    extra,
973                ),
974                Some("user") => {
975                    report.bump("message/user".to_string(), Coverage::Normalized, extra)
976                }
977                Some("assistant") => {
978                    report.bump("message/assistant".to_string(), Coverage::Normalized, extra)
979                }
980                Some("toolResult") => report.bump(
981                    "message/toolResult".to_string(),
982                    Coverage::Normalized,
983                    extra,
984                ),
985                Some("bashExecution") => report.bump(
986                    "message/bashExecution".to_string(),
987                    Coverage::Normalized,
988                    extra,
989                ),
990                Some("custom") => {
991                    report.bump("message/custom".to_string(), Coverage::Normalized, extra)
992                }
993                Some(other) => report.bump(
994                    format!("message/UnknownRole:{other}"),
995                    Coverage::Unmodeled,
996                    extra,
997                ),
998                None => report.bump(
999                    "message/UnknownRole:<none>".to_string(),
1000                    Coverage::Unmodeled,
1001                    extra,
1002                ),
1003            }
1004        }
1005        "custom_message" => report.bump("custom_message".to_string(), Coverage::Normalized, extra),
1006        "compaction" => report.bump("compaction".to_string(), Coverage::Normalized, extra),
1007        "branch_summary" => report.bump("branch_summary".to_string(), Coverage::Normalized, extra),
1008        "thinking_level_change" => report.bump(
1009            "thinking_level_change".to_string(),
1010            Coverage::Dropped,
1011            extra,
1012        ),
1013        "model_change" => report.bump("model_change".to_string(), Coverage::Normalized, extra),
1014        "custom" => report.bump("custom".to_string(), Coverage::Dropped, extra),
1015        "label" => report.bump("label".to_string(), Coverage::Dropped, extra),
1016        "session_info" => report.bump("session_info".to_string(), Coverage::Normalized, extra),
1017        other => report.bump(format!("<line>/{other}"), Coverage::Unmodeled, extra),
1018    }
1019}
1020
1021/// Score one Gemini CLI session record against the native loader.
1022fn audit_gemini_line(line: &str, report: &mut Report) {
1023    let raw: Value = match serde_json::from_str(line) {
1024        Ok(value) => value,
1025        Err(_) => {
1026            report.parse_errors += 1;
1027            return;
1028        }
1029    };
1030    let extra = EMPTY_EXTRA.get_or_init(Default::default);
1031    let kind = raw.get("type").and_then(Value::as_str);
1032    if kind.is_none() {
1033        let key = if raw.get("sessionId").is_some_and(Value::is_string) {
1034            "session_header"
1035        } else if raw.get("$set").is_some() {
1036            "metadata_update"
1037        } else {
1038            "<line>/<no-type>"
1039        };
1040        let coverage = if key == "<line>/<no-type>" {
1041            Coverage::Unmodeled
1042        } else {
1043            Coverage::Retained
1044        };
1045        report.bump(key.to_string(), coverage, extra);
1046        return;
1047    }
1048    match kind.unwrap_or_default() {
1049        "user" | "gemini" => {
1050            report.bump(
1051                kind.unwrap_or_default().to_string(),
1052                Coverage::Normalized,
1053                extra,
1054            );
1055            if let Some(parts) = raw.get("content").and_then(Value::as_array) {
1056                for part in parts {
1057                    if part.get("text").is_some() {
1058                        report.bump("content/text".into(), Coverage::Normalized, extra);
1059                    } else if part.get("inlineData").is_some() {
1060                        report.bump("content/inlineData".into(), Coverage::Normalized, extra);
1061                    } else if let Some(call) = part.get("functionCall") {
1062                        report.bump("content/functionCall".into(), Coverage::Normalized, extra);
1063                        if let Some(name) = call.get("name").and_then(Value::as_str) {
1064                            *report.tools.entry(name.to_string()).or_insert(0) += 1;
1065                        }
1066                    } else if let Some(response) = part.get("functionResponse") {
1067                        report.bump(
1068                            "content/functionResponse".into(),
1069                            Coverage::Normalized,
1070                            extra,
1071                        );
1072                        if let Some(name) = response.get("name").and_then(Value::as_str) {
1073                            *report.tools.entry(name.to_string()).or_insert(0) += 1;
1074                        }
1075                    } else {
1076                        report.bump("content/unknown".into(), Coverage::Unmodeled, extra);
1077                    }
1078                }
1079            } else if !raw
1080                .get("content")
1081                .is_some_and(|content| content.is_string() || content.is_null())
1082            {
1083                report.bump("content/nonstandard".into(), Coverage::Unmodeled, extra);
1084            }
1085            if raw.get("thoughts").is_some() {
1086                report.bump("thoughts".into(), Coverage::Retained, extra);
1087            }
1088        }
1089        "info" | "error" => report.bump(
1090            kind.unwrap_or_default().to_string(),
1091            Coverage::Retained,
1092            extra,
1093        ),
1094        other => report.bump(format!("<line>/{other}"), Coverage::Unmodeled, extra),
1095    }
1096}
1097
1098/// Score one Grok `chat_history.jsonl` record against the same shapes the
1099/// native loader actually consumes. Companion `updates.jsonl` streams are
1100/// excluded by [`audit_dir`], because they are ACP/runtime evidence rather
1101/// than the resumable transcript.
1102fn audit_grok_line(line: &str, report: &mut Report) {
1103    let raw: Value = match serde_json::from_str(line) {
1104        Ok(value) => value,
1105        Err(_) => {
1106            report.parse_errors += 1;
1107            return;
1108        }
1109    };
1110    let extra = EMPTY_EXTRA.get_or_init(Default::default);
1111    let Some(kind) = raw.get("type").and_then(Value::as_str) else {
1112        report.bump("<line>/<no-type>".to_string(), Coverage::Unmodeled, extra);
1113        return;
1114    };
1115    match kind {
1116        "system" => {
1117            let coverage = if raw.get("content").is_some_and(Value::is_string) {
1118                Coverage::Retained
1119            } else {
1120                Coverage::Unmodeled
1121            };
1122            report.bump("system".to_string(), coverage, extra);
1123        }
1124        "user" => {
1125            let (key, coverage) = classify_grok_user(&raw);
1126            report.bump(key.clone(), coverage, extra);
1127            audit_grok_content(raw.get("content"), &key, coverage, report);
1128        }
1129        "assistant" => {
1130            let content_supported = raw
1131                .get("content")
1132                .is_none_or(|content| content.is_null() || content.is_string());
1133            report.bump(
1134                "assistant".to_string(),
1135                if content_supported {
1136                    Coverage::Normalized
1137                } else {
1138                    Coverage::Unmodeled
1139                },
1140                extra,
1141            );
1142            if let Some(calls) = raw.get("tool_calls").and_then(Value::as_array) {
1143                for call in calls {
1144                    let modeled = call.get("id").is_some_and(Value::is_string)
1145                        && call.get("name").is_some_and(Value::is_string);
1146                    report.bump(
1147                        if modeled {
1148                            "assistant/tool_call".to_string()
1149                        } else {
1150                            "assistant/tool_call:invalid".to_string()
1151                        },
1152                        if modeled {
1153                            Coverage::Normalized
1154                        } else {
1155                            Coverage::Unmodeled
1156                        },
1157                        extra,
1158                    );
1159                    if let Some(name) = call.get("name").and_then(Value::as_str) {
1160                        *report.tools.entry(name.to_string()).or_insert(0) += 1;
1161                    }
1162                }
1163            } else if raw.get("tool_calls").is_some() {
1164                report.bump(
1165                    "assistant/tool_calls:non-array".to_string(),
1166                    Coverage::Unmodeled,
1167                    extra,
1168                );
1169            }
1170        }
1171        "tool_result" => {
1172            let modeled = raw.get("tool_call_id").is_some_and(Value::is_string);
1173            report.bump(
1174                "tool_result".to_string(),
1175                if modeled {
1176                    Coverage::Normalized
1177                } else {
1178                    Coverage::Unmodeled
1179                },
1180                extra,
1181            );
1182            audit_grok_content(
1183                raw.get("content"),
1184                "tool_result",
1185                Coverage::Normalized,
1186                report,
1187            );
1188        }
1189        // These are understood native records but deliberately remain in
1190        // the byte-exact raw prefix instead of becoming replayable messages.
1191        "reasoning" | "backend_tool_call" => {
1192            report.bump(kind.to_string(), Coverage::Dropped, extra)
1193        }
1194        other => report.bump(format!("<line>/{other}"), Coverage::Unmodeled, extra),
1195    }
1196}
1197
1198/// Classify a Grok `user` record using the exact replay boundary enforced by
1199/// `Session::from_grok_str`: generated context wrappers are native session
1200/// state, not human turns, and therefore remain raw-only. Separate record
1201/// keys are deliberate — [`Report::records`] stores one coverage value per
1202/// key, so mixing replayed and discarded users under a single `user` key
1203/// would make the last line scanned overwrite the truth for the whole corpus.
1204fn classify_grok_user(raw: &Value) -> (String, Coverage) {
1205    if raw.get("synthetic_reason").and_then(Value::as_str) == Some("supercode_system_event") {
1206        return ("user/system_event".to_string(), Coverage::Normalized);
1207    }
1208
1209    let content = grok_audit_text(raw.get("content"));
1210    let text = content.trim();
1211    if text.starts_with("<user_info>") {
1212        return (
1213            "user/injected_context:user_info".to_string(),
1214            Coverage::Dropped,
1215        );
1216    }
1217    if text.starts_with("<system-reminder>") {
1218        return (
1219            "user/injected_context:system-reminder".to_string(),
1220            Coverage::Dropped,
1221        );
1222    }
1223    if text.is_empty() {
1224        return ("user/empty".to_string(), Coverage::Dropped);
1225    }
1226    if text
1227        .strip_prefix("<user_query>")
1228        .and_then(|value| value.strip_suffix("</user_query>"))
1229        .is_some_and(|value| value.trim().is_empty())
1230    {
1231        return ("user/empty_query".to_string(), Coverage::Dropped);
1232    }
1233    ("user".to_string(), Coverage::Normalized)
1234}
1235
1236/// Mirror the loader's text extraction for the two organic Grok shapes:
1237/// direct string content and arrays containing either `{text: ...}` blocks
1238/// or string entries. Other scalar/object values stringify exactly as the
1239/// loader does, making the audit a behavioral classification rather than a
1240/// narrower invented schema.
1241fn grok_audit_text(content: Option<&Value>) -> String {
1242    match content {
1243        Some(Value::String(text)) => text.clone(),
1244        Some(Value::Array(items)) => items
1245            .iter()
1246            .filter_map(|item| {
1247                item.get("text")
1248                    .and_then(Value::as_str)
1249                    .or_else(|| item.as_str())
1250            })
1251            .collect::<Vec<_>>()
1252            .join("\n"),
1253        Some(other) => other.to_string(),
1254        None => String::new(),
1255    }
1256}
1257
1258fn audit_grok_content(
1259    content: Option<&Value>,
1260    prefix: &str,
1261    record_coverage: Coverage,
1262    report: &mut Report,
1263) {
1264    let extra = EMPTY_EXTRA.get_or_init(Default::default);
1265    match content {
1266        Some(Value::Array(items)) => {
1267            for item in items {
1268                let tag = item
1269                    .get("type")
1270                    .and_then(Value::as_str)
1271                    .unwrap_or("<no-type>");
1272                let modeled = item.is_string() || item.get("text").is_some_and(Value::is_string);
1273                let coverage = if record_coverage == Coverage::Dropped {
1274                    Coverage::Dropped
1275                } else if modeled {
1276                    Coverage::Normalized
1277                } else {
1278                    Coverage::Unmodeled
1279                };
1280                report.bump(format!("{prefix}/content/{tag}"), coverage, extra);
1281            }
1282        }
1283        Some(_) => report.bump(format!("{prefix}/content"), record_coverage, extra),
1284        None => report.bump(
1285            format!("{prefix}/content:<missing>"),
1286            if record_coverage == Coverage::Dropped {
1287                Coverage::Dropped
1288            } else {
1289                Coverage::Unmodeled
1290            },
1291            extra,
1292        ),
1293    }
1294}
1295
1296/// The frozen 12-part union discriminant values
1297/// (`docs/interop/research/opencode-fields.md` §3, `v1/session.ts:357-370`).
1298/// Anything outside this set is an UNKNOWN part type — never silently
1299/// dropped, always scored `Unmodeled` (§4.1's "no record/part discriminant
1300/// falls into an Unknown bucket" completeness guard).
1301const OPENCODE_KNOWN_PART_TYPES: &[&str] = &[
1302    "text",
1303    "reasoning",
1304    "tool",
1305    "file",
1306    "step-start",
1307    "step-finish",
1308    "snapshot",
1309    "patch",
1310    "agent",
1311    "subtask",
1312    "retry",
1313    "compaction",
1314];
1315
1316/// `ToolState`'s frozen discriminant values (`opencode-fields.md` §3.3,
1317/// `v1/session.ts:259-313`).
1318const OPENCODE_KNOWN_TOOL_STATUSES: &[&str] = &["pending", "running", "completed", "error"];
1319
1320/// Audit one envelope line of an OpenCode session
1321/// (`docs/interop/opencode-pi-spec.md` §1.2/§4.1): `{"key":[...],"value":...}`,
1322/// classified by the envelope `key`'s first component exactly like
1323/// [`crate::session::Session::from_opencode_str`]. Two second-level
1324/// discriminants get their own `Unknown*` buckets, mirroring pi's
1325/// `UnknownRole`/`UnknownImageShape` discipline (S6): `message/UnknownRole:*`
1326/// for a `message` record whose `role` isn't `user`/`assistant`, and
1327/// `part/UnknownType:*` for a `part` record whose `type` isn't one of the
1328/// frozen 12 — plus a THIRD level for `tool` parts specifically,
1329/// `part/tool/UnknownStatus:*`, for a `state.status` outside the frozen
1330/// four. All three must be empty over the committed fixture + real corpus.
1331fn audit_opencode_line(line: &str, report: &mut Report) {
1332    let raw: Value = match serde_json::from_str(line) {
1333        Ok(v) => v,
1334        Err(_) => {
1335            report.parse_errors += 1;
1336            return;
1337        }
1338    };
1339    let extra = EMPTY_EXTRA.get_or_init(Default::default);
1340    let Some(key) = raw.get("key").and_then(Value::as_array) else {
1341        report.bump("<line>/<no-key>".to_string(), Coverage::Unmodeled, extra);
1342        return;
1343    };
1344    let value = raw.get("value").cloned().unwrap_or(Value::Null);
1345    let kind = key.first().and_then(Value::as_str).unwrap_or("<no-kind>");
1346    match kind {
1347        "session" => report.bump("session".to_string(), Coverage::Normalized, extra),
1348        "message" => match value.get("role").and_then(Value::as_str) {
1349            Some("user") => report.bump("message/user".to_string(), Coverage::Normalized, extra),
1350            Some("assistant") => {
1351                report.bump("message/assistant".to_string(), Coverage::Normalized, extra)
1352            }
1353            Some(other) => report.bump(
1354                format!("message/UnknownRole:{other}"),
1355                Coverage::Unmodeled,
1356                extra,
1357            ),
1358            None => report.bump(
1359                "message/UnknownRole:<none>".to_string(),
1360                Coverage::Unmodeled,
1361                extra,
1362            ),
1363        },
1364        "part" => match value.get("type").and_then(Value::as_str) {
1365            Some(t) if OPENCODE_KNOWN_PART_TYPES.contains(&t) => {
1366                if t == "tool" {
1367                    // D5: tally the tool NAME (`tool`, e.g. "bash"/"edit"),
1368                    // not just the call-status bucket — previously
1369                    // `report.tools` was always empty for opencode corpora.
1370                    if let Some(name) = value.get("tool").and_then(Value::as_str) {
1371                        *report.tools.entry(name.to_string()).or_insert(0) += 1;
1372                    }
1373                    match value
1374                        .get("state")
1375                        .and_then(|s| s.get("status"))
1376                        .and_then(Value::as_str)
1377                    {
1378                        Some(s) if OPENCODE_KNOWN_TOOL_STATUSES.contains(&s) => {
1379                            report.bump(format!("part/tool/{s}"), Coverage::Normalized, extra)
1380                        }
1381                        Some(other) => report.bump(
1382                            format!("part/tool/UnknownStatus:{other}"),
1383                            Coverage::Unmodeled,
1384                            extra,
1385                        ),
1386                        None => report.bump(
1387                            "part/tool/UnknownStatus:<none>".to_string(),
1388                            Coverage::Unmodeled,
1389                            extra,
1390                        ),
1391                    }
1392                } else if t == "text" {
1393                    // D5: an `ignored:true` text part is EXCLUDED from
1394                    // replay by design (§2.2: "must not be re-emitted to
1395                    // the model") — it is recognized and preserved in
1396                    // `raw`, but never lands in canonical `messages`, so it
1397                    // is Dropped, not Normalized. A separate discriminant
1398                    // key keeps the two counted (and displayed) apart
1399                    // rather than one overwriting the other's coverage.
1400                    let ignored = value.get("ignored").and_then(Value::as_bool) == Some(true);
1401                    if ignored {
1402                        report.bump("part/text:ignored".to_string(), Coverage::Dropped, extra);
1403                    } else {
1404                        report.bump("part/text".to_string(), Coverage::Normalized, extra);
1405                    }
1406                } else if t == "file" {
1407                    // D5: the loader only canonicalizes a `data:`-URI
1408                    // `image/*` file part into `content_parts` (the SAME
1409                    // test `opencode_file_image_part` uses, reused here so
1410                    // audit can never drift from what convert actually
1411                    // replays). An `https:` link, a bare path, a PDF, or
1412                    // any other non-image/non-data-URI file is raw-only
1413                    // residue — Dropped, not Normalized.
1414                    if opencode_file_image_part(&value).is_some() {
1415                        report.bump("part/file".to_string(), Coverage::Normalized, extra);
1416                    } else {
1417                        report.bump("part/file:residue".to_string(), Coverage::Dropped, extra);
1418                    }
1419                } else {
1420                    // compaction drives the `compacted_out` boundary —
1421                    // Normalized. reasoning feeds `metadata["thinking"]`
1422                    // (recognized, deliberately not canonical content —
1423                    // Dropped, same label Claude/Codex `thinking` blocks
1424                    // get). step-start/step-finish/snapshot/patch/agent/
1425                    // subtask/retry are recognized but have NO clean home
1426                    // at all (§2.3) — also Dropped. Only a truly
1427                    // unrecognized type is Unmodeled.
1428                    let cov = match t {
1429                        "compaction" => Coverage::Normalized,
1430                        _ => Coverage::Dropped,
1431                    };
1432                    report.bump(format!("part/{t}"), cov, extra);
1433                }
1434            }
1435            Some(other) => report.bump(
1436                format!("part/UnknownType:{other}"),
1437                Coverage::Unmodeled,
1438                extra,
1439            ),
1440            None => report.bump(
1441                "part/UnknownType:<none>".to_string(),
1442                Coverage::Unmodeled,
1443                extra,
1444            ),
1445        },
1446        "session_diff" => report.bump("session_diff".to_string(), Coverage::Normalized, extra),
1447        "todo" => report.bump("todo".to_string(), Coverage::Normalized, extra),
1448        other => report.bump(format!("<line>/{other}"), Coverage::Unmodeled, extra),
1449    }
1450}
1451
1452fn jsonl_files(dir: &Path) -> Vec<PathBuf> {
1453    let mut out = Vec::new();
1454    let walker = ignore::WalkBuilder::new(dir)
1455        .standard_filters(false)
1456        .build();
1457    for entry in walker.flatten() {
1458        let p = entry.into_path();
1459        if p.extension().and_then(|e| e.to_str()) == Some("jsonl") {
1460            out.push(p);
1461        }
1462    }
1463    out.sort();
1464    out
1465}