iter rt.1: roundtrip-invariant audit tests — 3 new tests, all PASS first-shot
Three new reader-only tests pin the .ail.json / .ailx bijection plus AST-variant coverage. All three passed first observation across the current corpus — no roundtrip, schema-coverage, or CLI-drift gaps surfaced. - every_ailx_fixture_matches_its_json_counterpart (replaces the 3-pair handwritten exhibits): 57 paired .ailx fixtures pass. - every_ast_variant_is_observed_in_the_fixture_corpus (new): 34/34 AST variants exercised across 136 fixtures; exhaustive matches without _ wildcard so AST drift fails the build. - cli_render_then_parse_preserves_canonical_bytes_on_every_fixture (new): 136 fixtures round-trip through ail render → tempfile → ail parse with BLAKE3 identity on canonical bytes. No production code, no DESIGN.md changes (those follow in later iterations). Tests are pure readers of the repo per spec acceptance #7 — tempfile crate added as workspace dep for the CLI roundtrip's intermediate file outside the repo.
This commit is contained in:
Generated
+283
-1
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<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.json"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
fn strip_trailing_newlines(mut v: Vec<u8>) -> Vec<u8> {
|
||||
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 <fixture>` → 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 <tmp_ailx>` → 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::<String>::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");
|
||||
}
|
||||
@@ -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<VariantTag>) {
|
||||
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<VariantTag>) {
|
||||
visit_term(&m.body, observed);
|
||||
}
|
||||
|
||||
fn visit_term(t: &Term, observed: &mut HashSet<VariantTag>) {
|
||||
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<VariantTag>) {
|
||||
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<VariantTag>) {
|
||||
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<VariantTag>) {
|
||||
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<VariantTag>) {
|
||||
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<VariantTag>) {
|
||||
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<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.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<VariantTag> = HashSet::new();
|
||||
let mut load_failures = Vec::<String>::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<VariantTag> = EXPECTED_VARIANTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|v| !observed.contains(v))
|
||||
.collect();
|
||||
|
||||
if !missing.is_empty() {
|
||||
let names: Vec<String> = 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()
|
||||
);
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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<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(".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::<String>::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. `<stem>.ailx` → `<stem>.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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String>` 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<Term>`
|
||||
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<Type>` 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user