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:
@@ -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
|
||||
/// a syntax error returns a structured `surface-parse-error`
|
||||
/// diagnostic (non-empty diagnostics array, exit code != 0), rather
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.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 — 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
|
||||
|
||||
Reference in New Issue
Block a user