Files
AILang/crates/ailang-check/tests/workspace.rs
T
Brummel be259f9d46 feat(check): let-alias borrow propagation via alias-redirect (#57, 0064 iter 3)
Third iteration of spec 0064 (the #55-cutover hardening, #57). Closes
false-positive class 2: Term::Let walked its value in Position::Consume,
so (let a t (match a …)) over a borrow-mode t consumed t at the binding
and tripped consume-while-borrowed -- a let-alias of a borrowed value
read in a borrow position is not a consume.

Fix: the Checker gains a scoped aliases: HashMap<String,String>. A
(let a t body) whose value is a bare Var resolving to a tracked binder
records a -> root(t) for the body scope and skips the consume walk of
the value. A new resolve_alias helper maps a name to its root,
preferring a real binder at each chain step -- so an inner binder named
`a` (nested let, pattern binder, lam param) automatically shadows the
alias with NO change to with_binder/walk_arm/Lam (the shadowing is
resolved centrally in the resolver, not at three scattered introduction
sites). Every binder-state lookup keyed by name resolves through the
map first: use_var, the App borrow bump + decrement (pushing the
resolved root to `lent` so the decrement matches), callee_arg_modes,
and the reuse-as source.

Design calls (orchestrator):
- Alias REDIRECT, not state clone (spec scope decision 3): consuming
  the alias marks the single root consumed, so a real double-consume
  through an alias is still caught. A clone would track two independent
  binders and miss it (unsound). Pinned by the new
  let_alias_of_owned_double_consume_still_errors unit test:
  (let a p0 (seq a p0)) over an own heap p0 still fires
  use-after-consume.
- resolve_alias prefers binders -> shadowing needs no edits to the
  binder-introduction sites. Lower-risk than clearing aliases at each.
- reuse-as source (linearity.rs:663) is resolved through aliases too:
  it is the 4th binder-state-reading site; the spec's Fix 3 named three
  illustratively. Without it, (reuse-as <alias> …) would mis-fire
  reuse-as-source-not-bare-var on what IS a Var. Faithful completion of
  Fix 3.

The diagnostic now reports the resolved root name (the actual consumed
resource), not the surface alias.

Closes the design/contracts/0008-memory-model.md let-alias carve-out
(was "has not shipped yet") and extends its Ratified-by trailer to name
linearity.rs, where the propagation now lives -- a contract change
riding with the feature that forces it.

RED-first: examples/c2_let_alias.ail added to
harden_ownership_false_positives_are_clean (RED: consume-while-borrowed
on count's t); GREEN after. Verified: c2 RED->GREEN; 120 linearity unit
tests green; the soundness test fires; harden false-positive + heap
double-consume guards green; cargo test --workspace green;
bench/check.py 34/34, compile_check.py 24/24 stable. Diagnostic-only;
no schema/type/codegen change.

Class 4 (partition_eithers body rewrite) remains for the final
iteration of spec 0064.

refs #57
2026-06-01 18:12:25 +02:00

828 lines
28 KiB
Rust

//! Integration tests for `check_workspace` (Iter 5b).
//!
//! These tests drive the canonical `examples/ws_*.ail.json` files;
//! the loader and the checker together form the pipeline that `ail check`
//! runs in JSON mode against a workspace.
use ailang_check::{check_workspace, Severity};
use ailang_surface::load_workspace;
use std::path::Path;
/// assert that the linearity check's `suggested_rewrites`
/// payload is non-empty AND each `replacement` parses as a form-A AILang
/// term. This is the contract the check promises to consumers of
/// `ail check --json`: a machine can take the replacement string and
/// substitute it back into the source without re-tokenising the term.
fn assert_suggested_rewrites_well_formed(d: &ailang_check::Diagnostic) {
assert!(
!d.suggested_rewrites.is_empty(),
"diagnostic {} (def={:?}) has no suggested_rewrites",
d.code,
d.def
);
for r in &d.suggested_rewrites {
ailang_surface::parse_term(&r.replacement).unwrap_or_else(|e| {
panic!(
"suggested rewrite for `{}` does not parse as form-A AILang: {:?}\nreplacement: {}",
d.code, e, r.replacement
)
});
}
}
fn examples_dir() -> std::path::PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
Path::new(manifest).parent().unwrap().parent().unwrap().join("examples")
}
#[test]
fn happy_path_resolves_qualified_import() {
// ws_main imports ws_lib and calls `ws_lib.add` — fully typed.
// Expected: no diagnostics.
let entry = examples_dir().join("ws_main.ail");
let ws = load_workspace(&entry).expect("load ws_main");
let diags = check_workspace(&ws);
assert!(
diags.is_empty(),
"expected no diagnostics; got: {}",
serde_json::to_string_pretty(&diags).unwrap()
);
}
#[test]
fn unknown_import_is_reported() {
// ws_broken references `ws_lib.bogus` — module is there, def isn't.
let entry = examples_dir().join("ws_broken.ail");
let ws = load_workspace(&entry).expect("load ws_broken");
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 1, "got: {:?}", diags);
assert!(matches!(diags[0].severity, Severity::Error));
assert_eq!(diags[0].code, "unknown-import");
// Context must point structurally to module + def name.
assert_eq!(
diags[0].ctx.get("module").and_then(|v| v.as_str()),
Some("ws_lib")
);
assert_eq!(
diags[0].ctx.get("name").and_then(|v| v.as_str()),
Some("bogus")
);
}
#[test]
fn unknown_module_prefix_is_reported() {
// ws_unknown_module has no imports but references `nope.x`. Under
// prep.1's type-scoped resolution, `nope` is neither a known
// TypeDef nor an imported module, so the diagnostic narrowed from
// the legacy `unknown-module` to `type-scoped-receiver-not-a-type`
// — a more precise wording for the same failure mode.
let entry = examples_dir().join("ws_unknown_module.ail");
let ws = load_workspace(&entry).expect("load ws_unknown_module");
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 1, "got: {:?}", diags);
assert!(matches!(diags[0].severity, Severity::Error));
assert_eq!(diags[0].code, "type-scoped-receiver-not-a-type");
assert_eq!(
diags[0].ctx.get("name").and_then(|v| v.as_str()),
Some("nope")
);
}
#[test]
fn invalid_def_name_with_dot_is_reported() {
// Synthetic: a module with a def whose name contains a dot.
// We construct this as a Module directly and feed it into a
// trivial workspace, because the canonical convention should not
// let this through to disk in the first place.
use ailang_core::ast::*;
use std::collections::BTreeMap;
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "t".into(),
kernel: false,
imports: vec![],
defs: vec![Def::Const(ConstDef {
name: "weird.name".into(),
ty: Type::int(),
value: Term::Lit {
lit: Literal::Int { value: 0 },
},
doc: None,
})],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
};
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 1, "got: {:?}", diags);
assert_eq!(diags[0].code, "invalid-def-name");
assert_eq!(
diags[0].ctx.get("reason").and_then(|v| v.as_str()),
Some("contains-dot")
);
}
/// body-check is multi-diagnose. A module with two independent
/// errors in different defs must produce two diagnostics — the first
/// error must not short-circuit the second.
#[test]
fn body_errors_accumulate_across_defs() {
use ailang_core::ast::*;
use std::collections::BTreeMap;
// Two fns, each with a different body error:
// bad_a: arity mismatch — calls `+` with three arguments.
// bad_b: references a name that does not exist anywhere.
let bad_a = Def::Fn(FnDef {
name: "bad_a".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Lit { lit: Literal::Int { value: 1 } },
Term::Lit { lit: Literal::Int { value: 2 } },
Term::Lit { lit: Literal::Int { value: 3 } },
],
tail: false,
},
suppress: vec![],
doc: None,
export: None,
});
let bad_b = Def::Fn(FnDef {
name: "bad_b".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Var {
name: "this_does_not_exist".into(),
},
suppress: vec![],
doc: None,
export: None,
});
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "two_errors".into(),
kernel: false,
imports: vec![],
defs: vec![bad_a, bad_b],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
};
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 2, "want both errors, got: {:#?}", diags);
// Each diagnostic carries the offending def name as `def`.
let defs: Vec<&str> = diags
.iter()
.filter_map(|d| d.def.as_deref())
.collect();
assert!(defs.contains(&"bad_a"), "missing bad_a; got {defs:?}");
assert!(defs.contains(&"bad_b"), "missing bad_b; got {defs:?}");
}
/// a fn with all-explicit modes that consumes its
/// `(own (con List))` parameter twice should trigger
/// `use-after-consume` on the second occurrence and ship a
/// non-empty, well-formed `suggested_rewrites`.
///
/// Body:
/// `(seq (app sum_list xs) (app sum_list xs))`
///
/// Both `xs` are in Own arg position; the second use is after consume.
#[test]
fn use_after_consume_on_own_param_is_reported() {
use ailang_core::ast::*;
use std::collections::BTreeMap;
// Helper: a fn that consumes a (con List) and returns Int.
let sum_list = Def::Fn(FnDef {
name: "sum_list".into(),
ty: Type::Fn {
params: vec![Type::Con { name: "List".into(), args: vec![] }],
param_modes: vec![ParamMode::Own],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec!["ys".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![],
doc: None,
export: None,
});
// The offending fn. Body is `(+ (sum_list xs) (sum_list xs))` — both
// arg slots of `+` are Implicit/Consume, so `xs` is consumed twice
// without intervening clone. The second occurrence triggers
// `use-after-consume`. (Using `+` rather than `Seq` keeps the
// function's return-type Int, which matches its declared signature.)
let bad = Def::Fn(FnDef {
name: "bad".into(),
ty: Type::Fn {
params: vec![Type::Con { name: "List".into(), args: vec![] }],
param_modes: vec![ParamMode::Own],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec!["xs".into()],
body: Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::App {
callee: Box::new(Term::Var { name: "sum_list".into() }),
args: vec![Term::Var { name: "xs".into() }],
tail: false,
},
Term::App {
callee: Box::new(Term::Var { name: "sum_list".into() }),
args: vec![Term::Var { name: "xs".into() }],
tail: false,
},
],
tail: false,
},
suppress: vec![],
doc: None,
export: None,
});
// List ADT (referenced by both fn types via `Type::Con`); a real
// module needs the type to be in-scope for `check_type_well_formed`.
let list_adt = Def::Type(TypeDef {
name: "List".into(),
vars: vec![],
ctors: vec![Ctor {
name: "Nil".into(),
fields: vec![],
}],
doc: None,
drop_iterative: false,
param_in: BTreeMap::new(),
});
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "lin_uac".into(),
kernel: false,
imports: vec![],
defs: vec![list_adt, sum_list, bad],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
};
let diags = check_workspace(&ws);
let lin: Vec<&ailang_check::Diagnostic> = diags
.iter()
.filter(|d| d.code == "use-after-consume")
.collect();
assert_eq!(
lin.len(),
1,
"want exactly one use-after-consume; got: {:#?}",
diags
);
let d = lin[0];
assert!(matches!(d.severity, Severity::Error));
assert_eq!(d.def.as_deref(), Some("bad"));
assert_eq!(
d.ctx.get("binder").and_then(|v| v.as_str()),
Some("xs"),
"ctx should name the offending binder; got {:?}",
d.ctx
);
assert_suggested_rewrites_well_formed(d);
}
/// a fn whose body passes `xs` to a `Borrow` arg and then,
/// in a SIBLING arg slot of the same call, consumes it via an `Own`
/// arg, should trigger `consume-while-borrowed`.
///
/// Body:
/// `(app dual_fn xs xs)`
/// where `dual_fn` has param_modes = [Borrow, Own].
#[test]
fn consume_while_borrowed_in_sibling_arg_is_reported() {
use ailang_core::ast::*;
use std::collections::BTreeMap;
let list_adt = Def::Type(TypeDef {
name: "List".into(),
vars: vec![],
ctors: vec![Ctor {
name: "Nil".into(),
fields: vec![],
}],
doc: None,
drop_iterative: false,
param_in: BTreeMap::new(),
});
// dual_fn: (borrow List) → (own List) → Int.
let dual_fn = Def::Fn(FnDef {
name: "dual_fn".into(),
ty: Type::Fn {
params: vec![
Type::Con { name: "List".into(), args: vec![] },
Type::Con { name: "List".into(), args: vec![] },
],
param_modes: vec![ParamMode::Borrow, ParamMode::Own],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec!["a".into(), "b".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![],
doc: None,
export: None,
});
let bad = Def::Fn(FnDef {
name: "bad".into(),
ty: Type::Fn {
params: vec![Type::Con { name: "List".into(), args: vec![] }],
param_modes: vec![ParamMode::Own],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec!["xs".into()],
body: Term::App {
callee: Box::new(Term::Var { name: "dual_fn".into() }),
args: vec![
Term::Var { name: "xs".into() },
Term::Var { name: "xs".into() },
],
tail: false,
},
suppress: vec![],
doc: None,
export: None,
});
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "lin_cwb".into(),
kernel: false,
imports: vec![],
defs: vec![list_adt, dual_fn, bad],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
};
let diags = check_workspace(&ws);
let lin: Vec<&ailang_check::Diagnostic> = diags
.iter()
.filter(|d| d.code == "consume-while-borrowed")
.collect();
assert_eq!(
lin.len(),
1,
"want exactly one consume-while-borrowed; got: {:#?}",
diags
);
let d = lin[0];
assert!(matches!(d.severity, Severity::Error));
assert_eq!(d.def.as_deref(), Some("bad"));
assert_eq!(
d.ctx.get("binder").and_then(|v| v.as_str()),
Some("xs"),
"ctx should name the offending binder; got {:?}",
d.ctx
);
assert_suggested_rewrites_well_formed(d);
}
/// happy-path `(reuse-as xs (term-ctor List Cons ...))`
/// inside an all-explicit-mode `map_inc`-style fn must produce NO
/// diagnostics — neither `reuse-as-non-allocating-body` (body is a
/// Ctor) nor `reuse-as-source-not-bare-var` (source is `xs`) nor
/// `use-after-consume` (xs is matched then consumed once via
/// reuse-as in the Cons arm; the merge across arms is consistent).
///
/// Body:
/// `(match xs
/// (case Nil (term-ctor List Nil))
/// (case (Cons h t)
/// (reuse-as xs (term-ctor List Cons (+ h 1) (app map_inc t)))))`
#[test]
fn reuse_as_happy_path_in_map_inc_is_linearity_clean() {
use ailang_core::ast::*;
use std::collections::BTreeMap;
let list_adt = Def::Type(TypeDef {
name: "List".into(),
vars: vec![],
ctors: vec![
Ctor { name: "Nil".into(), fields: vec![] },
Ctor {
name: "Cons".into(),
fields: vec![
Type::Con { name: "Int".into(), args: vec![] },
Type::Con { name: "List".into(), args: vec![] },
],
},
],
doc: None,
drop_iterative: false,
param_in: BTreeMap::new(),
});
let map_inc_ty = Type::Fn {
params: vec![Type::Con { name: "List".into(), args: vec![] }],
param_modes: vec![ParamMode::Own],
ret: Box::new(Type::Con { name: "List".into(), args: vec![] }),
ret_mode: ParamMode::Own,
effects: vec![],
};
// Cons arm body:
// (reuse-as xs
// (term-ctor List Cons (app + h 1) (app map_inc t)))
let cons_arm_body = Term::ReuseAs {
source: Box::new(Term::Var { name: "xs".into() }),
body: Box::new(Term::Ctor {
type_name: "List".into(),
ctor: "Cons".into(),
args: vec![
Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "h".into() },
Term::Lit { lit: Literal::Int { value: 1 } },
],
tail: false,
},
Term::App {
callee: Box::new(Term::Var { name: "map_inc".into() }),
args: vec![Term::Var { name: "t".into() }],
tail: false,
},
],
}),
};
let map_inc_body = Term::Match {
scrutinee: Box::new(Term::Var { name: "xs".into() }),
arms: vec![
Arm {
pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
body: Term::Ctor {
type_name: "List".into(),
ctor: "Nil".into(),
args: vec![],
},
},
Arm {
pat: Pattern::Ctor {
ctor: "Cons".into(),
fields: vec![
Pattern::Var { name: "h".into() },
Pattern::Var { name: "t".into() },
],
},
body: cons_arm_body,
},
],
};
let map_inc = Def::Fn(FnDef {
name: "map_inc".into(),
ty: map_inc_ty,
params: vec!["xs".into()],
body: map_inc_body,
suppress: vec![],
doc: None,
export: None,
});
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "reuse_as_happy".into(),
kernel: false,
imports: vec![],
defs: vec![list_adt, map_inc],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
};
let diags = check_workspace(&ws);
assert!(
diags.is_empty(),
"happy-path reuse-as must check clean; got: {:#?}",
diags
);
}
/// a deliberately mismatched reuse-as — `(reuse-as xs
/// (Nil))` inside the Cons arm of a match on `xs` — must surface as
/// a `reuse-as-shape-mismatch` diagnostic. The source's path-ctor
/// (Cons, 2 fields) and the body ctor (Nil, 0 fields) have different
/// shapes; the in-place rewrite is unsafe; the build must fail.
///
/// The diagnostic must:
/// - carry code `reuse-as-shape-mismatch`
/// - identify the offending def (`f`)
/// - record `ctx.reason` as a stable kebab-case sub-code (here:
/// `field-count-mismatch`) so JSON consumers can branch on the
/// specific failure mode without prose-parsing
/// - ship a non-empty `suggested_rewrites` whose replacement parses
/// back as form-A AILang (the rewrite drops the wrapper and keeps
/// the body alone)
#[test]
fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() {
use ailang_core::ast::*;
use std::collections::BTreeMap;
let list_adt = Def::Type(TypeDef {
name: "List".into(),
vars: vec![],
ctors: vec![
Ctor { name: "Nil".into(), fields: vec![] },
Ctor {
name: "Cons".into(),
fields: vec![
Type::Con { name: "Int".into(), args: vec![] },
Type::Con { name: "List".into(), args: vec![] },
],
},
],
doc: None,
drop_iterative: false,
param_in: BTreeMap::new(),
});
let f_ty = Type::Fn {
params: vec![Type::Con { name: "List".into(), args: vec![] }],
param_modes: vec![ParamMode::Own],
ret: Box::new(Type::Con { name: "List".into(), args: vec![] }),
ret_mode: ParamMode::Own,
effects: vec![],
};
// Body:
// (match xs
// (Nil → Nil)
// (Cons h t → (reuse-as xs Nil))) ; BAD: 2-field Cons → 0-field Nil.
let cons_arm_body = Term::ReuseAs {
source: Box::new(Term::Var { name: "xs".into() }),
body: Box::new(Term::Ctor {
type_name: "List".into(),
ctor: "Nil".into(),
args: vec![],
}),
};
let body = Term::Match {
scrutinee: Box::new(Term::Var { name: "xs".into() }),
arms: vec![
Arm {
pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
body: Term::Ctor {
type_name: "List".into(),
ctor: "Nil".into(),
args: vec![],
},
},
Arm {
pat: Pattern::Ctor {
ctor: "Cons".into(),
fields: vec![
Pattern::Var { name: "h".into() },
Pattern::Var { name: "t".into() },
],
},
body: cons_arm_body,
},
],
};
let f = Def::Fn(FnDef {
name: "f".into(),
ty: f_ty,
params: vec!["xs".into()],
body,
suppress: vec![],
doc: None,
export: None,
});
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "reuse_as_mismatch".into(),
kernel: false,
imports: vec![],
defs: vec![list_adt, f],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
};
let diags = check_workspace(&ws);
let shape_diags: Vec<&ailang_check::Diagnostic> = diags
.iter()
.filter(|d| d.code == "reuse-as-shape-mismatch")
.collect();
assert_eq!(
shape_diags.len(),
1,
"expected exactly one reuse-as-shape-mismatch diagnostic; got: {diags:#?}"
);
let d = shape_diags[0];
assert!(matches!(d.severity, Severity::Error));
assert_eq!(d.def.as_deref(), Some("f"));
assert_eq!(
d.ctx.get("reason").and_then(|v| v.as_str()),
Some("field-count-mismatch"),
"ctx.reason must be the stable sub-code; ctx was: {}",
d.ctx
);
assert_suggested_rewrites_well_formed(d);
}
/// positive control. The ON-DISK `borrow_own_demo` fixture
/// (the only currently-shipping all-explicit-mode program) must remain
/// linearity-clean. If this regresses, the check has become incorrect:
/// `borrow_own_demo`'s `list_length` (borrow) and `sum_list` (own)
/// are exactly the canonical accept shape.
#[test]
fn borrow_own_demo_is_linearity_clean() {
let entry = examples_dir().join("borrow_own_demo.ail");
let ws = load_workspace(&entry).expect("load borrow_own_demo");
let diags = check_workspace(&ws);
let lin: Vec<&ailang_check::Diagnostic> = diags
.iter()
.filter(|d| {
d.code == "use-after-consume" || d.code == "consume-while-borrowed"
})
.collect();
assert!(
lin.is_empty(),
"borrow_own_demo must stay linearity-clean; got: {:#?}",
lin
);
// Belt-and-braces: the *whole* check should be clean too — modes
// are still metadata-only at the typechecker level (Iter 18a).
assert!(
diags.is_empty(),
"borrow_own_demo must check clean; got: {:#?}",
diags
);
}
/// #56 Fix 1+2: under universal activation the linearity analysis must
/// not false-fire on value-type params or on applied function params.
/// These three fixtures use explicit-mode signatures (so the analysis
/// is active today) and were RED before the hardening (docs/specs/0063).
#[test]
fn harden_ownership_false_positives_are_clean() {
for name in ["fp_value", "fp_hof", "fp_map", "c3_value_let", "c1_local_hof", "c2_let_alias"] {
let entry = examples_dir().join(format!("{name}.ail"));
let ws = load_workspace(&entry).unwrap_or_else(|e| panic!("load {name}: {e:?}"));
let diags = check_workspace(&ws);
let lin: Vec<&ailang_check::Diagnostic> = diags
.iter()
.filter(|d| d.code == "use-after-consume" || d.code == "consume-while-borrowed")
.collect();
assert!(lin.is_empty(), "{name} must be linearity-clean; got: {lin:#?}");
}
}
/// #56 type-gating: the exemption is value-type-only. A heap param
/// consumed twice (`real_consume.dup`, `(term-ctor Pair Pair b b)`) MUST
/// still fire use-after-consume — proving the fix did not blanket-silence
/// genuine multi-consume.
#[test]
fn harden_ownership_heap_double_consume_still_errors() {
let entry = examples_dir().join("real_consume.ail");
let ws = load_workspace(&entry).expect("load real_consume");
let diags = check_workspace(&ws);
assert!(
diags.iter().any(|d| d.code == "use-after-consume"),
"real_consume.dup must still fire use-after-consume; got: {diags:#?}"
);
}
/// RED for fieldtest finding B1 (docs/specs/0058): reading a
/// `borrow (RawBuf a)` *parameter* through a borrow-receiver op
/// (`RawBuf.get` / `RawBuf.size`) must check clean. Both ops are
/// declared `(borrow (RawBuf a))` in the receiver slot by the kernel
/// `raw_buf` module, so reading the receiver through a borrow is the
/// advertised use — the receiver is read, not consumed.
///
/// The linearity walk (`crates/ailang-check/src/linearity.rs`)
/// registers visible-module fns into its `globals` map under their
/// bare def name (`get` / `size`), but the call site spells the
/// type-scoped name `RawBuf.get` / `RawBuf.size`. `callee_arg_modes`
/// looks up `"RawBuf.get"`, misses, returns an empty mode vec, and the
/// receiver arg defaults to `Position::Consume` — consuming a binder
/// whose `borrow_count == 1` and firing a spurious
/// `consume-while-borrowed`.
#[test]
fn rawbuf_borrow_receiver_read_is_linearity_clean() {
let entry = examples_dir().join("raw_buf_borrow_read.ail");
let ws = load_workspace(&entry).expect("load raw_buf_borrow_read");
let diags = check_workspace(&ws);
let lin: Vec<&ailang_check::Diagnostic> = diags
.iter()
.filter(|d| {
d.code == "use-after-consume" || d.code == "consume-while-borrowed"
})
.collect();
assert!(
lin.is_empty(),
"reading a borrow-mode RawBuf param via RawBuf.get / RawBuf.size \
must not fire a linearity error; got: {:#?}",
lin
);
assert!(
diags.is_empty(),
"raw_buf_borrow_read must check clean; got: {:#?}",
diags
);
}
/// RED for #50: a kernel-tier `RawBuf` stored in a *user ADT field*
/// typed with the bare name `(con RawBuf (con Int))` must resolve to
/// `raw_buf.RawBuf<Int>` — exactly as the auto-import already does in
/// op/value positions (`(new RawBuf …)`, `RawBuf.get`). The property
/// this protects: the kernel-tier auto-import covers the
/// type-constructor position inside a user `data` field, not only
/// op/value positions, so an author can store a `RawBuf` in their own
/// ADT field without spelling the qualified `raw_buf.RawBuf`.
///
/// Cause: `qualify_workspace_module`
/// (`crates/ailang-check/src/lib.rs`) skips `Def::Type`, so a bare
/// cross-module type-con in a consumer module's ADT field is never
/// qualified; the `Term::Ctor` check arm only qualifies field types
/// when the *owning* type is itself cross-module (`owning_module =
/// Some`), not when a *local* type carries a cross-module field. The
/// field stays `RawBuf<Int>` and fails to unify with the constructor
/// arg's `raw_buf.RawBuf<Int>`.
///
/// Control twin `raw_buf_adt_field_qualified.ail` (same module, field
/// typed `(con raw_buf.RawBuf (con Int))`) checks clean today, which
/// isolates the cause to bare-name resolution in the type-con
/// position rather than any loader / syntax issue.
#[test]
fn rawbuf_in_user_adt_field_resolves_bare_name() {
let entry = examples_dir().join("raw_buf_adt_field_bare.ail");
let ws = load_workspace(&entry).expect("load raw_buf_adt_field_bare");
let diags = check_workspace(&ws);
assert!(
diags.is_empty(),
"a bare `RawBuf` ADT field type must resolve to raw_buf.RawBuf \
just like op/value positions do; got: {:#?}",
diags
);
}