Latest Results
Implement lexical scoping and shadowing for let bindings (#3425)
Implements lexical block scoping and local shadowing for BAML `let`,
`watch let`, and `for` bindings across HIR, TIR, MIR, generated
snapshots, and runtime regressions.
**Summary**
- Adds lexical scoping fixtures for valid shadowing and invalid
block-leak cases.
- Refactors HIR body walking so nested blocks, `for`/`while` loop
bodies, lambdas, match arms, and catch arms own the right scopes.
`Stmt::While` now pushes a Block scope around the body and the C-style
`after` step, mirroring `Stmt::For`.
- Allows local shadowing, repeated `_`, and parameter shadowing while
preserving top-level/member/parameter duplicate diagnostics.
- Adds visible-binding lookup and capture identity via binding IDs so
same-name captures resolve to the correct declaration.
- Restores TIR active local state across blocks, `for` loops, `while`
loops, match arms, and catch arms. PatId-keyed assignment tracking
distinguishes inner-shadow assignments (drop on scope exit, rule 3) from
outer-binding assignments (propagate, rule 2).
- Routes match-arm and catch-arm pattern bindings — including the
catch-clause stack-trace binding — through `declare_scoped_local`,
fixing a leak where the stack-trace binding was raw-inserted with no
paired restore.
- Lowers lambda captures by binding identity and marks captured MIR
locals by binding identity.
- Restores MIR watched-local cleanup on every exit path through a single
emitter (`emit_unwatch_to_depth`): block fallthrough, `for`/`while` body
fallthrough, match-arm and catch-arm body fallthrough, `throw`,
`break`/`continue`/`return`.
- Type-checks the C-style `for` `after` step (e.g. `i += 1`), which was
previously skipped. This surfaces two correct missing-name diagnostics
that update the `loops_c_for` and `header_requires_let_negative` LSP
fixtures.
- Adds runtime regressions and unignores now-supported branch-local and
lambda-shadowing tests.
- Accepts final HIR/TIR/MIR/codegen/diagnostics snapshots.
**Scoping and Evolving Container Rules**
- `let`, `watch let`, and `for` declarations are lexical: bindings
introduced inside a block, loop body, lambda, match arm, or catch arm do
not leak after that scope exits.
- Assignments and type-establishing mutations to an already-visible
outer binding still apply to that outer binding, even when they occur
inside a nested block or loop body.
- Shadowing is resolved by binding identity, not by name alone: mutating
a shadowed inner binding must not mutate or retype the outer binding.
- Empty evolving containers use the existing BAML heuristic: the first
type-establishing usage wins. This includes `push`, index assignment,
and similar mutations inside control-flow or loop bodies; the
typechecker does not treat a loop body as non-establishing just because
the loop might run zero times.
- Example: `let xs = []; for (let n in []) { xs.push("s") }; xs.push(1)`
establishes `xs` as `string[]` at the loop-body push, so the later
integer push is a type error.
**Validation**
- `cargo test -p baml_lsp2_actions_tests --lib`
- `cargo test -p baml_tests --lib`
- `cargo test -p bex_engine --test concurrent
test_closures_in_loop_vars`
- `cargo test -p baml_tests --test lexical_scoping`
- `cargo test -p baml_tests --test watch`
- `mise run fmt`
- `mise run clippy`
- `prek run --all-files`
**CI status**
- Green as of commit `1a0a1f8d5`. Earlier failures in
`baml_lsp2_actions_tests`, `baml_tests`, and `bex_engine --test
concurrent` (run
https://github.com/BoundaryML/baml/actions/runs/25013954913) are
resolved by this PR.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Fixes for duplicate-binding diagnostics and correct restoration of
outer variables after blocks, loops, and while bodies.
* Improved lambda capture and shadowing behavior so closures reference
the intended variables.
* **New Features**
* Stronger lexical-scoping guarantees across if/else, match, catch,
loops, and nested blocks.
* **Editor / LSP**
* More accurate completions and hover/type info for let/for bindings;
improved reference-finding respecting shadowing.
* **Tests / Chores**
* Numerous new/updated regression tests and updated snapshot
expectations; .gitignore updated.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> fix(bex_engine): substitute placeholder for non-convertible trace payload leaves (#3420)
## Summary
`baml-cli test` (and any other run that emits a trace event for a value
containing a closure, future, function ref, or class/enum definition)
printed `Failed to deep-copy VM value for trace payload` to stderr on
every invocation, and silently dropped the entire trace payload to
`Null`.
The trigger in practice: `$collect_tests` returns a `TestRegistry` whose
fields hold the test bodies as closures. The engine emits a
`FunctionEnd` trace event for that call, `vm_value_to_owned` calls
`as_owned_but_very_slow`, hits `CannotConvertToOwned { reason: "closure"
}` deep in the tree, and the whole result is replaced with `Null` plus
an unconditional `eprintln!`.
## Fix
- Add `BexValue::as_owned_for_trace`, which delegates to a free
`owned_inner` helper. In trace mode, non-convertible leaves (`closure`,
`future`, `function`, `class`, `enum`, `bound_method`, `cell`,
`function-ref`) become `<kind>` string placeholders instead of failing
the whole conversion.
- The strict `as_owned_but_very_slow` keeps its public signature and
behavior — strict callers (builtins, `bex_project`, IO codegen) are
unchanged. Both methods share `owned_inner` to avoid duplication.
- `vm_value_to_owned` (the engine's trace-payload path) calls
`as_owned_for_trace` and drops the `eprintln!` since the conversion can
no longer fail on the closure-leaf case.
The `_permit: PermitProof<'_>` argument on the public methods is kept
(to preserve the GC-exclusion proof at the API boundary) but isn't
threaded through `owned_inner` — it's a zero-sized type-level witness,
never destructured or method-called.
## Test plan
- [x] `cargo clippy -p bex_heap -- -D warnings` clean
- [x] `cargo test -p bex_heap --lib` (89 passed)
- [x] `cargo test -p bex_engine --lib` passes
- [x] `baml-cli test` on `hackathon/coding-agent`: 9 passed, no `Failed
to deep-copy VM value for trace payload` on stderr
- [x] `baml-cli run -e '...'`: clean stderr
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Improvements**
* Trace output now includes readable error text for previously
unrepresentable values, improving diagnostics.
* Conversions no longer emit noise to stderr; related errors are logged
cleanly.
* Deep-copy/conversion performance improved, reducing failures and
speeding trace generation.
<!-- end of auto-generated comment: release notes by coderabbit.ai --> Latest Branches
+27%
-21%
+1%
dependabot/cargo/baml_language/cargo-0ef43d590c © 2026 CodSpeed Technology