diff --git a/Cargo.lock b/Cargo.lock index 3d96f0e..d4d4d55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,8 +12,10 @@ dependencies = [ "ailang-prose", "ailang-surface", "anyhow", + "blake3", "clap", "serde_json", + "tempfile", ] [[package]] @@ -133,6 +135,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "blake3" version = "1.8.5" @@ -230,12 +238,56 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -248,6 +300,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -255,7 +313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -272,24 +330,58 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -308,6 +400,31 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -375,6 +492,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -401,12 +531,70 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -422,6 +610,100 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index b9ecd7d..556b96d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ anyhow = "1" thiserror = "1" clap = { version = "4", features = ["derive"] } indexmap = { version = "2", features = ["serde"] } +tempfile = "3" ailang-core = { path = "crates/ailang-core" } ailang-check = { path = "crates/ailang-check" } diff --git a/bench/orchestrator-stats/2026-05-12-iter-rt.1.json b/bench/orchestrator-stats/2026-05-12-iter-rt.1.json new file mode 100644 index 0000000..1982116 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-12-iter-rt.1.json @@ -0,0 +1,12 @@ +{ + "iter_id": "rt.1", + "date": "2026-05-12", + "mode": "standard", + "outcome": "DONE", + "tasks_total": 4, + "tasks_completed": 4, + "reloops_per_task": { "1": 0, "2": 0, "3": 0, "4": 0 }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null +} diff --git a/crates/ail/Cargo.toml b/crates/ail/Cargo.toml index 3d643bd..b624f77 100644 --- a/crates/ail/Cargo.toml +++ b/crates/ail/Cargo.toml @@ -17,3 +17,7 @@ ailang-prose.workspace = true serde_json.workspace = true clap.workspace = true anyhow.workspace = true + +[dev-dependencies] +tempfile.workspace = true +blake3.workspace = true diff --git a/crates/ail/tests/roundtrip_cli.rs b/crates/ail/tests/roundtrip_cli.rs new file mode 100644 index 0000000..cb931e1 --- /dev/null +++ b/crates/ail/tests/roundtrip_cli.rs @@ -0,0 +1,146 @@ +//! 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 +} + +/// 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(()) +} + +#[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"); +} diff --git a/crates/ailang-core/tests/schema_coverage.rs b/crates/ailang-core/tests/schema_coverage.rs new file mode 100644 index 0000000..fc3e9e6 --- /dev/null +++ b/crates/ailang-core/tests/schema_coverage.rs @@ -0,0 +1,399 @@ +//! 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, +]; + +fn visit_def(def: &Def, observed: &mut HashSet) { + match def { + Def::Fn(fd) => { + observed.insert(VariantTag::DefFn); + 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.value, 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); + if let Some(body) = &m.default { + visit_term(body, observed); + } + } + } + Def::Instance(id) => { + observed.insert(VariantTag::DefInstance); + visit_type(&id.type_, observed); + for m in &id.methods { + visit_instance_method(m, observed); + } + } + } +} + +fn visit_instance_method(m: &InstanceMethod, observed: &mut HashSet) { + 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 { callee, args, .. } => { + observed.insert(VariantTag::TermApp); + visit_term(callee, observed); + for a in args { + visit_term(a, observed); + } + } + Term::Let { value, body, .. } => { + observed.insert(VariantTag::TermLet); + visit_term(value, observed); + visit_term(body, observed); + } + Term::LetRec { ty, body, in_term, .. } => { + observed.insert(VariantTag::TermLetRec); + visit_type(ty, observed); + visit_term(body, observed); + visit_term(in_term, observed); + } + Term::If { cond, then, else_ } => { + observed.insert(VariantTag::TermIf); + visit_term(cond, observed); + visit_term(then, observed); + visit_term(else_, observed); + } + Term::Do { args, .. } => { + observed.insert(VariantTag::TermDo); + for a in args { + visit_term(a, 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 { param_tys, ret_ty, body, .. } => { + observed.insert(VariantTag::TermLam); + for ty in param_tys { + visit_type(ty, observed); + } + visit_type(ret_ty, observed); + visit_term(body, observed); + } + Term::Seq { lhs, rhs } => { + observed.insert(VariantTag::TermSeq); + visit_term(lhs, observed); + visit_term(rhs, 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, param_modes, ret, ret_mode, .. } => { + observed.insert(VariantTag::TypeFn); + for p in params { + visit_type(p, observed); + } + for m in param_modes { + visit_param_mode(m, observed); + } + visit_type(ret, observed); + visit_param_mode(ret_mode, 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); + } +} + +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() + ); +} diff --git a/crates/ailang-surface/tests/round_trip.rs b/crates/ailang-surface/tests/round_trip.rs index f1c1628..81caefd 100644 --- a/crates/ailang-surface/tests/round_trip.rs +++ b/crates/ailang-surface/tests/round_trip.rs @@ -1,17 +1,21 @@ //! Round-trip gate for the form-(A) projection. //! -//! For every `examples/*.ail.json` fixture, this test: +//! Two complementary checks over `examples/`: //! -//! 1. Loads the original module via `ailang_core::load_module`. -//! 2. Prints it with `ailang_surface::print`. -//! 3. Re-parses the printed text with `ailang_surface::parse`. -//! 4. Canonicalises both modules and asserts byte-equal canonical JSON. +//! 1. `print_then_parse_round_trips_every_fixture`: for every +//! `examples/*.ail.json` fixture, load → `print` → `parse` → +//! canonical bytes; assert byte-equal to original. //! -//! In addition, the three hand-written exhibits (`hello.ailx`, -//! `box.ailx`, `list_map_poly.ailx`) are parsed and asserted to produce -//! canonical JSON identical to their corresponding `.ail.json` -//! fixtures. This is the human/AI authoring ground-truth check that -//! the form is writeable without going through the printer. +//! 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. use std::path::{Path, PathBuf}; @@ -90,44 +94,105 @@ fn round_trip_one(path: &Path) -> Result<(), String> { Ok(()) } -#[test] -fn handwritten_exhibits_match_json_fixtures() { +fn list_ailx_fixtures() -> Vec { let dir = examples_dir(); - let pairs: &[(&str, &str)] = &[ - ("hello.ailx", "hello.ail.json"), - ("box.ailx", "box.ail.json"), - ("list_map_poly.ailx", "list_map_poly.ail.json"), - ]; - let mut failures = Vec::new(); - for (ailx, json) in pairs { - let ailx_path = dir.join(ailx); - let json_path = dir.join(json); - let text = std::fs::read_to_string(&ailx_path) - .unwrap_or_else(|e| panic!("read {}: {e}", ailx_path.display())); - let parsed = match ailang_surface::parse(&text) { - Ok(m) => m, + 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!("{ailx}: parse failed: {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 original = ailang_core::load_module(&json_path) - .unwrap_or_else(|e| panic!("load {}: {e}", json_path.display())); 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!( - "{ailx} vs {json}: canonical bytes differ.\noriginal: {s_orig}\nparsed: {s_round}" + "{} vs {}: canonical bytes differ.\noriginal: {s_orig}\nparsed: {s_round}", + ailx_path.display(), + json_path.display() )); + continue; } + paired += 1; } + if !failures.is_empty() { panic!( - "{} hand-written exhibit(s) failed:\n{}", + "{} 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 + ); } diff --git a/docs/journals/2026-05-12-iter-rt.1.md b/docs/journals/2026-05-12-iter-rt.1.md new file mode 100644 index 0000000..6f4c59f --- /dev/null +++ b/docs/journals/2026-05-12-iter-rt.1.md @@ -0,0 +1,128 @@ +# iter rt.1 — Roundtrip-Invariant audit tests + +**Date:** 2026-05-12 +**Started from:** 10ccd1406c886270d105cd7d5a08ec4b447030cd +**Status:** DONE +**Tasks completed:** 4 of 4 + +## Summary + +Three new tests landed; one existing test (handwritten exhibits) +was replaced with a dynamic counterpart. No production code or +DESIGN.md changes (those would be later iterations if any gap +surfaced). All three new tests passed on first observation — +the corpus carries enough breadth that the spec-intended audit +deliverable is the clean signal "no current gap" rather than a +defect list. + +## Per-task notes + +- iter rt.1.1: `crates/ailang-surface/tests/round_trip.rs` — + replaced static three-pair `handwritten_exhibits_match_json_fixtures` + with dynamic `every_ailx_fixture_matches_its_json_counterpart` + (and helper `list_ailx_fixtures`). Module doc rewritten to drop + the "three hand-written exhibits" phrasing. +- iter rt.1.2: `Cargo.toml` — `tempfile = "3"` added to + `[workspace.dependencies]`. `crates/ail/Cargo.toml` — new + `[dev-dependencies]` section with `tempfile.workspace = true` + and `blake3.workspace = true`. +- iter rt.1.3: `crates/ailang-core/tests/schema_coverage.rs` — new + file. Exhaustive AST visitor (`Def`, `Term`, `Pattern`, `Literal`, + `Type`, `ParamMode`) against the fixture corpus; flat `VariantTag` + enum + `EXPECTED_VARIANTS` array; assertion that every variant is + observed at least once. No `_` wildcards in outer match arms — + AST drift fails the build, not the test. +- iter rt.1.4: `crates/ail/tests/roundtrip_cli.rs` — new file. + Subprocess pipeline `ail render → tempfile → ail parse`, BLAKE3 + hash identity on canonical bytes, for every `examples/*.ail.json`. + +## Audit findings + +- `every_ailx_fixture_matches_its_json_counterpart`: + PASS. `.ailx cross-check ok (57 paired, 0 parse-only)`. + All 57 `.ailx` fixtures parse and produce canonical bytes + byte-equal to their same-stem `.ail.json` counterpart. Today + no `.ailx` exists without a JSON counterpart. + +- `every_ast_variant_is_observed_in_the_fixture_corpus`: + PASS. `schema coverage ok: 34 variants observed across 136 fixtures`. + All 34 expected `VariantTag` entries (5 `Def` + 13 `Term` + 4 + `Pattern` + 5 `Literal` + 4 `Type` + 3 `ParamMode`) are exercised + by at least one fixture. No load failures across the 136-file + corpus. + +- `cli_render_then_parse_preserves_canonical_bytes_on_every_fixture`: + PASS. `CLI roundtrip ok for 136 fixtures`. Every `examples/*.ail.json` + survives `ail render → tempfile → ail parse` with BLAKE3 hash + identity on canonical bytes. + +- `cargo test --workspace --quiet` (Step 4.5): PASS, no + regressions in the pre-existing test suite. + +## Concerns + +- Plan-text errata in Task 3's visitor scaffold. The plan in + `docs/plans/rt.1.md` Step 3.2 destructured AST nodes with field + names that do not match `crates/ailang-core/src/ast.rs` (the + authoritative source). Step 3.4 of the plan explicitly anticipated + this and prescribed inline correction by reading `ast.rs` and + adjusting the destructuring. The corrections applied: + - `Def::Fn(fd)`: dropped the `for m in &fd.params { visit_param_mode(m, …) }` + loop (`fd.params: Vec` is parameter names, not modes). + - `Def::Const(cd)`: walks `cd.value` (the actual field name), not + `cd.body`. + - `Def::Class(cd)`: walks `m.ty` plus optional `m.default: Option` + on each `ClassMethod`; no `m.body`/`m.params` fields exist on + `ClassMethod`. + - `Def::Instance(id)`: walks `id.type_: Type` (singular) and + `id.methods`; no `id.args: Vec` field exists. + `InstanceMethod` has only `name` and `body`. + - `Term::App`: `{ callee, args, .. }` (not `{ f, args }`). + - `Term::Let`: `{ value, body, .. }` (not `{ bound, body }`). + - `Term::LetRec`: `{ ty, body, in_term, .. }` (per actual AST: + `name, ty, params, body, in_term`), not the plan's + `{ bindings, body }`. + - `Term::If`: `{ cond, then, else_ }` (not `{ cond, then_b, else_b }`). + - `Term::Do`: `{ args, .. }` only (no `stmts`/`ret` fields). + - `Term::Lam`: `{ param_tys, ret_ty, body, .. }` (no `modes` field + exists on `Lam`). + - `Term::Seq`: `{ lhs, rhs }` (not `{ stmts, ret }`). + - ParamMode discovery routed through `visit_type` on + `Type::Fn { param_modes, ret_mode, .. }` — the only place + `ParamMode` appears in the AST. The plan tried to visit + ParamMode from `Def::Fn.params` (wrong type) and `Term::Lam.modes` + (non-existent field). + + The spec-compliance intent of Step 3.2 ("every AST enum variant is + visited at least once via an exhaustive match with no `_` wildcard") + is preserved; only the destructuring patterns adapted to the actual + AST. All 34 expected variants observe coverage in the corpus, and + the build remains a tripwire for future AST additions. + +- Plan template note: a future `planner`/`brainstorm` pass on + tests-against-AST might cheaply read `ast.rs` and emit a + destructuring-correct scaffold instead of relying on the + implementer to repair under the Step 3.4 escape hatch. Not + rt.1 work. + +## Known debt + +- None added by this iter. The three new tests are pure readers + and add no production-code surface; their existence pins three + invariants that future iters can use to detect regression + rather than rediscover. + +## Files touched + +- `Cargo.toml` — workspace dep `tempfile = "3"`. +- `Cargo.lock` — auto-updated by cargo for `tempfile`. +- `crates/ail/Cargo.toml` — new `[dev-dependencies]` section. +- `crates/ailang-surface/tests/round_trip.rs` — module doc + rewrite; `handwritten_exhibits_match_json_fixtures` → + `every_ailx_fixture_matches_its_json_counterpart` (+ helper). +- `crates/ailang-core/tests/schema_coverage.rs` — new file. +- `crates/ail/tests/roundtrip_cli.rs` — new file. + +## Stats + +bench/orchestrator-stats/2026-05-12-iter-rt.1.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 52b41fd..b45242d 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -16,3 +16,4 @@ - 2026-05-11 — iter 23.4: mono-pass unification (class methods + polymorphic free fns in one fixpoint; codegen-time specialiser removed) → 2026-05-11-iter-23.4.md - 2026-05-12 — iter 23.5: prelude free fns (ne/lt/le/gt/ge) + E2E (positive / user-ADT / Float-NoInstance); milestone 23 closed → 2026-05-12-iter-23.5.md - 2026-05-12 — audit-23: milestone 23 close (architect clean, compile_check ratified per H2 / 5-fn workload), DESIGN.md stub-fix tidy → 2026-05-12-audit-23.md +- 2026-05-12 — iter rt.1: roundtrip invariant audit tests (3 new tests + ailx-pair dynamic); all PASS first-shot, no gaps surfaced → 2026-05-12-iter-rt.1.md