fix: mut-local diagnostics doubled the [code] prefix in human stderr (fieldtest F2)

The four mut-local CheckError variants (MutAssignOutOfScope,
AssignTypeMismatch, UnsupportedMutVarType, MutVarCapturedByLambda)
embedded the bracketed kebab code in their thiserror Display body,
while the non-JSON CLI formatter independently prepends [code] from
CheckError::code() — doubling it. Non-mut variants never embedded
the bracket; this brings the four in line with that convention.

RED-first via the debug skill: ct1_check_cli.rs pins the observable
property (code appears exactly once in the rendered human
diagnostic). GREEN was a trivial four-string mechanical edit.

Tests 598 -> 599.
This commit is contained in:
2026-05-15 10:13:50 +02:00
parent 1faee673f7
commit 1ff7e811af
4 changed files with 128 additions and 4 deletions
+64
View File
@@ -187,6 +187,70 @@ fn check_ordering_match_post_migration_is_clean() {
); );
} }
/// Property (RED — fieldtest mut-local F2): in non-JSON (human) mode,
/// the human-stderr formatter prepends the diagnostic code exactly once
/// as the canonical `[code]` bracket prefix (the cli-diag-human format).
/// The four mut-local `CheckError` variants
/// (`mut-assign-out-of-scope`, `assign-type-mismatch`,
/// `mut-var-unsupported-type`, `mut-var-captured-by-lambda`) must NOT
/// also embed `[code] ` inside their `thiserror` Display body, which
/// would render the bracketed code TWICE in the user-visible stderr
/// line (`error: [code] fn: [code] message`).
///
/// This pins the *observable* doubling on the real CLI human path
/// (`crates/ail/src/main.rs` non-JSON `Cmd::Check` arm), not the raw
/// Display string of one variant in isolation: it counts occurrences
/// of the literal `[<code>]` token in the rendered stderr and requires
/// exactly one. Drops to GREEN once the four Display bodies shed their
/// leading `[<code>] ` prefix (the formatter's prefix then supplies it
/// once). The non-mut variants are unaffected because they never
/// embedded the bracket.
#[test]
fn check_human_mode_renders_mut_diagnostic_code_exactly_once() {
// (fixture filename, kebab diagnostic code) for the four mut-local
// negative fixtures shipped by the mut-local milestone.
let cases = [
(
"test_mut_assign_out_of_scope.ail.json",
"mut-assign-out-of-scope",
),
(
"test_mut_assign_type_mismatch.ail.json",
"assign-type-mismatch",
),
(
"test_mut_var_unsupported_type.ail.json",
"mut-var-unsupported-type",
),
(
"test_mut_var_captured_by_lambda.ail.json",
"mut-var-captured-by-lambda",
),
];
for (fixture_name, code) in cases {
let fixture = examples_dir().join(fixture_name);
let output = Command::new(ail_bin())
.args(["check", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check (human mode) must fail on {fixture_name}"
);
let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
// The canonical cli-diag-human prefix is `[<code>]`. It must
// appear exactly once in the rendered human diagnostic — the
// formatter supplies it; the Display body must not also embed it.
let needle = format!("[{code}]");
let occurrences = stderr.matches(&needle).count();
assert_eq!(
occurrences, 1,
"expected `[{code}]` exactly once in human stderr, found {occurrences}; \
full stderr:\n{stderr}"
);
}
}
/// Property: `ail check --json` on a `.ail` (Form A) source file with /// Property: `ail check --json` on a `.ail` (Form A) source file with
/// a syntax error returns a structured `surface-parse-error` /// a syntax error returns a structured `surface-parse-error`
/// diagnostic (non-empty diagnostics array, exit code != 0), rather /// diagnostic (non-empty diagnostics array, exit code != 0), rather
+4 -4
View File
@@ -641,7 +641,7 @@ pub enum CheckError {
/// surfaces "you might have meant one of: X / Y / Z" without /// surfaces "you might have meant one of: X / Y / Z" without
/// duplicates when the same name appears at multiple nesting /// duplicates when the same name appears at multiple nesting
/// depths. /// depths.
#[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(", "))] #[error("cannot assign to `{name}` here — not declared as a mut-var in the enclosing (mut ...) block. Available mut-vars in scope: [{}]", available.join(", "))]
MutAssignOutOfScope { MutAssignOutOfScope {
name: String, name: String,
available: Vec<String>, available: Vec<String>,
@@ -650,7 +650,7 @@ pub enum CheckError {
/// Iter mut.2: `Term::Assign { name, value }` reached typecheck /// Iter mut.2: `Term::Assign { name, value }` reached typecheck
/// inside a valid mut-scope, but `value`'s synth type differs /// inside a valid mut-scope, but `value`'s synth type differs
/// from the var's declared type. /// from the var's declared type.
#[error("[assign-type-mismatch] cannot assign `{got}` to mut-var `{name} : {declared}` — the assigned value's type must match the var's declared type")] #[error("cannot assign `{got}` to mut-var `{name} : {declared}` — the assigned value's type must match the var's declared type")]
AssignTypeMismatch { AssignTypeMismatch {
name: String, name: String,
declared: String, declared: String,
@@ -662,7 +662,7 @@ pub enum CheckError {
/// primitives (Int, Float, Bool, Unit). The temporary /// primitives (Int, Float, Bool, Unit). The temporary
/// restriction is named explicitly in spec /// restriction is named explicitly in spec
/// `docs/specs/2026-05-15-mut-local.md` §"Out of scope". /// `docs/specs/2026-05-15-mut-local.md` §"Out of scope".
#[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.")] #[error("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 { UnsupportedMutVarType {
name: String, name: String,
ty: String, ty: String,
@@ -676,7 +676,7 @@ pub enum CheckError {
/// Capturing one into a closure would require either lifting it /// Capturing one into a closure would require either lifting it
/// to the heap (deferred milestone) or rejecting the capture. /// to the heap (deferred milestone) or rejecting the capture.
/// This milestone takes the latter route. /// This milestone takes the latter route.
#[error("[mut-var-captured-by-lambda] mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")] #[error("mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")]
MutVarCapturedByLambda { name: String }, MutVarCapturedByLambda { name: String },
/// Iter 22b.3: an internal invariant in the typechecker / mono pass /// Iter 22b.3: an internal invariant in the typechecker / mono pass
@@ -0,0 +1,59 @@
# bugfix mut-diag-double-code — doubled `[code]` prefix in mut-local human diagnostics
**Date:** 2026-05-15
**Status:** DONE
**Trigger:** fieldtest finding F2 (`docs/specs/2026-05-15-fieldtest-mut-local.md`).
**Started from:** 1faee67
## Symptom
Non-JSON `ail check` stderr rendered the kebab diagnostic code
twice for the four mut-local `CheckError` variants, e.g.
```
error: [mut-var-captured-by-lambda] main: [mut-var-captured-by-lambda] mut-var `x` cannot be captured...
```
## Cause
`MutAssignOutOfScope`, `AssignTypeMismatch`,
`UnsupportedMutVarType`, `MutVarCapturedByLambda` were authored in
iters mut.2 / mut.4-tidy with their `#[error("[<code>] ...")]`
`thiserror` Display bodies embedding the bracketed code. The
non-JSON `Cmd::Check` formatter in `crates/ail/src/main.rs`
independently prepends `[{d.code}]` from `CheckError::code()`.
Embedded + prepended = doubled. The non-mut `CheckError` variants
never embedded the bracket, so only these four doubled — they were
the inconsistent ones, brought in line here.
## Fix
RED-first via the `debug` skill (`ailang-debugger` wrote the
failing test; the GREEN side was a trivial four-string mechanical
edit applied inline by the Boss per the CLAUDE.md
trivial-mechanical-edit carve-out — no review-and-commit
discipline shed).
- RED test: `crates/ail/tests/ct1_check_cli.rs`
`check_human_mode_renders_mut_diagnostic_code_exactly_once`.
Pins the *observable* property: the code appears exactly once in
the rendered human diagnostic, exercising the same formatter path
the CLI uses (not a brittle assertion on the raw `#[error]` body).
- GREEN: dropped the leading `[<code>] ` literal from the four
`#[error(...)]` Display strings in `crates/ailang-check/src/lib.rs`.
The mut-typecheck pin tests (`mut_typecheck_pin.rs`) and the
in-source unit assertions match on the struct variants / `code()`,
not the Display string, so the prefix removal did not touch them.
## Tests
598 → 599 green (the new RED-now-GREEN human-formatter test).
Full workspace clean, zero failures.
## Not in scope (stays queued)
F1/F4 (accumulator-over-iteration: the milestone motivation is
unmet without a loop construct) and F3 (zero-arg `(app f)` rejected
at parse) from the same fieldtest are NOT addressed here — they are
design/roadmap items, not this bug.
+1
View File
@@ -74,3 +74,4 @@
- 2026-05-15 — iter mut.2: typecheck for `Term::Mut` + `Term::Assign` replacing the iter mut.1 dispatch stubs; three new `CheckError` variants (`MutAssignOutOfScope`, `AssignTypeMismatch`, `UnsupportedMutVarType`) with bracketed-`[code]:` Display + `code()` + `ctx()` arms; `synth` signature threads `mut_scope_stack: &mut Vec<IndexMap<String, Type>>` after `locals`, propagated through all recursive call sites in `lib.rs` plus `mono.rs:712/1337` + `lift.rs:723` + `builtins.rs` test helpers + the mq.3 in-test helper at `lib.rs:6663`; `Term::Var` resolution prepended with innermost-first mut-scope lookup; `Term::Mut` arm gates var types to {Int,Float,Bool,Unit} and push/pops a fresh frame; `Term::Assign` arm walks the stack innermost-first, emits `AssignTypeMismatch` on type mismatch and `MutAssignOutOfScope` (with `available` flattened across all frames) on miss; five `.ail.json` fixtures under `examples/` (four negative + one positive nested-shadow) + driver `crates/ailang-check/tests/mut_typecheck_pin.rs`; `carve_out_inventory.rs` EXPECTED extended 7→12 for the new negative-fixture set; `examples/mut.ail` typechecks clean (`ok (26 symbols across 2 modules)`); tests 579 → 592 → 2026-05-15-iter-mut.2.md - 2026-05-15 — iter mut.2: typecheck for `Term::Mut` + `Term::Assign` replacing the iter mut.1 dispatch stubs; three new `CheckError` variants (`MutAssignOutOfScope`, `AssignTypeMismatch`, `UnsupportedMutVarType`) with bracketed-`[code]:` Display + `code()` + `ctx()` arms; `synth` signature threads `mut_scope_stack: &mut Vec<IndexMap<String, Type>>` after `locals`, propagated through all recursive call sites in `lib.rs` plus `mono.rs:712/1337` + `lift.rs:723` + `builtins.rs` test helpers + the mq.3 in-test helper at `lib.rs:6663`; `Term::Var` resolution prepended with innermost-first mut-scope lookup; `Term::Mut` arm gates var types to {Int,Float,Bool,Unit} and push/pops a fresh frame; `Term::Assign` arm walks the stack innermost-first, emits `AssignTypeMismatch` on type mismatch and `MutAssignOutOfScope` (with `available` flattened across all frames) on miss; five `.ail.json` fixtures under `examples/` (four negative + one positive nested-shadow) + driver `crates/ailang-check/tests/mut_typecheck_pin.rs`; `carve_out_inventory.rs` EXPECTED extended 7→12 for the new negative-fixture set; `examples/mut.ail` typechecks clean (`ok (26 symbols across 2 modules)`); tests 579 → 592 → 2026-05-15-iter-mut.2.md
- 2026-05-15 — iter mut.3: codegen + e2e for `Term::Mut` + `Term::Assign` closing the mut-local milestone end-to-end; mut-var allocas hoisted to fn entry block via a per-`Emitter` side buffer `pending_entry_allocas: String` + a captured `entry_block_end_marker: Option<usize>` byte position spliced via `String::insert_str` at the end of `emit_fn`; `Term::Mut` arm in `lower_term` emits one alloca per var into the side buffer, lowers each init at the current position, emits a `store` to bind, push/pops a per-block save stack for proper shadowing of outer mut-vars; `Term::Assign` arm emits `store <ty> <value_ssa>, ptr <alloca>` and yields the canonical Unit SSA; `Term::Var` arm prepends mut-var lookup with a `load` emission. Two beyond-plan adjustments: (1) `synth_with_extras`'s parallel `Term::Var` arm needed the same mut-var lookup precedence as `lower_term`, (2) `lambda.rs` thunk emission needed save/restore of all three new `Emitter` fields across the lambda-body boundary plus an in-thunk splice at the lambda's own entry marker so mut-blocks inside a closure body hoist to the closure's entry not the outer fn's. Two e2e fixtures `examples/mut_counter.ail` (Int) + `examples/mut_sum_floats.ail` (Float); both run end-to-end and print `55`. DESIGN.md "What **is** supported" subsection gains a "Local mutable state" bullet. mut-local milestone end-to-end closed. Tests 592 → 594 → 2026-05-15-iter-mut.3.md - 2026-05-15 — iter mut.3: codegen + e2e for `Term::Mut` + `Term::Assign` closing the mut-local milestone end-to-end; mut-var allocas hoisted to fn entry block via a per-`Emitter` side buffer `pending_entry_allocas: String` + a captured `entry_block_end_marker: Option<usize>` byte position spliced via `String::insert_str` at the end of `emit_fn`; `Term::Mut` arm in `lower_term` emits one alloca per var into the side buffer, lowers each init at the current position, emits a `store` to bind, push/pops a per-block save stack for proper shadowing of outer mut-vars; `Term::Assign` arm emits `store <ty> <value_ssa>, ptr <alloca>` and yields the canonical Unit SSA; `Term::Var` arm prepends mut-var lookup with a `load` emission. Two beyond-plan adjustments: (1) `synth_with_extras`'s parallel `Term::Var` arm needed the same mut-var lookup precedence as `lower_term`, (2) `lambda.rs` thunk emission needed save/restore of all three new `Emitter` fields across the lambda-body boundary plus an in-thunk splice at the lambda's own entry marker so mut-blocks inside a closure body hoist to the closure's entry not the outer fn's. Two e2e fixtures `examples/mut_counter.ail` (Int) + `examples/mut_sum_floats.ail` (Float); both run end-to-end and print `55`. DESIGN.md "What **is** supported" subsection gains a "Local mutable state" bullet. mut-local milestone end-to-end closed. Tests 592 → 594 → 2026-05-15-iter-mut.3.md
- 2026-05-15 — iter mut.4-tidy: close mut-local audit drift. Architect's two `[high]` items addressed — new `CheckError::MutVarCapturedByLambda` rejects any lambda whose body's free vars hit the enclosing `mut_scope_stack` (via the existing `desugar::free_vars_in_term` helper, gated on non-empty stack), and `codegen/lambda.rs`'s blame-typechecker `Internal` path becomes `unreachable!` once typecheck is the gate. Two `[medium]` stale-history comments cleaned in DESIGN.md §"Term (expression)" and `ailang-codegen/src/lib.rs`. New negative fixture `examples/test_mut_var_captured_by_lambda.ail.json` + driver test extension; `carve_out_inventory.rs` EXPECTED 12→13. Spec §"Out of scope" amended with the lambda-capture rejection bullet. Bench: `compile_check.py` `check_ms` showed a uniform ~30-50% regression across the suite traced to a fixed-cost startup tax (mut-* added ~1400 lines of typecheck/codegen surface; the short-circuit on empty `mut_scope_stack` walk in Term::Var did not move the needle, falsifying the hot-path hypothesis); ratified as a feature tax with paired baseline update on `bench/baseline_compile.json`. `check.py` tail-noise envelope continues the audit-pd carve-out (no ratify needed). `cross_lang.py` clean. Tests 594 → 598. mut-local milestone audit closed → 2026-05-15-iter-mut.4-tidy.md - 2026-05-15 — iter mut.4-tidy: close mut-local audit drift. Architect's two `[high]` items addressed — new `CheckError::MutVarCapturedByLambda` rejects any lambda whose body's free vars hit the enclosing `mut_scope_stack` (via the existing `desugar::free_vars_in_term` helper, gated on non-empty stack), and `codegen/lambda.rs`'s blame-typechecker `Internal` path becomes `unreachable!` once typecheck is the gate. Two `[medium]` stale-history comments cleaned in DESIGN.md §"Term (expression)" and `ailang-codegen/src/lib.rs`. New negative fixture `examples/test_mut_var_captured_by_lambda.ail.json` + driver test extension; `carve_out_inventory.rs` EXPECTED 12→13. Spec §"Out of scope" amended with the lambda-capture rejection bullet. Bench: `compile_check.py` `check_ms` showed a uniform ~30-50% regression across the suite traced to a fixed-cost startup tax (mut-* added ~1400 lines of typecheck/codegen surface; the short-circuit on empty `mut_scope_stack` walk in Term::Var did not move the needle, falsifying the hot-path hypothesis); ratified as a feature tax with paired baseline update on `bench/baseline_compile.json`. `check.py` tail-noise envelope continues the audit-pd carve-out (no ratify needed). `cross_lang.py` clean. Tests 594 → 598. mut-local milestone audit closed → 2026-05-15-iter-mut.4-tidy.md
- 2026-05-15 — bugfix mut-diag-double-code: fieldtest F2 — the four mut-local `CheckError` variants embedded `[<code>]` in their `#[error]` Display body while the non-JSON CLI formatter also prepends `[code]`, doubling it in human stderr; dropped the embedded prefix from the four strings, bringing them in line with all non-mut variants; RED-first via `debug` (`ct1_check_cli.rs::check_human_mode_renders_mut_diagnostic_code_exactly_once`), GREEN applied inline as a trivial mechanical edit; tests 598 → 599 → 2026-05-15-bugfix-mut-diag-double-code.md