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
@@ -0,0 +1,40 @@
; Fieldtest cut55-1 — decision (a): a list helper that READS without consuming.
;
; As an author reading the ledger (model/contract 0008): "reach for
; `borrow` only to read/pass-through a heap value you were lent". A
; length count walks the list and reads it; it does not move any heap
; field out. So `xs` should be `(borrow (con List))`. The recursive
; call passes the tail through in a read position — still a borrow.
;
; Expected: ail check -> ok. ail run -> prints 3.
(module cut55_1_readonly_length
(data List
(doc "Monomorphic Int list.")
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn length
(doc "Borrow xs and count its elements without consuming it.")
(type
(fn-type
(params (borrow (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + 1 (app length t))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let xs
(term-ctor List Cons 10
(term-ctor List Cons 20
(term-ctor List Cons 30
(term-ctor List Nil))))
(seq (app print (app length xs)) (do io/print_str "\n"))))))
@@ -0,0 +1,33 @@
; Fieldtest cut55-1b — decision (a), over-strict `own` that does NOT trip
; the lint (boundary case).
;
; Same shape as cut55-1 but `xs` is annotated `(own (con List))`. One might
; expect the `over-strict-mode` lint to fire (xs is only read at the top
; level). It does NOT: the Cons arm recurses via `(app length t)`, and
; `length`'s own self-param is `(own ...)`, so passing the heap tail `t`
; into it counts as a consume. The lint's premise (consume_count == 0) is
; therefore false, and no warning is emitted. The fixture still legitimately
; ACCEPTs (an over-strict but sound annotation is allowed). cut55_1c is the
; sibling where the lint DOES fire (a true read-only with no recursive
; consume).
;
; Expected: ail check -> ok, exit 0 (no over-strict-mode warning).
(module cut55_1b_overstrict_own
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn length
(doc "Annotated own but only reads xs — should be flagged over-strict.")
(type
(fn-type
(params (own (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + 1 (app length t)))))))
@@ -0,0 +1,27 @@
; Fieldtest cut55-1c — decision (a), over-strict lint on a true read-only.
;
; is_empty inspects only the head constructor of xs. No pattern binder is
; consumed (the Cons arm binds h and t but uses neither). xs is `(own)`.
; Per contract 0008 conditions 1-4 all hold (consume_count 0, no heap
; binder consumed, List is not a value type, body is not intrinsic), so
; over-strict-mode SHOULD fire suggesting borrow. Check exits 0 (Warning).
;
; Expected: ail check -> over-strict-mode warning naming xs/borrow, exit 0.
(module cut55_1c_overstrict_isempty
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn is_empty
(doc "Own xs but only inspect its head ctor — over-strict, should be borrow.")
(type
(fn-type
(params (own (con List)))
(ret (own (con Bool)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) true)
(case (pat-ctor Cons h t) false)))))
@@ -0,0 +1,51 @@
; Fieldtest cut55-2 — decision (b): consume/transform a heap value.
;
; bump rebuilds the list with each element +1. It moves the tail `t` into
; a recursive call and packs the new head into a fresh Cons. The input
; allocation is consumed (reused conceptually). This is `own` territory:
; the author writes `(own (con List))` for xs without hesitation, since
; the value is transformed and not merely read.
;
; Expected: ail check -> ok. ail run -> prints 11, 21, 31 (each +1).
(module cut55_2_consume_map
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn bump
(doc "Own xs; return a new list with every element incremented.")
(type
(fn-type
(params (own (con List)))
(ret (own (con List)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) (term-ctor List Nil))
(case (pat-ctor Cons h t)
(term-ctor List Cons (app + h 1) (app bump t))))))
(fn print_all
(doc "Own xs; print each element on its own line, consuming the list.")
(type (fn-type (params (own (con List))) (ret (own (con Unit))) (effects IO)))
(params xs)
(body
(match xs
(case (pat-ctor Nil) (lit-unit))
(case (pat-ctor Cons h t)
(seq
(seq (app print h) (do io/print_str "\n"))
(app print_all t))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let xs
(term-ctor List Cons 10
(term-ctor List Cons 20
(term-ctor List Cons 30
(term-ctor List Nil))))
(app print_all (app bump xs))))))
@@ -0,0 +1,34 @@
; Fieldtest cut55-2b — decision (b), false-positive GUARD for the #58 fix.
;
; This is the legitimate borrow-traverse-and-rebuild that MUST be accepted.
; `bump` borrows xs and, in the Cons arm, reads the head `h` (a value-typed
; Int, copied), passes the tail `t` to its own recursive call, and packs a
; fresh Cons. The recursive `bump`'s param is `(borrow (con List))`, so `t`
; is only BORROWED, never moved into an `(own)` slot — nothing of the
; borrowed xs is consumed. A fresh List is allocated and returned `own`.
;
; This pins that the #58 fix (inherit borrow on heap sub-binders of a
; borrowed scrutinee) does NOT over-fire: a borrow-position use of a heap
; sub-binder is fine. Contrast cut55_2c (feeds `t` to an OWN sink -> reject)
; and cut55_2d (consumes the whole borrowed xs -> reject).
;
; Expected: ail check -> ok, exit 0.
(module cut55_2b_borrow_traversal_clean
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn bump
(doc "Borrow xs and rebuild a fresh +1 list; tail t is only borrowed, not consumed.")
(type
(fn-type
(params (borrow (con List)))
(ret (own (con List)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) (term-ctor List Nil))
(case (pat-ctor Cons h t)
(term-ctor List Cons (app + h 1) (app bump t)))))))
@@ -0,0 +1,39 @@
; Fieldtest cut55-2c — decision (b), genuine consume-while-borrowed.
;
; leak borrows xs, but in the Cons arm it hands the tail `t` to sum_own,
; whose param is `(own (con List))` — that call CONSUMES t, which is a
; heap field of the borrowed xs. Per the ledger, consuming a value you
; only borrowed is rejected (consume-while-borrowed).
;
; Expected: ail check -> error naming the consumed binder and pointing at
; `own`, exit 1.
(module cut55_2c_consume_borrow_reject
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn sum_own
(doc "Own xs, consume it, return the sum.")
(type
(fn-type
(params (own (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app + h (app sum_own t))))))
(fn leak
(doc "Borrow xs but feed its tail to an own-consuming sink — illegal.")
(type
(fn-type
(params (borrow (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app sum_own t))))))
@@ -0,0 +1,35 @@
; Fieldtest cut55-2d — decision (b), consume the borrowed param as a whole.
;
; passthrough borrows xs and hands the WHOLE xs to sum_own, an `(own)`
; sink that consumes it. The caller of passthrough still owns xs (borrow
; contract), so passthrough consuming it is a double-ownership violation.
; Per the ledger this is consume-while-borrowed and must be rejected.
;
; Expected: ail check -> error naming xs and the own-consuming call, exit 1.
(module cut55_2d_consume_borrow_whole
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn sum_own
(doc "Own xs, consume it, return the sum.")
(type
(fn-type
(params (own (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app + h (app sum_own t))))))
(fn passthrough
(doc "Borrow xs but pass the whole thing to an own sink — illegal consume.")
(type
(fn-type
(params (borrow (con List)))
(ret (own (con Int)))))
(params xs)
(body (app sum_own xs))))
@@ -0,0 +1,34 @@
; Fieldtest cut55-3 — decision (c): value-typed param read multiple times.
;
; clamp reads n three times (two comparisons + a return). n: Int is a
; value type. Per the ledger, `(borrow V)` on a value type is a check
; error — `own` is the only legal mode for Int/Bool/Float/Unit, even when
; the value is read many times and never "consumed" in the heap sense.
; So the author writes `(own (con Int))` for every param here.
;
; Expected: ail check -> ok (no over-strict warning, value types exempt).
; ail run -> prints 5 (clamp 12 0 5 -> 5), then 3 (clamp 3 0 5 -> 3).
(module cut55_3_value_param
(fn clamp
(doc "Clamp n into [lo, hi]. All three params are value-typed Ints, read repeatedly.")
(type
(fn-type
(params (own (con Int)) (own (con Int)) (own (con Int)))
(ret (own (con Int)))))
(params n lo hi)
(body
(if (app lt n lo)
lo
(if (app lt hi n)
hi
n))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(seq
(seq (app print (app clamp 12 0 5)) (do io/print_str "\n"))
(seq (app print (app clamp 3 0 5)) (do io/print_str "\n"))))))
@@ -0,0 +1,20 @@
; Fieldtest cut55-3b — decision (c), WRONG mode on purpose.
;
; An author who reasons "n is only read, never consumed, so borrow it"
; writes `(borrow (con Int))`. The ledger says this is illegal: borrow on
; a value type is a check error, own is the only legal mode there. I
; expect a `borrow-over-value` error that NAMES the param and tells me to
; use `own`.
;
; Expected: ail check -> error [borrow-over-value], exit 1.
(module cut55_3b_borrow_over_value
(fn double
(doc "Borrow a value-typed Int — illegal, own is the only legal mode.")
(type
(fn-type
(params (borrow (con Int)))
(ret (own (con Int)))))
(params n)
(body (app + n n))))
@@ -0,0 +1,28 @@
; Fieldtest cut55-4 — decision (d), borrow-return rejected at the signature.
;
; An author who wants "give me a view of the first element without taking
; ownership" might try to declare the RETURN as a borrow:
; (ret (borrow (con List)))
; Per the ledger a borrow may not escape into a return; `borrow-return` is
; rejected at the signature. I expect a clear error that tells me the
; return cannot be a borrow (and, ideally, to clone or return own).
;
; Expected: ail check -> error [borrow-return], exit 1.
(module cut55_4_borrow_return_reject
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn tail_view
(doc "Try to return a borrowed view of the tail — illegal escape.")
(type
(fn-type
(params (borrow (con List)))
(ret (borrow (con List)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) xs)
(case (pat-ctor Cons h t) t)))))
@@ -0,0 +1,51 @@
; Fieldtest cut55-4b — decision (d), the correct way to project out of a borrow.
;
; After borrow-return is rejected, the author returns `(own ...)` and uses
; `(clone X)` (the explicit RC inc from contract 0008) to lift a borrowed
; field into an owned value that may legally escape. head_clone borrows
; xs, reads its head h (an Int value — copied freely), and returns it own.
; For the heap-tail case we clone the tail to return an owned view.
;
; This is the "did the author find the clone escape hatch?" check.
;
; Expected: ail check -> ok. ail run -> prints 10 (head of [10,20,30]).
(module cut55_4b_clone_to_return
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn head_or_zero
(doc "Borrow xs; return its head Int (value type, copied) or 0.")
(type
(fn-type
(params (borrow (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) h))))
(fn tail_copy
(doc "Borrow xs; return an OWNED copy of the tail via clone (escape hatch).")
(type
(fn-type
(params (borrow (con List)))
(ret (own (con List)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) (term-ctor List Nil))
(case (pat-ctor Cons h t) (clone t)))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let xs
(term-ctor List Cons 10
(term-ctor List Cons 20
(term-ctor List Nil)))
(seq (app print (app head_or_zero xs)) (do io/print_str "\n"))))))
@@ -0,0 +1,26 @@
; Fieldtest cut55-5 — the cutover's core promise: no defaulted mode.
;
; An author used to the pre-cutover language (or one who forgets the
; rule) writes a bare fn-type param slot `(con List)` with no `own`/
; `borrow` wrapper. Post-#55 there is no default; the parser/checker must
; reject the bare slot and tell me to annotate a mode.
;
; Expected: ail check -> error (bare slot / missing mode), exit 1.
(module cut55_5_bare_slot_reject
(data List
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn length
(doc "Bare param slot — no mode. Must be rejected after the cutover.")
(type
(fn-type
(params (con List))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app + 1 (app length t)))))))