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
|
/// 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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user