Files
AILang/crates/ail/tests/roundtrip_cli.rs
T
Brummel 9fdc4cacff iter form-a.1 (Tasks 6-12): milestone close
Second half of the form-a-default-authoring milestone-close iter
(Boss-decided strategy C, big-bang). All seven tasks DONE; cargo
test --workspace green at every per-task boundary.

T6 — Bench-driver suffix flip from .ail.json to .ail across 4
Python scripts + run.sh. compile_check.py + cross_lang.py exit 0.

T7 — Re-author e2e.rs raw-JSON-inspect tests:
- diff_detects_changed_def — derive sum.ail.json on-the-fly via
  `ail parse examples/sum.ail` into tempdir, then mutate + diff.
- borrow_own_demo_modes_are_metadata_only — same pattern.
- reuse_as_demo_under_rc_uses_inplace_rewrite — same pattern.
- render_parse_round_trip_canonical — RETIRED (subsumed by T1's
  cli_parse_then_render_then_parse_is_idempotent over whole corpus).
- ail_run_accepts_ail_source_with_same_stdout_as_ail_json —
  re-authored to derive hello.ail.json in a per-process tempdir
  from hello.ail via `ail parse`, then assert dual-form stdout.

T8 — Bulk-delete 156 non-carve-out .ail.json. Inventory:
8 .ail.json (carve-outs, alphabetical: broken_unbound + prelude
+ 3× test_22b2_* + 3× test_ct1_*) + 157 .ail. carve_out_inventory
test un-#[ignore]'d and green. Forward-pulled 20 repairs that the
T1-5 dispatch's recon missed (12 Group-B suffix + 5 Group-A
load_workspace + 3 ail_run sites). Also forward-pulled T9 Step 5
(schema_coverage corpus flip from .ail.json to .ail) to satisfy
T8's green-gate.

T9 — Retire obsolete roundtrip tests:
- print_then_parse_round_trips_every_fixture (round_trip.rs)
- every_ail_fixture_matches_its_json_counterpart (round_trip.rs)
- cli_render_then_parse_preserves_canonical_bytes_on_every_fixture
- Dead helpers: list_json_fixtures ×2, round_trip_one,
  strip_trailing_newlines.
Schema-coverage corpus already flipped in T8 (forward-pull).

T10 — DESIGN.md §"Roundtrip Invariant" (lines 2027-2109) restated
with parse-determinism + idempotency + CLI-pipeline-idempotency +
carve-out-anchor framing. Five surviving enforcement tests named.
§"Float literals" and §"Why anchored at top level" preserved.

T11 — §A4 doctrine edits: CLAUDE.md:5-6 + DESIGN.md:465-466.
Canonical form remains JSON-AST; authoring projection is .ail;
build derives JSON-AST in-process via ailang_surface::parse.

T12 — Milestone close:
- WhatsNew entry: user-facing language, lead with the change.
- Roadmap: [milestone] form-a struck [x] with closing note.
- Final inventory verified: 8 .ail.json + 157 .ail.
- Final cargo test --workspace: 557 passed, 0 failed, 3 ignored.
- bench/compile_check.py + bench/cross_lang.py: exit 0.

Test math: pre-iter 558 baseline + 3 new T1 tests = 561, − 1
(T7 retire) − 3 (T9 retire) = 557 final.

INDEX.md appended with the full iter summary covering T1-T12 (the
T1-5 commit at 77b28ad deferred the INDEX line to full-iter close).

Milestone [Form-A as the default authoring surface] structurally
closed. The compile-time-embed carve-out (prelude.ail.json) is
the subject of the queued follow-up milestone [Prelude embed:
Form-A as compile-time source]. audit-form-a runs as the next
dispatch.
2026-05-13 11:31:39 +02:00

162 lines
5.5 KiB
Rust

//! CLI-pipeline idempotency gate: every `examples/*.ail` survives
//! `ail parse | ail render | ail parse` with byte identity on the
//! canonical JSON output.
//!
//! 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 parse` and `ail render` 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.
//!
//! Retired iter form-a.1 T9:
//! `cli_render_then_parse_preserves_canonical_bytes_on_every_fixture`
//! (walked `.ail.json` corpus; replaced by the corpus-flipped
//! `cli_parse_then_render_then_parse_is_idempotent` below, added in
//! T1). The `list_json_fixtures` helper and `roundtrip_one` helper
//! were the only consumers of `.ail.json` walks and are retired
//! alongside.
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_ail_fixtures() -> Vec<PathBuf> {
let dir = examples_dir();
let mut paths: Vec<PathBuf> = 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"))
.unwrap_or(false)
})
.collect();
paths.sort();
paths
}
/// CLI-pipeline idempotency (post-form-a-default-authoring §C3): for
/// every `.ail` fixture, the user-facing pipeline
/// `ail parse <ail> | ail render | ail parse` is byte-identical
/// to direct `ail parse <ail>`. Pins drift internal tests cannot see.
#[test]
fn cli_parse_then_render_then_parse_is_idempotent() {
let fixtures = list_ail_fixtures();
assert!(
!fixtures.is_empty(),
"no .ail fixtures found under {}",
examples_dir().display()
);
let tmpdir = tempfile::TempDir::new().expect("create tempdir");
let mut checked = 0usize;
let mut failures = Vec::<String>::new();
for ail_path in &fixtures {
// Direct parse → JSON tempfile.
let json1 = tmpdir.path().join(format!("v1_{checked}.ail.json"));
let parse1 = Command::new(ail_bin())
.args([
"parse",
ail_path.to_str().unwrap(),
"-o",
json1.to_str().unwrap(),
])
.output()
.expect("spawn ail parse #1");
if !parse1.status.success() {
failures.push(format!(
"{}: first ail parse exit={:?}\nstderr: {}",
ail_path.display(),
parse1.status.code(),
String::from_utf8_lossy(&parse1.stderr),
));
checked += 1;
continue;
}
// Render that JSON → .ail tempfile.
let ail_round = tmpdir.path().join(format!("round_{checked}.ail"));
let rendered = Command::new(ail_bin())
.args(["render", json1.to_str().unwrap()])
.output()
.expect("spawn ail render");
if !rendered.status.success() {
failures.push(format!(
"{}: ail render exit={:?}\nstderr: {}",
ail_path.display(),
rendered.status.code(),
String::from_utf8_lossy(&rendered.stderr),
));
checked += 1;
continue;
}
std::fs::write(&ail_round, &rendered.stdout)
.expect("write ail_round tempfile");
// Re-parse → second JSON tempfile.
let json2 = tmpdir.path().join(format!("v2_{checked}.ail.json"));
let parse2 = Command::new(ail_bin())
.args([
"parse",
ail_round.to_str().unwrap(),
"-o",
json2.to_str().unwrap(),
])
.output()
.expect("spawn ail parse #2");
if !parse2.status.success() {
failures.push(format!(
"{}: second ail parse exit={:?}\nstderr: {}",
ail_path.display(),
parse2.status.code(),
String::from_utf8_lossy(&parse2.stderr),
));
checked += 1;
continue;
}
// Assert the two canonical JSONs are byte-equal.
let b1 = std::fs::read(&json1).expect("read json1");
let b2 = std::fs::read(&json2).expect("read json2");
if b1 != b2 {
let s1 = String::from_utf8_lossy(&b1).into_owned();
let s2 = String::from_utf8_lossy(&b2).into_owned();
failures.push(format!(
"{}: CLI parse|render|parse non-idempotent.\n v1: {s1}\n v2: {s2}",
ail_path.display(),
));
}
checked += 1;
}
if !failures.is_empty() {
panic!(
"CLI parse|render|parse non-idempotent on {} of {} fixtures:\n{}",
failures.len(),
checked,
failures.join("\n\n")
);
}
assert!(checked > 0, "no .ail fixtures found");
eprintln!("CLI parse|render|parse idempotent for {checked} fixtures");
}