From 10ccd1406c886270d105cd7d5a08ec4b447030cd Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 12 May 2026 09:24:50 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20rt.1=20=E2=80=94=20three=20new=20tests?= =?UTF-8?q?=20(ailx=20pair=20cross-check=20dynamic=20+=20schema=5Fcoverage?= =?UTF-8?q?=20+=20CLI=20roundtrip=20via=20BLAKE3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/rt.1.md | 930 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 930 insertions(+) create mode 100644 docs/plans/rt.1.md diff --git a/docs/plans/rt.1.md b/docs/plans/rt.1.md new file mode 100644 index 0000000..4df39ee --- /dev/null +++ b/docs/plans/rt.1.md @@ -0,0 +1,930 @@ +# rt.1 — Roundtrip Invariant Audit Tests — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-12-roundtrip-invariant.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Add three new test files that together verify the +`.ail.json` ↔ `.ailx` bijection plus AST-variant coverage of the +fixture corpus, all dynamically iterating `examples/` (no hardcoded +fixture lists). Surface any roundtrip / coverage gaps as fail-RED +test output for later iterations to fix. + +**Architecture:** Three reader-only test files added to existing +crates: replace one static-pair test in `ailang-surface` with a +dynamic-pair one; add new `tests/schema_coverage.rs` to +`ailang-core` (exhaustive-match AST visitor against +fixture-corpus observed variants); add new `tests/roundtrip_cli.rs` +to `ail` (subprocess `render → tempfile → parse`, BLAKE3 identity). +One dev-deps Cargo edit on the `ail` crate plus one workspace dep +(`tempfile`) — no production code changes. + +**Tech Stack:** `ailang-core` (AST + canonical bytes), `ailang-surface` +(parse/print), `ail` (CLI binary under test), `tempfile = "3"`, +`blake3` (already workspace). + +**Files this plan creates or modifies:** + +- Modify: `crates/ailang-surface/tests/round_trip.rs:1-14, 93-133` + — replace `handwritten_exhibits_match_json_fixtures` with the + dynamic `every_ailx_fixture_matches_its_json_counterpart`; update + the module-level doc comment to drop the "three hand-written + exhibits" wording. +- Modify: `Cargo.toml:19-26` — add `tempfile = "3"` to + `[workspace.dependencies]`. +- Modify: `crates/ail/Cargo.toml:11-19` — append a + `[dev-dependencies]` section with `tempfile.workspace = true` + and `blake3.workspace = true`. +- Create: `crates/ailang-core/tests/schema_coverage.rs` — exhaustive + AST visitor over fixture-loaded modules; flat `VariantTag` enum; + asserts every variant of `Def` / `Term` / `Pattern` / `Literal` / + `Type` / `ParamMode` is observed at least once. +- Create: `crates/ail/tests/roundtrip_cli.rs` — dynamic iteration + over `examples/*.ail.json`, subprocess pipeline `ail render` → + `tempfile::TempDir` → `ail parse`, BLAKE3 hash of canonical bytes + on both sides. + +--- + +## Task 1: Replace static-pair handwritten test with dynamic pair cross-check + +**Files:** +- Modify: `crates/ailang-surface/tests/round_trip.rs:1-14, 93-133` + +- [ ] **Step 1.1: Update the module-level doc comment** + + Replace the existing `crates/ailang-surface/tests/round_trip.rs:1-14` + doc block with: + + ```rust + //! Round-trip gate for the form-(A) projection. + //! + //! Two complementary checks over `examples/`: + //! + //! 1. `print_then_parse_round_trips_every_fixture`: for every + //! `examples/*.ail.json` fixture, load → `print` → `parse` → + //! canonical bytes; assert byte-equal to original. + //! + //! 2. `every_ailx_fixture_matches_its_json_counterpart`: for every + //! `examples/*.ailx` fixture, parse → canonical bytes; if a + //! same-stem `.ail.json` counterpart exists, assert canonical-byte + //! equality against it. The `.ailx` side is the human/AI authoring + //! ground-truth — fixtures must be writeable without going through + //! the printer and still match the JSON-AST. + //! + //! Both tests gather fixtures dynamically via `read_dir`; no hardcoded + //! lists. The tests are pure readers — they do not write into the + //! working tree. + ``` + +- [ ] **Step 1.2: Replace the test function** + + Delete `handwritten_exhibits_match_json_fixtures` at + `crates/ailang-surface/tests/round_trip.rs:93-133` and replace it + with: + + ```rust + fn list_ailx_fixtures() -> Vec { + let dir = examples_dir(); + let mut paths: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display())) + .filter_map(|entry| entry.ok()) + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".ailx")) + .unwrap_or(false) + }) + .collect(); + paths.sort(); + paths + } + + #[test] + fn every_ailx_fixture_matches_its_json_counterpart() { + let fixtures = list_ailx_fixtures(); + assert!( + !fixtures.is_empty(), + "no .ailx fixtures found under {}", + examples_dir().display() + ); + + let mut failures = Vec::::new(); + let mut paired = 0usize; + let mut parse_only = 0usize; + + for ailx_path in &fixtures { + let text = match std::fs::read_to_string(ailx_path) { + Ok(t) => t, + Err(e) => { + failures.push(format!("{}: read failed: {e}", ailx_path.display())); + continue; + } + }; + let parsed = match ailang_surface::parse(&text) { + Ok(m) => m, + Err(e) => { + failures.push(format!("{}: parse failed: {e}", ailx_path.display())); + continue; + } + }; + + // Same-stem counterpart lookup. `.ailx` → `.ail.json`. + let stem = ailx_path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(".ailx")) + .unwrap_or(""); + let json_path = ailx_path.with_file_name(format!("{stem}.ail.json")); + + if !json_path.exists() { + // Spec: parse success alone is sufficient for `.ailx` files + // without a JSON counterpart. (Today: none expected.) + parse_only += 1; + continue; + } + + let original = match ailang_core::load_module(&json_path) { + Ok(m) => m, + Err(e) => { + failures.push(format!( + "{}: load_module({}) failed: {e}", + ailx_path.display(), + json_path.display() + )); + continue; + } + }; + let bytes_orig = ailang_core::canonical::to_bytes(&original); + let bytes_parsed = ailang_core::canonical::to_bytes(&parsed); + if bytes_orig != bytes_parsed { + let s_orig = String::from_utf8_lossy(&bytes_orig).into_owned(); + let s_round = String::from_utf8_lossy(&bytes_parsed).into_owned(); + failures.push(format!( + "{} vs {}: canonical bytes differ.\noriginal: {s_orig}\nparsed: {s_round}", + ailx_path.display(), + json_path.display() + )); + continue; + } + paired += 1; + } + + if !failures.is_empty() { + panic!( + "{} of {} .ailx fixture(s) failed cross-check (paired ok: {}, parse-only: {}):\n{}", + failures.len(), + fixtures.len(), + paired, + parse_only, + failures.join("\n\n") + ); + } + eprintln!( + ".ailx cross-check ok ({} paired, {} parse-only)", + paired, parse_only + ); + } + ``` + +- [ ] **Step 1.3: Run the updated test** + + Run: `cargo test --quiet -p ailang-surface --test round_trip` + + Expected: either PASS for both tests (all 57 `.ailx` fixtures pair + correctly with their `.ail.json` counterparts), or a structured + panic listing every mismatching fixture pair with canonical-byte + diffs. A failure here is iteration deliverable, not a regression: + capture the list verbatim into the per-iter journal as the audit + output for later iterations. + +--- + +## Task 2: Workspace dep `tempfile`, ail dev-deps for tempfile + blake3 + +**Files:** +- Modify: `Cargo.toml:19-26` +- Modify: `crates/ail/Cargo.toml:11-19` + +- [ ] **Step 2.1: Add `tempfile` to workspace dependencies** + + Edit `Cargo.toml`. After the `indexmap` line in `[workspace.dependencies]` + (currently line 26), append: + + ```toml + tempfile = "3" + ``` + + The full `[workspace.dependencies]` block after the edit reads: + + ```toml + [workspace.dependencies] + serde = { version = "1", features = ["derive"] } + serde_json = { version = "1", features = ["preserve_order"] } + blake3 = "1" + anyhow = "1" + thiserror = "1" + clap = { version = "4", features = ["derive"] } + indexmap = { version = "2", features = ["serde"] } + tempfile = "3" + + ailang-core = { path = "crates/ailang-core" } + ... + ``` + +- [ ] **Step 2.2: Add dev-deps to ail crate** + + Edit `crates/ail/Cargo.toml`. Append after the existing + `[dependencies]` block (after line 19): + + ```toml + + [dev-dependencies] + tempfile.workspace = true + blake3.workspace = true + ``` + +- [ ] **Step 2.3: Verify the workspace still builds and tests still pass** + + Run: `cargo test --workspace --quiet` + + Expected: PASS — all existing tests still green. New deps are + declared but not yet used (Task 4 will use them). + +--- + +## Task 3: New `crates/ailang-core/tests/schema_coverage.rs` + +**Files:** +- Create: `crates/ailang-core/tests/schema_coverage.rs` + +- [ ] **Step 3.1: Create the file with the visitor scaffold** + + Write `crates/ailang-core/tests/schema_coverage.rs` with this + initial scaffold (imports + `VariantTag` enum + helper signatures): + + ```rust + //! Schema-coverage audit: every AST enum variant of `Def`, `Term`, + //! `Pattern`, `Literal`, `Type`, `ParamMode` is exercised by at + //! least one fixture under `examples/`. + //! + //! Why this exists: the round-trip tests in `ailang-surface` are + //! only as strong as the fixture corpus that drives them. A schema + //! variant with zero fixture coverage round-trips trivially — there + //! is nothing to round-trip. This test makes the corpus's coverage + //! observable, so a gap surfaces as a fail-loud test, not as silent + //! confidence. + //! + //! How it stays in sync with the AST: every match arm below is + //! exhaustive (no `_ => ...` wildcard). When a new AST variant + //! lands, the compiler rejects this file until the visitor and the + //! `VariantTag` enum are extended in lockstep. + //! + //! Pure reader: this test loads fixtures but does not modify any + //! committed content. A failure means a fixture must be added (in + //! a separate iteration); the test must not be relaxed. + + use std::collections::HashSet; + use std::path::PathBuf; + + use ailang_core::ast::{ + Def, InstanceMethod, Literal, Module, ParamMode, Pattern, Term, Type, + }; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + enum VariantTag { + // Def + DefFn, + DefConst, + DefType, + DefClass, + DefInstance, + // Term + TermLit, + TermVar, + TermApp, + TermLet, + TermLetRec, + TermIf, + TermDo, + TermCtor, + TermMatch, + TermLam, + TermSeq, + TermClone, + TermReuseAs, + // Pattern + PatternWild, + PatternVar, + PatternLit, + PatternCtor, + // Literal + LiteralInt, + LiteralBool, + LiteralStr, + LiteralUnit, + LiteralFloat, + // Type + TypeCon, + TypeFn, + TypeVar, + TypeForall, + // ParamMode + ParamModeImplicit, + ParamModeOwn, + ParamModeBorrow, + } + + /// Lists every expected variant. Stored as a const array so that + /// adding a new `VariantTag` here and forgetting to add the + /// corresponding match arm in the visitor is a Rust compile error + /// (and vice versa). + const EXPECTED_VARIANTS: &[VariantTag] = &[ + VariantTag::DefFn, + VariantTag::DefConst, + VariantTag::DefType, + VariantTag::DefClass, + VariantTag::DefInstance, + VariantTag::TermLit, + VariantTag::TermVar, + VariantTag::TermApp, + VariantTag::TermLet, + VariantTag::TermLetRec, + VariantTag::TermIf, + VariantTag::TermDo, + VariantTag::TermCtor, + VariantTag::TermMatch, + VariantTag::TermLam, + VariantTag::TermSeq, + VariantTag::TermClone, + VariantTag::TermReuseAs, + VariantTag::PatternWild, + VariantTag::PatternVar, + VariantTag::PatternLit, + VariantTag::PatternCtor, + VariantTag::LiteralInt, + VariantTag::LiteralBool, + VariantTag::LiteralStr, + VariantTag::LiteralUnit, + VariantTag::LiteralFloat, + VariantTag::TypeCon, + VariantTag::TypeFn, + VariantTag::TypeVar, + VariantTag::TypeForall, + VariantTag::ParamModeImplicit, + VariantTag::ParamModeOwn, + VariantTag::ParamModeBorrow, + ]; + ``` + +- [ ] **Step 3.2: Add the per-enum visitor functions** + + Append to `crates/ailang-core/tests/schema_coverage.rs`: + + ```rust + fn visit_def(def: &Def, observed: &mut HashSet) { + match def { + Def::Fn(fd) => { + observed.insert(VariantTag::DefFn); + for m in &fd.params { + visit_param_mode(m, observed); + } + visit_type(&fd.ty, observed); + visit_term(&fd.body, observed); + } + Def::Const(cd) => { + observed.insert(VariantTag::DefConst); + visit_type(&cd.ty, observed); + visit_term(&cd.body, observed); + } + Def::Type(td) => { + observed.insert(VariantTag::DefType); + for ctor in &td.ctors { + for field_ty in &ctor.fields { + visit_type(field_ty, observed); + } + } + } + Def::Class(cd) => { + observed.insert(VariantTag::DefClass); + for m in &cd.methods { + visit_type(&m.ty, observed); + } + } + Def::Instance(id) => { + observed.insert(VariantTag::DefInstance); + for arg in &id.args { + visit_type(arg, observed); + } + for m in &id.methods { + visit_instance_method(m, observed); + } + } + } + } + + fn visit_instance_method(m: &InstanceMethod, observed: &mut HashSet) { + for mode in &m.params { + visit_param_mode(mode, observed); + } + visit_term(&m.body, observed); + } + + fn visit_term(t: &Term, observed: &mut HashSet) { + match t { + Term::Lit { lit } => { + observed.insert(VariantTag::TermLit); + visit_literal(lit, observed); + } + Term::Var { .. } => { + observed.insert(VariantTag::TermVar); + } + Term::App { f, args } => { + observed.insert(VariantTag::TermApp); + visit_term(f, observed); + for a in args { + visit_term(a, observed); + } + } + Term::Let { bound, body, .. } => { + observed.insert(VariantTag::TermLet); + visit_term(bound, observed); + visit_term(body, observed); + } + Term::LetRec { bindings, body } => { + observed.insert(VariantTag::TermLetRec); + for (_n, _ty, bterm) in bindings { + visit_term(bterm, observed); + } + visit_term(body, observed); + } + Term::If { cond, then_b, else_b } => { + observed.insert(VariantTag::TermIf); + visit_term(cond, observed); + visit_term(then_b, observed); + visit_term(else_b, observed); + } + Term::Do { stmts, ret } => { + observed.insert(VariantTag::TermDo); + for s in stmts { + visit_term(s, observed); + } + visit_term(ret, observed); + } + Term::Ctor { args, .. } => { + observed.insert(VariantTag::TermCtor); + for a in args { + visit_term(a, observed); + } + } + Term::Match { scrutinee, arms } => { + observed.insert(VariantTag::TermMatch); + visit_term(scrutinee, observed); + for arm in arms { + visit_pattern(&arm.pat, observed); + visit_term(&arm.body, observed); + } + } + Term::Lam { modes, body, .. } => { + observed.insert(VariantTag::TermLam); + for m in modes { + visit_param_mode(m, observed); + } + visit_term(body, observed); + } + Term::Seq { stmts, ret } => { + observed.insert(VariantTag::TermSeq); + for s in stmts { + visit_term(s, observed); + } + visit_term(ret, observed); + } + Term::Clone { value } => { + observed.insert(VariantTag::TermClone); + visit_term(value, observed); + } + Term::ReuseAs { source, body } => { + observed.insert(VariantTag::TermReuseAs); + visit_term(source, observed); + visit_term(body, observed); + } + } + } + + fn visit_pattern(p: &Pattern, observed: &mut HashSet) { + match p { + Pattern::Wild => { + observed.insert(VariantTag::PatternWild); + } + Pattern::Var { .. } => { + observed.insert(VariantTag::PatternVar); + } + Pattern::Lit { lit } => { + observed.insert(VariantTag::PatternLit); + visit_literal(lit, observed); + } + Pattern::Ctor { fields, .. } => { + observed.insert(VariantTag::PatternCtor); + for f in fields { + visit_pattern(f, observed); + } + } + } + } + + fn visit_literal(l: &Literal, observed: &mut HashSet) { + match l { + Literal::Int { .. } => { + observed.insert(VariantTag::LiteralInt); + } + Literal::Bool { .. } => { + observed.insert(VariantTag::LiteralBool); + } + Literal::Str { .. } => { + observed.insert(VariantTag::LiteralStr); + } + Literal::Unit => { + observed.insert(VariantTag::LiteralUnit); + } + Literal::Float { .. } => { + observed.insert(VariantTag::LiteralFloat); + } + } + } + + fn visit_type(t: &Type, observed: &mut HashSet) { + match t { + Type::Con { args, .. } => { + observed.insert(VariantTag::TypeCon); + for a in args { + visit_type(a, observed); + } + } + Type::Fn { params, ret, .. } => { + observed.insert(VariantTag::TypeFn); + for p in params { + visit_type(p, observed); + } + visit_type(ret, observed); + } + Type::Var { .. } => { + observed.insert(VariantTag::TypeVar); + } + Type::Forall { body, .. } => { + observed.insert(VariantTag::TypeForall); + visit_type(body, observed); + } + } + } + + fn visit_param_mode(m: &ParamMode, observed: &mut HashSet) { + match m { + ParamMode::Implicit => { + observed.insert(VariantTag::ParamModeImplicit); + } + ParamMode::Own => { + observed.insert(VariantTag::ParamModeOwn); + } + ParamMode::Borrow => { + observed.insert(VariantTag::ParamModeBorrow); + } + } + } + + fn visit_module(m: &Module, observed: &mut HashSet) { + for def in &m.defs { + visit_def(def, observed); + } + } + ``` + +- [ ] **Step 3.3: Add fixture discovery + the top-level test** + + Append to `crates/ailang-core/tests/schema_coverage.rs`: + + ```rust + fn examples_dir() -> PathBuf { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + crate_dir.parent().unwrap().parent().unwrap().join("examples") + } + + fn list_json_fixtures() -> Vec { + let dir = examples_dir(); + let mut paths: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display())) + .filter_map(|entry| entry.ok()) + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".ail.json")) + .unwrap_or(false) + }) + .collect(); + paths.sort(); + paths + } + + #[test] + fn every_ast_variant_is_observed_in_the_fixture_corpus() { + let fixtures = list_json_fixtures(); + assert!( + !fixtures.is_empty(), + "no .ail.json fixtures found under {}", + examples_dir().display() + ); + + let mut observed: HashSet = HashSet::new(); + let mut load_failures = Vec::::new(); + + for path in &fixtures { + match ailang_core::load_module(path) { + Ok(m) => visit_module(&m, &mut observed), + Err(e) => load_failures.push(format!("{}: {e}", path.display())), + } + } + + // Load failures fail the test on their own — a fixture that + // does not load is a corpus bug, surface it loud. + if !load_failures.is_empty() { + panic!( + "{} fixture(s) failed to load during coverage scan:\n{}", + load_failures.len(), + load_failures.join("\n") + ); + } + + let missing: Vec = EXPECTED_VARIANTS + .iter() + .copied() + .filter(|v| !observed.contains(v)) + .collect(); + + if !missing.is_empty() { + let names: Vec = missing.iter().map(|v| format!("{v:?}")).collect(); + panic!( + "{} AST variant(s) have NO fixture coverage in {}:\n {}\n\n\ + every variant must be exercised by at least one fixture; \ + add a fixture that uses each missing variant (do NOT \ + weaken this test).", + missing.len(), + examples_dir().display(), + names.join("\n ") + ); + } + + eprintln!( + "schema coverage ok: {} variants observed across {} fixtures", + observed.len(), + fixtures.len() + ); + } + ``` + +- [ ] **Step 3.4: Verify the visitor field names match the AST** + + Run: `cargo build --quiet -p ailang-core --tests` + + Expected: PASS. If the build fails, the visitor's destructuring + pattern does not match the current AST struct field names — fix + the field names by reading `crates/ailang-core/src/ast.rs` and + adjusting the destructuring pattern in the offending arm. Do NOT + add `_` wildcards in the outer `match` arms; field-level + destructuring may use `..` to ignore irrelevant fields. + +- [ ] **Step 3.5: Run the schema-coverage test** + + Run: `cargo test --quiet -p ailang-core --test schema_coverage` + + Expected: either PASS (every AST variant has at least one + fixture-exemplar in `examples/`), or a structured panic listing + the missing variants. A failure here is iteration deliverable — + capture the missing-variant list into the per-iter journal as + audit output. + +--- + +## Task 4: New `crates/ail/tests/roundtrip_cli.rs` + +**Files:** +- Create: `crates/ail/tests/roundtrip_cli.rs` + +- [ ] **Step 4.1: Create the file with imports, helpers, and binary lookup** + + Write `crates/ail/tests/roundtrip_cli.rs`: + + ```rust + //! CLI roundtrip: every `examples/*.ail.json` survives + //! `ail render` → tempfile → `ail parse` with BLAKE3 hash + //! identity on canonical bytes. + //! + //! This is the user-facing-surface defence line. Crate-internal + //! roundtrip tests in `ailang-surface` cover the same property + //! at the library level; this test additionally protects the + //! `ail render` and `ail parse` CLI wrappers from drift (output + //! formatting, exit codes, stdout framing). + //! + //! Pure reader: the test writes only to a `tempfile::TempDir` + //! that lives outside the repo and is cleaned up by Drop. No + //! committed content is mutated. + + use std::path::{Path, PathBuf}; + use std::process::Command; + + fn ail_bin() -> &'static str { + env!("CARGO_BIN_EXE_ail") + } + + fn workspace_root() -> PathBuf { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + Path::new(manifest_dir).parent().unwrap().parent().unwrap().to_path_buf() + } + + fn examples_dir() -> PathBuf { + workspace_root().join("examples") + } + + fn list_json_fixtures() -> Vec { + let dir = examples_dir(); + let mut paths: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display())) + .filter_map(|entry| entry.ok()) + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".ail.json")) + .unwrap_or(false) + }) + .collect(); + paths.sort(); + paths + } + + fn strip_trailing_newlines(mut v: Vec) -> Vec { + while v.last() == Some(&b'\n') { + v.pop(); + } + v + } + ``` + +- [ ] **Step 4.2: Add the per-fixture roundtrip helper** + + Append to `crates/ail/tests/roundtrip_cli.rs`: + + ```rust + /// Run the full render → parse pipeline for one fixture. Returns + /// Ok(()) on hash-identity, Err(reason) otherwise. + fn roundtrip_one(fixture: &Path, tmpdir: &Path) -> Result<(), String> { + // Step A: load the original fixture and compute canonical bytes + // (independent of how the JSON file is formatted on disk). + let original_module = ailang_core::load_module(fixture) + .map_err(|e| format!("load_module: {e}"))?; + let bytes_orig = ailang_core::canonical::to_bytes(&original_module); + let h_orig = blake3::hash(&bytes_orig); + + // Step B: `ail render ` → captured stdout = ailx text. + let render_out = Command::new(ail_bin()) + .args(["render", fixture.to_str().unwrap()]) + .output() + .map_err(|e| format!("spawn ail render: {e}"))?; + if !render_out.status.success() { + return Err(format!( + "ail render exit={:?}\nstderr: {}", + render_out.status.code(), + String::from_utf8_lossy(&render_out.stderr), + )); + } + + // Step C: write the rendered .ailx into the tempdir. + let stem = fixture + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(".ail.json")) + .unwrap_or("fixture"); + let tmp_ailx = tmpdir.join(format!("{stem}.round.ailx")); + std::fs::write(&tmp_ailx, &render_out.stdout) + .map_err(|e| format!("write tempfile {}: {e}", tmp_ailx.display()))?; + + // Step D: `ail parse ` → captured stdout = canonical + // bytes + trailing newline. + let parse_out = Command::new(ail_bin()) + .args(["parse", tmp_ailx.to_str().unwrap()]) + .output() + .map_err(|e| format!("spawn ail parse: {e}"))?; + if !parse_out.status.success() { + return Err(format!( + "ail parse exit={:?}\nstderr: {}", + parse_out.status.code(), + String::from_utf8_lossy(&parse_out.stderr), + )); + } + let bytes_round = strip_trailing_newlines(parse_out.stdout); + let h_round = blake3::hash(&bytes_round); + + if h_orig != h_round { + let s_orig = String::from_utf8_lossy(&bytes_orig).into_owned(); + let s_round = String::from_utf8_lossy(&bytes_round).into_owned(); + return Err(format!( + "BLAKE3 mismatch:\n orig: {}\n round: {}\noriginal bytes: {s_orig}\nround bytes: {s_round}", + h_orig.to_hex(), + h_round.to_hex(), + )); + } + Ok(()) + } + ``` + +- [ ] **Step 4.3: Add the top-level dynamic test** + + Append to `crates/ail/tests/roundtrip_cli.rs`: + + ```rust + #[test] + fn cli_render_then_parse_preserves_canonical_bytes_on_every_fixture() { + let fixtures = list_json_fixtures(); + assert!( + !fixtures.is_empty(), + "no .ail.json fixtures found under {}", + examples_dir().display() + ); + + let tmpdir = tempfile::TempDir::new().expect("create tempdir"); + let mut failures = Vec::::new(); + let mut passed = 0usize; + + for fixture in &fixtures { + match roundtrip_one(fixture, tmpdir.path()) { + Ok(()) => passed += 1, + Err(msg) => failures.push(format!("{}: {msg}", fixture.display())), + } + } + + if !failures.is_empty() { + panic!( + "CLI roundtrip failed for {} of {} fixtures (passed: {}):\n{}", + failures.len(), + fixtures.len(), + passed, + failures.join("\n\n") + ); + } + eprintln!("CLI roundtrip ok for {passed} fixtures"); + } + ``` + +- [ ] **Step 4.4: Run the CLI roundtrip test** + + Run: `cargo test --quiet -p ail --test roundtrip_cli` + + Expected: either PASS (every `.ail.json` round-trips through the + CLI), or a structured panic listing each fixture that failed with + its diagnostic. A failure here is iteration deliverable — capture + the failing-fixture list into the per-iter journal as audit + output. + +- [ ] **Step 4.5: Final workspace test run** + + Run: `cargo test --workspace --quiet` + + Expected: every existing test still PASS plus the three new tests + report their status. If any of the three new tests fail, the + failing-fixture lists are the audit deliverable; do NOT relax the + tests to make them green. Capture the full failure output into + the per-iter journal entry. + +--- + +## Per-iter journal entry + +After Task 4 completes, append a per-iter journal file at +`docs/journals/2026-05-12-iter-rt.1.md` with this skeleton (filled +in with the actual audit findings from steps 1.3, 3.5, 4.4, 4.5): + +```markdown +# iter rt.1 — Roundtrip-Invariant audit tests + +Three new tests landed; one existing test (handwritten exhibits) +was replaced with a dynamic counterpart. No production code or +DESIGN.md changes (those are later iterations). + +## Audit findings + +- `every_ailx_fixture_matches_its_json_counterpart`: +- `every_ast_variant_is_observed_in_the_fixture_corpus`: +- `cli_render_then_parse_preserves_canonical_bytes_on_every_fixture`: + +## Follow-up + +If any of the three reported failures, queue rt.2..N to fix each +gap class in render or parse code (never by relaxing tests). +``` + +Also append a one-line entry to `docs/journals/INDEX.md`: + +``` +- 2026-05-12 — iter rt.1: roundtrip invariant audit tests (3 new tests + ailx-pair dynamic) → 2026-05-12-iter-rt.1.md +```