9339279181
Terminal iteration of the kernel-extension-mechanics milestone. Ships
the four language-level mechanisms named in the spec's § Goal:
Module.kernel + TypeDef.param-in schema, their Form-A surface,
flag-driven kernel-tier auto-injection, and generic param-in checker
enforcement with a new diagnostic.
Schema (Tasks 1+2). Module gains a `kernel: bool` field
(skip_serializing_if = is_false), TypeDef gains a
`param_in: BTreeMap<String, BTreeSet<String>>` field
(skip-if-empty, kebab-renamed to "param-in"). Both fields are
strictly additive — every pre-existing fixture's canonical-JSON hash
is bit-stable except `prelude.ail`, which intentionally gains
`(kernel)`. The struct-literal sweep covered ~104 Module sites and
~35 TypeDef sites across the workspace; the additive serde-default
covers JSON deserialise paths, only Rust struct literals broke.
Form-A surface (Tasks 3+4). `(kernel)` is a bare module-header
attribute; `(param-in (a Int Float) (b Str))` is one outer
TypeDef-body clause carrying one or more inner var-lists (OQ1
decision — mirrors `(ctors …)`, one parser arm, deterministic
BTreeMap iteration). Both round-trip Form-A → JSON → Form-A
bit-identical.
Workspace-load migration (Task 5). The hardcoded `&["prelude"]`
literal at loader.rs:108 became a `modules.values().filter(|m|
m.kernel)` derivation; `parse_prelude()` injection stays because
the prelude has no on-disk manifest in user workspaces. Prelude
now carries `(kernel)` in its source, so the new filter picks it
up automatically. Code-path migration only — observable behaviour
is identical (prelude_free_fns.rs stays green). prelude hash
re-pinned (af372f28c726f29f) with Honesty-Rule provenance comment.
WorkspaceLoadError::ReservedModuleName diagnostic prose
repurposed: any built-in kernel module name is reserved
(currently prelude + kernel_stub), not specifically prelude. CLI
mapping at main.rs updated in lockstep.
Stub crate (Task 6). New `crates/ailang-kernel-stub/` is a
zero-dependency leaf crate carrying only `pub const STUB_AIL:
&str` with the Form-A source of the kernel_stub module (one
parametric TypeDef with param-in, one ctor). The parse hop —
`parse_kernel_stub()` — lives in ailang-surface next to
parse_prelude, keeping the crate-dependency graph acyclic
(`ailang-surface → ailang-kernel-stub → ailang-core`, no
back-edge). The stub is injected unconditionally in all builds as
the ratifying fixture for the kernel-extension mechanism; future
base extensions may add more or retire the stub. Drift-pinned by
`kernel_stub_module_round_trips`.
Checker (Task 7). New `CheckError::ParamNotInRestrictedSet`
variant + code() + ctx() arms + enforcement in
`check_type_well_formed`'s Type::Con arm — generic, data-driven
from the TypeDef, mentions no specific extension type. Two
in-source tests pin both the rejection (`Str` outside `{Int,
Float}`) and the acceptance (`Int` inside) paths.
Workspace-load integration tests (Task 8). New
`workspace_kernel.rs` integration-test crate with three tests:
auto-import without explicit `(import …)` declaration, two
kernel-tier modules co-load, explicit-import-overrides-auto-
import precedence preserved. Loader is import-tree-only so the
auto-import tests use a bridge module that brings the kernel
module into the workspace via the import graph — docstring
captures the reachability nuance for future readers.
Doc-state transitions (Task 9). INDEX.md kernel-extensions row
annotation transitions from "design accepted 2026-05-28; impl in
progress" to "mechanisms milestone closed 2026-05-28; raw-buf and
series milestones pending". Whitepaper STATUS + auto-import +
param-in sections transitioned forward→present for shipped
mechanisms; forward-tense survives only in sections describing
the still-pending raw-buf/series milestones (per Honesty-Rule).
data-model contract gains anchor blocks for both new schema
fields.
Side-effect: every binary's IR snapshot now contains ~52 lines
for `drop_kernel_stub_StubT` because the stub is auto-injected
into every workspace load. Snapshots refreshed; e2e expects 4
modules per workspace (prelude + kernel_stub + entry + zero or
more user modules) instead of the previous 3.
Plan defects scrubbed in the implementation (folded back into
the planner template via the planner's self-review checklist
next time): Task 4 sample test src used fictional
`(ctors (MkT a))` list form (project grammar is per-`(ctor MkT
a)`); Task 6 original wiring would have created a cycle
ailang-surface → ailang-kernel-stub → ailang-surface (inverted —
stub crate is zero-dep, parse hop lives in surface); Task 7 in-
source tests referenced a fictional `check_type_in_module`
helper (used the existing Workspace + check_workspace
convention); Task 8 first integration test expected loader to
auto-load kernel modules from disk (loader is import-tree-only;
tests use a bridge module).
Concern-5 fix folded in pre-commit: workspace.rs ReservedModuleName
doc-prose initially said "in test/dev builds" for kernel_stub —
but stub is unconditionally injected in all builds. Doc copy
tightened to present-state per Honesty-Rule.
Stats: 0 spec-review-loops, 0 quality-review-loops, 2 sweep-script
retries on Task 2 (brace-depth bug on nested vec![Ctor{…}],
recovered via per-file checkout + rewritten anchor-on-existing-
field sweep), 1 e2e-snapshot refresh on Task 6.
721 lines
23 KiB
Rust
721 lines
23 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
|
|
);
|
|
}
|