feat(check): resolve local function-typed binder modes (#57, 0064 iter 2)

Second iteration of spec 0064 (the #55-cutover hardening, #57). Closes
false-positive class 1: applying a local function-typed binder -- a HOF
predicate param like std_list.filter's `p` -- treated its arguments as
Consume because callee_arg_modes resolved only the global symbol table
(locals returned an empty mode vec). So `(app p h)` with `p` a local
(borrow ...) predicate consumed the heap element `h`, and reusing `h`
in the kept Cons false-fired use-after-consume.

Fix: BinderState gains fn_param_modes: Option<Vec<ParamMode>>, seeded
at all three function-typed binder introduction sites via a new
fn_modes_of helper (strip_forall + Type::Fn { param_modes }, None for
non-fn / empty-modes so the caller falls through to globals): params
and lam-params from their signature type (param_tys), let-binders from
the LetBinderTypes table built in iter 1. callee_arg_modes reads a
local Var callee's fn_param_modes before the global lookup (a local
binder shadows a global -- correct lexical scope); the existing App
arg-walk maps a Borrow mode to a borrow walk unchanged, so the fix is
confined to the seeding + the one local-precedence read.

Scope call (orchestrator): all three seeding sites, not just the param
path that unblocks filter -- the point of this hardening cycle is to
leave no latent class (the #57-from-0063-deferral lesson), and the
extraction is uniform with the table already built.

RED-first: examples/c1_local_hof.ail added to
harden_ownership_false_positives_are_clean (RED: use-after-consume on
filter_box's h) before the fix; GREEN after. Two in-source unit tests
pin both seeding paths (param via signature, let via a hand-built
table). Verified: c1 RED->GREEN; 119 linearity unit tests green;
harden_ownership_false_positives_are_clean green (now c1 + c3); the
heap-double-consume must-stay-RED guard still fires; cargo test
--workspace green; bench/check.py 34/34, compile_check.py 24/24 stable.
Diagnostic-only; no schema/type/codegen change.

The stale `// Locals never carry fn-types in 18c.2` comment in
callee_arg_modes (and the fn's doc header) is rewritten to the accurate
local-precedence behaviour -- it sat in the replaced lines and directly
contradicted the new path.

Classes 2 (let-alias redirect) and 4 (partition_eithers rewrite) remain
for later iterations of spec 0064.

refs #57
This commit is contained in:
2026-06-01 17:57:40 +02:00
parent 2ad58ada17
commit cc5991eeb3
3 changed files with 173 additions and 14 deletions
+149 -13
View File
@@ -213,6 +213,12 @@ struct BinderState {
/// for it. Invariant per binder; set at the introduction site /// for it. Invariant per binder; set at the introduction site
/// (param / pattern / lam) where the type is locally available. /// (param / pattern / lam) where the type is locally available.
is_value: bool, is_value: bool,
/// For a function-typed binder (a HOF predicate param, or a
/// `let`/`lam`-bound function value), its declared parameter modes
/// — so `callee_arg_modes` can resolve `(app p h)` where `p` is a
/// local `(borrow …)` predicate and walk `h` as a borrow, not a
/// consume. `None` for a non-function binder.
fn_param_modes: Option<Vec<ParamMode>>,
} }
/// top-level entry. Walks every fn in `m` and emits /// top-level entry. Walks every fn in `m` and emits
@@ -346,6 +352,18 @@ fn strip_forall(t: &Type) -> &Type {
} }
} }
/// Pull a function-typed value's declared parameter modes out of its
/// type, unwrapping a leading `forall`. Returns `None` for any
/// non-function type and for a fn-type with no explicit modes (legacy
/// all-implicit), so a caller that gets `None` falls back to the
/// global symbol table exactly as before.
fn fn_modes_of(t: &Type) -> Option<Vec<ParamMode>> {
match strip_forall(t) {
Type::Fn { param_modes, .. } if !param_modes.is_empty() => Some(param_modes.clone()),
_ => None,
}
}
/// Per-fn check. Skips fns whose signature has any `Implicit` param — /// Per-fn check. Skips fns whose signature has any `Implicit` param —
/// see module-level scope rules. /// see module-level scope rules.
fn check_fn( fn check_fn(
@@ -398,6 +416,7 @@ fn check_fn(
ParamMode::Own | ParamMode::Implicit => 0, ParamMode::Own | ParamMode::Implicit => 0,
}, },
is_value: param_tys.get(i).map(type_is_value).unwrap_or(false), is_value: param_tys.get(i).map(type_is_value).unwrap_or(false),
fn_param_modes: param_tys.get(i).and_then(|t| fn_modes_of(t)),
}; };
checker.binders.insert(name.clone(), initial); checker.binders.insert(name.clone(), initial);
} }
@@ -521,14 +540,14 @@ impl<'a> Checker<'a> {
} }
Term::Let { name, value, body } => { Term::Let { name, value, body } => {
self.walk(value, Position::Consume); self.walk(value, Position::Consume);
let is_value = self let teed = self
.let_binder_types .let_binder_types
.get(&(self.def_name.to_string(), name.clone())) .get(&(self.def_name.to_string(), name.clone()));
.map(type_is_value) let is_value = teed.map(type_is_value).unwrap_or(false);
.unwrap_or(false); let fn_param_modes = teed.and_then(|t| fn_modes_of(t));
self.with_binder( self.with_binder(
name, name,
BinderState { is_value, ..BinderState::default() }, BinderState { is_value, fn_param_modes, ..BinderState::default() },
|this| { |this| {
this.walk(body, pos); this.walk(body, pos);
}, },
@@ -609,6 +628,7 @@ impl<'a> Checker<'a> {
saved_for_params.insert(p.clone(), self.binders.remove(p)); saved_for_params.insert(p.clone(), self.binders.remove(p));
let st = BinderState { let st = BinderState {
is_value: param_tys.get(i).map(type_is_value).unwrap_or(false), is_value: param_tys.get(i).map(type_is_value).unwrap_or(false),
fn_param_modes: param_tys.get(i).and_then(|t| fn_modes_of(t)),
..BinderState::default() ..BinderState::default()
}; };
self.binders.insert(p.clone(), st); self.binders.insert(p.clone(), st);
@@ -767,19 +787,30 @@ impl<'a> Checker<'a> {
/// Resolve the param-mode vector of a fn-call's callee. /// Resolve the param-mode vector of a fn-call's callee.
/// ///
/// Currently only handles `callee = Term::Var { name }` resolving /// For `callee = Term::Var { name }`, a local function-typed binder
/// to a global fn type (with explicit `param_modes`). All other /// (a HOF predicate param, or a `let`/`lam`-bound function value)
/// callee shapes return an empty vec → all args default to /// carrying `fn_param_modes` is resolved first, then a global fn
/// Consume. This is the conservative, "no false negatives on the /// type (with explicit `param_modes`). All other callee shapes
/// borrow-arg case" behaviour: if we can't see the modes, we /// return an empty vec → all args default to Consume. This is the
/// assume Consume and may miss a borrow. /// conservative, "no false negatives on the borrow-arg case"
/// behaviour: if we can't see the modes, we assume Consume and may
/// miss a borrow.
fn callee_arg_modes(&self, callee: &Term, n_args: usize) -> Vec<ParamMode> { fn callee_arg_modes(&self, callee: &Term, n_args: usize) -> Vec<ParamMode> {
let name = match callee { let name = match callee {
Term::Var { name } => name, Term::Var { name } => name,
_ => return vec![], _ => return vec![],
}; };
// Locals never carry fn-types in 18c.2 (no first-class fn vars // A local function-typed binder (HOF predicate param, or a
// with mode info). Look up the global symbol table. // `let`/`lam`-bound function value) carries its declared arg
// modes on its `BinderState`. Prefer those over the global
// table so `(app p h)` with `p` a local `(borrow …)` predicate
// borrows `h` instead of consuming it. A local binder shadows a
// global of the same name, which is the correct lexical scope.
if let Some(s) = self.binders.get(name) {
if let Some(modes) = &s.fn_param_modes {
return modes.clone();
}
}
let ty = match self.globals.get(name) { let ty = match self.globals.get(name) {
Some(t) => t, Some(t) => t,
None => { None => {
@@ -1477,6 +1508,111 @@ mod tests {
); );
} }
/// fn f(p0: (borrow (List -> Bool, inner arg Borrow)), h: (own List)).
/// The inner predicate's arg is `Borrow`, so applying `p0` to `h`
/// borrows `h` (it stays reusable). Used by the class-1 tests.
fn fn_with_borrow_pred_and_heap(name: &str, body: Term) -> Def {
let list = Type::Con { name: "List".into(), args: vec![] };
Def::Fn(FnDef {
name: name.into(),
ty: Type::Fn {
params: vec![
Type::Fn {
params: vec![list.clone()],
param_modes: vec![ParamMode::Borrow],
ret: Box::new(Type::bool_()),
ret_mode: ParamMode::Own,
effects: vec![],
},
list.clone(),
],
param_modes: vec![ParamMode::Borrow, ParamMode::Own],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Own,
effects: vec![],
},
params: vec!["p0".into(), "h".into()],
body,
suppress: vec![],
doc: None,
export: None,
})
}
/// Class 1 (param path): a local borrow-mode predicate param `p0`
/// applied to a heap binder `h` (`(seq (app p0 h) h)`) does NOT
/// consume `h` — `p0`'s declared `(borrow …)` arg mode makes the
/// application a borrow, so `h` is reusable. RED until the
/// local-fn-param-mode resolution lands.
#[test]
fn borrow_fn_param_applied_does_not_consume_heap_arg() {
let body = Term::Seq {
lhs: Box::new(Term::App {
callee: Box::new(Term::Var { name: "p0".into() }),
args: vec![Term::Var { name: "h".into() }],
tail: false,
}),
rhs: Box::new(Term::Var { name: "h".into() }),
};
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![fn_with_borrow_pred_and_heap("f", body)],
};
let diags = check_module(&m);
assert!(
!diags.iter().any(|d| d.code == "use-after-consume"),
"applying a borrow-mode predicate param borrows its arg; got {diags:?}"
);
}
/// Class 1 (let path): a `let`-bound function value whose type is
/// teed as a `(borrow …)` predicate resolves its modes from the
/// LetBinderTypes table — applying it to heap binder `h` borrows
/// `h`. The let-value term is a trivial literal; the hand-built
/// table is the unit under test.
#[test]
fn borrow_fn_let_binder_applied_does_not_consume_heap_arg() {
let body = Term::Let {
name: "g".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
body: Box::new(Term::Seq {
lhs: Box::new(Term::App {
callee: Box::new(Term::Var { name: "g".into() }),
args: vec![Term::Var { name: "p0".into() }],
tail: false,
}),
rhs: Box::new(Term::Var { name: "p0".into() }),
}),
};
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)],
};
let list = Type::Con { name: "List".into(), args: vec![] };
let mut lbt = LetBinderTypes::new();
lbt.insert(
("f".into(), "g".into()),
Type::Fn {
params: vec![list.clone()],
param_modes: vec![ParamMode::Borrow],
ret: Box::new(Type::bool_()),
ret_mode: ParamMode::Own,
effects: vec![],
},
);
let diags = check_module_with_visible(&m, &[], &lbt);
assert!(
!diags.iter().any(|d| d.code == "use-after-consume"),
"a let-bound borrow predicate borrows its arg via the teed modes; got {diags:?}"
);
}
/// A fn with one `(own (con Int))` value-type param `p0`, returning /// A fn with one `(own (con Int))` value-type param `p0`, returning
/// Int. Used to exercise the value-type exemption. /// Int. Used to exercise the value-type exemption.
fn fn_with_int_own(name: &str, body: Term) -> Def { fn fn_with_int_own(name: &str, body: Term) -> Def {
+1 -1
View File
@@ -725,7 +725,7 @@ fn borrow_own_demo_is_linearity_clean() {
/// is active today) and were RED before the hardening (docs/specs/0063). /// is active today) and were RED before the hardening (docs/specs/0063).
#[test] #[test]
fn harden_ownership_false_positives_are_clean() { fn harden_ownership_false_positives_are_clean() {
for name in ["fp_value", "fp_hof", "fp_map", "c3_value_let"] { for name in ["fp_value", "fp_hof", "fp_map", "c3_value_let", "c1_local_hof"] {
let entry = examples_dir().join(format!("{name}.ail")); let entry = examples_dir().join(format!("{name}.ail"));
let ws = load_workspace(&entry).unwrap_or_else(|e| panic!("load {name}: {e:?}")); let ws = load_workspace(&entry).unwrap_or_else(|e| panic!("load {name}: {e:?}"));
let diags = check_workspace(&ws); let diags = check_workspace(&ws);
+23
View File
@@ -0,0 +1,23 @@
(module c1_local_hof
(data Box
(doc "heap cell")
(ctor Box (con Int)))
(data List
(doc "boxed list of boxes")
(ctor Nil)
(ctor Cons (con Box) (con List)))
(fn filter_box
(doc "borrow-mode predicate p applied to h; h reused in the kept Cons")
(type
(fn-type
(params (borrow (fn-type (params (borrow (con Box))) (ret (own (con Bool)))))
(own (con List)))
(ret (own (con List)))))
(params p xs)
(body
(match xs
(case (pat-ctor Nil) (term-ctor List Nil))
(case (pat-ctor Cons h t)
(if (app p h)
(term-ctor List Cons h (app filter_box p t))
(app filter_box p t)))))))