feat(check): harden the ownership analysis for universal activation (0063)

The strict linearity check (use-after-consume / consume-while-borrowed,
linearity.rs) runs today only on the ~45 of 258 all-explicit-mode fns;
its activation gate skips any fn with a bare/Implicit param. Deleting
ParamMode::Implicit (#55) would turn it on universally and surface ~21%
false positives in two classes. This closes both, making the core
ownership analysis sharp across the whole codebase for the first time —
the precondition for #55. Diagnostic-only: no schema, no hash, no
codegen change; the two existing diagnostic codes simply fire on a
smaller, more correct set.

Fix 1 — value-type exemption. A new is_value_type predicate
(ailang-core::primitives) names the unboxed set {Int,Bool,Float,Unit};
BinderState gains an is_value flag seeded from the binder's locally
available type (param signatures, ctor field types for pattern binders,
lam typed-params), and use_var short-circuits the Consume arm for it. A
value type has no refcount and is never consumed, so multi-read is
always legal.

  Str is deliberately NOT in the value set, though is_primitive_name
  includes it: drop.rs:490-492 lowers only Int/Bool/Float/Unit to
  non-ptr; Str is a ptr, RC-dec'd, so a multi-consume of Str without
  clone is a real use-after-free. is_value_type is the Str-excluding
  subset of is_primitive_name, pinned by a unit test. is_heap_type and
  the over-strict-mode lint are left untouched (separate concern).

Fix 2 — application is a borrow. Term::App walks its callee in
Position::Borrow (was Consume). Applying a function value reads it; it
stays live for further applications. Global fn-refs are untracked
(no-op); a tracked function-typed binder (a HOF param) is no longer
consumed by application. This fixes both the own-f-param
use-after-consume and the borrow-f-param consume-while-borrowed in the
recursion-passing HOF shape (map_int f t). Passing a function value as
an arg is unchanged — it follows the callee param_modes.

Scope decision: value-typed let-binders stay conservatively tracked as
heap (their type is inferred, not annotated, and the linearity walk
does not re-run inference). This is soundness-safe — over-strict, never
unsound — and not a measured corpus shape; lifting it would need a full
binder->type table out of the type-checker.

Verification: all three false-positive classes reproduced today against
explicit-mode fns and shipped as RED->GREEN fixtures
(examples/fp_{value,hof,map}.ail); examples/real_consume.ail stays RED
(heap double-consume still fires) to prove the exemption is type-gated.
715 workspace tests green; bench/check.py 0/34 and bench/compile_check.py
0/24 regressed. merge_states carries is_value so the flag survives
branch merges. RED-first verified per task.

closes #56
This commit is contained in:
2026-06-01 15:38:32 +02:00
parent 47fb328aca
commit aaa70d4c35
7 changed files with 360 additions and 11 deletions
+32
View File
@@ -17,6 +17,18 @@ pub fn is_primitive_name(name: &str) -> bool {
matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float")
}
/// Returns `true` iff `name` is an **unboxed value type** — no RC, no
/// heap slab, copied by value. This is the `Str`-excluding subset of
/// [`is_primitive_name`]: `Str` is a primitive zero-arity ctor but is
/// heap-allocated (`ptr`, RC-`dec`'d — codegen `drop.rs:490-492` lowers
/// only `Int`/`Bool`/`Float`/`Unit` to non-`ptr`). Used by the
/// linearity analysis to exempt value-type binders from
/// consume-tracking: a value type is never consumed, so multi-use is
/// always legal.
pub fn is_value_type(name: &str) -> bool {
matches!(name, "Int" | "Bool" | "Float" | "Unit")
}
/// Returns the static-lifetime surface name iff `name` is a
/// primitive. Used by the mono pass to embed the human-readable
/// form in monomorphised symbol names; the static lifetime is what
@@ -60,4 +72,24 @@ mod tests {
"Float surface name must be \"Float\""
);
}
/// `is_value_type` is the unboxed/no-RC subset of the primitives:
/// it agrees with `is_primitive_name` on every name EXCEPT `Str`,
/// which is a primitive zero-arity ctor but is heap-allocated
/// (`ptr`, RC'd) — see drop.rs:490-492.
#[test]
fn value_type_is_primitive_minus_str() {
for name in ["Int", "Bool", "Float", "Unit"] {
assert!(is_value_type(name), "{name} must be a value type");
assert!(is_primitive_name(name), "{name} must be a primitive");
}
// The sole divergence: Str is a primitive but NOT a value type.
assert!(!is_value_type("Str"), "Str is heap-allocated, not a value type");
assert!(is_primitive_name("Str"), "Str is still a primitive zero-arity ctor");
// Non-primitives are neither.
for name in ["List", "Foo", ""] {
assert!(!is_value_type(name));
assert!(!is_primitive_name(name));
}
}
}