Files
AILang/crates/ailang-surface/tests/round_trip.rs
T
Brummel 76b21c00eb feat(lang): eliminate the Implicit ownership default — totality + the drop-soundness it demasks (#55)
Deletes `ParamMode::Implicit`. `ParamMode` is now `{Own, Borrow}`:
every fn-type slot on every signature carries an explicit `own` or
`borrow`, no defaulted position survives anywhere (model 0008 §2,
spec 0062). The parser rejects a bare fn-type slot; `borrow-return`
and `borrow-over-value` reject at the signature; the corpus is
migrated to minimal-ownership modes (consumed ⇒ own, read-only-heap
⇒ borrow, value ⇒ trivial-own). The documented `Implicit`-ret-mode
leak is fixed: an owned heap return now drops exactly once (live=0,
acceptance criterion 5).

This was the easy half. Removing the default ACTIVATED a family of
drop paths that `Implicit` had silently skipped — the pre-cutover
language was leaking (and in places mis-dropping) here rather than
crashing, because an Implicit scrutinee turned the drop off. Making
the modes explicit (Own) turned those paths on and exposed two
latent-bug clusters, all fixed RED-first as part of this cutover:

Drop-soundness family (four legs):
  A. lit-sub-pattern double-free — the desugar re-matched the same
     owned scrutinee in the lit fall-through; fixed by grouping
     consecutive same-ctor arms into one match (bind fields once),
     in ailang-core desugar.
  B. Cons-husk leak on non-tail arm bodies — the lit-sub-pattern
     desugar rebound the owned scrutinee via `Let $mp = xs`, which
     bumped consume_count and suppressed the existing fn-return
     partial_drop. Fixed by not rebinding a bare-Var scrutinee
     (one husk-freeing mechanism, not two).
  C. polymorphic `drop_<T>` rc_dec'd monomorphised value fields —
     the per-ADT drop fn was emitted once from the polymorphic
     TypeDef, defaulting type-var fields to ptr and rc_dec'ing
     inline Ints (segfault). Fixed with per-monomorph drop
     functions (new ailang-codegen::dropmono): the drop set is
     collected from the lowered MIR, value-type fields are skipped,
     heap fields still freed once; monomorphic-concrete ADTs keep
     their byte-identical un-suffixed drop symbol.
  D. static Str literal passed to an `(own Str)` param — the
     literal lowers to a header-less rodata constant; the callee's
     now-active rc_dec read its length field as a refcount and
     freed a static address (segfault). Fixed with the missing
     fourth StrRep::Static→Heap promotion in lower_to_mir's App arm,
     gated on Own mode (borrow args stay static, no regression).

over-strict-mode lint over-fired: it suggested `(borrow V)` for
value-typed params (which `borrow-over-value` rejects — own is the
only legal mode there) and fired on `(intrinsic)` bodies (whose
consumption the linearity walk cannot observe). Tightened to skip
both; contract 0008 updated to the narrowed firing scope.

Irreversible step — canonical-form hash reset (model 0008 §6,
acceptance criterion 6). Every signature now carries explicit modes,
so the hashable canonical JSON changed for every module. RATIFY:
the corpus-wide hash-pin reset (hash_pin, prelude_module_hash_pin,
mono_hash_stability, eq_ord_e2e, embed_export_hash_stable, the
ct4/iter*/loop_recur schema-extension pins) and the list ir_snapshot
golden were regenerated once, deliberately, as the intended one-time
consequence of removing the mode elision from the canonical form —
not a regression. Each regenerated hash verified deterministic across
two runs.

Also fixes a pre-existing latent failure surfaced by the verification
gate, unrelated to this cutover: the `every_contract_names_a_resolvable_
ratifying_test` resolver (design_index_pin) could not resolve the
" + " dual-link ratifying-test form (`uniqueness.rs + linearity.rs`)
that the #57 audit-close (dfdc65f) introduced — it shipped red on that
commit. Resolver taught the dual-link form, mirroring its sibling.

Verification: cargo test --workspace = 731 passed, 0 failed (twice,
stable); e2e 102 passed, no binary exits non-zero (corpus crash-free);
grep-clean for Implicit/fn_implicit/mode_eq across crates; every drop
fix confirmed via emitted IR + AILANG_RC_STATS balance on the head==K,
head!=K, and Nil paths. Three BLOCKEDs en route (the unsound first
husk-dec attempt, the over-strict derivation premise, the leg-B fix
direction) were each treated as a real design/spec gap and rediagnosed,
not patched over.

Supersedes #54 (return-position-only leak patch). Precondition #57
(linearity hardening) was already met. Spec docs/specs/0062, plan
docs/plans/0121.

closes #55
2026-06-02 00:03:46 +02:00

259 lines
11 KiB
Rust

//! Roundtrip Invariant gate (in-process) for the form-(A) projection.
//!
//! 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. `parse_is_deterministic_over_every_ail_fixture` — pins
//! **parse-determinism** (property 1 of the post-form-a Roundtrip
//! Invariant, design/contracts/0009-roundtrip-invariant.md). 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. `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
//! — pins **idempotency-under-print** (property 2). 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.
//!
//! The other two properties of the post-form-a Roundtrip Invariant
//! live in sibling test crates: **CLI-pipeline-idempotency** is
//! pinned by `crates/ail/tests/roundtrip_cli.rs::cli_parse_then_render_then_parse_is_idempotent`,
//! and **carve-out-anchor** is pinned by
//! `crates/ailang-core/tests/carve_out_inventory.rs::examples_ail_json_inventory_matches_carve_outs`.
//!
//! 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::PathBuf;
fn examples_dir() -> PathBuf {
// `CARGO_MANIFEST_DIR` is the surface crate root; examples live two
// levels up.
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
crate_dir.parent().unwrap().parent().unwrap().join("examples")
}
// 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`.
/// Fixtures that intentionally do NOT parse — the `#55` cutover
/// reject corpus. Negative fixtures whose whole point is that the
/// parser rejects them, so the parse/round-trip properties below are
/// not meaningful for them and the corpus scan must skip them.
/// Mirrors the same list in `crates/ail/tests/roundtrip_cli.rs`.
///
/// - `bare_slot_reject.ail`: a bare fn-type slot with no mode. The
/// binary-ParamMode parser rejects it ("fn-type slot requires a
/// mode: write (own T) or (borrow T)").
const NON_PARSEABLE_FIXTURES: &[&str] = &["bare_slot_reject.ail"];
fn list_ail_fixtures() -> Vec<PathBuf> {
let dir = examples_dir();
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display()))
.filter_map(|entry| entry.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".ail") && !NON_PARSEABLE_FIXTURES.contains(&n))
.unwrap_or(false)
})
.collect();
paths.sort();
paths
}
/// Pins **idempotency-under-print** (property 2 of the post-form-a
/// Roundtrip Invariant, design/contracts/0009-roundtrip-invariant.md): for every
/// well-formed `.ail` text `t`, the composition `parse → print → parse`
/// is idempotent on the AST.
///
/// Direct enforcement complements the parse-determinism gate above
/// (which alone does not constrain the printer). Together they pin
/// `parse → print` as a left-inverse modulo canonical form for every
/// `.ail` fixture in the corpus.
#[test]
fn parse_then_print_then_parse_is_idempotent_on_every_ail_fixture() {
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 passed = 0usize;
for path in &fixtures {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
failures.push(format!("{}: read failed: {e}", path.display()));
continue;
}
};
let parsed_once = match ailang_surface::parse(&text) {
Ok(m) => m,
Err(e) => {
failures.push(format!("{}: parse(t) failed: {e}", path.display()));
continue;
}
};
let printed = ailang_surface::print(&parsed_once);
let parsed_twice = match ailang_surface::parse(&printed) {
Ok(m) => m,
Err(e) => {
failures.push(format!(
"{}: parse(print(parse(t))) failed: {e}\n--- printed ---\n{printed}",
path.display()
));
continue;
}
};
let bytes_a = ailang_core::canonical::to_bytes(&parsed_once);
let bytes_b = ailang_core::canonical::to_bytes(&parsed_twice);
if bytes_a != bytes_b {
let s_a = String::from_utf8_lossy(&bytes_a).into_owned();
let s_b = String::from_utf8_lossy(&bytes_b).into_owned();
failures.push(format!(
"{}: parse → print → parse is NOT idempotent.\nparse(t): {s_a}\nparse(print(parse(t))): {s_b}",
path.display()
));
continue;
}
passed += 1;
}
if !failures.is_empty() {
panic!(
"{} of {} .ail fixture(s) failed idempotency check (passed: {}):\n{}",
failures.len(),
fixtures.len(),
passed,
failures.join("\n\n")
);
}
eprintln!("parse→print→parse idempotency ok for {passed} .ail fixtures");
}
/// prep.2 (kernel-extension-mechanics): pins the Form-A round-trip for
/// `(new T <type-arg> <value-arg>)`. The fixture exercises the
/// `parse_new` dispatcher + the `write_term` Term::New arm, including
/// the Type-vs-Value arg disambiguation on the parser side (head
/// keyword `con` → NewArg::Type; bare integer → NewArg::Value).
///
/// Inline rather than `examples/*.ail` because no existing fixture
/// uses `Term::New` and the iter's out-of-scope note explicitly
/// declines a fixture migration in prep.2 (the construct will only
/// see real fixtures once raw-buf's codegen lands).
#[test]
fn round_trip_term_new_mixed_args() {
// (new Series (con Float) 3) — a Type-positional arg followed by
// a Value-positional arg. The body lives inside an otherwise
// minimal module shell that the printer emits in canonical form.
let form_a = "(module new_demo\n (data Series\n (ctor MkSeries (con Int)))\n (fn new\n (type (fn-type (params (own (con Int))) (ret (own (con Series)))))\n (params n)\n (body (term-ctor Series MkSeries n)))\n (fn main\n (type (fn-type (params) (ret (own (con Series)))))\n (params)\n (body (new Series (con Float) 3))))";
let parsed_once = ailang_surface::parse(form_a)
.expect("Form-A round-trip fixture must parse");
let printed = ailang_surface::print(&parsed_once);
let parsed_twice = ailang_surface::parse(&printed).expect("print(parse(x)) must re-parse");
let bytes_a = ailang_core::canonical::to_bytes(&parsed_once);
let bytes_b = ailang_core::canonical::to_bytes(&parsed_twice);
assert_eq!(
bytes_a, bytes_b,
"Term::New round-trip parse→print→parse is not idempotent.\nfirst: {}\nprint: {printed}\nsecond: {}",
String::from_utf8_lossy(&bytes_a),
String::from_utf8_lossy(&bytes_b),
);
}
/// Parse-determinism (post-form-a-default-authoring §C3): for every
/// well-formed `.ail` text `t`, `parse(t)` produces the same canonical
/// bytes every invocation. Loader is a pure function of input — no
/// randomness, no time dependence, no environment leak.
#[test]
fn parse_is_deterministic_over_every_ail_fixture() {
let mut checked = 0usize;
for ail_path in list_ail_fixtures() {
let text = std::fs::read_to_string(&ail_path)
.unwrap_or_else(|e| panic!("read {ail_path:?}: {e}"));
let m1 = ailang_surface::parse(&text)
.unwrap_or_else(|e| panic!("parse {ail_path:?}: {e:?}"));
let m2 = ailang_surface::parse(&text)
.unwrap_or_else(|e| panic!("parse {ail_path:?} (2nd): {e:?}"));
let b1 = ailang_core::canonical::to_bytes(&m1);
let b2 = ailang_core::canonical::to_bytes(&m2);
assert_eq!(
b1, b2,
"parse non-determinism on {ail_path:?}",
);
checked += 1;
}
assert!(checked > 0, "no .ail fixtures found");
}
/// prep.3 (kernel-extension-mechanics): the `(kernel)` module-header
/// attribute round-trips Form-A → JSON → Form-A bit-identical and
/// preserves the parsed bool through both halves.
#[test]
fn kernel_module_header_round_trips() {
let src = "(module k\n (kernel))\n";
let m = ailang_surface::parse(src).expect("Form-A parse of (kernel) module");
assert!(m.kernel, "parsed kernel: true");
let printed = ailang_surface::print(&m);
// Round-trip: print, re-parse, kernel still true.
let m2 = ailang_surface::parse(&printed).expect("re-parse printed form");
assert!(m2.kernel, "kernel: true survives round-trip");
// Default form (no (kernel)) stays kernel: false.
let plain = ailang_surface::parse("(module p)\n").expect("Form-A parse plain module");
assert!(!plain.kernel);
}
/// prep.3 (kernel-extension-mechanics): the `(param-in (var t1 t2 ...))`
/// TypeDef attribute round-trips Form-A → JSON → Form-A bit-
/// identical and preserves the parsed BTreeMap through both halves,
/// including the BTreeSet's deterministic alphabetical iteration
/// order.
#[test]
fn typedef_param_in_round_trips_surface() {
let src = concat!(
"(module m\n",
" (data T (vars a)\n",
" (ctor MkT a)\n",
" (param-in (a Int Float))))\n",
);
let m = ailang_surface::parse(src).expect("Form-A parse of (param-in ...)");
let td = match &m.defs[0] {
ailang_core::ast::Def::Type(t) => t,
_ => panic!("expected Type def"),
};
let allowed = td.param_in.get("a").expect("var `a` bound in param_in");
assert!(allowed.contains("Int"));
assert!(allowed.contains("Float"));
assert_eq!(allowed.len(), 2);
let printed = ailang_surface::print(&m);
let m2 = ailang_surface::parse(&printed).expect("re-parse printed form");
let td2 = match &m2.defs[0] {
ailang_core::ast::Def::Type(t) => t,
_ => panic!("expected Type def after round-trip"),
};
assert_eq!(td2.param_in, td.param_in, "param_in survives round-trip");
}