aver-lang 0.27.0

VM and transpiler for Aver, a statically-typed language designed for AI-assisted development
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
//! HIR → MIR lowering, waves 1 + 2 + 3a + 3b + 3c (all sub-waves).
//!
//! Phase 3 of #252 lowers `ResolvedProgramView` into `MirProgram`
//! in widening waves so review surface stays small.
//!
//! **Wave 1** — leaf subset, no callee resolver, no patterns:
//! - `ResolvedExpr::Literal` → `MirExpr::Literal`
//! - `ResolvedExpr::Resolved { slot, .. }` → `MirExpr::Local`
//! - `ResolvedExpr::BinOp` → `MirExpr::BinOp`
//! - `ResolvedExpr::Neg` → `MirExpr::Neg`
//!
//! **Wave 2 (added in this commit)** — call shapes + product types:
//! - `ResolvedExpr::Call(Fn, args)` → `MirExpr::Call` w/ `MirCallee::Fn(FnId)`
//! - `ResolvedExpr::Call(Builtin, args)` → `MirExpr::Call` w/ `MirCallee::Builtin(name)`
//! - `ResolvedExpr::Ctor(User { ctor_id, .. }, args)` → `MirExpr::Construct`
//! - `ResolvedExpr::RecordCreate { type_id: Some(_), … }` → `MirExpr::RecordCreate`
//! - `ResolvedExpr::RecordUpdate { type_id: Some(_), … }` → `MirExpr::RecordUpdate`
//! - `ResolvedExpr::Attr(base, field)` → `MirExpr::Project`
//!
//! **Wave 3a (this commit)** — multi-stmt bodies via `Let` chains:
//! - `Stmt::Binding { name, value }` + `Stmt::Expr(value)` in
//!   sequence ending in `Stmt::Expr` right-folds into nested
//!   `MirExpr::Let { binding, value, body }` nodes.
//! - Binding slot comes from `FnResolution.local_slots[name]`;
//!   intermediate `Stmt::Expr` gets a fresh synthetic `LocalId`
//!   drawn from a counter starting at `local_count`.
//!
//! **Wave 3b (this commit)** — structured `match` + patterns:
//! - `ResolvedExpr::Match { subject, arms }` → `MirExpr::Match`.
//! - User-ctor patterns (`ResolvedCtor::User { ctor_id, .. }`)
//!   lower to `MirPattern::Ctor { ctor: MirCtor::User(_), bindings }`.
//! - Cons / Tuple / Ident / Literal / Wildcard / EmptyList lower
//!   structurally. Pattern bindings draw their `LocalId`s from
//!   `ResolvedMatchArm.binding_slots` (preorder-flat, same shape
//!   `ast_rewrite::pattern_binding_names` walks).
//! - Built-in constructor patterns (`Result.Ok`, `Option.Some`,
//!   …) stay wave 3c territory — fns matching on them are
//!   silently skipped, same skip-rule that wave 2 applied to
//!   built-in ctor *constructions*.
//!
//! **Wave 3c-i (this commit)** — typed identity for built-in
//!   constructors:
//! - `ResolvedExpr::Ctor(Builtin(bc), args)` →
//!   `MirConstruct { ctor: MirCtor::Builtin(bc), args }`.
//! - `ResolvedPattern::Ctor(Builtin(bc), names)` →
//!   `MirPattern::Ctor { ctor: MirCtor::Builtin(bc), bindings }`.
//! - User ctors stay on `MirCtor::User(CtorId)`; the two flavors
//!   ride the same node shape so backends pattern-match once.
//! - `SkipReason::BuiltinCtorConstruction` /
//!   `BuiltinCtorPattern` drop out of `LowerStats.skipped` (the
//!   variants stay in the enum for historical attribution).
//!
//! **Wave 3c-ii** — `Try` (`?` propagation): `ErrorProp(inner)` →
//! `MirExpr::Try(inner)`. RFC pin: stays a node, backends pick
//! the final shape. The `let x = step()?; body` form composes
//! through wave 3a's stmt-chain (`Let { value: Try(_), … }`).
//!
//! **Wave 3c-iii** — tail calls: `TailCall { target, args }` →
//! `MirExpr::TailCall(MirTailCall { target: FnId, args })`. TCO
//! upstream already classified the call; MIR preserves `FnId`
//! identity. Backends pick wasm-gc tail-call insn / VM tail
//! dispatch / Rust loop rewrite.
//!
//! **Wave 3c-iv** — collection literals: `List` / `Tuple` /
//! `MapLiteral` / `InterpolatedStr` all lower element-wise; the
//! outer MIR node carries the structural shape. Note: `interp_lower`
//! upstream of MIR desugars interpolated strings into buffer-build
//! calls before MIR sees the tree, so `InterpolatedStr` is rarely
//! reached in practice — the lowering exists for symmetry.
//!
//! **Wave 3c-v** — `IndependentProduct(items, unwrap_results)` →
//! `MirExpr::IndependentProduct { items, unwrap_results }`. The
//! compile-time independence mode (`complete` / `cancel` /
//! `sequential`) is NOT carried in MIR per RFC — that's an
//! aver.toml runtime policy decision.
//!
//! Final remaining `SkipReason`s after the first-class-fn waves:
//! - `MissingResolution` / `EmptyBody` / `BindingOnlyTail` /
//!   `BindingSlotLookupMissing` / `PatternSlotShortfall` —
//!   defensive guards for upstream pipeline gaps.
//! - `UnsupportedCallee` — only `Unresolved` callees (typecheck-error
//!   recovery) now. Buffer intrinsics (`MirCallee::Intrinsic`) and
//!   first-class-fn *calls* (`MirCallee::LocalSlot` → `CALL_VALUE`)
//!   lower.
//!
//! Fn-value *passing* (`callWith(dbl)` → bare `dbl`) used to drop as
//! `UnresolvedIdent`; it now lowers to `MirExpr::FnValue`, which the VM
//! walker resolves through the shared `compile_ident` symbol path. The
//! `UnresolvedIdent` variant is retained for the coverage gate's
//! "no-longer-drops" assertion but is no longer produced.
//! - `BuiltinRecord` / `BuiltinCtorConstruction` / `BuiltinCtorPattern`
//!   / `UnsupportedTry` / `UnsupportedTailCall` / `UnsupportedList` /
//!   `UnsupportedTuple` / `UnsupportedMap` / `UnsupportedInterpolatedStr`
//!   / `UnsupportedIndependentProduct` — kept in the enum for historical
//!   attribution; no longer reachable from the lowerer (built-in records
//!   now ride `MirRecordCreate.type_name` when they carry no `TypeId`).
//!
//! Functions that use anything outside the waves' supported
//! subset are dropped — `MirProgram.fns` only contains what the
//! waves so far knew how to handle. Every drop bumps a counter
//! on `MirProgram.stats.skipped` keyed by the first
//! [`SkipReason`](crate::ir::mir::SkipReason) the lowerer hit
//! inside the fn. Phase 4's coverage gate consumes that to
//! prove the lowered subset covers the corpus before the VM
//! slice migrates.
//!
//! ## LocalId mapping
//!
//! Wave 1+2 use the resolver's slot index
//! (`ResolvedExpr::Resolved { slot, .. }`) directly as the
//! `LocalId`. Phase 2's RFC pin was "assign at MIR construction
//! time" — that gives the future optimizer freedom to renumber,
//! but during lowering itself we use the slot the resolver
//! already assigned. The slot is already unique per function
//! body, so wave 1+2 don't need a fresh counter yet. Wave 3
//! will introduce one when `Let` bindings start producing fresh
//! locals that the resolver didn't see.

use crate::ast::{FnResolution, Spanned};
use crate::ir::hir::{
    ResolvedCallee, ResolvedCtor, ResolvedExpr, ResolvedFnBody, ResolvedFnDef, ResolvedMatchArm,
    ResolvedPattern, ResolvedStmt, ResolvedTopLevel,
};

use super::expr::{
    MirBinOp, MirCall, MirCallee, MirConstruct, MirCtor, MirEffectAnnotation, MirExpr, MirLet,
    MirMatch, MirMatchArm, MirPattern, MirProject, MirRecordCreate, MirRecordField,
    MirRecordUpdate,
};
use super::program::{LocalId, MirFn, MirParam, MirProgram};
use super::stats::SkipReason;

/// Lower an entry-module resolved-item list into a `MirProgram`.
/// Function bodies outside the supported subset are dropped —
/// `MirProgram.fns` only contains what the lowerer's waves so far
/// knew how to handle. Every drop bumps a counter on
/// `MirProgram.stats.skipped` keyed by the first `SkipReason` the
/// lowerer hit inside the fn, so Phase 4's coverage gate can prove
/// that the lowered subset actually covers the corpus.
pub fn lower_program(items: &[ResolvedTopLevel]) -> MirProgram {
    let mut program = MirProgram::empty();
    for item in items {
        let ResolvedTopLevel::FnDef(fd) = item else {
            continue;
        };
        match lower_fn(fd, &mut program) {
            Ok(mir_fn) => {
                program.fns.insert(fd.fn_id, mir_fn);
                program.stats.record_lowered();
            }
            Err(reason) => {
                program.stats.record_skip(reason);
            }
        }
    }
    program
}

/// Lower a single top-level statement's value expression to MIR.
///
/// Top-level module statements (`x = expr` / a bare `expr` at module
/// scope) aren't part of any `ResolvedFnDef`, so they don't go through
/// `lower_program`. The VM compiler resolves them on the fly and lowers
/// each value here against the supplied `program` (whose builtin /
/// instantiation tables grow consistently with the entry program). The
/// caller walks the result with the MIR walker and emits the
/// `STORE_GLOBAL` / `POP` itself, so no MIR-level global-binding node is
/// needed. Returns `Err(SkipReason)` if the expression is outside the
/// lowerable subset.
pub fn lower_top_level_value(
    value: &Spanned<ResolvedExpr>,
    program: &mut MirProgram,
) -> Result<Spanned<MirExpr>, SkipReason> {
    lower_expr(value, program)
}

/// Lower one `ResolvedFnDef` if its body fits the supported subset.
/// Returns `Err(SkipReason)` for the dominant unsupported shape —
/// the caller drops the fn from the MIR program and records the
/// reason in `LowerStats.skipped`.
fn lower_fn(fd: &ResolvedFnDef, program: &mut MirProgram) -> Result<MirFn, SkipReason> {
    let ResolvedFnBody::Block(stmts) = &*fd.body;
    if stmts.is_empty() {
        return Err(SkipReason::EmptyBody);
    }
    // Synthetic-local counter starts past the resolver's slots so the
    // stmt-chain lowering can mint fresh slots for opaque-let temps.
    // Its final value is the function's true local_count — the VM
    // walker must reserve this many frame slots, not just the
    // resolver's `local_count`, or a `STORE_LOCAL` to a synthetic slot
    // overruns the frame.
    let base_local_count = fd
        .resolution
        .as_ref()
        .map_or(fd.params.len() as u32, |r| u32::from(r.local_count));
    let mut next_synthetic_local = base_local_count;
    let body = match stmts.len() {
        1 => {
            // Single-stmt body: must be `Expr`. Bindings alone
            // can't produce a value. No opaque-let temps here, so the
            // counter stays at `base_local_count`.
            let ResolvedStmt::Expr(expr) = &stmts[0] else {
                return Err(SkipReason::BindingOnlyTail);
            };
            lower_expr(expr, program)?
        }
        _ => {
            // Multi-stmt body (wave 3a): right-fold into `Let`
            // chains. Last stmt must be `Expr` — it's the body's
            // final value; everything before it gets opaque-let'd
            // so effectful intermediate calls survive into MIR.
            let resolution = fd
                .resolution
                .as_ref()
                .ok_or(SkipReason::MissingResolution)?;
            lower_stmt_chain(stmts, resolution, &mut next_synthetic_local, program)?
        }
    };
    let local_count = next_synthetic_local;

    let params = fd
        .params
        .iter()
        .enumerate()
        .map(|(i, (name, ty))| MirParam {
            // Wave 1's local numbering matches the resolver's
            // parameter-slot convention (params occupy the lowest
            // slot indices). When wave 2 adds let bindings, they
            // continue from `params.len()`.
            local: LocalId(i as u32),
            name: name.clone(),
            ty: format!("{ty:?}"),
        })
        .collect();
    let effects = fd
        .effects
        .iter()
        .map(|e| MirEffectAnnotation {
            name: e.node.clone(),
        })
        .collect();
    Ok(MirFn {
        fn_id: fd.fn_id,
        name: fd.name.clone(),
        params,
        return_type: format!("{:?}", fd.return_type),
        effects,
        body,
        local_count,
        // Carry the resolver's per-slot alias table onto the MIR fn so
        // backends read ownership off MIR (see `MirFn::aliased_slots`).
        // Fns lowered without a resolution (single-expr bodies that
        // skipped slot resolution) get an empty table — every slot then
        // reads not-aliased, matching the resolver's out-of-range
        // default.
        aliased_slots: fd
            .resolution
            .as_ref()
            .map(|r| r.aliased_slots.clone())
            .unwrap_or_default(),
        // Int-representation tags stay empty at lowering: they are filled
        // only by the late `bare_i64::rewrite_for_rust` pass on the Rust
        // codegen clone. Everywhere else, every Int value is boxed.
        repr: super::program::MirFnRepr::default(),
    })
}

/// Expression lowering. Returns `Err(SkipReason)` for any
/// construct outside the supported subset so `lower_fn` can drop
/// the whole function and record the dominant reason.
fn lower_expr(
    expr: &Spanned<ResolvedExpr>,
    program: &mut MirProgram,
) -> Result<Spanned<MirExpr>, SkipReason> {
    let mir = match &expr.node {
        // ── Wave 1 ──────────────────────────────────────────────
        ResolvedExpr::Literal(lit) => MirExpr::Literal(wrap(lit.clone(), expr)),
        ResolvedExpr::Resolved {
            slot,
            name,
            last_use,
        } => {
            let local = super::expr::MirLocal {
                slot: LocalId(u32::from(*slot)),
                last_use: last_use.0,
                name: name.clone(),
            };
            MirExpr::Local(wrap(local, expr))
        }
        ResolvedExpr::BinOp(op, lhs, rhs) => MirExpr::BinOp(wrap(
            MirBinOp {
                op: *op,
                lhs: Box::new(lower_expr(lhs, program)?),
                rhs: Box::new(lower_expr(rhs, program)?),
            },
            expr,
        )),
        ResolvedExpr::Neg(inner) => MirExpr::Neg(Box::new(lower_expr(inner, program)?)),

        // ── Wave 2 ──────────────────────────────────────────────
        ResolvedExpr::Call(callee, args) => {
            let mir_callee = match callee {
                ResolvedCallee::Fn(fn_id) => MirCallee::Fn(*fn_id),
                ResolvedCallee::Builtin(name) => MirCallee::Builtin(program.intern_builtin(name)),
                // Synthesis-only buffer-build / stringify intrinsics from
                // the deforestation pass — carried through so the walker
                // emits the BUFFER_* / CONCAT opcodes instead of dropping
                // the fn to the HIR fallback.
                ResolvedCallee::Intrinsic(intrinsic) => MirCallee::Intrinsic(*intrinsic),
                // First-class fn value in a local slot — `f(x)` where `f`
                // is a `Fn(..)` param / let-bound fn. Dispatches through
                // the VM's `CALL_VALUE` at the walker. `name` is threaded
                // verbatim so the Rust walker can emit the fn-pointer call
                // by name; the VM addresses by slot and ignores it.
                ResolvedCallee::LocalSlot {
                    slot,
                    name,
                    last_use,
                } => MirCallee::LocalSlot {
                    slot: *slot,
                    name: name.clone(),
                    last_use: last_use.0,
                },
                // Unresolved callees (typecheck-rejected recovery) still
                // ride the HIR fallback.
                ResolvedCallee::Unresolved { .. } => {
                    return Err(SkipReason::UnsupportedCallee);
                }
            };
            let mir_args = args
                .iter()
                .map(|e| lower_expr(e, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::Call(wrap(
                MirCall {
                    callee: mir_callee,
                    args: mir_args,
                },
                expr,
            ))
        }
        ResolvedExpr::Ctor(ResolvedCtor::User { ctor_id, .. }, args) => {
            let mir_args = args
                .iter()
                .map(|e| lower_expr(e, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::Construct(wrap(
                MirConstruct {
                    ctor: MirCtor::User(*ctor_id),
                    args: mir_args,
                },
                expr,
            ))
        }
        // Wave 3c-i: built-in ctors (`Result.Ok` / `Result.Err` /
        // `Option.Some` / `Option.None`) lower through
        // `MirCtor::Builtin`, riding the same `MirConstruct` shape
        // as user ctors. Backends pick their final emit; until
        // then `Construct(Builtin(_), …)` is the canonical form.
        ResolvedExpr::Ctor(ResolvedCtor::Builtin(bc), args) => {
            let mir_args = args
                .iter()
                .map(|e| lower_expr(e, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::Construct(wrap(
                MirConstruct {
                    ctor: MirCtor::Builtin(*bc),
                    args: mir_args,
                },
                expr,
            ))
        }
        ResolvedExpr::Ctor(ResolvedCtor::Unresolved { .. }, _) => {
            return Err(SkipReason::UnresolvedCtor);
        }
        // User records carry a `TypeId`; built-in product types
        // (`HttpResponse`, `Header`, `Buffer`, …) carry `None` and ride
        // their canonical `type_name`. Both lower the same shape — the
        // walker resolves the arena type via `TypeId` when present, else
        // by name.
        ResolvedExpr::RecordCreate {
            type_id,
            type_name,
            fields,
        } => {
            let mir_fields = fields
                .iter()
                .map(|(name, value)| {
                    Ok(MirRecordField {
                        name: name.clone(),
                        value: lower_expr(value, program)?,
                    })
                })
                .collect::<Result<Vec<_>, SkipReason>>()?;
            MirExpr::RecordCreate(wrap(
                MirRecordCreate {
                    type_id: *type_id,
                    type_name: type_name.clone(),
                    fields: mir_fields,
                },
                expr,
            ))
        }
        ResolvedExpr::RecordUpdate {
            type_id,
            type_name,
            base,
            updates,
        } => {
            let mir_updates = updates
                .iter()
                .map(|(name, value)| {
                    Ok(MirRecordField {
                        name: name.clone(),
                        value: lower_expr(value, program)?,
                    })
                })
                .collect::<Result<Vec<_>, SkipReason>>()?;
            MirExpr::RecordUpdate(wrap(
                MirRecordUpdate {
                    base: Box::new(lower_expr(base, program)?),
                    type_id: *type_id,
                    type_name: type_name.clone(),
                    updates: mir_updates,
                },
                expr,
            ))
        }
        ResolvedExpr::Attr(base, field) => MirExpr::Project(wrap(
            MirProject {
                base: Box::new(lower_expr(base, program)?),
                field: field.clone(),
            },
            expr,
        )),

        // ── Wave 3b ─────────────────────────────────────────────
        ResolvedExpr::Match { subject, arms } => {
            let mir_subject = lower_expr(subject, program)?;
            let mir_arms = arms
                .iter()
                .map(|a| lower_arm(a, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::Match(wrap(
                MirMatch {
                    subject: Box::new(mir_subject),
                    arms: mir_arms,
                },
                expr,
            ))
        }

        // ── Wave 3c-ii ──────────────────────────────────────────
        // `expr?` — `?` propagation. RFC-pinned: stays a node.
        // Backends pick the final shape (Rust `?`, VM tag-check +
        // early return, wasm-gc `br_on_struct`). The bind-and-
        // propagate form `let x = step()?; body` composes via Let
        // wrapping a Try (wave 3a's right-fold does this for free
        // — `step()?` lowers to `Try(step())` and the Let-chain
        // walker places it on the Let's `value` side).
        ResolvedExpr::ErrorProp(inner) => MirExpr::Try(Box::new(lower_expr(inner, program)?)),

        // ── Wave 3c-iii ─────────────────────────────────────────
        // Tail call — same SCC as the surrounding fn. The TCO pass
        // upstream of MIR already classified the call as tail
        // position and emitted `ResolvedExpr::TailCall`. Backends
        // pick the final shape: wasm-gc tail-call instructions,
        // VM tail dispatch, Rust loop rewrite. `FnId` is preserved
        // — no name lookup on the backend side.
        ResolvedExpr::TailCall { target, args } => {
            let mir_args = args
                .iter()
                .map(|e| lower_expr(e, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::TailCall(wrap(
                super::expr::MirTailCall {
                    target: *target,
                    args: mir_args,
                },
                expr,
            ))
        }

        // ── Wave 3c-iv ──────────────────────────────────────────
        // Collection literals — lists, tuples, maps, interpolated
        // strings. Each lowers element-by-element through
        // `lower_expr`; the outer node carries the structural
        // shape so backends pick their build strategy (List
        // prepend chain vs vec-grow, Map flat-hashtable vs HAMT,
        // interpolation buffer-build vs format-string).
        ResolvedExpr::List(items) => {
            let mir_items = items
                .iter()
                .map(|e| lower_expr(e, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::List(mir_items)
        }
        ResolvedExpr::Tuple(items) => {
            let mir_items = items
                .iter()
                .map(|e| lower_expr(e, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::Tuple(mir_items)
        }
        ResolvedExpr::MapLiteral(pairs) => {
            let mir_pairs = pairs
                .iter()
                .map(|(k, v)| Ok((lower_expr(k, program)?, lower_expr(v, program)?)))
                .collect::<Result<Vec<_>, SkipReason>>()?;
            MirExpr::MapLiteral(mir_pairs)
        }
        ResolvedExpr::InterpolatedStr(parts) => {
            let mir_parts = parts
                .iter()
                .map(|p| match p {
                    crate::ir::hir::ResolvedStrPart::Literal(s) => {
                        Ok(super::expr::MirStrPart::Literal(s.clone()))
                    }
                    crate::ir::hir::ResolvedStrPart::Parsed(inner) => {
                        Ok(super::expr::MirStrPart::Expr(lower_expr(inner, program)?))
                    }
                })
                .collect::<Result<Vec<_>, SkipReason>>()?;
            MirExpr::InterpolatedStr(mir_parts)
        }

        // ── Wave 3c-v ───────────────────────────────────────────
        // `(a, b, c)!` (raw tuple of Results) or `(a, b, c)?!`
        // (unwrap each Ok, propagate first Err). The compile-time
        // independence mode (`complete` / `cancel` / `sequential`)
        // is NOT carried in MIR per RFC — that's an aver.toml
        // runtime policy. MIR represents only the source-level
        // shape: items + the `unwrap_results` bool.
        ResolvedExpr::IndependentProduct(items, unwrap_results) => {
            let mir_items = items
                .iter()
                .map(|e| lower_expr(e, program))
                .collect::<Result<Vec<_>, _>>()?;
            MirExpr::IndependentProduct(wrap(
                super::expr::MirIndependentProduct {
                    items: mir_items,
                    unwrap_results: *unwrap_results,
                },
                expr,
            ))
        }

        // ── Final catch-all ─────────────────────────────────────
        // A bare ident that survived resolution is a fn referenced as
        // a *value* (`callWith(dbl)` passes `dbl`) — the resolver
        // already turned locals into `Resolved`, nullary ctors into
        // `Ctor`, and builtins into `Builtin`, so what's left is a
        // static fn/builtin name. Carry it as `FnValue`; the backend
        // resolves the symbol. A genuinely-dangling name (typecheck-
        // rejected input) flows through too and surfaces as an
        // unresolved-symbol error at the backend, not a lowering drop.
        ResolvedExpr::Ident(name) => MirExpr::FnValue(name.clone()),
    };
    Ok(wrap(mir, expr))
}

/// Wave 3b — lower one resolved match arm. Pattern bindings draw
/// their `LocalId`s from `arm.binding_slots`, which the resolver
/// fills in preorder of the bindings as they appear in the
/// pattern (same walk as `ast_rewrite::pattern_binding_names`).
/// Returns `Err(SkipReason)` for built-in / unresolved ctor
/// patterns or for slot-count desyncs.
fn lower_arm(arm: &ResolvedMatchArm, program: &mut MirProgram) -> Result<MirMatchArm, SkipReason> {
    let slots: &[u16] = arm.binding_slots.get().map(Vec::as_slice).unwrap_or(&[]);
    let mut cursor = 0usize;
    let pattern = lower_pattern(&arm.pattern, slots, &mut cursor)?;
    if cursor != slots.len() {
        return Err(SkipReason::PatternSlotShortfall);
    }
    let body = lower_expr(&arm.body, program)?;
    Ok(MirMatchArm { pattern, body })
}

/// Wave 3b — lower a `ResolvedPattern` while consuming binding
/// slots from `slots[*cursor..]` in preorder. Returns
/// `Err(SkipReason)` on built-in / unresolved ctor patterns or on
/// a slot shortfall.
fn lower_pattern(
    pattern: &ResolvedPattern,
    slots: &[u16],
    cursor: &mut usize,
) -> Result<MirPattern, SkipReason> {
    Ok(match pattern {
        ResolvedPattern::Wildcard => MirPattern::Wildcard,
        ResolvedPattern::Literal(lit) => MirPattern::Literal(lit.clone()),
        ResolvedPattern::Ident(name) => {
            let slot = take_slot(slots, cursor)?;
            MirPattern::Bind(LocalId(u32::from(slot)), name.clone())
        }
        ResolvedPattern::EmptyList => MirPattern::EmptyList,
        ResolvedPattern::Cons(head_name, tail_name) => {
            let head = take_slot(slots, cursor)?;
            let tail = take_slot(slots, cursor)?;
            MirPattern::Cons {
                head: LocalId(u32::from(head)),
                head_name: head_name.clone(),
                tail: LocalId(u32::from(tail)),
                tail_name: tail_name.clone(),
            }
        }
        ResolvedPattern::Tuple(items) => {
            let mir_items = items
                .iter()
                .map(|p| lower_pattern(p, slots, cursor))
                .collect::<Result<Vec<_>, _>>()?;
            MirPattern::Tuple(mir_items)
        }
        ResolvedPattern::Ctor(ResolvedCtor::User { ctor_id, .. }, names) => {
            let bindings = take_pattern_bindings(slots, cursor, names.len())?;
            MirPattern::Ctor {
                ctor: MirCtor::User(*ctor_id),
                bindings,
                binding_names: names.clone(),
            }
        }
        // Wave 3c-i — built-in ctor patterns ride the same shape
        // as user-ctor patterns via `MirCtor::Builtin`.
        ResolvedPattern::Ctor(ResolvedCtor::Builtin(bc), names) => {
            let bindings = take_pattern_bindings(slots, cursor, names.len())?;
            MirPattern::Ctor {
                ctor: MirCtor::Builtin(*bc),
                bindings,
                binding_names: names.clone(),
            }
        }
        // Unresolved ctors still drop — typechecker already
        // surfaced the error; MIR refuses to emit half-resolved
        // identity.
        ResolvedPattern::Ctor(ResolvedCtor::Unresolved { .. }, _) => {
            return Err(SkipReason::UnresolvedCtor);
        }
    })
}

/// Helper for ctor-pattern lowering — consume `arity` slots in
/// preorder. Used by both user-ctor and built-in-ctor branches so
/// the slot-cursor advance rule lives in one place.
fn take_pattern_bindings(
    slots: &[u16],
    cursor: &mut usize,
    arity: usize,
) -> Result<Vec<LocalId>, SkipReason> {
    let mut bindings = Vec::with_capacity(arity);
    for _ in 0..arity {
        let slot = take_slot(slots, cursor)?;
        bindings.push(LocalId(u32::from(slot)));
    }
    Ok(bindings)
}

fn take_slot(slots: &[u16], cursor: &mut usize) -> Result<u16, SkipReason> {
    let slot = *slots.get(*cursor).ok_or(SkipReason::PatternSlotShortfall)?;
    *cursor += 1;
    Ok(slot)
}

/// Wrap a freshly-lowered node in `Spanned` while inheriting the
/// source's line. Phase 6 wave 1: propagate the HIR-side type
/// stamp into MIR so downstream consumers (VM walker's typed
/// opcodes, future inliner / monomorphizer) can read
/// `mir_expr.ty()` without re-deriving the type.
fn wrap<T, U>(node: T, source: &Spanned<U>) -> Spanned<T> {
    let ty = std::sync::OnceLock::new();
    if let Some(t) = source.ty() {
        let _ = ty.set(t.clone());
    }
    Spanned {
        node,
        line: source.line,
        ty,
    }
}

/// Right-fold a list of `ResolvedStmt`s into a single `MirExpr`
/// via `Let` nesting. Wave 3a — the first lowering that produces
/// multi-stmt bodies.
///
/// Rules:
/// - The last stmt must be `Stmt::Expr`; it becomes the innermost
///   body of the resulting `Let` chain.
/// - `Stmt::Binding { name, value }` uses the slot the resolver
///   already assigned (`resolution.local_slots[name]`) as the
///   binding's `LocalId`. Missing slot → the fn is skipped.
/// - `Stmt::Expr` at non-tail position is treated as a let
///   binding to a fresh synthetic `LocalId` so its (potentially
///   effectful) value still gets evaluated. The synthetic
///   counter starts at `resolution.local_count` so it can't
///   collide with the resolver's own slots.
fn lower_stmt_chain(
    stmts: &[ResolvedStmt],
    resolution: &FnResolution,
    next_synthetic_local: &mut u32,
    program: &mut MirProgram,
) -> Result<Spanned<MirExpr>, SkipReason> {
    let (last, rest) = stmts.split_last().ok_or(SkipReason::EmptyBody)?;
    // Last stmt must produce a value.
    let ResolvedStmt::Expr(tail_expr) = last else {
        return Err(SkipReason::BindingOnlyTail);
    };
    let mut body = lower_expr(tail_expr, program)?;

    // Right-fold: walk earlier stmts in reverse, wrapping each
    // around the accumulating `body`.
    for stmt in rest.iter().rev() {
        let (binding, binding_name, value_expr) = match stmt {
            ResolvedStmt::Binding { name, value, .. } if name == "_" => {
                // Discard binding `_ = effect()?` — the idiomatic "run an
                // effect, drop the Ok, continue" form. The resolver
                // assigns `_` no slot (it returns `u16::MAX` and never
                // inserts it), so there's nothing to look up. Evaluate the
                // value for its effects under a fresh synthetic slot the
                // body never reads, exactly as a non-tail `Stmt::Expr`.
                let fresh = LocalId(*next_synthetic_local);
                *next_synthetic_local += 1;
                (fresh, String::new(), value)
            }
            ResolvedStmt::Binding { name, value, .. } => {
                let slot = *resolution
                    .local_slots
                    .get(name)
                    .ok_or(SkipReason::BindingSlotLookupMissing)?;
                (LocalId(u32::from(slot)), name.clone(), value)
            }
            ResolvedStmt::Expr(expr) => {
                let fresh = LocalId(*next_synthetic_local);
                *next_synthetic_local += 1;
                // Synthetic introduction — no source-level name.
                (fresh, String::new(), expr)
            }
        };
        let mir_value = lower_expr(value_expr, program)?;
        let span = mir_value.line;
        body = Spanned {
            node: MirExpr::Let(Spanned {
                node: MirLet {
                    binding,
                    binding_name,
                    value: Box::new(mir_value),
                    body: Box::new(body),
                },
                line: span,
                ty: std::sync::OnceLock::new(),
            }),
            line: span,
            ty: std::sync::OnceLock::new(),
        };
    }
    Ok(body)
}