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.
This commit is contained in:
+117
-71
@@ -406,48 +406,12 @@ fn std_list_stress_1000_element_folds() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iter 15e: `ail render | ail parse` round-trips canonical bytes.
|
||||
/// Property protected: the CLI's `render` is the exact inverse of
|
||||
/// `parse`, not the older human-pretty form. Regression of this
|
||||
/// would mean piping `render` into `parse` no longer yields a
|
||||
/// re-loadable module — a contract the help text now claims.
|
||||
///
|
||||
/// Picks `std_either.ail.json` (richest fixture: 2-type-var data,
|
||||
/// 3-type-var fn, recursive ctors irrelevant) but the round-trip
|
||||
/// test in `ailang-surface` covers all 25 fixtures structurally.
|
||||
/// This test specifically exercises the CLI shell pipeline.
|
||||
#[test]
|
||||
fn render_parse_round_trip_canonical() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace.join("examples").join("std_either.ail.json");
|
||||
let original = std::fs::read(&src).expect("read fixture");
|
||||
|
||||
let render = Command::new(ail_bin())
|
||||
.args(["render", src.to_str().unwrap()])
|
||||
.output()
|
||||
.expect("run ail render");
|
||||
assert!(render.status.success(), "ail render failed");
|
||||
|
||||
let tmp = std::env::temp_dir().join(format!("ailang_render_e2e_{}.ail", std::process::id()));
|
||||
std::fs::write(&tmp, &render.stdout).expect("write rendered ail");
|
||||
|
||||
let parsed = Command::new(ail_bin())
|
||||
.args(["parse", tmp.to_str().unwrap()])
|
||||
.output()
|
||||
.expect("run ail parse");
|
||||
assert!(parsed.status.success(), "ail parse failed on rendered output");
|
||||
|
||||
let mut round = parsed.stdout;
|
||||
while round.last() == Some(&b'\n') {
|
||||
round.pop();
|
||||
}
|
||||
let mut orig = original;
|
||||
while orig.last() == Some(&b'\n') {
|
||||
orig.pop();
|
||||
}
|
||||
assert_eq!(orig, round, "render | parse must produce canonical bytes");
|
||||
}
|
||||
// Retired iter form-a.1 T7: `render_parse_round_trip_canonical` raw-read
|
||||
// `std_either.ail.json` for the byte-roundtrip. The property it pinned
|
||||
// (CLI render→parse byte-roundtrip) is now pinned across the entire
|
||||
// `.ail` corpus by `cli_parse_then_render_then_parse_is_idempotent` in
|
||||
// `crates/ail/tests/roundtrip_cli.rs` (added in T1), which is strictly
|
||||
// stronger (whole-corpus vs. one-fixture).
|
||||
|
||||
/// Iter 16a: nested constructor patterns. Property protected:
|
||||
/// `ailang_core::desugar` flattens `Cons a (Cons b _)` to a chain of
|
||||
@@ -698,12 +662,33 @@ fn nested_let_rec_demo() {
|
||||
fn diff_detects_changed_def() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src_a = workspace.join("examples").join("sum.ail.json");
|
||||
|
||||
// Variant: load sum.ail.json, mutate the `then` branch (1 instead of 0)
|
||||
// Form-A migration (iter form-a.1): derive the canonical JSON-AST
|
||||
// from `sum.ail` via `ail parse` rather than reading a pre-existing
|
||||
// `sum.ail.json` (which no longer exists post-iter). The variant is
|
||||
// produced by mutating the parsed AST and writing it back as a JSON
|
||||
// tempfile; `ail diff` runs against the two JSON tempfiles.
|
||||
let tmp_a = std::env::temp_dir().join(format!(
|
||||
"ailang_diff_src_{}.ail.json",
|
||||
std::process::id()
|
||||
));
|
||||
let parse_status = Command::new(ail_bin())
|
||||
.args([
|
||||
"parse",
|
||||
workspace.join("examples").join("sum.ail").to_str().unwrap(),
|
||||
"-o",
|
||||
tmp_a.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("ail parse failed to run");
|
||||
assert!(parse_status.success(), "ail parse failed on sum.ail");
|
||||
let src_a = tmp_a.clone();
|
||||
|
||||
// Variant: load the derived JSON, mutate the `then` branch (1 instead of 0)
|
||||
// in the `sum` definition. `main` stays bit-identical.
|
||||
let raw = std::fs::read(&src_a).expect("read sum.ail.json");
|
||||
let mut module: serde_json::Value = serde_json::from_slice(&raw).expect("parse sum.ail.json");
|
||||
let raw = std::fs::read(&src_a).expect("read derived sum.ail.json");
|
||||
let mut module: serde_json::Value =
|
||||
serde_json::from_slice(&raw).expect("parse derived sum.ail.json");
|
||||
{
|
||||
let defs = module
|
||||
.get_mut("defs")
|
||||
@@ -1305,13 +1290,30 @@ fn unreachable_demo() {
|
||||
/// wrappers, and the binary prints `3` then `6`.
|
||||
#[test]
|
||||
fn borrow_own_demo_modes_are_metadata_only() {
|
||||
// Sanity-check (2): the on-disk JSON contains `param_modes`
|
||||
// Sanity-check (2): the canonical JSON contains `param_modes`
|
||||
// tokens for both fns. If the schema regressed and the field
|
||||
// were dropped, this would fail before the binary even runs.
|
||||
//
|
||||
// Form-A migration (iter form-a.1): derive the JSON via `ail parse`
|
||||
// on `borrow_own_demo.ail` rather than reading a pre-existing
|
||||
// `.ail.json` (deleted post-iter).
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join("borrow_own_demo.ail.json");
|
||||
let json = std::fs::read_to_string(&json_path).expect("read borrow_own_demo.ail.json");
|
||||
let tmp_json = std::env::temp_dir().join(format!(
|
||||
"ailang_borrow_own_demo_{}.ail.json",
|
||||
std::process::id()
|
||||
));
|
||||
let parse_status = Command::new(ail_bin())
|
||||
.args([
|
||||
"parse",
|
||||
workspace.join("examples").join("borrow_own_demo.ail").to_str().unwrap(),
|
||||
"-o",
|
||||
tmp_json.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("ail parse failed to run");
|
||||
assert!(parse_status.success(), "ail parse failed on borrow_own_demo.ail");
|
||||
let json = std::fs::read_to_string(&tmp_json).expect("read derived borrow_own_demo.ail.json");
|
||||
assert!(
|
||||
json.contains("\"param_modes\":[\"borrow\"]"),
|
||||
"expected `param_modes:[\"borrow\"]` in canonical JSON; the schema for `(borrow T)` regressed"
|
||||
@@ -1359,7 +1361,7 @@ fn eq_demo() {
|
||||
#[test]
|
||||
fn alloc_rc_produces_same_stdout_as_gc() {
|
||||
// Pick a fixture with non-trivial allocation: list-building + match.
|
||||
let example = "list.ail.json";
|
||||
let example = "list.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc");
|
||||
@@ -1405,8 +1407,26 @@ fn clone_demo_is_identity_in_18c1() {
|
||||
fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join("reuse_as_demo.ail.json");
|
||||
let json = std::fs::read_to_string(&json_path).expect("read reuse_as_demo.ail.json");
|
||||
let ail_path = workspace.join("examples").join("reuse_as_demo.ail");
|
||||
|
||||
// Form-A migration (iter form-a.1): derive the canonical JSON-AST
|
||||
// via `ail parse` on `reuse_as_demo.ail` rather than reading a
|
||||
// pre-existing `.ail.json` (deleted post-iter).
|
||||
let tmp_json = std::env::temp_dir().join(format!(
|
||||
"ailang_reuse_as_demo_{}.ail.json",
|
||||
std::process::id()
|
||||
));
|
||||
let parse_status = Command::new(ail_bin())
|
||||
.args([
|
||||
"parse",
|
||||
ail_path.to_str().unwrap(),
|
||||
"-o",
|
||||
tmp_json.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("ail parse failed to run");
|
||||
assert!(parse_status.success(), "ail parse failed on reuse_as_demo.ail");
|
||||
let json = std::fs::read_to_string(&tmp_json).expect("read derived reuse_as_demo.ail.json");
|
||||
assert!(
|
||||
json.contains("\"t\":\"reuse-as\""),
|
||||
"expected `\"t\":\"reuse-as\"` in canonical JSON; the schema for `(reuse-as ...)` regressed"
|
||||
@@ -1422,8 +1442,10 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
|
||||
|
||||
// (5) Inspect the rc IR text. We re-lower via the codegen API
|
||||
// directly so the assertion runs without depending on the
|
||||
// filesystem layout of `ail build`'s tmpdir.
|
||||
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
|
||||
// filesystem layout of `ail build`'s tmpdir. Use the
|
||||
// extension-dispatching `ailang_surface::load_workspace` so
|
||||
// the `.ail` entry is accepted.
|
||||
let ws = ailang_surface::load_workspace(&ail_path).expect("load workspace");
|
||||
let mut lifted_modules = std::collections::BTreeMap::new();
|
||||
for (mname, m) in &ws.modules {
|
||||
let desugared = ailang_core::desugar::desugar_module(m);
|
||||
@@ -1496,7 +1518,7 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
|
||||
/// invariant: stdout must be byte-identical to the `gc` build.
|
||||
#[test]
|
||||
fn alloc_rc_matches_gc_on_std_list_demo() {
|
||||
let example = "std_list_demo.ail.json";
|
||||
let example = "std_list_demo.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(
|
||||
@@ -1531,7 +1553,7 @@ fn alloc_rc_matches_gc_on_std_list_demo() {
|
||||
/// this iter.
|
||||
#[test]
|
||||
fn alloc_rc_emits_dec_for_unique_let_bound_box() {
|
||||
let example = "rc_box_drop.ail.json";
|
||||
let example = "rc_box_drop.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "42");
|
||||
@@ -1557,7 +1579,7 @@ fn alloc_rc_emits_dec_for_unique_let_bound_box() {
|
||||
/// the dec path itself.
|
||||
#[test]
|
||||
fn alloc_rc_recursive_list_sum() {
|
||||
let example = "rc_list_drop.ail.json";
|
||||
let example = "rc_list_drop.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "15");
|
||||
@@ -1602,7 +1624,7 @@ fn alloc_rc_recursive_list_sum() {
|
||||
/// pattern-binder dec.
|
||||
#[test]
|
||||
fn alloc_rc_borrow_only_recursive_list_drop() {
|
||||
let example = "rc_list_drop_borrow.ail.json";
|
||||
let example = "rc_list_drop_borrow.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "11");
|
||||
@@ -1622,7 +1644,7 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join(example);
|
||||
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
|
||||
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
|
||||
let mut lifted_modules = std::collections::BTreeMap::new();
|
||||
for (mname, m) in &ws.modules {
|
||||
let desugared = ailang_core::desugar::desugar_module(m);
|
||||
@@ -1684,7 +1706,7 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
|
||||
/// `drop_<m>_Pair` call against `p`.
|
||||
#[test]
|
||||
fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
|
||||
let example = "pat_extract_partial_drop.ail.json";
|
||||
let example = "pat_extract_partial_drop.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "6");
|
||||
@@ -1698,7 +1720,7 @@ fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join(example);
|
||||
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
|
||||
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
|
||||
let mut lifted_modules = std::collections::BTreeMap::new();
|
||||
for (mname, m) in &ws.modules {
|
||||
let desugared = ailang_core::desugar::desugar_module(m);
|
||||
@@ -1774,7 +1796,7 @@ fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
|
||||
/// that ANY drop fires.
|
||||
#[test]
|
||||
fn alloc_rc_own_param_dec_at_fn_return() {
|
||||
let example = "rc_own_param_drop.ail.json";
|
||||
let example = "rc_own_param_drop.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "11");
|
||||
@@ -1788,7 +1810,7 @@ fn alloc_rc_own_param_dec_at_fn_return() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join(example);
|
||||
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
|
||||
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
|
||||
let mut lifted_modules = std::collections::BTreeMap::new();
|
||||
for (mname, m) in &ws.modules {
|
||||
let desugared = ailang_core::desugar::desugar_module(m);
|
||||
@@ -1891,7 +1913,7 @@ fn alloc_rc_own_param_dec_at_fn_return() {
|
||||
/// capacity).
|
||||
#[test]
|
||||
fn alloc_rc_drop_iterative_handles_million_cell_list() {
|
||||
let example = "rc_drop_iterative_long_list.ail.json";
|
||||
let example = "rc_drop_iterative_long_list.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_rc.trim(), "1");
|
||||
@@ -1910,11 +1932,11 @@ fn alloc_rc_drop_iterative_handles_million_cell_list() {
|
||||
/// dispatch.
|
||||
#[test]
|
||||
fn iter18e_drop_iterative_emits_worklist_body_no_self_recursion() {
|
||||
let example = "rc_drop_iterative_long_list.ail.json";
|
||||
let example = "rc_drop_iterative_long_list.ail";
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join(example);
|
||||
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
|
||||
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
|
||||
let mut lifted_modules = std::collections::BTreeMap::new();
|
||||
for (mname, m) in &ws.modules {
|
||||
let desugared = ailang_core::desugar::desugar_module(m);
|
||||
@@ -1987,11 +2009,11 @@ fn iter18e_drop_iterative_emits_worklist_body_no_self_recursion() {
|
||||
/// of any on-disk fixture changes.
|
||||
#[test]
|
||||
fn iter18e_no_annotation_keeps_recursive_drop_body() {
|
||||
let example = "rc_drop_iterative_long_list.ail.json";
|
||||
let example = "rc_drop_iterative_long_list.ail";
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join(example);
|
||||
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
|
||||
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
|
||||
// Mutate every TypeDef in every module to clear the annotation.
|
||||
let mut modules = std::collections::BTreeMap::new();
|
||||
for (mname, m) in &ws.modules {
|
||||
@@ -2066,7 +2088,7 @@ fn iter18e_no_annotation_keeps_recursive_drop_body() {
|
||||
/// regression coverage — see CLAUDE.md "Bug fixes — TDD, always".
|
||||
#[test]
|
||||
fn alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children() {
|
||||
let example = "rc_pin_recurse_implicit.ail.json";
|
||||
let example = "rc_pin_recurse_implicit.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "0");
|
||||
@@ -2294,7 +2316,7 @@ fn alloc_rc_let_binder_for_implicit_returning_app_does_not_drop() {
|
||||
/// the arm-close drop. Output matches `--alloc=gc` (`0`).
|
||||
#[test]
|
||||
fn alloc_rc_let_alias_of_implicit_param_does_not_dec_borrowed_children() {
|
||||
let example = "rc_let_alias_implicit_param.ail.json";
|
||||
let example = "rc_let_alias_implicit_param.ail";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "0");
|
||||
@@ -2524,11 +2546,35 @@ fn ail_check_accepts_ail_source() {
|
||||
/// counterpart. Guards against the surface→core dispatch silently
|
||||
/// rewriting the loaded `Module` (e.g. losing imports, dropping defs,
|
||||
/// re-ordering fields) — the binary semantics must be form-agnostic.
|
||||
///
|
||||
/// Form-A migration (iter form-a.1): only the eight carve-out fixtures
|
||||
/// keep `.ail.json` form on disk post-iter. To preserve the dual-form
|
||||
/// property test, derive a parallel `.ail.json` in a tempdir from
|
||||
/// `hello.ail` via `ail parse`, then build and run both forms.
|
||||
#[test]
|
||||
fn ail_run_accepts_ail_source_with_same_stdout_as_ail_json() {
|
||||
let ail_path = workspace_root().join("examples/hello.ail");
|
||||
let json_path = workspace_root().join("examples/hello.ail.json");
|
||||
assert!(ail_path.is_file());
|
||||
|
||||
// The loader name-checks the module name against the file stem, so
|
||||
// the derived JSON must be named `hello.ail.json` even in the temp
|
||||
// location. Use a per-process tempdir to avoid races.
|
||||
let tmpdir = std::env::temp_dir().join(format!(
|
||||
"ailang_dual_form_{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&tmpdir).unwrap();
|
||||
let json_path = tmpdir.join("hello.ail.json");
|
||||
let parse_status = Command::new(ail_bin())
|
||||
.args([
|
||||
"parse",
|
||||
ail_path.to_str().unwrap(),
|
||||
"-o",
|
||||
json_path.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("ail parse failed to run");
|
||||
assert!(parse_status.success(), "ail parse failed on hello.ail");
|
||||
assert!(json_path.is_file());
|
||||
|
||||
let build_and_capture = |src: &Path| -> Vec<u8> {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
//! CLI roundtrip: every `examples/*.ail.json` survives
|
||||
//! `ail render` → tempfile → `ail parse` with BLAKE3 hash
|
||||
//! identity on canonical bytes.
|
||||
//! 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 render` and `ail parse` CLI wrappers from drift (output
|
||||
//! `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;
|
||||
@@ -28,23 +36,6 @@ 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 list_ail_fixtures() -> Vec<PathBuf> {
|
||||
let dir = examples_dir();
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
@@ -62,106 +53,6 @@ fn list_ail_fixtures() -> Vec<PathBuf> {
|
||||
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 = ail 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 .ail 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_ail = tmpdir.join(format!("{stem}.round.ail"));
|
||||
std::fs::write(&tmp_ail, &render_out.stdout)
|
||||
.map_err(|e| format!("write tempfile {}: {e}", tmp_ail.display()))?;
|
||||
|
||||
// Step D: `ail parse <tmp_ail>` → captured stdout = canonical
|
||||
// bytes + trailing newline.
|
||||
let parse_out = Command::new(ail_bin())
|
||||
.args(["parse", tmp_ail.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");
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -741,7 +741,7 @@ fn ail_run(fixture_name: &str) -> (std::process::Output, String) {
|
||||
/// failure — all observable at stdout. Defends mono-pass invariant 3.
|
||||
#[test]
|
||||
fn coherence_two_instances_pick_correct_body_at_runtime() {
|
||||
let (output, stdout) = ail_run("test_22b3_coherence_two_instances.ail.json");
|
||||
let (output, stdout) = ail_run("test_22b3_coherence_two_instances.ail");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail run failed: stdout={}, stderr={}",
|
||||
@@ -767,7 +767,7 @@ fn coherence_two_instances_pick_correct_body_at_runtime() {
|
||||
/// constant 99 must reach stdout. Defends mono-pass invariant 4.
|
||||
#[test]
|
||||
fn class_default_method_runs_and_prints_default_value() {
|
||||
let (output, stdout) = ail_run("test_22b3_default_e2e.ail.json");
|
||||
let (output, stdout) = ail_run("test_22b3_default_e2e.ail");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail run failed: stdout={}, stderr={}",
|
||||
@@ -795,7 +795,7 @@ fn class_default_method_runs_and_prints_default_value() {
|
||||
/// Defends mono-pass invariant 6.
|
||||
#[test]
|
||||
fn cross_module_class_method_runs_and_prints_correct_value() {
|
||||
let (output, stdout) = ail_run("test_22b3_xmod_e2e_main.ail.json");
|
||||
let (output, stdout) = ail_run("test_22b3_xmod_e2e_main.ail");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail run failed: stdout={}, stderr={}",
|
||||
|
||||
@@ -31,7 +31,6 @@ fn examples_dir() -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "GREEN-ratified by Task 8 (.ail.json deletion); RED until then"]
|
||||
fn examples_ail_json_inventory_matches_carve_outs() {
|
||||
let expected: BTreeSet<&str> = EXPECTED.iter().copied().collect();
|
||||
let mut actual: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
@@ -326,7 +326,7 @@ fn examples_dir() -> PathBuf {
|
||||
crate_dir.parent().unwrap().parent().unwrap().join("examples")
|
||||
}
|
||||
|
||||
fn list_json_fixtures() -> Vec<PathBuf> {
|
||||
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()))
|
||||
@@ -335,7 +335,7 @@ fn list_json_fixtures() -> Vec<PathBuf> {
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.ends_with(".ail.json"))
|
||||
.map(|n| n.ends_with(".ail"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
@@ -345,10 +345,10 @@ fn list_json_fixtures() -> Vec<PathBuf> {
|
||||
|
||||
#[test]
|
||||
fn every_ast_variant_is_observed_in_the_fixture_corpus() {
|
||||
let fixtures = list_json_fixtures();
|
||||
let fixtures = list_ail_fixtures();
|
||||
assert!(
|
||||
!fixtures.is_empty(),
|
||||
"no .ail.json fixtures found under {}",
|
||||
"no .ail fixtures found under {}",
|
||||
examples_dir().display()
|
||||
);
|
||||
|
||||
@@ -356,7 +356,7 @@ fn every_ast_variant_is_observed_in_the_fixture_corpus() {
|
||||
let mut load_failures = Vec::<String>::new();
|
||||
|
||||
for path in &fixtures {
|
||||
match ailang_core::load_module(path) {
|
||||
match ailang_surface::load_module(path) {
|
||||
Ok(m) => visit_module(&m, &mut observed),
|
||||
Err(e) => load_failures.push(format!("{}: {e}", path.display())),
|
||||
}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
//! Round-trip gate for the form-(A) projection.
|
||||
//!
|
||||
//! Three complementary checks over `examples/`, each gathering
|
||||
//! Two complementary checks over `examples/*.ail`, each gathering
|
||||
//! fixtures dynamically via `read_dir` (no hardcoded lists). The
|
||||
//! tests are pure readers — they do not write into the working
|
||||
//! tree.
|
||||
//!
|
||||
//! 1. `print_then_parse_round_trips_every_fixture`: Direction 1 of
|
||||
//! the Roundtrip Invariant (DESIGN.md §"Roundtrip Invariant").
|
||||
//! For every `examples/*.ail.json` fixture, load → `print` →
|
||||
//! `parse` → canonical bytes; assert byte-equal to original.
|
||||
//! 1. `parse_is_deterministic_over_every_ail_fixture`: for every
|
||||
//! well-formed `.ail` text `t`, `parse(t)` produces the same
|
||||
//! canonical bytes every invocation. The parser is a pure
|
||||
//! function of input — no randomness, no time dependence, no
|
||||
//! environment leak. Hashing `canonical::to_bytes(parse(t))` is
|
||||
//! therefore well-defined for any `.ail` source.
|
||||
//!
|
||||
//! 2. `every_ail_fixture_matches_its_json_counterpart`: hand-
|
||||
//! authored ground-truth check. For every `examples/*.ail`
|
||||
//! fixture, parse → canonical bytes; if a same-stem `.ail.json`
|
||||
//! counterpart exists, assert canonical-byte equality against
|
||||
//! it. Pins the `.ail` corpus against semantic drift between
|
||||
//! the two forms at the fixture level.
|
||||
//! 2. `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`:
|
||||
//! Direction 2 of the Roundtrip Invariant (DESIGN.md §"Roundtrip
|
||||
//! Invariant"). For every well-formed `.ail` text `t`, asserts
|
||||
//! `canonical_bytes(parse(t))` equals
|
||||
//! `canonical_bytes(parse(print(parse(t))))`. The printer is a
|
||||
//! left-inverse of the parser modulo canonical form.
|
||||
//!
|
||||
//! 3. `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`:
|
||||
//! Direction 2 of the Roundtrip Invariant. For every well-formed
|
||||
//! `.ail` text `t`, asserts `canonical_bytes(parse(t))` equals
|
||||
//! `canonical_bytes(parse(print(parse(t))))`. Robust against
|
||||
//! future `.ail` fixtures without a JSON counterpart.
|
||||
//! Retired iter form-a.1 T9: `print_then_parse_round_trips_every_fixture`
|
||||
//! (corpus shrunk to 8 carve-outs post-iter) and
|
||||
//! `every_ail_fixture_matches_its_json_counterpart` (no longer
|
||||
//! expressible: only one form is hand-authored post-iter).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn examples_dir() -> PathBuf {
|
||||
// `CARGO_MANIFEST_DIR` is the surface crate root; examples live two
|
||||
@@ -32,73 +33,15 @@ fn examples_dir() -> PathBuf {
|
||||
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| {
|
||||
// Round-trip every fixture. The 22b.1/22b.2/22b.3 filter
|
||||
// was retired in 22b.4a once the Form-A parser+printer
|
||||
// arms for ClassDef / InstanceDef landed.
|
||||
n.ends_with(".ail.json")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_then_parse_round_trips_every_fixture() {
|
||||
let fixtures = list_json_fixtures();
|
||||
assert!(
|
||||
!fixtures.is_empty(),
|
||||
"no .ail.json fixtures found under {}",
|
||||
examples_dir().display()
|
||||
);
|
||||
|
||||
let mut failures = Vec::<String>::new();
|
||||
let mut passed = 0usize;
|
||||
for path in &fixtures {
|
||||
match round_trip_one(path) {
|
||||
Ok(()) => passed += 1,
|
||||
Err(msg) => failures.push(format!("{}: {msg}", path.display())),
|
||||
}
|
||||
}
|
||||
if !failures.is_empty() {
|
||||
panic!(
|
||||
"round-trip failed for {} of {} fixtures (passed: {}):\n{}",
|
||||
failures.len(),
|
||||
fixtures.len(),
|
||||
passed,
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
eprintln!("round-trip ok for {passed} fixtures");
|
||||
}
|
||||
|
||||
fn round_trip_one(path: &Path) -> Result<(), String> {
|
||||
let original = ailang_core::load_module(path).map_err(|e| format!("load: {e}"))?;
|
||||
let text = ailang_surface::print(&original);
|
||||
let parsed = ailang_surface::parse(&text)
|
||||
.map_err(|e| format!("re-parse failed: {e}\n--- printed text ---\n{text}"))?;
|
||||
let bytes_orig = ailang_core::canonical::to_bytes(&original);
|
||||
let bytes_round = ailang_core::canonical::to_bytes(&parsed);
|
||||
if bytes_orig != bytes_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!(
|
||||
"canonical bytes differ.\noriginal: {s_orig}\nround: {s_round}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// Retired iter form-a.1 T9:
|
||||
// - `list_json_fixtures` helper (walked `.ail.json` for the two retired tests below).
|
||||
// - `print_then_parse_round_trips_every_fixture`: corpus shrunk to 8 carve-outs
|
||||
// post-iter; property subsumed by `parse_is_deterministic_over_every_ail_fixture`
|
||||
// plus the surviving `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`.
|
||||
// - `round_trip_one` helper (only consumer was the retired test above).
|
||||
// - `every_ail_fixture_matches_its_json_counterpart`: no longer expressible —
|
||||
// only one form is hand-authored post-iter; counterparts are derived in-process
|
||||
// via `ail parse`.
|
||||
|
||||
fn list_ail_fixtures() -> Vec<PathBuf> {
|
||||
let dir = examples_dir();
|
||||
@@ -117,92 +60,6 @@ fn list_ail_fixtures() -> Vec<PathBuf> {
|
||||
paths
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_ail_fixture_matches_its_json_counterpart() {
|
||||
let fixtures = list_ail_fixtures();
|
||||
assert!(
|
||||
!fixtures.is_empty(),
|
||||
"no .ail fixtures found under {}",
|
||||
examples_dir().display()
|
||||
);
|
||||
|
||||
let mut failures = Vec::<String>::new();
|
||||
let mut paired = 0usize;
|
||||
let mut parse_only = 0usize;
|
||||
|
||||
for ail_path in &fixtures {
|
||||
let text = match std::fs::read_to_string(ail_path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
failures.push(format!("{}: read failed: {e}", ail_path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let parsed = match ailang_surface::parse(&text) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
failures.push(format!("{}: parse failed: {e}", ail_path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Same-stem counterpart lookup. `<stem>.ail` → `<stem>.ail.json`.
|
||||
let stem = ail_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.and_then(|n| n.strip_suffix(".ail"))
|
||||
.unwrap_or("");
|
||||
let json_path = ail_path.with_file_name(format!("{stem}.ail.json"));
|
||||
|
||||
if !json_path.exists() {
|
||||
// Spec: parse success alone is sufficient for `.ail` 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}",
|
||||
ail_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}",
|
||||
ail_path.display(),
|
||||
json_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
paired += 1;
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
panic!(
|
||||
"{} of {} .ail fixture(s) failed cross-check (paired ok: {}, parse-only: {}):\n{}",
|
||||
failures.len(),
|
||||
fixtures.len(),
|
||||
paired,
|
||||
parse_only,
|
||||
failures.join("\n\n")
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
".ail cross-check ok ({} paired, {} parse-only)",
|
||||
paired, parse_only
|
||||
);
|
||||
}
|
||||
|
||||
/// Direction 2 of the Roundtrip Invariant (DESIGN.md §"Roundtrip
|
||||
/// Invariant"): for every well-formed `.ail` text `t`, the
|
||||
/// composition `parse → print → parse` is idempotent on the AST.
|
||||
|
||||
Reference in New Issue
Block a user