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
This commit is contained in:
2026-06-02 00:03:46 +02:00
parent 05c3c018de
commit 76b21c00eb
342 changed files with 3196 additions and 1503 deletions
+17 -106
View File
@@ -770,17 +770,14 @@ pub enum Type {
/// `(own T)` wrappers from the surface form. They are metadata
/// on `Type::Fn`, not new `Type` variants — so unification,
/// occurs, apply, and every other `Type` match-arm keeps working
/// unchanged. `param_modes` is omitted from canonical JSON when
/// every entry is `Implicit`; `ret_mode` is omitted when it is
/// `Implicit`, so pre-mode-annotation fixtures hash
/// bit-identically. Full contract in
/// unchanged. Both are always present (one mode per slot,
/// `param_modes.len() == params.len()`); ownership has no default
/// (spec 0062). Full contract in
/// `design/contracts/0008-memory-model.md`.
Fn {
params: Vec<Type>,
#[serde(default, skip_serializing_if = "all_implicit")]
param_modes: Vec<ParamMode>,
ret: Box<Type>,
#[serde(default, skip_serializing_if = "ParamMode::is_implicit")]
ret_mode: ParamMode,
#[serde(default)]
effects: Vec<String>,
@@ -829,123 +826,37 @@ impl Type {
Type::Con { name: "Float".into(), args: vec![] }
}
/// Build a `Type::Fn` with all parameter modes set to
/// `ParamMode::Implicit` and `ret_mode` set to `Implicit`. This
/// is the form every typechecker / desugar / codegen site that
/// synthesises a fn-type should use, so that newly inferred
/// fn-types retain pre-mode-annotation canonical-JSON bytes.
pub fn fn_implicit(params: Vec<Type>, ret: Type, effects: Vec<String>) -> Type {
/// Build a `Type::Fn` with every parameter mode and the return
/// mode set to `ParamMode::Own`. The synthesis form for every
/// typechecker / desugar / codegen site that builds a fn-type;
/// `Own` is correct by construction (spec 0062 Data flow: the old
/// typechecker made `Implicit ≡ Own`, so synthesised fn-types were
/// already semantically `Own`).
pub fn fn_owned(params: Vec<Type>, ret: Type, effects: Vec<String>) -> Type {
let n = params.len();
Type::Fn {
params,
param_modes: vec![ParamMode::Implicit; n],
param_modes: vec![ParamMode::Own; n],
ret: Box::new(ret),
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
effects,
}
}
}
/// Visit every `Type::Fn` in `m`, letting `f` rewrite its modes.
/// `f(params_len, param_modes, ret_mode)`. Used by the throwaway
/// `migrate-modes` tool (spec 0062); has no other caller and is
/// removed if the migration machinery is retired.
pub fn for_each_fn_type_mut(
m: &mut Module,
f: &mut impl FnMut(usize, &mut Vec<ParamMode>, &mut ParamMode),
) {
fn walk_ty(t: &mut Type, f: &mut impl FnMut(usize, &mut Vec<ParamMode>, &mut ParamMode)) {
match t {
Type::Fn { params, param_modes, ret, ret_mode, .. } => {
let n = params.len();
for p in params.iter_mut() { walk_ty(p, f); }
walk_ty(ret, f);
f(n, param_modes, ret_mode);
}
Type::Con { args, .. } => { for a in args.iter_mut() { walk_ty(a, f); } }
Type::Forall { body, .. } => walk_ty(body, f),
Type::Var { .. } => {}
}
}
for def in m.defs.iter_mut() {
if let Def::Fn(fd) = def {
walk_ty(&mut fd.ty, f);
}
}
}
/// Per-parameter / return mode marker on a [`Type::Fn`]. Full
/// contract lives in `design/contracts/0008-memory-model.md`.
///
/// `Implicit` is the legacy state for fn-types that were constructed
/// before the borrow/own surface annotations existed. Semantically,
/// `Implicit ≡ Own`; the distinction exists only so pre-annotation
/// JSON fixtures continue to serialize without a `"mode"` wrapper
/// and therefore keep their canonical-JSON hash.
///
/// `Own` and `Borrow` are author-asserted: the surface form
/// `(own T)` / `(borrow T)` round-trips through this enum.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
/// Ownership has no default: every fn-type slot carries an explicit
/// `Own` or `Borrow` (spec 0062).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParamMode {
/// Unannotated / back-compat. Treated as `Own` by the typechecker.
#[default]
Implicit,
/// `(own T)` — caller transfers ownership; callee consumes.
Own,
/// `(borrow T)` — caller retains ownership; callee may not consume.
Borrow,
}
impl ParamMode {
/// Used by the `skip_serializing_if` predicate on
/// [`Type::Fn::ret_mode`].
pub fn is_implicit(&self) -> bool {
matches!(self, ParamMode::Implicit)
}
}
/// Serde helper for [`Type::Fn::param_modes`]. Returns `true` when
/// every entry is [`ParamMode::Implicit`] (or when the list is
/// empty), so canonical JSON omits the field for any fn-type without
/// explicit `(borrow)` / `(own)` annotations and pre-annotation
/// fixtures keep bit-identical hashes.
fn all_implicit(modes: &[ParamMode]) -> bool {
modes.iter().all(|m| m.is_implicit())
}
/// Equality of [`ParamMode`] for the purposes of `Type` equality.
/// `Implicit` and `Own` are treated as the same mode; `Borrow` is
/// distinct. This keeps pre-annotation fixtures (whose fn-types
/// serialize `Implicit`) compatible with newly-written fixtures
/// that mark the same fn-type explicitly with `(own T)`.
fn mode_eq(a: &ParamMode, b: &ParamMode) -> bool {
match (a, b) {
(ParamMode::Borrow, ParamMode::Borrow) => true,
(ParamMode::Borrow, _) | (_, ParamMode::Borrow) => false,
// Implicit and Own are interchangeable.
_ => true,
}
}
/// Equality of two `param_modes` slices, robust to the
/// "elided when all-implicit" representation used by typechecker /
/// desugar / codegen sites that construct fn-types with
/// `param_modes: vec![]`. Both slices are normalised to "implicit
/// padding to match the longer one"; equality then proceeds
/// element-wise via [`mode_eq`].
fn mode_slices_eq(a: &[ParamMode], b: &[ParamMode]) -> bool {
let n = a.len().max(b.len());
for i in 0..n {
let x = a.get(i).copied().unwrap_or(ParamMode::Implicit);
let y = b.get(i).copied().unwrap_or(ParamMode::Implicit);
if !mode_eq(&x, &y) {
return false;
}
}
true
}
impl PartialEq for Type {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
@@ -971,8 +882,8 @@ impl PartialEq for Type {
) => {
ap == bp
&& ar == br
&& mode_slices_eq(apm, bpm)
&& mode_eq(arm, brm)
&& apm == bpm
&& arm == brm
&& {
let mut a = ae.clone();
let mut b = be.clone();
+157 -46
View File
@@ -933,7 +933,7 @@ impl Desugarer {
ret: ret.clone(),
effects: effects.clone(),
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
}
}
Type::Forall { .. } => panic!(
@@ -1115,8 +1115,6 @@ impl Desugarer {
arms,
};
}
let s = self.fresh();
let s_var = Term::Var { name: s.clone() };
// `default` is unreachable for valid programs (the
// typechecker requires either a catch-all arm or exhaustive
// ctor coverage). Use the polymorphic bottom builtin
@@ -1125,6 +1123,28 @@ impl Desugarer {
// `Unit`-typed `_` arm to dominate it. Codegen lowers the
// var to LLVM `unreachable`.
let default = Term::Var { name: "__unreachable__".into() };
// When the scrutinee is ALREADY a bare `Term::Var` (a function
// param or an existing let-binder), do NOT introduce a fresh
// `Let $mp = <scrutinee>` rebind: reuse the binder directly as
// the chain scrutinee. The rebind is spurious here and, post-#55,
// harmful — walking the `Let` value in `Position::Consume` bumps
// the param's `consume_count` to 1, which trips the fn-return
// husk-free gate (`consume_count != 0` ⇒ skip) and relocates the
// outer-cell ownership onto the internal `$mp` binder the gate
// never inspects, leaking the moved-from outer cell. Reusing the
// binder keeps `consume_count == 0` and populates `moved_slots`
// exactly as the single-match case, so the existing gate fires
// unchanged (refs #55, examples/lit_pat_ctor_tail_drop.ail,
// examples/lit_pat_nil_scrutinee_drop.ail).
//
// A COMPOUND-expression scrutinee (not a bare Var) still needs
// the `$mp` rebind so it is evaluated once and shared across all
// chain arms rather than re-evaluated per arm.
if matches!(scrutinee, Term::Var { .. }) {
return self.build_chain(&scrutinee, &arms, &default);
}
let s = self.fresh();
let s_var = Term::Var { name: s.clone() };
let chain = self.build_chain(&s_var, &arms, &default);
Term::Let {
name: s,
@@ -1136,16 +1156,109 @@ impl Desugarer {
/// Recursively builds a chain of single-arm matches with a shared
/// fall-through. Empty arms ⇒ `default`; otherwise the first arm
/// is desugared with the rest of the chain as its fall-through.
///
/// Consecutive arms whose head pattern is the *same* outer ctor
/// (same name + arity) are grouped into ONE [`Term::Match`] arm
/// that binds the ctor fields exactly once, then branches the
/// per-arm sub-patterns over those bound field vars. This is the
/// single-ownership-scope invariant the arm-close drop accounting
/// in `match_lower` relies on: a ctor's owned children are bound
/// (and therefore dropped) under one match scope per ctor, never
/// re-matched on the same scrutinee. Re-matching the same owned
/// scrutinee under a second ctor scope (the pre-fix lowering of a
/// lit sub-pattern's `else` branch) bound — and the post-#55
/// `Own`-param arm-close dropped — the same heap child twice
/// (double-free; refs #55, examples/lit_pat_ctor_tail_drop.ail).
fn build_chain(&mut self, s_var: &Term, arms: &[Arm], default: &Term) -> Term {
if arms.is_empty() {
return default.clone();
}
let head = &arms[0];
// Detect a maximal run of consecutive arms sharing the head
// arm's outer ctor + arity. A run of length ≥ 1 of ctor arms
// is lowered as a single bind-once ctor-match; everything else
// (Wild / Var / Lit head) falls through to the per-arm path.
if let Pattern::Ctor { ctor, fields } = &head.pat {
let arity = fields.len();
let group_len = arms
.iter()
.take_while(|a| match &a.pat {
Pattern::Ctor {
ctor: c,
fields: f,
} => c == ctor && f.len() == arity,
_ => false,
})
.count();
let group = &arms[..group_len];
let rest = &arms[group_len..];
let rest_chain = self.build_chain(s_var, rest, default);
return self.build_ctor_group(s_var, ctor, arity, group, rest_chain);
}
let rest = &arms[1..];
let fall_k = self.build_chain(s_var, rest, default);
self.desugar_one_arm(s_var, head, fall_k)
}
/// Lowers a run of consecutive arms that all match the same outer
/// ctor `ctor`/`arity` into a single [`Term::Match`] that binds the
/// ctor fields once. The bound field vars are threaded through each
/// arm's sub-patterns (via [`wrap_sub`](Self::wrap_sub)); the arms'
/// internal fall-throughs chain left-to-right, ending in
/// `rest_chain` (the arms after the group, which by construction
/// match a *different* ctor or are catch-alls). The single match's
/// wildcard arm also routes to `rest_chain`. No arm in the group
/// re-matches `s_var`, so the ctor's owned children live under
/// exactly one match scope.
fn build_ctor_group(
&mut self,
s_var: &Term,
ctor: &str,
arity: usize,
group: &[Arm],
rest_chain: Term,
) -> Term {
let fresh_vars: Vec<String> = (0..arity).map(|_| self.fresh()).collect();
let flat_fields: Vec<Pattern> = fresh_vars
.iter()
.map(|n| Pattern::Var { name: n.clone() })
.collect();
// Build the group body inside-out: the last arm falls to
// `rest_chain`; each earlier arm falls to the next arm's body.
// Within an arm, fold the field sub-patterns right-to-left so
// the deepest field is matched first (matches the single-arm
// `desugar_one_arm` ordering).
let mut inner = rest_chain.clone();
for arm in group.iter().rev() {
let sub_fields = match &arm.pat {
Pattern::Ctor { fields, .. } => fields,
// `build_chain` only calls this with ctor arms.
_ => unreachable!("build_ctor_group requires ctor arms"),
};
let mut arm_body = arm.body.clone();
for (sub, fv) in sub_fields.iter().zip(fresh_vars.iter()).rev() {
arm_body = self.wrap_sub(fv, sub, arm_body, &inner);
}
inner = arm_body;
}
Term::Match {
scrutinee: Box::new(s_var.clone()),
arms: vec![
Arm {
pat: Pattern::Ctor {
ctor: ctor.to_string(),
fields: flat_fields,
},
body: inner,
},
Arm {
pat: Pattern::Wild,
body: rest_chain,
},
],
}
}
/// Lowers one arm into a term. Wild/Var arms drop the chain (the
/// arm matches everything); Lit and Ctor arms emit a `Term::Match`
/// with the desugared head pattern as the first arm and a
@@ -1988,7 +2101,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["xs".into()],
body: body_match,
@@ -2046,7 +2159,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["xs".into()],
body: original.clone(),
@@ -2138,7 +2251,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["n".into()],
body: Box::new(Term::Var { name: "n".into() }),
@@ -2164,7 +2277,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: fact_letrec_term(),
@@ -2228,7 +2341,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["n".into()],
// (let-rec helper (params x) (type Int -> Int)
@@ -2241,7 +2354,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["x".into()],
body: Box::new(Term::App {
@@ -2278,7 +2391,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
"lifted fn type should have capture appended; got {:?}",
lifted.ty
@@ -2348,7 +2461,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["x".into()],
body: Box::new(Term::App {
@@ -2377,7 +2490,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Let {
@@ -2451,7 +2564,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["z".into()],
body: Box::new(Term::App {
@@ -2498,7 +2611,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["p".into()],
body: outer_body,
@@ -2548,7 +2661,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["x".into()],
body: Box::new(Term::Let {
@@ -2578,7 +2691,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: letrec,
@@ -2611,7 +2724,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["x".into()],
body: Box::new(Term::App {
@@ -2641,7 +2754,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: letrec,
@@ -2733,7 +2846,7 @@ mod tests {
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["k".into()],
// 2026-05-21 operator-routing-eq-ord: keep `==` here (vs
@@ -2786,7 +2899,7 @@ mod tests {
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
}),
},
params: vec!["x".into()],
@@ -2862,7 +2975,7 @@ mod tests {
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["k".into()],
body: Box::new(Term::Var { name: "x".into() }),
@@ -2891,7 +3004,7 @@ mod tests {
ret: Box::new(Type::Var { name: "a".into() }),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
}),
},
params: vec!["x".into()],
@@ -2932,7 +3045,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["j".into()],
body: Box::new(Term::App {
@@ -2953,7 +3066,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["i".into()],
body: Box::new(inner),
@@ -2975,7 +3088,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: outer,
@@ -3011,7 +3124,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["j".into()],
body: Box::new(Term::App {
@@ -3035,7 +3148,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["i".into()],
body: Box::new(inner),
@@ -3057,7 +3170,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: outer,
@@ -3189,7 +3302,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["n".into()],
body: body_match,
@@ -3207,15 +3320,15 @@ mod tests {
!any_lit_pattern(body),
"desugarer must remove every Pattern::Lit from the term tree; got: {body:#?}"
);
// The desugar wraps the match in `let $mp_N = scrutinee in <chain>`,
// and the chain head must be a `Term::If` (the lit arm).
let chain = match body {
Term::Let { body, .. } => body.as_ref(),
other => panic!("expected outer Let from chain machinery, got {other:?}"),
};
// The scrutinee is a bare `Term::Var` (the param `n`), so the
// chain machinery reuses the binder directly without a spurious
// `let $mp_N = n` rebind (the rebind would bump the param's
// `consume_count` and break the fn-return husk-free gate; refs
// #55). The chain head is therefore the `Term::If` (the lit arm)
// sitting directly at the function body.
assert!(
matches!(chain, Term::If { .. }),
"expected Term::If at the chain head, got {chain:?}"
matches!(body, Term::If { .. }),
"expected Term::If at the chain head, got {body:?}"
);
}
@@ -3264,7 +3377,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["xs".into()],
body: body_match,
@@ -3347,7 +3460,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["n".into()],
body: body_match,
@@ -3361,13 +3474,11 @@ mod tests {
Def::Fn(f) => &f.body,
_ => unreachable!(),
};
// Outer is `let $mp_N = scrutinee in <chain>`. The chain is
// `if (== sv 0) then 100 else if (== sv 1) then 200 else __unreachable__`.
let chain = match body {
Term::Let { body, .. } => body.as_ref(),
other => panic!("expected outer Let from chain machinery, got {other:?}"),
};
let inner_else = match chain {
// The scrutinee is a bare `Term::Var` (the param `n`), so there
// is no `let $mp_N = n` rebind (it would break the fn-return
// husk-free gate; refs #55). The body IS the chain directly:
// `if (== n 0) then 100 else if (== n 1) then 200 else __unreachable__`.
let inner_else = match body {
Term::If { else_, .. } => else_.as_ref(),
other => panic!("expected outer If, got {other:?}"),
};
+1 -1
View File
@@ -179,7 +179,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["a".into(), "b".into()],
body: Term::App {
+15 -15
View File
@@ -1811,7 +1811,7 @@ mod tests {
"defs": [{
"kind": "fn",
"name": "f",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": type_con }, "effects": [] },
"type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": type_con }, "ret_mode": "own", "effects": [] },
"params": [],
"body": { "t": "lit", "lit": { "kind": "unit" } }
}],
@@ -1829,7 +1829,7 @@ mod tests {
"defs": [
{ "kind": "type", "name": type_name, "ctors": [] },
{ "kind": "fn", "name": "f",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": type_name }, "effects": [] },
"type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": type_name }, "ret_mode": "own", "effects": [] },
"params": [],
"body": { "t": "lit", "lit": { "kind": "unit" } } }
],
@@ -1847,7 +1847,7 @@ mod tests {
"defs": [{
"kind": "fn",
"name": "f",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": type_con }, "effects": [] },
"type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": type_con }, "ret_mode": "own", "effects": [] },
"params": [],
"body": { "t": "lit", "lit": { "kind": "unit" } }
}],
@@ -1982,7 +1982,7 @@ mod tests {
"defs": [{
"kind": "fn",
"name": "f",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": [] },
"type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": [] },
"params": [],
"body": {
"t": "lam",
@@ -2026,7 +2026,7 @@ mod tests {
"defs": [{
"kind": "fn",
"name": "f",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": [] },
"type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": [] },
"params": [],
"body": {
"t": "ctor",
@@ -2173,8 +2173,8 @@ mod tests {
{ "class": "other.MyEq", "type": { "k": "var", "name": "a" } }
],
"body": {
"k": "fn", "params": [{ "k": "var", "name": "a" }],
"ret": { "k": "con", "name": "Unit" }, "effects": []
"k": "fn", "params": [{ "k": "var", "name": "a" }], "param_modes": ["own"],
"ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": []
}
},
"params": ["x"],
@@ -2226,8 +2226,8 @@ mod tests {
"methods": [
{ "name": "tshow",
"type": { "k": "fn",
"params": [{ "k": "var", "name": "a" }],
"ret": { "k": "con", "name": "Str" },
"params": [{ "k": "var", "name": "a" }], "param_modes": ["own"],
"ret": { "k": "con", "name": "Str" }, "ret_mode": "own",
"effects": [] } }
]
},
@@ -2326,9 +2326,9 @@ mod tests {
}],
body: Box::new(Type::Fn {
params: vec![Type::Var { name: "a".to_string() }],
param_modes: vec![],
param_modes: vec![crate::ast::ParamMode::Own],
ret: Box::new(Type::Var { name: "a".to_string() }),
ret_mode: Default::default(),
ret_mode: crate::ast::ParamMode::Own,
effects: vec![],
}),
};
@@ -2392,8 +2392,8 @@ mod tests {
],
"body": {
"k": "fn",
"params": [{ "k": "var", "name": "b" }],
"ret": { "k": "con", "name": "Unit" },
"params": [{ "k": "var", "name": "b" }], "param_modes": ["own"],
"ret": { "k": "con", "name": "Unit" }, "ret_mode": "own",
"effects": []
}
}
@@ -2597,8 +2597,8 @@ mod tests {
],
"body": {
"k": "fn",
"params": [{ "k": "var", "name": "a" }],
"ret": { "k": "con", "name": "Unit" },
"params": [{ "k": "var", "name": "a" }], "param_modes": ["own"],
"ret": { "k": "con", "name": "Unit" }, "ret_mode": "own",
"effects": []
}
},
+32 -4
View File
@@ -119,14 +119,42 @@ fn every_contract_names_a_resolvable_ratifying_test() {
for row in &contracts {
// columns: id | consumer/lifetime | ratifying-test | link
let rt = &row[2];
// take the path token (before any " (" note)
let path = rt.split(" (").next().unwrap_or(rt).trim();
// strip a trailing " (...)" note, then resolve every
// " + "-separated path segment. A dual ratifier such as
// "uniqueness.rs + linearity.rs (in-source mod tests)" names
// two real files — both must resolve, mirroring the
// dual-link handling in `link_target_exists` (clause-1).
// The second segment is a bare leafname relative to the
// first segment's directory.
let body = rt.split(" (").next().unwrap_or(rt).trim();
let segments: Vec<&str> = body.split(" + ").map(str::trim).collect();
let first = segments[0];
assert!(
root().join(path).exists(),
root().join(first).exists(),
"ratifying-test does not resolve to a real file: {:?} (contract {:?})",
path,
first,
row[0]
);
let base_dir = std::path::Path::new(first)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
for seg in &segments[1..] {
// a later segment may be a full repo-relative path or a
// bare leafname rooted at the first segment's directory.
let resolved = if root().join(seg).exists() {
root().join(seg)
} else {
root().join(base_dir.join(seg))
};
assert!(
resolved.exists(),
"ratifying-test dual segment does not resolve to a real \
file: {:?} (contract {:?})",
seg,
row[0]
);
}
}
}
@@ -82,7 +82,7 @@ fn design_md_anchors_every_term_variant() {
r#""t": "letrec""#,
Term::LetRec {
name: "f".into(),
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
ty: Type::fn_owned(vec![], Type::int(), vec![]),
params: vec![],
body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
in_term: Box::new(Term::Var { name: "f".into() }),
@@ -248,7 +248,7 @@ fn design_md_anchors_every_pattern_variant() {
fn design_md_anchors_every_type_variant() {
let exemplars: Vec<(&str, Type)> = vec![
(r#""k": "con""#, Type::int()),
(r#""k": "fn""#, Type::fn_implicit(vec![], Type::unit(), vec![])),
(r#""k": "fn""#, Type::fn_owned(vec![], Type::unit(), vec![])),
(r#""k": "var""#, Type::Var { name: "a".into() }),
(
r#""k": "forall""#,
@@ -313,7 +313,7 @@ fn design_md_anchors_every_def_kind() {
name: "f".into(),
doc: None,
suppress: vec![],
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
ty: Type::fn_owned(vec![], Type::int(), vec![]),
params: vec![],
body: Term::Lit { lit: Literal::Int { value: 0 } },
export: None,
@@ -338,7 +338,7 @@ fn design_md_anchors_every_def_kind() {
superclass: None,
methods: vec![ClassMethod {
name: "show".into(),
ty: Type::fn_implicit(vec![Type::Var { name: "a".into() }], Type::str_(), vec![]),
ty: Type::fn_owned(vec![Type::Var { name: "a".into() }], Type::str_(), vec![]),
default: None,
}],
doc: None,
@@ -378,18 +378,16 @@ fn design_md_anchors_every_def_kind() {
/// Every `ParamMode` variant must have its serialized string form present
/// in design/contracts/0002-data-model.md. The mode annotations are load-bearing for
/// ownership checking; an LLM author must know all three forms.
/// ownership checking; an LLM author must know both forms.
#[test]
fn design_md_anchors_every_parammode_variant() {
let exemplars: Vec<(&str, ParamMode)> = vec![
(r#""implicit""#, ParamMode::Implicit),
(r#""own""#, ParamMode::Own),
(r#""borrow""#, ParamMode::Borrow),
];
for (anchor, mode) in exemplars {
let _: &'static str = match mode {
ParamMode::Implicit => "implicit",
ParamMode::Own => "own",
ParamMode::Borrow => "borrow",
};
@@ -525,7 +523,7 @@ fn design_md_anchors_nested_struct_keys() {
let _ = Suppress { code: "x".into(), because: "y".into() };
let _ = ClassMethod {
name: "m".into(),
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
ty: Type::fn_owned(vec![], Type::int(), vec![]),
default: None,
};
let _ = InstanceMethod {
@@ -21,7 +21,7 @@ fn fn_without_export_hash_is_unchanged() {
// equal the value captured before the field existed.
let def = Def::Fn(FnDef {
name: "f".into(),
ty: Type::fn_implicit(vec![Type::int()], Type::int(), vec![]),
ty: Type::fn_owned(vec![Type::int()], Type::int(), vec![]),
params: vec!["x".into()],
body: Term::Var { name: "x".into() },
doc: None,
@@ -29,7 +29,7 @@ fn fn_without_export_hash_is_unchanged() {
export: None,
});
// GOLDEN: captured pre-field (Step 2) from `def_hash` on this shape.
let golden = "b4662aa70839f60b";
let golden = "8ce080ee897b3f80";
assert_eq!(def_hash(&def), golden,
"pre-M1 fn hash drifted — additive-field invariant violated");
}
+10 -10
View File
@@ -31,8 +31,8 @@ fn sample_fn() -> Def {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
param_modes: vec![ParamMode::Own, ParamMode::Own],
ret_mode: ParamMode::Own,
},
params: vec!["a".into(), "b".into()],
body: Term::App {
@@ -86,7 +86,7 @@ fn iter13a_schema_extension_preserves_pre_13a_hashes() {
let sum_mod = ailang_surface::load_module(&examples.join("sum.ail"))
.expect("examples/sum.ail loads");
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "25343a2e5927a257");
assert_eq!(def_hash(sum_def), "19920ec4123d35d6");
let list_mod = ailang_surface::load_module(&examples.join("list.ail"))
.expect("examples/list.ail loads");
@@ -108,7 +108,7 @@ fn loop_recur_schema_extension_preserves_pre_loop_recur_hashes() {
let sum_mod = ailang_surface::load_module(&examples.join("sum.ail"))
.expect("examples/sum.ail loads");
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "25343a2e5927a257");
assert_eq!(def_hash(sum_def), "19920ec4123d35d6");
let list_mod = ailang_surface::load_module(&examples.join("list.ail"))
.expect("examples/list.ail loads");
@@ -162,7 +162,7 @@ fn iter19b_schema_extension_preserves_pre_19b_hashes() {
let sum_mod = ailang_surface::load_module(&examples.join("sum.ail"))
.expect("examples/sum.ail loads");
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "25343a2e5927a257");
assert_eq!(def_hash(sum_def), "19920ec4123d35d6");
}
/// adding `Def::Class` and `Def::Instance` must
@@ -177,7 +177,7 @@ fn iter22b1_schema_extension_preserves_pre_22b_hashes() {
let sum_mod = ailang_surface::load_module(&examples.join("sum.ail"))
.expect("examples/sum.ail loads");
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "25343a2e5927a257");
assert_eq!(def_hash(sum_def), "19920ec4123d35d6");
let list_mod = ailang_surface::load_module(&examples.join("list.ail"))
.expect("examples/list.ail loads");
@@ -220,7 +220,7 @@ fn forall_without_constraints_hashes_bit_identical_to_pre_22b2() {
let t = Type::Forall {
vars: vec!["a".into()],
constraints: vec![],
body: Box::new(Type::fn_implicit(
body: Box::new(Type::fn_owned(
vec![Type::Var { name: "a".into() }],
Type::Var { name: "a".into() },
vec![],
@@ -254,7 +254,7 @@ fn ct4_migrated_fixtures_have_canonical_form_hashes() {
// io/print_str no longer adds a trailing newline.
assert_eq!(
def_hash(main_def),
"8ed47b4062ce00f5",
"602d7a6d6ba72bc4",
"ordering_match::main canonical hash must match captured post-fputs-swap value"
);
@@ -272,7 +272,7 @@ fn ct4_migrated_fixtures_have_canonical_form_hashes() {
assert_eq!(dup_classmod_mod.defs.len(), 1, "test_22b1_dup_classmod expected to have exactly 1 def (class TShow)");
assert_eq!(
def_hash(&dup_classmod_mod.defs[0]),
"b8bca96c2d09ed93",
"4ca71c7d8212c96a",
"test_22b1_dup_classmod class TShow canonical hash; post-24.2 captured value"
);
}
@@ -286,7 +286,7 @@ fn ct4_unmigrated_fixtures_remain_bit_identical() {
let sum_mod = ailang_surface::load_module(&examples.join("sum.ail"))
.expect("examples/sum.ail loads");
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "25343a2e5927a257",
assert_eq!(def_hash(sum_def), "19920ec4123d35d6",
"sum.sum hash drifted across canonical-form tightening — unexpected");
let list_mod = ailang_surface::load_module(&examples.join("list.ail"))
+12 -6
View File
@@ -67,7 +67,6 @@ enum VariantTag {
TypeVar,
TypeForall,
// ParamMode
ParamModeImplicit,
ParamModeOwn,
ParamModeBorrow,
}
@@ -111,7 +110,6 @@ const EXPECTED_VARIANTS: &[VariantTag] = &[
VariantTag::TypeFn,
VariantTag::TypeVar,
VariantTag::TypeForall,
VariantTag::ParamModeImplicit,
VariantTag::ParamModeOwn,
VariantTag::ParamModeBorrow,
];
@@ -341,9 +339,6 @@ fn visit_type(t: &Type, observed: &mut HashSet<VariantTag>) {
fn visit_param_mode(m: &ParamMode, observed: &mut HashSet<VariantTag>) {
match m {
ParamMode::Implicit => {
observed.insert(VariantTag::ParamModeImplicit);
}
ParamMode::Own => {
observed.insert(VariantTag::ParamModeOwn);
}
@@ -364,6 +359,17 @@ fn examples_dir() -> PathBuf {
crate_dir.parent().unwrap().parent().unwrap().join("examples")
}
/// Fixtures that intentionally do NOT parse — the `#55` cutover
/// reject corpus. Negative fixtures whose whole point is that the
/// parser rejects them, so they have no AST to scan for variant
/// coverage and the 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)
@@ -373,7 +379,7 @@ fn list_ail_fixtures() -> Vec<PathBuf> {
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".ail"))
.map(|n| n.ends_with(".ail") && !NON_PARSEABLE_FIXTURES.contains(&n))
.unwrap_or(false)
})
.collect();
+3 -3
View File
@@ -49,7 +49,7 @@ fn spec_mentions_every_term_variant() {
"(let-rec",
Term::LetRec {
name: "f".into(),
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
ty: Type::fn_owned(vec![], Type::int(), vec![]),
params: vec![],
body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
in_term: Box::new(Term::Var { name: "f".into() }),
@@ -207,7 +207,7 @@ fn spec_mentions_every_type_variant() {
("(con ", Type::int()),
(
"(fn-type",
Type::fn_implicit(vec![], Type::unit(), vec![]),
Type::fn_owned(vec![], Type::unit(), vec![]),
),
(
"TYVAR-NAME",
@@ -271,7 +271,7 @@ fn spec_mentions_every_def_kind() {
name: "f".into(),
doc: None,
suppress: vec![],
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
ty: Type::fn_owned(vec![], Type::int(), vec![]),
params: vec![],
body: Term::Lit { lit: Literal::Int { value: 0 } },
export: None,
+5 -5
View File
@@ -48,7 +48,7 @@ fn kernel_tier_module_auto_imports_without_explicit_import() {
"(module bridge\n",
" (import k_mod)\n",
" (fn anchor\n",
" (type (forall (vars a) (fn-type (params (con KT a)) (ret (con Unit)))))\n",
" (type (forall (vars a) (fn-type (params (own (con KT a))) (ret (own (con Unit))))))\n",
" (params k) (body unit)))\n",
),
);
@@ -61,7 +61,7 @@ fn kernel_tier_module_auto_imports_without_explicit_import() {
"(module consumer\n",
" (import bridge)\n",
" (fn use_kt\n",
" (type (forall (vars a) (fn-type (params (con KT a)) (ret (con Unit)))))\n",
" (type (forall (vars a) (fn-type (params (own (con KT a))) (ret (own (con Unit))))))\n",
" (params k) (body unit)))\n",
),
);
@@ -106,7 +106,7 @@ fn two_kernel_tier_modules_coload() {
" (import k_a)\n",
" (import k_b)\n",
" (fn anchor\n",
" (type (fn-type (params (con A) (con B)) (ret (con Unit))))\n",
" (type (fn-type (params (own (con A)) (own (con B))) (ret (own (con Unit)))))\n",
" (params a b) (body unit)))\n",
),
);
@@ -117,7 +117,7 @@ fn two_kernel_tier_modules_coload() {
"(module consumer\n",
" (import bridge)\n",
" (fn use_ab\n",
" (type (fn-type (params (con A) (con B)) (ret (con Unit))))\n",
" (type (fn-type (params (own (con A)) (own (con B))) (ret (own (con Unit)))))\n",
" (params a b) (body unit)))\n",
),
);
@@ -151,7 +151,7 @@ fn explicit_import_takes_precedence_over_auto_import() {
"(module consumer\n",
" (import k_mod)\n",
" (fn use_kt\n",
" (type (fn-type (params (con KT)) (ret (con Unit))))\n",
" (type (fn-type (params (own (con KT))) (ret (own (con Unit)))))\n",
" (params k) (body unit)))\n",
),
);