test(cutover): live accept/reject corpus for the #55 ownership surface

Wires the #55-cutover fieldtest into a durable regression net. The
fieldtest exercised the post-cutover ParamMode={Own,Borrow} surface as a
downstream LLM author would and produced 12 fixtures + a report; left as
loose files they would be dead fixtures that drift. This commits them as
a protected property.

crates/ail/tests/cut55_cutover_surface.rs — one table-driven test running
each fixture through `ail check` and asserting accept (exit 0) or reject
at the right pipeline stage. Reject rows key on the bracketed diagnostic
CODE (consume-while-borrowed, borrow-over-value, borrow-return-not-permitted,
surface-parse-error), NOT message text, so the test is decoupled from the
diagnostic-wording improvement tracked separately.

examples/fieldtest/cut55_*.ail — the corpus. cut55_2c is the #58 repro
(now correctly rejected). cut55_2b was misanalysed by the fieldtest as a
consume-while-borrowed reject; in fact bump's recursive param is
(borrow List) so the tail is only borrowed, never consumed into an own
slot — `ail check` correctly accepts it. Renamed to
cut55_2b_borrow_traversal_clean and recast as the #58 false-positive
guard (a borrow-position use of a heap sub-binder must stay accepted).
cut55_1b similarly does not fire over-strict-mode (its recursive call
consumes the tail, so the lint's consume_count==0 premise fails) — comment
corrected; it is a plain accept. cut55_1c is the genuine over-strict-mode
example (accepts exit 0, emits the warning).

docs/specs/0065-eliminate-implicit-mode fieldtest report — moved 2c to
the reject set, added the 2b false-positive-guard section, marked the
double-free spec_gap RESOLVED (it shipped as the #58 fix).

Relates to #55.
This commit is contained in:
2026-06-02 00:45:21 +02:00
parent 19757480b7
commit e25580e3a3
14 changed files with 770 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
//! Regression net for the #55 ParamMode={Own,Borrow} totality cutover
//! (commit `76b21c0`, spec 0062 / plan 0121) and its #58 follow-up
//! (`b129af1`, sub-binder borrow inheritance).
//!
//! Property protected: the post-#55-cutover ownership surface accepts the
//! legitimate authoring forms and rejects the unsound / over-strict ones,
//! AT THE RIGHT PIPELINE STAGE. Concretely, for each fixture under
//! examples/fieldtest/cut55_*.ail, `ail check` must:
//! - exit 0 for the legitimate forms (read-only borrow, consume own,
//! value-typed own, clone-to-return escape hatch, and the
//! borrow-traverse-and-rebuild that must NOT trip the #58 check);
//! - reject the unsound / over-strict forms with the EXACT diagnostic
//! code, at the stage that owns the rule:
//! * bare fn-type slot -> `surface-parse-error` (parser stage),
//! * borrow over value type -> `borrow-over-value` (signature),
//! * borrowed return -> `borrow-return-not-permitted` (sig),
//! * consume while borrowed -> `consume-while-borrowed` (linearity),
//! and emit the `over-strict-mode` advisory (a warning, exit 0) on a
//! true read-only `(own)` heap param.
//!
//! Coupling discipline: the assertions key on the bracketed diagnostic
//! CODE (e.g. `[consume-while-borrowed]`) appearing in stderr, never on
//! the surrounding message wording — that wording is slated to change
//! (the borrow-return and consume-while-borrowed friction tidy-ups noted
//! in the spec-0065 findings) and the regression net must survive it.
//! Each row carries the fixture stem so a failure names which authoring
//! form regressed.
//!
//! Without this test the twelve cut55_* fixtures are dead files that the
//! build never executes; a regression in the totality check (a relaxed
//! reject, a moved stage, a re-introduced default) would land silently.
use std::path::{Path, PathBuf};
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
fn fixture(stem: &str) -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
workspace
.join("examples")
.join("fieldtest")
.join(format!("{stem}.ail"))
}
/// What `ail check` must do with a fixture. Codes name the bracketed
/// diagnostic tag, never the message prose.
enum Expect {
/// exit 0, no warning expected (legitimate form).
Accept,
/// exit 0, but the `over-strict-mode` advisory warning must appear.
AcceptWithWarning(&'static str),
/// exit non-zero, and `[code]` must appear in stderr.
Reject(&'static str),
}
fn check(stem: &str) -> (bool, String) {
let out = Command::new(ail_bin())
.args(["check", fixture(stem).to_str().unwrap()])
.output()
.expect("ail check failed to run");
let stderr = String::from_utf8(out.stderr).expect("stderr utf8");
(out.status.success(), stderr)
}
#[test]
fn cut55_cutover_surface_accepts_and_rejects_at_the_right_stage() {
// (fixture stem, expectation). The stem is in every failure message
// so a red row names the regressed authoring form.
let table: &[(&str, Expect)] = &[
// --- decision (a): borrow vs own on a heap value -----------------
("cut55_1_readonly_length", Expect::Accept),
// over-strict own that does NOT trip the lint: the recursive call
// consumes the heap tail into an own self-param, so the lint's
// consume_count==0 premise fails. Still a sound (if strict)
// annotation -> accepted, silent.
("cut55_1b_overstrict_own", Expect::Accept),
// true read-only own: nothing consumed -> the advisory fires.
(
"cut55_1c_overstrict_isempty",
Expect::AcceptWithWarning("over-strict-mode"),
),
// --- decision (b): consume vs borrow -----------------------------
("cut55_2_consume_map", Expect::Accept),
// false-positive guard for #58: borrow-traverse-and-rebuild, the
// recursive param is (borrow) so the tail is never consumed.
("cut55_2b_borrow_traversal_clean", Expect::Accept),
// #58 fix: tail sub-binder of a borrowed scrutinee moved into an
// own sink -> reject at the linearity check.
(
"cut55_2c_consume_borrow_reject",
Expect::Reject("consume-while-borrowed"),
),
// whole borrowed param moved into an own sink -> reject.
(
"cut55_2d_consume_borrow_whole",
Expect::Reject("consume-while-borrowed"),
),
// --- decision (c): value-typed params ----------------------------
("cut55_3_value_param", Expect::Accept),
// borrow over an unboxed value type -> reject at the signature.
(
"cut55_3b_borrow_over_value",
Expect::Reject("borrow-over-value"),
),
// --- decision (d): borrowed return -------------------------------
// a (borrow) return is refcount-invisible -> reject at the signature.
(
"cut55_4_borrow_return_reject",
Expect::Reject("borrow-return-not-permitted"),
),
// the clone escape hatch lifts a borrowed field to an owned return.
("cut55_4b_clone_to_return", Expect::Accept),
// --- the cutover's core promise: no defaulted mode ---------------
// a bare fn-type slot is rejected at the PARSER stage, not linearity.
(
"cut55_5_bare_slot_reject",
Expect::Reject("surface-parse-error"),
),
];
for (stem, expect) in table {
let (ok, stderr) = check(stem);
match expect {
Expect::Accept => {
assert!(
ok,
"fixture `{stem}` must be ACCEPTED (exit 0) by the \
post-#55 ownership surface, but `ail check` rejected \
it. stderr:\n{stderr}"
);
}
Expect::AcceptWithWarning(code) => {
assert!(
ok,
"fixture `{stem}` must exit 0 (the `{code}` advisory \
is a warning, not an error), but `ail check` exited \
non-zero. stderr:\n{stderr}"
);
assert!(
stderr.contains(&format!("[{code}]")),
"fixture `{stem}` must emit the `[{code}]` advisory \
warning, but it did not appear in stderr. stderr:\n{stderr}"
);
}
Expect::Reject(code) => {
assert!(
!ok,
"fixture `{stem}` must be REJECTED (non-zero exit) by \
the post-#55 ownership surface, but `ail check` \
accepted it (exit 0). stderr:\n{stderr}"
);
assert!(
stderr.contains(&format!("[{code}]")),
"fixture `{stem}` must be rejected with diagnostic code \
`[{code}]`, but that code did not appear in stderr. \
The reject may have moved to a different stage or code. \
stderr:\n{stderr}"
);
}
}
}
}