diff --git a/crates/ail/tests/ct1_check_cli.rs b/crates/ail/tests/ct1_check_cli.rs index a12ccb7..d0eaf3c 100644 --- a/crates/ail/tests/ct1_check_cli.rs +++ b/crates/ail/tests/ct1_check_cli.rs @@ -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 `[]` token in the rendered stderr and requires +/// exactly one. Drops to GREEN once the four Display bodies shed their +/// leading `[] ` 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 `[]`. 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 /// a syntax error returns a structured `surface-parse-error` /// diagnostic (non-empty diagnostics array, exit code != 0), rather diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 5c676ab..7061b06 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -641,7 +641,7 @@ pub enum CheckError { /// surfaces "you might have meant one of: X / Y / Z" without /// duplicates when the same name appears at multiple nesting /// 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 { name: String, available: Vec, @@ -650,7 +650,7 @@ pub enum CheckError { /// 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. - #[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 { name: String, declared: String, @@ -662,7 +662,7 @@ pub enum CheckError { /// primitives (Int, Float, Bool, Unit). The temporary /// restriction is named explicitly in spec /// `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 { name: String, ty: String, @@ -676,7 +676,7 @@ pub enum CheckError { /// Capturing one into a closure would require either lifting it /// to the heap (deferred milestone) or rejecting the capture. /// 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 }, /// Iter 22b.3: an internal invariant in the typechecker / mono pass diff --git a/docs/journals/2026-05-15-bugfix-mut-diag-double-code.md b/docs/journals/2026-05-15-bugfix-mut-diag-double-code.md new file mode 100644 index 0000000..14d6411 --- /dev/null +++ b/docs/journals/2026-05-15-bugfix-mut-diag-double-code.md @@ -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("[] ...")]` +`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 `[] ` 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. diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 4609697..bbcbb87 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -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>` 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` 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 , ptr ` 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 — bugfix mut-diag-double-code: fieldtest F2 — the four mut-local `CheckError` variants embedded `[]` 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