Files
AILang/docs/plans/0068-iter-mut.2.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
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.
2026-05-28 13:31:31 +02:00

30 KiB

mut.2 — typecheck — Implementation Plan

Parent spec: docs/specs/0029-mut-local.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to 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.jsonassign to a name not declared as a mut-var of any enclosing Term::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.jsonTerm::Assign directly in a fn body with no Term::Mut ancestor.
  • Create: examples/test_mut_nested_shadow_legal.ail.json — positive: outer var x : Int shadowed by inner var x : Float in 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 three CheckError variants + code() arms + ctx() arms.
  • Modify: crates/ailang-check/src/lib.rs:2647-2662synth signature: add mut_scope_stack: &mut Vec<indexmap::IndexMap<String, Type>>.
  • Modify: crates/ailang-check/src/lib.rs:1885, 2634 — top-of-walk synth entry points (check_fn and check_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 to Term::Var arm.
  • Modify: crates/ailang-check/src/lib.rs:3488-3497 — replace Term::Mut and Term::Assign Internal stubs 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 both crate::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 synth signature

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 in lib.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.rs re-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-2941Term::Var arm in synth.

  • Step 1: Locate the Term::Var resolution 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 — replace Term::Mut Internal stub.

  • 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::Mut stub 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 — replace Term::Assign Internal stub.

  • 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::Assign stub 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.jsonvar x : Int = 0; assign x 1.5 (Float).
  • test_mut_var_unsupported_type.ail.jsonvar s : Str = "hello".
  • test_mut_assign_outside_mut.ail.json — fn body is Term::Assign { name: "x", value: ... } directly (no enclosing Term::Mut).
  • test_mut_nested_shadow_legal.ail.json — outer mut has var x : Int = 1; inner mut has var x : Float = 2.0 plus body that returns x (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 for Term::Assign (Task 5), Term::Var resolution 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 literal todo!() 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 commit instructions.

  • Boss decisions named: the four recon open questions are resolved in this plan: (1) fixtures go to examples/ with the driver under crates/ailang-check/tests/ (Task 6 lead paragraph); (2) mut_scope_stack is a synth parameter, not an Env field (Task 2 Step 1); (3) MutAssignOutOfScope.available is flattened across all enclosing frames innermost-first (Task 5 Step 3); (4) mono.rs re-synth sites verified at lines 712 + 1337 (Task 2 Step 5).


Acceptance gate for iteration mut.2

  • cargo build --workspace green.
  • cargo test --workspace green.
  • All five new tests under crates/ailang-check/tests/mut_typecheck_pin.rs green.
  • The three CheckError variants have code() arms returning the three kebab codes.
  • examples/mut.ail (the six-fn round-trip fixture from mut.1) typechecks clean via cargo run --bin ail -- check examples/mut.ail.
  • Term::Mut and Term::Assign reaching codegen still produce CodegenError::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.