Skip to content

fix(compiler2): reject unsound local reassignment and validate index subscripts (B-236) - #3779

Merged
codeshaunted merged 3 commits into
canaryfrom
avery/b-236
Jun 16, 2026
Merged

codeshaunted merged 3 commits into
canaryfrom
avery/b-236

Conversation

@codeshaunted

@codeshaunted codeshaunted commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes B-236: an empty [] in an if/else else-branch crashed the VM with expected map, got array. Investigating it surfaced a small family of related index/assignment soundness holes — all programs that compiled but aborted the VM at runtime. All are now caught at compile time (or made null-safe).

Root causes & fixes

1. Unannotated-local reassignment was unchecked (the B-236 crash).
let x = {} gives x a (evolving) map type; reassigning [] (directly or via if/else) was silently accepted, so x stayed map-typed while holding an array at runtime → as_map abort. Reassignments to an unannotated local are now validated against the local's current flow type. An empty evolving container ([] / {}, element never) may still adopt a value of the same kind (let a = []; a = [1,2,3] keeps working), but cross-kind and unrelated-type reassignments are rejected. Generalizes to scalars (int ← string), classes, and nominal typing.

2. Index key type was never validated.
list["s"] and map[0] aborted the VM (expected int, got string / expected string, got int). A list / byte-array now requires an int subscript and a map its declared key type.

3. Null subscript aborted the VM with a confusing got any.
Plain a[i] with a nullable i is now a compile error (must narrow). The null-safe a?.[i] only guarded the base; it now also short-circuits to null on a null index (only when the index type is nullable), symmetric with the base guard. This keeps valid chains like node?.children?.[node?.value] working and makes b?.items?.[b?.idx] return null instead of crashing.

Net rule: [] requires a non-null, correctly-typed subscript; ?.[] is fully null-safe.

Tests

  • TIR diagnostic tests for every rejected/accepted case (reassignment, index kind, nullable index) — written test-first.
  • Runtime tests for ?.[] null-safety (returns null, not a crash).
  • Regenerated 10 LSP diagnostic fixtures that had encoded the missing diagnostics.
  • Full workspace suite green (5887 passed), baml_src corpus green (1919), fmt + clippy clean.

Files

  • crates/baml_compiler2_tir/src/builder.rs — reassignment check + index-key validation
  • crates/baml_compiler2_mir/src/lower.rs — null-safe index guard for ?.[]
  • crates/baml_tests/... — regression tests
  • crates/baml_lsp2_actions_tests/test_files/... — regenerated goldens

Summary by CodeRabbit

  • Bug Fixes

    • Improved indexing type validation: list/byte-array indices must be non-null integers, and map indices must be non-null strings (including better handling of optional/nullable indices).
    • Fixed indexing/null-safety behavior so optional indexing correctly short-circuits to null when the index is null.
    • Strengthened reassignment checks for evolving locals to prevent incompatible container-kind changes and clearer type-mismatch enforcement.
    • Refined type-mismatch diagnostics for invalid assignments and indexing across arrays, maps, classes, and primitives.
  • Tests

    • Expanded and updated LSP and compiler regression fixtures to cover these scenarios and expected diagnostics.

…subscripts (B-236)

An empty `[]` in an if/else else-branch could crash the VM with `expected map,
got array`. Root cause: reassigning an *unannotated* local was never
type-checked and never updated the local's flow type, so a map-typed local could
hold an array at runtime; indexing it then aborted the VM. Reassignments are now
validated against the local's current type — an empty evolving container (`[]` /
`{}`) may still adopt a same-kind value, but cross-kind and unrelated-type
reassignments are rejected.

Two adjacent index-subscript soundness holes, surfaced while investigating, are
fixed in the same pass:

- The index key type was never validated, so `list["s"]` / `map[0]` aborted the
  VM (`expected int, got string` / `expected string, got int`). A list or
  byte-array now requires an `int` subscript and a map its declared key type.

- A null subscript aborted the VM with the confusing `got any`. Plain `a[i]` now
  requires a non-null index; the null-safe `a?.[i]` short-circuits to null on a
  null index too, symmetric with its existing base guard (only when the index
  type is nullable).

Adds regression tests (TIR diagnostics, runtime null-safety) and regenerates the
LSP diagnostic fixtures that had encoded the missing diagnostics.
@linear

linear Bot commented Jun 16, 2026

Copy link
Copy Markdown

B-236

@vercel

vercel Bot commented Jun 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jun 16, 2026 10:44pm
promptfiddle Ready Ready Preview, Comment Jun 16, 2026 10:44pm
promptfiddle2 Ready Ready Preview, Comment Jun 16, 2026 10:44pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds compile-time enforcement of index subscript types in the TIR builder (rejecting non-int list keys, non-string map keys, and nullable indices), blocks cross-kind reassignment of unannotated locals, extends MIR lowering to null-short-circuit nullable index values in ?.[], and updates all matching test fixtures and regression tests.

Changes

Index key type checking, reassignment compatibility, and null-safe optional index lowering

Layer / File(s) Summary
TIR: index key type checking for reads and writes
baml_language/crates/baml_compiler2_tir/src/builder.rs
Adds check_index_key_type enforcing int for list/byte-array and string for map keys, wires it into infer_index_expr, infer_optional_index_expr (with allow_null = true), and replaces the manual key check in try_index_assign_mutation.
TIR: reassignment compatibility for unannotated locals
baml_language/crates/baml_compiler2_tir/src/builder.rs
Adds reassignment_is_compatible (B-236 rules: allows same-kind empty evolving container re-typing, rejects cross-kind reassignments) and inserts a guard in Stmt::Assign for unannotated locals with a known current_ty.
MIR: null-safe optional index lowering
baml_language/crates/baml_compiler2_mir/src/lower.rs
Introduces lower_optional_index_access that branches to bb_null when the index type is nullable, then calls emit_index_access; refactors lower_index to delegate index emission; updates call-site dispatch for simplified signatures.
Unit tests: reassignment and index key validation
baml_language/crates/baml_tests/src/compiler2_tir/inference.rs
Adds has_type_mismatch helper and new tests covering cross-kind reassignment rejection, same-kind empty-array regression, string/int key rejection on list/map, nullable index rejection, and control-flow narrowing acceptance.
Integration tests: compile-time and runtime optional index regression
baml_language/crates/baml_tests/tests/type_error_repro.rs
Replaces runtime null-index repro with a compile-time #[should_panic] test; adds two runtime tests for ?.[] returning Null on null index and the correct element on valid index.
Test fixture diagnostics updates
baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/*, loops/for.baml, maps/key_and_value_typecheck.baml
Updates expected //- diagnostics blocks across all affected .baml fixtures to match new E0001/E0006/E0010 type-mismatch messages for assignments, maps, loops, and index typing.

Sequence Diagram(s)

sequenceDiagram
    participant TIR as TIR Builder
    participant KeyCheck as check_index_key_type
    participant MIR as MIR Lowerer
    participant BB as Basic Blocks

    TIR->>KeyCheck: infer_index_expr(index_ty, allow_null=false)
    KeyCheck-->>TIR: TypeMismatch if wrong/nullable key
    TIR->>KeyCheck: infer_optional_index_expr(index_ty, allow_null=true)
    KeyCheck-->>TIR: TypeMismatch if wrong key (nullable allowed)
    TIR->>MIR: lower_optional_index(base, index)
    MIR->>MIR: lower_optional_index_access(base, index, bb_null)
    alt index_ty is nullable
        MIR->>BB: branch to bb_null on null index
        BB-->>MIR: result = null
    else index is non-null
        MIR->>BB: emit_index_access(base, index)
        BB-->>MIR: result = element
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • BoundaryML/baml#3374: Modifies infer_optional_index_expr and optional index typing logic in the same builder.rs file, directly related to this PR's index-null and key-type enforcement changes.
  • BoundaryML/baml#3699: Refactors optional index/member-access lowering in lower.rs (remove Ty::Optional, represent nullability as T | null), directly preceding the lower_optional_index_access null-guard added here.

Poem

🐇 Hop, hop, the index gate is set,
A null subscript? Rejected—no regret!
The list wants int, the map a string,
Cross-kind reassign? Not happening!
Now ?.[] guards its null with care,
Safe types for all—the warren's fair. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the main changes: rejecting unsound local reassignment and validating index subscripts, with reference to issue B-236.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch avery/b-236

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 16, 2026 •

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@vercel
vercel Bot temporarily deployed to Preview – beps June 16, 2026 21:59 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 16, 2026 22:05 Inactive
@github-actions

Copy link
Copy Markdown

No description provided.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
baml_language/crates/baml_tests/src/compiler2_tir/inference.rs (1)

694-701: ⚡ Quick win

Make mismatch assertions specific to each scenario.

has_type_mismatch() currently passes on any diagnostic containing "type mismatch", so tests can succeed due to an unrelated mismatch in the snippet. Consider parameterizing the helper with an expected fragment (or expected/got pair) per test to keep these regressions precise.

Suggested tightening
-fn has_type_mismatch(source: &str) -> bool {
+fn has_type_mismatch(source: &str, expected_fragment: &str) -> bool {
     let mut db = make_db();
     db.add_file("test.baml", source);
     baml_project::collect_compiler2_diagnostics(&db)
         .iter()
-        .any(|diag| diag.message.contains("type mismatch"))
+        .any(|diag| {
+            diag.message.contains("type mismatch")
+                && diag.message.contains(expected_fragment)
+        })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_tests/src/compiler2_tir/inference.rs` around lines
694 - 701, The `has_type_mismatch` function currently matches any diagnostic
containing "type mismatch" which is too broad and can cause tests to pass for
unrelated type mismatches. Parameterize the function to accept an additional
parameter containing an expected message fragment (such as a specific expected
or got type string), and update the diagnostic check to verify that the message
contains both "type mismatch" and this expected fragment. Then update all call
sites of `has_type_mismatch` throughout the test file to pass the relevant
expected fragment for each specific test scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 5811-5826: The index expression is being evaluated twice: once at
line 5811 when creating index_op for the null check, and again when lower_index
is called at line 5826. To fix this, refactor the code to reuse the
already-evaluated index_op operand instead of passing the original index
expression to lower_index again. You may need to either modify the lower_index
method signature to accept an operand instead of an expression, or restructure
the control flow to pass the pre-computed operand through to avoid re-evaluation
of potentially side-effectful index expressions.

---

Nitpick comments:
In `@baml_language/crates/baml_tests/src/compiler2_tir/inference.rs`:
- Around line 694-701: The `has_type_mismatch` function currently matches any
diagnostic containing "type mismatch" which is too broad and can cause tests to
pass for unrelated type mismatches. Parameterize the function to accept an
additional parameter containing an expected message fragment (such as a specific
expected or got type string), and update the diagnostic check to verify that the
message contains both "type mismatch" and this expected fragment. Then update
all call sites of `has_type_mismatch` throughout the test file to pass the
relevant expected fragment for each specific test scenario.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ff192b1c-9810-41ac-8926-f65d522b6ab6

📥 Commits

Reviewing files that changed from the base of the PR and between 4fa6c6e and 475a52c.

📒 Files selected for processing (13)
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/arrays.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/class_arrays.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/classes.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/maps.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/nested_arrays.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/nominal_typing.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/invalid_assignment/primitives.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/loops/for.baml
  • baml_language/crates/baml_lsp2_actions_tests/test_files/syntax/maps/key_and_value_typecheck.baml
  • baml_language/crates/baml_tests/src/compiler2_tir/inference.rs
  • baml_language/crates/baml_tests/tests/type_error_repro.rs

@vercel
vercel Bot temporarily deployed to Preview – beps June 16, 2026 22:12 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 16, 2026 22:18 Inactive
Addresses review feedback on the `?.[]` null-safe index guard: the index
expression was lowered once for the null check and again in the access, so a
side-effectful / non-deterministic subscript would be evaluated twice. Extract a
shared `emit_index_access` that takes the already-lowered operand, so the index
is evaluated once. No behavior change for pure indices.
@vercel
vercel Bot temporarily deployed to Preview – beps June 16, 2026 22:27 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 16, 2026 22:32 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
baml_language/crates/baml_compiler2_mir/src/lower.rs (2)

5748-5767: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid evaluating the optional-index base twice.

Line 5748 lowers base for the null check, but Lines 5802-5803 lower the same base again before the actual access. For next()?.[i], this can call next() twice and index a different value than the one that was null-checked.

Proposed fix
-    fn lower_optional_index(&mut self, base: AstExprId, index: AstExprId, dest: Place) {
+    fn lower_optional_index(&mut self, base: AstExprId, index: AstExprId, dest: Place) {
+        let base_ty = self.expr_ty(base);
         let base_op = self.lower_to_operand(base);
 
         let is_null = Rvalue::BinaryOp {
             op: BinOp::Eq,
-            left: base_op,
+            left: base_op.clone(),
             right: Operand::Constant(Constant::Null),
         };
@@
             self.builder.set_current_block(bb_access);
-            self.lower_optional_index_access(base, index, dest, bb_null);
+            self.lower_optional_index_access(base_op, &base_ty, index, dest, bb_null);
         } else {
@@
             self.builder.set_current_block(bb_access);
-            self.lower_optional_index_access(base, index, dest.clone(), bb_null);
+            self.lower_optional_index_access(base_op, &base_ty, index, dest.clone(), bb_null);
@@
     fn lower_optional_index_access(
         &mut self,
-        base: AstExprId,
+        base_op: Operand,
+        base_ty: &RuntimeTy,
         index: AstExprId,
         dest: Place,
         bb_null: BlockId,
     ) {
-        let base_ty = self.expr_ty(base);
-        let base_op = self.lower_to_operand(base);
         let index_ty = self.expr_ty(index);
@@
-        self.emit_index_access(base_op, &base_ty, index_op, index_ty, dest);
+        self.emit_index_access(base_op, base_ty, index_op, index_ty, dest);
     }

Also applies to: 5802-5803

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler2_mir/src/lower.rs` around lines 5748 -
5767, The code lowers the base expression twice: once at line 5748 to create the
null check (stored in base_op), and again around lines 5802-5803 during the
actual access. This causes side effects in the base expression to execute twice,
potentially null-checking one value but accessing a different one. Store the
lowered base operand from line 5748 in base_op and pass this pre-computed
operand to the lower_optional_index_access method instead of passing the raw
base expression again, ensuring the same value is used for both the null check
and the subsequent access.

11537-11573: ⚠️ Potential issue | 🟠 Major

Add index null-guard to optional-index lvalue lowering.

The rvalue path (lower_optional_index_access, lines 5791–5822) properly guards nullable index subscripts with a null-check that short-circuits to bb_null. However, the lvalue path (lines 11537–11587) only guards the base and directly uses the index operand without checking for null. While TIR's check_index_key_type with allow_null=true (line 4488) strips null from the index type at compile time, this narrowing is a type-level assumption that doesn't guarantee runtime non-nullability when the index is a plain variable (e.g., arr?.[i] where i: int?). The index guard in the rvalue path mirrors the base guard as documented (lines 5791–5794); the lvalue path should do the same.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler2_mir/src/lower.rs` around lines 11537 -
11573, The lvalue path for OptionalIndex is missing a null-guard for the index
operand. While the base is properly checked for null and guarded with a branch
to bb_null, the index operand is used directly without a null-check. The rvalue
path (lower_optional_index_access) correctly guards both base and index with
identical null-checks. Add an index null-guard after converting the base to a
local: create a null-check using BinaryOp::Eq comparing the index operand
against Constant::Null, assign the result to a temporary boolean local, branch
to bb_null if the index is null, and only proceed to use the index operand
within the bb_continue block. This mirrors the base guard pattern already
implemented in the same lvalue block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 5748-5767: The code lowers the base expression twice: once at line
5748 to create the null check (stored in base_op), and again around lines
5802-5803 during the actual access. This causes side effects in the base
expression to execute twice, potentially null-checking one value but accessing a
different one. Store the lowered base operand from line 5748 in base_op and pass
this pre-computed operand to the lower_optional_index_access method instead of
passing the raw base expression again, ensuring the same value is used for both
the null check and the subsequent access.
- Around line 11537-11573: The lvalue path for OptionalIndex is missing a
null-guard for the index operand. While the base is properly checked for null
and guarded with a branch to bb_null, the index operand is used directly without
a null-check. The rvalue path (lower_optional_index_access) correctly guards
both base and index with identical null-checks. Add an index null-guard after
converting the base to a local: create a null-check using BinaryOp::Eq comparing
the index operand against Constant::Null, assign the result to a temporary
boolean local, branch to bb_null if the index is null, and only proceed to use
the index operand within the bb_continue block. This mirrors the base guard
pattern already implemented in the same lvalue block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3b1b712f-161d-400a-8dc3-0a3c54757795

📥 Commits

Reviewing files that changed from the base of the PR and between 475a52c and 03ffc3b.

📒 Files selected for processing (1)
  • baml_language/crates/baml_compiler2_mir/src/lower.rs

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 16, 2026 22:44 Inactive
@codeshaunted
codeshaunted added this pull request to the merge queue Jun 16, 2026
Merged via the queue into canary with commit 8921e4c Jun 16, 2026
52 checks passed
@codeshaunted
codeshaunted deleted the avery/b-236 branch June 16, 2026 23:07

This branch was previously deployed

3 inactive deployments
Preview – promptfiddle — 03ffc3b9 Deployed Jun 16, 2026 by vercel[bot]
Preview – promptfiddle2 — 03ffc3b9 Deployed Jun 16, 2026 by vercel[bot]
Preview – beps — 03ffc3b9 Deployed Jun 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant