All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
30 KiB
mut.2 — typecheck — Implementation Plan
Parent spec:
docs/specs/0029-mut-local.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Replace the iter mut.1 dispatch stubs for Term::Mut and
Term::Assign in synth with real typecheck logic. Add three new
CheckError variants (MutAssignOutOfScope, AssignTypeMismatch,
UnsupportedMutVarType) and their code() + ctx() arms. Introduce
a mut_scope_stack: &mut Vec<IndexMap<String, Type>> parameter on
synth, thread it through all call sites (recursive inside lib.rs
plus the two re-synth sites in mono.rs), prepend it to the
Term::Var resolution chain. Ship five fixtures under examples/
plus one Rust driver under crates/ailang-check/tests/ to gate the
behaviour. Codegen stays untouched — examples/mut.ail still does
not run, but it now typechecks cleanly.
Architecture: The natural locality for mut-scope state is a
synth-parameter slot (matching the existing locals / effects /
subst / counter / residuals / free_fn_calls / warnings
threading pattern), NOT an Env field. The stack is per-walk
(reset on every fn body) and lexically scoped (push on
Term::Mut-entry, pop on Term::Mut-exit); both properties argue
against an Env field. Each frame is an IndexMap<String, Type> so
declaration order is preserved (relevant for the available list in
MutAssignOutOfScope). The Term::Var resolution order becomes:
mut-scope (innermost-first) → existing chain (locals → params →
top-level defs → builtins → class-methods). Innermost-wins on
shadowing falls out for free.
Tech Stack: ailang-check only. No ailang-core, ailang-surface,
ailang-codegen, ailang-prose, or ail changes.
Files this plan creates or modifies:
- Create:
examples/test_mut_assign_out_of_scope.ail.json—assignto a name not declared as a mut-var of any enclosingTerm::Mut. - Create:
examples/test_mut_assign_type_mismatch.ail.json—(assign x 1.0)against(var x (con Int) 0). - Create:
examples/test_mut_var_unsupported_type.ail.json—(var s (con Str) "...")— the deferred-type case. - Create:
examples/test_mut_assign_outside_mut.ail.json—Term::Assigndirectly in a fn body with noTerm::Mutancestor. - Create:
examples/test_mut_nested_shadow_legal.ail.json— positive: outervar x : Intshadowed by innervar x : Floatin nested mut. - Create:
crates/ailang-check/tests/mut_typecheck_pin.rs— driver test loading each fixture and asserting expected diagnostic code (or empty for the positive case). - Modify:
crates/ailang-check/src/lib.rs:384-746— add threeCheckErrorvariants +code()arms +ctx()arms. - Modify:
crates/ailang-check/src/lib.rs:2647-2662—synthsignature: addmut_scope_stack: &mut Vec<indexmap::IndexMap<String, Type>>. - Modify:
crates/ailang-check/src/lib.rs:1885, 2634— top-of-walk synth entry points (check_fnandcheck_const— find exact names by looking for synth-callers in lib.rs that do NOT recurse from synth itself). - Modify:
crates/ailang-check/src/lib.rs:2671-2941— prepend mut-scope resolution toTerm::Vararm. - Modify:
crates/ailang-check/src/lib.rs:3488-3497— replaceTerm::MutandTerm::AssignInternalstubs with real logic. - Modify:
crates/ailang-check/src/lib.rs:~19 internal synth() calls— thread new param through every recursive site. - Modify:
crates/ailang-check/src/mono.rs:712, 1337— thread new param through bothcrate::synth(...)call sites.
Task 1 — CheckError variants + code() + ctx()
Files:
-
Modify:
crates/ailang-check/src/lib.rs:384-746 -
Step 1: Write the failing pin test
Add to crates/ailang-check/src/lib.rs #[cfg(test)] mod tests block:
#[test]
fn mut_typecheck_diagnostics_have_kebab_codes() {
use indexmap::IndexMap;
let e1 = CheckError::MutAssignOutOfScope {
name: "foo".into(),
available: vec!["bar".into(), "baz".into()],
};
let e2 = CheckError::AssignTypeMismatch {
name: "count".into(),
declared: "Int".into(),
got: "Float".into(),
};
let e3 = CheckError::UnsupportedMutVarType {
name: "s".into(),
ty: "Str".into(),
};
assert_eq!(e1.code(), "mut-assign-out-of-scope");
assert_eq!(e2.code(), "assign-type-mismatch");
assert_eq!(e3.code(), "mut-var-unsupported-type");
let _ = (e1.ctx(), e2.ctx(), e3.ctx()); // smoke-test ctx() doesn't panic
}
- Step 2: Run to verify red
Run: cargo test --workspace -p ailang-check mut_typecheck_diagnostics_have_kebab_codes
Expected: FAIL — CheckError has no MutAssignOutOfScope / AssignTypeMismatch / UnsupportedMutVarType variants.
- Step 3: Add the three variants
In the CheckError enum body (around crates/ailang-check/src/lib.rs:384-639),
immediately before the Internal variant at line 638, insert:
/// Iter mut.2: `Term::Assign { name, .. }` reached typecheck but
/// `name` is not a mut-var of any enclosing `Term::Mut` on the
/// lexical scope stack.
///
/// `available` lists every mut-var name visible at the assign
/// site, flattened across all enclosing mut-frames (innermost
/// first), so the diagnostic can surface "you might have meant
/// one of: X / Y / Z".
MutAssignOutOfScope {
name: String,
available: Vec<String>,
},
/// Iter mut.2: `Term::Assign { name, value }` reached typecheck
/// inside a valid mut-scope, but `value`'s synth type differs
/// from the var's declared type.
AssignTypeMismatch {
name: String,
declared: String,
got: String,
},
/// Iter mut.2: `Term::Mut { vars, .. }` declared a `MutVar`
/// whose declared `ty` is not one of the four supported scalar
/// primitives (Int, Float, Bool, Unit). The temporary
/// restriction is named explicitly in spec
/// `docs/specs/0029-mut-local.md` §"Out of scope".
UnsupportedMutVarType {
name: String,
ty: String,
},
- Step 4: Add
code()arms
In the impl CheckError { pub fn code(&self) -> &'static str { match self { ... } } }
block (around line 648), add three new arms before the Internal arm:
CheckError::MutAssignOutOfScope { .. } => "mut-assign-out-of-scope",
CheckError::AssignTypeMismatch { .. } => "assign-type-mismatch",
CheckError::UnsupportedMutVarType { .. } => "mut-var-unsupported-type",
- Step 5: Add
ctx()arms
In the impl CheckError { pub fn ctx(&self) -> serde_json::Value { match self { ... } } }
block (around line 688), add three new arms before the Internal arm.
Each emits a structured JSON payload consistent with sibling
diagnostics:
CheckError::MutAssignOutOfScope { name, available } => json!({
"name": name,
"available": available,
}),
CheckError::AssignTypeMismatch { name, declared, got } => json!({
"name": name,
"expected": declared,
"actual": got,
}),
CheckError::UnsupportedMutVarType { name, ty } => json!({
"name": name,
"type": ty,
}),
- Step 6: Add
Display-style message for each variant
The existing CheckError derives thiserror::Error and the variants
carry #[error("...")] attributes — verify by reading 1-2 existing
variants for the exact attribute shape, then add:
#[error("[mut-assign-out-of-scope] cannot assign to `{name}` here — not declared as a mut-var in the enclosing (mut ...) block. Available mut-vars in scope: [{}]", available.join(", "))]
MutAssignOutOfScope { name: String, available: Vec<String> },
#[error("[assign-type-mismatch] cannot assign `{got}` to mut-var `{name} : {declared}` — the assigned value's type must match the var's declared type")]
AssignTypeMismatch { name: String, declared: String, got: String },
#[error("[mut-var-unsupported-type] mut-var `{name} : {ty}` is not supported in this milestone — only stack-resident primitive types (Int, Float, Bool, Unit) may be declared as mut-vars. Heap-RC-managed types (Str, ADTs, fn-values) are deferred to a follow-on milestone.")]
UnsupportedMutVarType { name: String, ty: String },
The bracketed-[code]: prefix is consistent with the cli-diag-human
iter convention (2026-05-14).
- Step 7: Run to verify green
Run: cargo test --workspace -p ailang-check mut_typecheck_diagnostics_have_kebab_codes
Expected: PASS.
Run: cargo build --workspace 2>&1 | tail -10
Expected: green (no other consumers of CheckError are broken because
adding variants is additive and we have not introduced consumers yet).
Task 2 — synth signature: thread mut_scope_stack
Files:
- Modify:
crates/ailang-check/src/lib.rs:2647-2662— signature. - Modify:
crates/ailang-check/src/lib.rs— ~19 internal recursive call sites. - Modify:
crates/ailang-check/src/lib.rs:1885, 2634— entry-point callers. - Modify:
crates/ailang-check/src/mono.rs:712, 1337— re-synth call sites.
The new parameter is mut_scope_stack: &mut Vec<indexmap::IndexMap<String, Type>>.
Position: immediately after locals (canonical mid-position; matches
the existing convention of locals-adjacent scope state).
- Step 1: Extend
synthsignature
In crates/ailang-check/src/lib.rs:2647, change:
pub(crate) fn synth(
term: &Term,
locals: &IndexMap<String, Type>,
// ... existing params ...
) -> Result<(Type, ...), CheckError> {
to:
pub(crate) fn synth(
term: &Term,
locals: &IndexMap<String, Type>,
mut_scope_stack: &mut Vec<indexmap::IndexMap<String, Type>>,
// ... existing params ...
) -> Result<(Type, ...), CheckError> {
(The exact return shape and the other params are whatever the function already has — preserve them verbatim and only add the new slot.)
- Step 2: Run to verify the parameter is exposed
Run: cargo build --workspace -p ailang-check 2>&1 | head -10
Expected: many error[E0061]: this function takes N arguments but ...
errors at every synth(...) call site. This is the structural red
signal.
- Step 3: Update entry-point callers in
lib.rs
Locate the two top-of-walk entry points around lines 1885 and 2634
(grep for synth( calls that are NOT inside pub(crate) fn synth
itself). Each entry-point caller passes a fresh empty stack:
let mut mut_scope_stack: Vec<indexmap::IndexMap<String, Type>> = Vec::new();
let result = synth(term, &locals, &mut mut_scope_stack, /* existing args */)?;
The stack is dropped at the end of the entry point; it is per-fn-body.
- Step 4: Update all recursive
synth(...)call sites inlib.rs
Per the recon, the recursive call sites are at lines 3016, 3052, 3061, 3063, 3075, 3077, 3078, 3096, 3191, 3200, 3218, 3293, 3295, 3304, 3392, 3419, 3435, 3477 (and possibly some lift / mono call sites; the build errors enumerate them). Each becomes:
synth(<term>, locals, mut_scope_stack, /* existing args */)
The implementer should grep for synth( in lib.rs after Step 3 and
update every remaining call site.
- Step 5: Update
mono.rsre-synth call sites
Two sites: crates/ailang-check/src/mono.rs:712 and :1337. Both
invoke crate::synth(...). Both need:
let mut mut_scope_stack: Vec<indexmap::IndexMap<String, Type>> = Vec::new();
crate::synth(<term>, locals, &mut mut_scope_stack, /* existing args */)
(Each call site gets its own fresh stack — these are re-synth sites that begin from a top-of-body position, so an empty stack is correct.)
- Step 6: Verify build green
Run: cargo build --workspace 2>&1 | tail -10
Expected: green.
Run: cargo test --workspace -p ailang-check 2>&1 | tail -5
Expected: all existing tests still pass (the new parameter is
unconsumed at the existing call sites; behaviour is identical).
Task 3 — Term::Var resolution: prepend mut-scope
Files:
-
Modify:
crates/ailang-check/src/lib.rs:2671-2941—Term::Vararm insynth. -
Step 1: Locate the
Term::Varresolution arm
In synth, find the Term::Var { name } => { ... } arm. The
existing resolution chain consults locals first, then params, then
the top-level def map, then builtins, then class-method dispatch.
- Step 2: Prepend mut-scope lookup
Immediately before the existing locals.get(name) lookup, insert:
// Iter mut.2: mut-vars take precedence over let-bound / param /
// top-level / builtin / class-method resolutions. Innermost-first.
for frame in mut_scope_stack.iter().rev() {
if let Some(ty) = frame.get(name) {
// Mut-vars are monomorphic by spec §"var element types" —
// no Forall instantiation, no free_fn_owner recording.
return Ok((ty.clone(), /* whatever return-tuple shape is */));
}
}
The exact return-tuple shape must match the existing Term::Var
arm's return shape (the function returns more than just Type; see
the locals-hit branch for the canonical form, then mirror it).
- Step 3: Pin test — mut-var shadows outer let
Add to #[cfg(test)] mod tests:
#[test]
fn mut_var_resolution_shadows_outer_let() {
// (let x 1 (mut (var x (con Float) 2.0) x))
// -> the inner x is Float, the outer x is Int;
// the body's x resolves to the mut-var (Float).
let term = Term::Let {
name: "x".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
body: Box::new(Term::Mut {
vars: vec![ailang_core::ast::MutVar {
name: "x".into(),
ty: Type::float(),
init: Term::Lit { lit: Literal::Float { bits: "4000000000000000".into() } },
}],
body: Box::new(Term::Var { name: "x".into() }),
}),
};
let (ty, _) = synth_in_builtins_env(&term);
assert_eq!(ty, Type::float(), "inner mut-var x : Float must shadow outer let x : Int");
}
(synth_in_builtins_env is the existing test helper; use whichever
helper the existing builtins-related tests in lib.rs use to drive
synth.)
- Step 4: Run to verify pass
Run: cargo test --workspace -p ailang-check mut_var_resolution_shadows_outer_let
Expected: PASS.
Task 4 — Term::Mut typecheck arm
Files:
-
Modify:
crates/ailang-check/src/lib.rs:3488-3492— replaceTerm::MutInternalstub. -
Step 1: Write the failing positive pin
Add to #[cfg(test)] mod tests:
#[test]
fn mut_block_typechecks_with_int_var_and_returns_int() {
// (mut (var x (con Int) 0) (assign x (app + x 1)) x)
// -> Int.
let term = Term::Mut {
vars: vec![ailang_core::ast::MutVar {
name: "x".into(),
ty: Type::int(),
init: Term::Lit { lit: Literal::Int { value: 0 } },
}],
body: Box::new(Term::Seq {
lhs: Box::new(Term::Assign {
name: "x".into(),
value: Box::new(Term::App {
func: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "x".into() },
Term::Lit { lit: Literal::Int { value: 1 } },
],
// ... whatever tail-call flag default the existing tests use
}),
}),
rhs: Box::new(Term::Var { name: "x".into() }),
}),
};
let (ty, _) = synth_in_builtins_env(&term);
assert_eq!(ty, Type::int(), "mut block returning a mut-var of type Int must synth as Int");
}
- Step 2: Run to verify red
Run: cargo test --workspace -p ailang-check mut_block_typechecks_with_int_var_and_returns_int
Expected: FAIL with CheckError::Internal from the mut.1 stub.
- Step 3: Replace the
Term::Mutstub with real logic
At crates/ailang-check/src/lib.rs:3488-3492 (the Term::Mut arm
in synth), replace:
Term::Mut { .. } => {
Err(CheckError::Internal(
"Term::Mut not yet supported in typecheck (deferred to iter mut.2)".into(),
))
}
with:
Term::Mut { vars, body } => {
// Gate var types: only Int / Float / Bool / Unit allowed in
// mut.2 per spec §"Out of scope".
fn is_supported_mut_var_type(ty: &Type) -> bool {
matches!(ty, Type::Con { name, args }
if args.is_empty()
&& matches!(name.as_str(), "Int" | "Float" | "Bool" | "Unit"))
}
// Build a fresh frame.
let mut frame: indexmap::IndexMap<String, Type> = indexmap::IndexMap::new();
for v in vars {
if !is_supported_mut_var_type(&v.ty) {
return Err(CheckError::UnsupportedMutVarType {
name: v.name.clone(),
ty: render_type(&v.ty), // existing helper; or use std::fmt path
});
}
// Synth init in the outer scope (current mut_scope_stack contents
// are already on the stack; this frame is not yet pushed, so the
// var's own name does not shadow itself during init).
let (init_ty, /* ...other return components... */) = synth(
&v.init,
locals,
mut_scope_stack,
/* existing args */
)?;
// Unify init type with declared type.
unify(&init_ty, &v.ty, /* existing unify args */)?;
// Bind name → declared type in the current frame.
frame.insert(v.name.clone(), v.ty.clone());
}
// Push the completed frame.
mut_scope_stack.push(frame);
// Synth body in the extended scope.
let body_result = synth(body, locals, mut_scope_stack, /* existing args */);
// Pop the frame regardless of body outcome.
mut_scope_stack.pop();
// Return the body's type (or propagate the error).
body_result
}
The exact return-tuple shape and the names of helpers (unify,
render_type for the type→String renderer) must match the existing
code conventions; the implementer reads the canonical form from
adjacent arms (e.g. the Term::Let arm for the unify pattern and
the type-renderer call shape).
The function is_supported_mut_var_type lives as a small helper at
file scope (top of lib.rs near the other is_* helpers) or inline.
Either is acceptable; pick whichever matches the existing convention.
- Step 4: Run to verify pass
Run: cargo test --workspace -p ailang-check mut_block_typechecks_with_int_var_and_returns_int
Expected: PASS.
Task 5 — Term::Assign typecheck arm
Files:
-
Modify:
crates/ailang-check/src/lib.rs:3493-3497— replaceTerm::AssignInternalstub. -
Step 1: Write the failing positive pin
Add to #[cfg(test)] mod tests:
#[test]
fn assign_to_in_scope_mut_var_synths_unit() {
// Inside a mut frame with (var x Int 0), (assign x 1) :: Unit.
let term = Term::Mut {
vars: vec![ailang_core::ast::MutVar {
name: "x".into(),
ty: Type::int(),
init: Term::Lit { lit: Literal::Int { value: 0 } },
}],
body: Box::new(Term::Assign {
name: "x".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
}),
};
let (ty, _) = synth_in_builtins_env(&term);
assert_eq!(ty, Type::unit(), "mut block whose body is a lone assign must synth as Unit");
}
- Step 2: Run to verify red
Run: cargo test --workspace -p ailang-check assign_to_in_scope_mut_var_synths_unit
Expected: FAIL with the Term::Assign Internal stub.
- Step 3: Replace the
Term::Assignstub with real logic
At crates/ailang-check/src/lib.rs:3493-3497, replace:
Term::Assign { .. } => {
Err(CheckError::Internal(
"Term::Assign not yet supported in typecheck (deferred to iter mut.2)".into(),
))
}
with:
Term::Assign { name, value } => {
// Walk mut_scope_stack innermost-first looking for `name`.
let declared: Option<Type> = mut_scope_stack
.iter()
.rev()
.find_map(|frame| frame.get(name).cloned());
match declared {
None => {
// Flatten every mut-var name in every frame, innermost-
// first, dedup-preserving-order.
let mut available: Vec<String> = Vec::new();
for frame in mut_scope_stack.iter().rev() {
for k in frame.keys() {
if !available.contains(k) {
available.push(k.clone());
}
}
}
Err(CheckError::MutAssignOutOfScope {
name: name.clone(),
available,
})
}
Some(declared_ty) => {
// Synth the value.
let (value_ty, /* ... */) = synth(
value,
locals,
mut_scope_stack,
/* existing args */
)?;
// Compare to declared.
if /* unifies, see existing convention */ != declared {
return Err(CheckError::AssignTypeMismatch {
name: name.clone(),
declared: render_type(&declared_ty),
got: render_type(&value_ty),
});
}
// Produce Unit.
Ok((Type::unit(), /* whatever return-tuple shape */))
}
}
}
The unification step uses the same machinery as Term::Let's init
check — read the adjacent arm to copy the canonical shape (likely
unify(&value_ty, &declared_ty, &mut subst)? or similar; the
implementer matches the existing convention).
- Step 4: Run to verify pass
Run: cargo test --workspace -p ailang-check assign_to_in_scope_mut_var_synths_unit
Expected: PASS.
Run: cargo test --workspace -p ailang-check 2>&1 | tail -5
Expected: all check-side tests pass; build is green.
Task 6 — Five fixtures + driver test
Files:
- Create:
examples/test_mut_assign_out_of_scope.ail.json - Create:
examples/test_mut_assign_type_mismatch.ail.json - Create:
examples/test_mut_var_unsupported_type.ail.json - Create:
examples/test_mut_assign_outside_mut.ail.json - Create:
examples/test_mut_nested_shadow_legal.ail.json - Create:
crates/ailang-check/tests/mut_typecheck_pin.rs
These fixtures are .ail.json (canonical-form-rejection style)
because they exercise typecheck failures specifically; the form-A
parser would not necessarily reject them, but typecheck must. The
seven-file form-A carve-out list (per pd.3, journal 2026-05-14)
explicitly admits negative typecheck fixtures as .ail.json-only.
Note the existing convention precedent: examples/broken_unbound.ail.json
and examples/test_22b2_*.ail.json.
- Step 1: Create
test_mut_assign_out_of_scope.ail.json
{
"name": "test_mut_assign_out_of_scope",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"ty": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": ["IO"] },
"params": [],
"body": {
"t": "mut",
"vars": [
{ "name": "x", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 0 } } }
],
"body": {
"t": "assign",
"name": "y",
"value": { "t": "lit", "lit": { "kind": "int", "value": 1 } }
}
}
}
]
}
The exact module-level shape (root keys, FnDef field names) must
match the canonical schema; the implementer cross-checks against
examples/broken_unbound.ail.json as the closest precedent.
- Step 2: Create the four remaining fixtures
Each follows the same shape, varying:
test_mut_assign_type_mismatch.ail.json—var x : Int = 0;assign x 1.5(Float).test_mut_var_unsupported_type.ail.json—var s : Str = "hello".test_mut_assign_outside_mut.ail.json— fn body isTerm::Assign { name: "x", value: ... }directly (no enclosingTerm::Mut).test_mut_nested_shadow_legal.ail.json— outer mut hasvar x : Int = 1; inner mut hasvar x : Float = 2.0plus body that returnsx(Float). The outer body returns whatever the inner mut returns.
The positive fixture's main fn returns Float and must typecheck clean.
- Step 3: Create the driver test
crates/ailang-check/tests/mut_typecheck_pin.rs:
//! Iter mut.2: pin tests that the five typecheck fixtures produce
//! the expected diagnostic codes (or zero diagnostics for the
//! positive nested-shadow case).
//!
//! Spec: docs/specs/0029-mut-local.md §"Testing strategy /
//! Typecheck negative tests".
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.parent().unwrap()
.join("examples")
}
fn check_fixture(fixture_name: &str) -> Vec<String> {
// Whatever the existing canonical helper is for the
// load-then-check-then-collect-codes pattern. Inspect
// crates/ailang-check/tests/show_dispatch_pin.rs (or whichever
// pin test is closest) for the convention.
todo!("convention from sibling pin tests");
}
#[test]
fn assign_out_of_scope_emits_mut_assign_out_of_scope() {
let codes = check_fixture("test_mut_assign_out_of_scope.ail.json");
assert_eq!(codes, vec!["mut-assign-out-of-scope".to_string()]);
}
#[test]
fn assign_type_mismatch_emits_assign_type_mismatch() {
let codes = check_fixture("test_mut_assign_type_mismatch.ail.json");
assert_eq!(codes, vec!["assign-type-mismatch".to_string()]);
}
#[test]
fn mut_var_str_type_emits_mut_var_unsupported_type() {
let codes = check_fixture("test_mut_var_unsupported_type.ail.json");
assert_eq!(codes, vec!["mut-var-unsupported-type".to_string()]);
}
#[test]
fn assign_outside_mut_emits_mut_assign_out_of_scope() {
let codes = check_fixture("test_mut_assign_outside_mut.ail.json");
assert_eq!(codes, vec!["mut-assign-out-of-scope".to_string()]);
}
#[test]
fn nested_shadow_typechecks_clean() {
let codes = check_fixture("test_mut_nested_shadow_legal.ail.json");
assert!(codes.is_empty(), "expected zero diagnostics, got {:?}", codes);
}
The check_fixture helper body must match the existing
sibling-pin-test convention. The implementer reads
crates/ailang-check/tests/show_dispatch_pin.rs (or whichever is
closest) and copies the canonical shape. Do NOT leave a todo!()
in the final file — that is a placeholder forbidden by the plan
discipline. Resolve to the canonical helper before declaring done.
- Step 4: Run the driver
Run: cargo test --workspace -p ailang-check mut_typecheck_pin
Expected: all five tests PASS.
- Step 5: Run full check-crate tests
Run: cargo test --workspace -p ailang-check 2>&1 | tail -5
Expected: every test green, including all the new ones + the
existing baseline.
Task 7 — Verify examples/mut.ail typechecks clean
Files:
-
(no edits; verification only)
-
Step 1: Hand-check via
cargo run ail check
Run: cargo run --quiet --bin ail -- check examples/mut.ail 2>&1
Expected: zero diagnostics (all six fns in the fixture typecheck
clean — they were authored to do so).
If any of the six fns emit a diagnostic, that is a real bug in either the spec semantics or the implementation. Stop, diagnose, and either fix the typecheck logic or amend the fixture (the latter needs Boss sign-off because the fixture is the spec's load-bearing positive sample).
- Step 2: Verify the workspace tests pass
Run: cargo test --workspace 2>&1 | tail -5
Expected: every test green.
Self-review checklist
-
Spec coverage: every section of spec §"Iteration mut.2 — Typecheck" has a matching task. Cross-check: typecheck arm for
Term::Mut(Task 4), typecheck arm forTerm::Assign(Task 5),Term::Varresolution prepending (Task 3), three diagnostics (Task 1), five fixtures (Task 6). All covered. -
Placeholder scan: grep for "TBD" / "TODO" / "implement later" / "similar to Task" / "appropriate error handling" / "fill in later". The Task 6 Step 3 driver contains a
todo!()in the plan text (intentional, as the helper convention is read off sibling tests at execution time) — but a literaltodo!()MUST NOT survive into the committed file. The Step 3 instruction names this explicitly. -
Type-name consistency:
CheckError::MutAssignOutOfScope,CheckError::AssignTypeMismatch,CheckError::UnsupportedMutVarType,mut_scope_stack,IndexMap<String, Type>,Type::int(),Type::float(),Type::unit(), the kebab codes (mut-assign-out-of-scope,assign-type-mismatch,mut-var-unsupported-type). Each appears identically across tasks. -
Step granularity: each step is 2-5 minutes (a single match arm; a single signature change; a single fixture).
-
No commit steps: verified — the plan contains no
git commitinstructions. -
Boss decisions named: the four recon open questions are resolved in this plan: (1) fixtures go to
examples/with the driver undercrates/ailang-check/tests/(Task 6 lead paragraph); (2)mut_scope_stackis asynthparameter, not anEnvfield (Task 2 Step 1); (3)MutAssignOutOfScope.availableis flattened across all enclosing frames innermost-first (Task 5 Step 3); (4)mono.rsre-synth sites verified at lines 712 + 1337 (Task 2 Step 5).
Acceptance gate for iteration mut.2
cargo build --workspacegreen.cargo test --workspacegreen.- All five new tests under
crates/ailang-check/tests/mut_typecheck_pin.rsgreen. - The three
CheckErrorvariants havecode()arms returning the three kebab codes. examples/mut.ail(the six-fn round-trip fixture from mut.1) typechecks clean viacargo run --bin ail -- check examples/mut.ail.Term::MutandTerm::Assignreaching codegen still produceCodegenError::Internal(codegen recognition is deferred to mut.3 and is NOT in scope for this iteration).
The iteration does NOT include any examples/mut_counter.ail or
examples/mut_sum_floats.ail end-to-end run; those land with mut.3.