const std = @import("std");
const Allocator = std.mem.Allocator;
const ast = @import("../ast.zig");
const Node = ast.Node;
const types = @import("types.zig");
const inst_mod = @import("inst.zig");
const mod_mod = @import("module.zig");
const type_bridge = @import("type_bridge.zig");
const unescape = @import("../unescape.zig");
const parser_mod = @import("../parser.zig");
const interp_mod = @import("interp.zig");
const errors = @import("../errors.zig");
const jni_descriptor = @import("jni_descriptor.zig");
const program_index_mod = @import("program_index.zig");
const ProgramIndex = program_index_mod.ProgramIndex;
const GlobalInfo = program_index_mod.GlobalInfo;
const StructTemplate = program_index_mod.StructTemplate;
const TemplateParam = program_index_mod.TemplateParam;
const ProtocolDeclInfo = program_index_mod.ProtocolDeclInfo;
const ProtocolMethodInfo = program_index_mod.ProtocolMethodInfo;
const ModuleConstInfo = program_index_mod.ModuleConstInfo;
const TypeResolver = @import("type_resolver.zig").TypeResolver;
const ResolveEnv = @import("type_resolver.zig").ResolveEnv;
const PackResolver = @import("packs.zig").PackResolver;
const ExprTyper = @import("expr_typer.zig").ExprTyper;
const CallResolver = @import("calls.zig").CallResolver;
const GenericResolver = @import("generics.zig").GenericResolver;
const ProtocolResolver = @import("protocols.zig").ProtocolResolver;
const CoercionResolver = @import("conversions.zig").CoercionResolver;
const semantic_diagnostics = @import("semantic_diagnostics.zig");

const TypeId = types.TypeId;
const StringId = types.StringId;
const Ref = inst_mod.Ref;
const BlockId = inst_mod.BlockId;
const FuncId = inst_mod.FuncId;
const Function = inst_mod.Function;
const Module = mod_mod.Module;
const Builder = mod_mod.Builder;

/// Names that must keep external LLVM linkage because the OS loader (not
/// sx code) is the caller. Without this they'd default to internal and
/// either DCE away or stay hidden from the dynamic symbol table.
/// Anything starting with `Java_` is a JNI native method that Android's
/// runtime resolves by name mangling — same rule.
fn isExportedEntryName(name: []const u8) bool {
    return std.mem.eql(u8, name, "main") or
        std.mem.eql(u8, name, "JNI_OnLoad") or
        std.mem.startsWith(u8, name, "Java_");
}

// ── Scope ───────────────────────────────────────────────────────────────

pub const Binding = struct {
    ref: Ref,
    ty: TypeId,
    is_alloca: bool, // true if ref is a pointer that needs load
    is_ref_capture: bool = false, // `for xs: (*x)` — `ref` is `*elem`; auto-deref in value positions
};

// `init` / `deinit` / `put` are pub so collaborator unit tests (e.g.
// calls.test.zig) can stand up a lexical scope and exercise the
// scope-dependent call forms (closure / fn-pointer callees) without
// driving a full function lowering.
pub const Scope = struct {
    map: std.StringHashMap(Binding),
    fn_names: std.StringHashMap([]const u8), // bare name → mangled name for local functions
    parent: ?*Scope,

    pub fn init(alloc: Allocator, parent: ?*Scope) Scope {
        return .{
            .map = std.StringHashMap(Binding).init(alloc),
            .fn_names = std.StringHashMap([]const u8).init(alloc),
            .parent = parent,
        };
    }

    pub fn deinit(self: *Scope) void {
        self.map.deinit();
        self.fn_names.deinit();
    }

    pub fn put(self: *Scope, name: []const u8, binding: Binding) void {
        self.map.put(name, binding) catch unreachable;
    }

    pub fn lookup(self: *const Scope, name: []const u8) ?Binding {
        if (self.map.get(name)) |b| return b;
        if (self.parent) |p| return p.lookup(name);
        return null;
    }

    pub fn lookupFn(self: *const Scope, name: []const u8) ?[]const u8 {
        if (self.fn_names.get(name)) |mangled| return mangled;
        if (self.parent) |p| return p.lookupFn(name);
        return null;
    }
};

/// A pending block-scoped cleanup: `defer` (runs on every block exit) or
/// `onfail` (runs only when an error leaves the block, binding the in-flight
/// tag). Both share one declaration-ordered stack so error-exit cleanup runs
/// them interleaved in reverse order (ERR E1.7).
const CleanupEntry = struct {
    body: *const Node,
    is_onfail: bool,
    binding: ?[]const u8 = null,
};

// ── Lowering ────────────────────────────────────────────────────────────

pub const Lowering = struct {
    module: *Module,
    builder: Builder,
    alloc: Allocator,
    scope: ?*Scope = null,
    break_target: ?BlockId = null,
    continue_target: ?BlockId = null,
    block_counter: u32 = 0,
    comptime_counter: u32 = 0,
    main_file: ?[]const u8 = null, // path of the main file; imported functions are declared extern
    resolved_root: ?*const Node = null, // full AST root (for building comptime modules)
    comptime_param_nodes: ?std.StringHashMap(*const Node) = null, // active comptime substitutions
    target_type: ?TypeId = null, // target type for struct/enum literals without explicit names
    lowered_functions: std.StringHashMap(void), // tracks which functions have been fully lowered
    local_fn_counter: u32 = 0, // unique counter for mangling local function names
    /// Declaration-name / import / visibility facts (architecture phase A1,
    /// `ProgramIndex`). Owns `import_flags`; borrows `module_scopes` /
    /// `import_graph` from the compilation driver. Reached via
    /// `self.program_index.<field>`; populated by scan/registration code.
    program_index: ProgramIndex,
    current_source_file: ?[]const u8 = null, // source file of function currently being lowered
    // Implicit Context parameter machinery. When the program imports
    // `std.sx` (and therefore declares `Context :: struct {...}`), every
    // default-conv sx function gains a synthetic `__sx_ctx: *void` param
    // at slot 0, and `current_ctx_ref` is bound to that param on each
    // function-body entry. `lowerCall` / `call_indirect` prepend this ref
    // to the args of every sx-to-sx call. push Context.{...} rebinds it
    // to a stack-allocated Context for the lexical body. See
    // `~/.claude/plans/lets-see-options-for-merry-dijkstra.md`.
    implicit_ctx_enabled: bool = false,
    current_ctx_ref: Ref = Ref.none,
    sel_register_name_fid: ?FuncId = null, // lazily-declared `sel_registerName` extern (non-literal selector fallback)
    jni_env_stack: std.ArrayList(Ref) = std.ArrayList(Ref).empty, // lexical `#jni_env(env)` Ref stack — top is current scope's env for omitted-env `#jni_call`
    jni_env_stack_base: usize = 0, // index above which the currently-lowering fn's `#jni_env` scopes live; outer-fn Refs aren't valid in this fn's instruction stream
    jni_env_tl_get_fid: ?FuncId = null, // extern `sx_jni_env_tl_get` (from library/vendors/sx_jni_runtime/sx_jni_env_tl.c)
    jni_env_tl_set_fid: ?FuncId = null, // extern `sx_jni_env_tl_set`
    needs_jni_env_tl_runtime: bool = false, // set when lowering touches the JNI env TL; signals Compilation to auto-link the runtime .c
    trace_push_fid: ?FuncId = null, // extern `sx_trace_push` (ERR E3.1, from library/vendors/sx_trace_runtime/sx_trace.c)
    trace_clear_fid: ?FuncId = null, // extern `sx_trace_clear`
    needs_trace_runtime: bool = false, // set when lowering emits a trace push/clear; signals Compilation to auto-link sx_trace.c
    chain_fail_target: ?ChainFailTarget = null, // ERR E2.4: when set, a failable `or` chain routes its TOTAL failure here (an absorbing consumer like `catch`) instead of propagating to the function
    current_foreign_class: ?*const ast.ForeignClassDecl = null, // set while lowering a `#jni_main` (or any sx-defined `#jni_class`) bodied method — `super.method(args)` dispatch resolves the parent class against this fcd's `#extends`
    current_foreign_method: ?ast.ForeignMethodDecl = null, // the specific method whose body is being lowered; `super.<same_name>(...)` reuses its signature
    type_bindings: ?std.StringHashMap(TypeId) = null, // generic type param bindings ($T → concrete TypeId)
    current_match_tags: ?[]const u64 = null, // type tags for current match arm (for runtime dispatch)
    force_block_value: bool = false, // set by lowerBlockValue to extract if-else values
    block_terminated: bool = false, // set when constant-folded if emits a return/br into current block
    in_lambda_body: bool = false, // true while lowering a closure-literal body; sharpens the `raise`-not-failable diagnostic (ERR E5.1: tell the user to annotate `-> (T, !)`)
    defer_stack: std.ArrayList(CleanupEntry) = std.ArrayList(CleanupEntry).empty, // block-scoped defer + onfail cleanup stack
    func_defer_base: usize = 0, // defer stack base for current function (lowerReturn drains to this)
    deferred_type_fns: std.ArrayList([]const u8) = std.ArrayList([]const u8).empty, // functions deferred until all types registered
    processing_deferred: bool = false, // true when processing deferred functions (prevents re-deferral)
    struct_defaults_map: std.StringHashMap([]const ?*const Node) = std.StringHashMap([]const ?*const Node).init(std.heap.page_allocator), // struct name → field defaults
    struct_instance_bindings: std.StringHashMap(std.StringHashMap(TypeId)) = std.StringHashMap(std.StringHashMap(TypeId)).init(std.heap.page_allocator), // mangled struct name → type param bindings
    struct_instance_template: std.StringHashMap([]const u8) = std.StringHashMap([]const u8).init(std.heap.page_allocator), // mangled struct name → template name
    comptime_value_bindings: ?std.StringHashMap(i64) = null, // comptime value bindings ($N → integer value)
    protocol_thunk_map: std.StringHashMap([]const FuncId) = std.StringHashMap([]const FuncId).init(std.heap.page_allocator), // "Proto\x00Type" → thunk FuncIds
    protocol_vtable_type_map: std.StringHashMap(TypeId) = std.StringHashMap(TypeId).init(std.heap.page_allocator), // protocol name → vtable struct TypeId
    protocol_vtable_global_map: std.StringHashMap(inst_mod.GlobalId) = std.StringHashMap(inst_mod.GlobalId).init(std.heap.page_allocator), // "Proto\x00Type" → vtable GlobalId
    param_impl_map: std.StringHashMap(std.ArrayList(ParamImplEntry)) = std.StringHashMap(std.ArrayList(ParamImplEntry)).init(std.heap.page_allocator), // "Proto\x00<arg_mangled>\x00<src_mangled>" → impl entries (parameterised protocols only; list lets Phase 4/5 detect cross-module overlap)
    /// Pack-variadic impl entries — separate map keyed by `"Proto\x00<arg_mangled>"`
    /// (NO source suffix) so a single impl `Closure(..$args) -> $R` can be
    /// matched against many concrete source shapes. Concrete impls in
    /// `param_impl_map` win when both match (specificity rule).
    param_impl_pack_map: std.StringHashMap(std.ArrayList(PackParamImplEntry)) = std.StringHashMap(std.ArrayList(PackParamImplEntry)).init(std.heap.page_allocator),
    /// Active pack bindings during monomorphisation. Mirrors `type_bindings`
    /// but for variadic pack names: `args → [T1, T2, ...]`. Read by
    /// `resolveTypeWithBindings` on closure_type_expr to substitute
    /// `Closure(..$args) -> $R` into a concrete closure type.
    pack_bindings: ?std.StringHashMap([]const TypeId) = null,
    /// Active when lowering an inlined comptime-call body. `return X;`
    /// inside the body must NOT emit a `ret` into the caller's LLVM
    /// function — instead it stores X into `.slot` (typed `.ret_ty`)
    /// and sets `block_terminated` so the inliner can load the slot
    /// once the body finishes. Without this, a body like
    /// `{ return 42; }` truncates the caller's basic block mid-flight
    /// and trips LLVM's "Terminator found in the middle of a basic
    /// block" verifier.
    inline_return_target: ?InlineReturnInfo = null,
    /// Active pack-arg-node bindings during a comptime call's body lowering.
    /// Maps the pack-param name (e.g. `args`) to the slice of call-site
    /// argument AST nodes. `lowerIndexExpr` (and `inferExprType`) check
    /// this map when the index expression's base is an identifier matching
    /// a pack name AND the index is a comptime int literal — substitutes
    /// with the i-th call arg's lowered value so the static type tracks
    /// the call arg's real type instead of `Any`. The `[]Any` slice path
    /// remains the runtime-indexed fallback for non-literal indices.
    pack_arg_nodes: ?std.StringHashMap([]const *const Node) = null,
    /// Active pack-arity bindings during a pack-fn mono's body lowering.
    /// Maps the pack-param name (e.g. `args`) to N. `lowerFieldAccess`
    /// uses this to resolve `args.len` to a compile-time constant Ref
    /// when no `args` slice is in scope (the mono path doesn't
    /// materialise the slice).
    pack_param_count: ?std.StringHashMap(u32) = null,
    /// Type-only pack binding consulted by `inferExprType` for
    /// `args[<lit>]` (parallel to `pack_arg_nodes` which carries the
    /// AST substitution used at lowering time). Holds the concrete
    /// call-site arg types in declaration order — same data the
    /// mono's pack-param signature uses. Lets generic-`$R` return
    /// inference resolve `args[i]` to the correct concrete type even
    /// before the mono's scope is set up.
    pack_arg_types: ?std.StringHashMap([]const TypeId) = null,
    /// Active during a protocol-pack mono's body lowering: pack-param name →
    /// constraint protocol name (`..xs: Box` ⇒ `xs` → `"Box"`). Lets
    /// `lowerFieldAccess` enforce the interface-only rule — a member access
    /// `xs[i].<m>` is rejected unless `<m>` is one of the protocol's methods.
    /// Null / absent for the comptime `..$args` pack (no constraint).
    pack_constraint: ?std.StringHashMap([]const u8) = null,
    struct_const_map: std.StringHashMap(StructConstInfo) = std.StringHashMap(StructConstInfo).init(std.heap.page_allocator), // "Struct.CONST" → value info
    foreign_name_map: std.StringHashMap([]const u8) = std.StringHashMap([]const u8).init(std.heap.page_allocator), // sx name → C name for #foreign renames
    target_config: ?@import("../target.zig").TargetConfig = null, // compilation target (for inline if)
    comptime_constants: std.StringHashMap(ComptimeValue) = std.StringHashMap(ComptimeValue).init(std.heap.page_allocator), // compile-time known constants (e.g. OS, ARCH)
    diagnostics: ?*errors.DiagnosticList = null, // error reporting with source locations
    xx_reentrancy: std.AutoHashMap(u64, void) = std.AutoHashMap(u64, void).init(std.heap.page_allocator), // (src_ty, dst_ty) pairs currently being resolved through user-space Into; prevents infinite monomorphisation when a convert body re-enters the same xx
    /// Whole-program-converged inferred error sets (ERR E1.4b): top-level
    /// bare-`!` function name → its sorted escape-tag ids (literal raises +
    /// pure-failable `try` edges, fix-pointed across the call graph). The
    /// shared `!` placeholder TypeId stays empty; this side map holds the real
    /// per-function sets (sidesteps the name-only error-set interning). Read by
    /// `lowerTry`'s named-caller widening and the empty-inferred warning.
    inferred_error_sets: std.StringHashMap([]const u32) = std.StringHashMap([]const u32).init(std.heap.page_allocator),
    /// Whole-program-converged inferred error sets keyed by closure/function
    /// VALUE-signature shape (ERR E5.1 sub-feature 2): every occurrence of
    /// `Closure(<sig>) -> (T, !)` with a structurally identical value-signature
    /// shares one node; each bare-`!` closure literal of that shape unions its
    /// escape tags in. Read by `checkEscapeWidening` when a `try` operand is a
    /// closure/fn-type SLOT call (no static fn name). Key = `closureShapeKey`.
    shape_inferred_sets: std.StringHashMap([]const u32) = std.StringHashMap([]const u32).init(std.heap.page_allocator),

    pub const ComptimeValue = union(enum) {
        int_val: i64,
        enum_tag: struct { ty: TypeId, tag: u32 },
    };

    const StructConstInfo = struct {
        value: *const Node,
        ty: ?TypeId, // null if no type annotation (inferred)
    };

    /// One impl block for a parameterised protocol (e.g. `impl Into(Block) for Closure() -> void`).
    /// Stored in `param_impl_map` keyed by (protocol_name, target_args_mangled, source_mangled).
    /// `defining_module` enables import-scoped visibility + cross-module duplicate diagnostics.
    pub const ParamImplEntry = struct {
        methods: []const *const ast.FnDecl,
        source_ty: TypeId,
        target_args: []const TypeId,
        defining_module: []const u8,
        span: ast.Span,
    };

    const InlineReturnInfo = struct { slot: Ref, ret_ty: TypeId, done_bb: BlockId };

    /// ERR E2.4 — where a failable `or` chain's TOTAL failure routes when the
    /// chain is the operand of an absorbing consumer (`catch`). `bb` is a block
    /// with a single parameter typed `set` (the error tag); the chain branches
    /// there with its final error instead of propagating to the function.
    const ChainFailTarget = struct { bb: BlockId, set: TypeId };

    /// Pack-variadic impl entry — `impl Proto(Args...) for Closure(Prefix..., ..$pack) -> $ret`.
    /// Matches any concrete closure source whose first `prefix_len` param types
    /// equal `source_pack_ty`'s fixed prefix; the tail binds to `pack_var_name`
    /// (e.g. "args") and the source's return type binds to `ret_var_name`
    /// (e.g. "R") when the impl's return is generic. `ret_var_name == null`
    /// means the return type is concrete and must match exactly.
    pub const PackParamImplEntry = struct {
        methods: []const *const ast.FnDecl,
        source_pack_ty: TypeId,
        target_args: []const TypeId,
        defining_module: []const u8,
        span: ast.Span,
        pack_var_name: []const u8,
        ret_var_name: ?[]const u8,
    };

    pub fn init(module: *Module) Lowering {
        return .{
            .module = module,
            .builder = Builder.init(module),
            .alloc = module.alloc,
            .lowered_functions = std.StringHashMap(void).init(module.alloc),
            .program_index = ProgramIndex.init(module.alloc),
        };
    }

    // ── Public entry point ──────────────────────────────────────────

    /// Lower all top-level declarations from a root node.
    /// Pass 1: Scan all declarations (register ASTs, types, extern stubs).
    /// Pass 2: Lower only `main` (everything else is lowered lazily on demand).
    pub fn lowerRoot(self: *Lowering, root: *const Node) void {
        const decls = switch (root.data) {
            .root => |r| r.decls,
            else => return,
        };
        // Pass 0: pre-scan for `Context :: struct {...}`. If the program
        // imports `std.sx` it has Context, and every default-conv sx
        // function gets the implicit `__sx_ctx` param. Otherwise the
        // implicit-ctx machinery stays fully disabled — programs that
        // call only libc directly keep their bare C ABI.
        self.implicit_ctx_enabled = detectContextDecl(decls);
        self.module.has_implicit_ctx = self.implicit_ctx_enabled;
        // Pass 1: scan — register all function ASTs, struct types, extern stubs
        self.scanDecls(decls);
        // Pass 1b: inject compile-time constants (OS, ARCH, POINTER_SIZE) from target config
        self.injectComptimeConstants();
        // Pass 1c: emit the process-wide default Context global, statically
        // initialised to a CAllocator-backed Allocator value. Used by FFI
        // wrappers in Step 4 and by the interp's `callWithDefaultContext`
        // entry. Only fires when the program imports `std.sx` (so Context +
        // Allocator + CAllocator are all registered).
        self.emitDefaultContextGlobal();
        // Pass 1d: converge inferred (`bare !`) error sets across the whole
        // program (ERR E1.4b). Runs before body lowering so `lowerTry`'s
        // named-caller widening sees each bare-`!` callee's converged set; also
        // emits the empty-inferred warning.
        self.convergeInferredErrorSets();
        // Pass 1d': converge inferred (`bare !`) error sets per closure/fn-type
        // SHAPE (ERR E5.1 sub-feature 2). Runs after the name-keyed pass so a
        // closure's `try named_fn()` edge resolves against the converged
        // top-level sets; before body lowering so `try slot(x)` widening sees
        // the full per-shape union.
        self.convergeClosureShapeSets();
        // Pass 1e: error-flow checks (ERR E1.8 value-slot liveness + E1.7
        // cleanup-body absorption) over the main file's functions. Runs after
        // the error-set convergence passes (so failable callees resolve) and
        // before body lowering — purely a diagnostic pass; `core.zig` halts on
        // any error before codegen.
        self.checkErrorFlow(decls);
        // Pass 1f: reject identifiers used in a type position that name no
        // declared type / primitive / in-scope generic param (issue 0064).
        // Runs after scanning (so every real type name is registered) and
        // before body lowering, so the diagnostic halts via `core.zig`
        // `hasErrors()` before the empty-struct stub can reach codegen. Owned by
        // `semantic_diagnostics.UnknownTypeChecker` (A2.4); built only when
        // diagnostics are active, querying ProgramIndex + TypeResolver.
        if (self.diagnostics) |diags| {
            const checker = semantic_diagnostics.UnknownTypeChecker{
                .alloc = self.alloc,
                .diagnostics = diags,
                .types = &self.module.types,
                .index = &self.program_index,
                .main_file = self.main_file,
            };
            checker.run(decls);
        }
        // Pass 2: lower main (and comptime side-effects)
        self.lowerMainAndComptime(decls);
        // Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered
        self.lowerDeferredTypeFns();
        // Pass 4: target-specific entry-point sanity checks
        self.checkRequiredEntryPoints();
        // Pass 4a: validate main's signature (ERR E4.2 entry-point gate).
        self.validateMainSignature();
        // Pass 4b: eagerly lower bodied methods on sx-defined `#objc_class`
        // declarations. The Obj-C runtime calls these via IMP pointers
        // registered in M1.2 A.4 — no sx-side call path drives lazy
        // lowering, so we trigger it here. Mirrors the JNI eager-lower
        // pattern in Pass 5.
        self.lowerObjcDefinedClassMethods();
        // Pass 5: synthesize JNI-mangled exports for `#jni_main` bodied methods.
        // Android's JNI runtime resolves `private native sx_<m>(...)` declared in
        // the bundled classes.dex by looking up the symbol
        // `Java_<pkg-mangled>_<Class>_sx_1<m-mangled>` in the loaded .so. Each
        // bodied method on a `#jni_main #jni_class` decl becomes an exported
        // C-ABI fn with that name; the JNIEnv* / jobject params are prepended,
        // then the user-declared params (with type-erased pointers since JNI
        // doesn't carry sx-side types across the binding).
        self.synthesizeJniMainStubs();
    }

    /// ERR E4.2: the entry-point signature gate. `main` must take no parameters
    /// and have a SINGLE-slot return: void (`()` / `-> ()` / `-> void`), an
    /// integer (POSIX exit code, truncated to u8), or `-> !` / `-> !Named` (the
    /// error tag rides the single return register). The multi-slot
    /// `-> (T, !)` tuple return is NOT yet supported — the JIT calls main as
    /// `() -> i32`, so a 2-slot `{value, error}` return ABI-mismatches and
    /// segfaults; that shape lands with the E4.2 entry-point wrapper. Any other
    /// shape (`-> string`, `-> f64`, a non-failable tuple, …) is a clean
    /// diagnostic rather than a silent miscompile.
    fn validateMainSignature(self: *Lowering) void {
        const fd = self.program_index.fn_ast_map.get("main") orelse return;

        if (fd.params.len != 0) {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, fd.params[0].name_span, "main: parameters must be empty; return type must be void, an integer, or `!`", .{});
            }
            return;
        }

        const rt = self.resolveReturnType(fd);
        // Single-slot returns the JIT's `() -> i32` ABI handles directly:
        //   void / integer, and a pure failable `-> !` (a bare u32 error tag).
        if (rt == .void or self.isIntEx(rt)) return;
        if (self.errorChannelOf(rt)) |chan| {
            if (rt == chan) {
                // pure `-> !` / `-> !Named`. The emitted entry-point wrapper
                // (emit_llvm `emitFailableMainRet`) calls `sx_trace_report_unhandled`
                // on an escaping error, so the AOT path must auto-link the trace
                // runtime even when the body emits no other push/clear.
                self.needs_trace_runtime = true;
                return;
            }
            // `-> (T, !)` — value-carrying failable. Accepted only for a single
            // **integer** value slot (`{int, error_set}`): the wrapper extracts
            // the value + tag from the returned tuple, exits `value as u8` on
            // success / reports + exits 1 on error. Multi-value `-> (T1, T2, !)`
            // or a non-integer value slot stays rejected — there's no single
            // integer exit code to map it to.
            const ti = self.module.types.get(rt);
            if (ti == .tuple and ti.tuple.fields.len == 2 and self.isIntEx(ti.tuple.fields[0])) {
                self.needs_trace_runtime = true;
                return;
            }
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, if (fd.return_type) |rtn| rtn.span else null, "a value-carrying failable `main` must be `-> (int, !)` (one integer value slot); got '{s}'. Use `-> !` (no value), `-> (int, !)`, or a non-failable integer return", .{self.formatTypeName(rt)});
            }
            return;
        }

        if (self.diagnostics) |diags| {
            diags.addFmt(.err, if (fd.return_type) |rtn| rtn.span else null, "main: return type must be void, an integer, or `!`; got '{s}'", .{self.formatTypeName(rt)});
        }
    }

    // ── ERR E1.7 / E1.8 — error-flow analysis ───────────────────────────────
    //
    // One structured, path-sensitive walk over each MAIN-file function body
    // (imported modules are trusted) drives two checks:
    //
    //  • E1.8 (value-slot liveness): a `v, err := failable()` destructure binds
    //    `v` "live only where `err` is proven absent". A read of `v` is legal
    //    iff `err` is proven null on the current path — established by
    //    `if !err { … }` (proven inside) or `if err { return/raise }` (proven on
    //    the fall-through). Error-set `==`/tag-compares do NOT prove absence.
    //
    //  • E1.7 (cleanup absorption): a bare failable call in a `defer`/`onfail`
    //    body (with no `catch` / `or value`) is rejected — its error has nowhere
    //    to propagate (the block is already exiting). See `checkCleanupBody`.

    /// Per-function registration state for the flow walk. `bindings` maps each
    /// failable value-slot variable to its partner error variable; `err_vars`
    /// is the set of those error variables (so conditions over them refine the
    /// proven-null set). Both are monotonic — entries are added as destructures
    /// are encountered, never removed (out-of-scope names simply stop being read).
    const FlowCtx = struct {
        bindings: std.StringHashMap([]const u8),
        err_vars: std.StringHashMap(void),
    };

    /// The proven-null set: error-variable names known to be absent on the
    /// current path. Threaded by value-clone across branches; the join after an
    /// `if` is the intersection of the reachable branches' sets.
    const ProvenSet = std.ArrayList([]const u8);

    fn provenHas(set: ProvenSet, name: []const u8) bool {
        for (set.items) |n| if (std.mem.eql(u8, n, name)) return true;
        return false;
    }

    fn provenAdd(self: *Lowering, set: *ProvenSet, name: []const u8) void {
        if (!provenHas(set.*, name)) set.append(self.alloc, name) catch {};
    }

    fn provenClone(self: *Lowering, set: ProvenSet) ProvenSet {
        var out = ProvenSet.empty;
        out.appendSlice(self.alloc, set.items) catch {};
        return out;
    }

    fn provenIntersect(self: *Lowering, a: ProvenSet, b: ProvenSet) ProvenSet {
        var out = ProvenSet.empty;
        for (a.items) |n| if (provenHas(b, n)) (out.append(self.alloc, n) catch {});
        return out;
    }

    /// Pass 1e: run the error-flow checks over every function defined in the
    /// main file. Library modules are assumed well-formed (and may use patterns
    /// this conservative check would over-reject), so they are skipped.
    fn checkErrorFlow(self: *Lowering, decls: []const *const Node) void {
        if (self.diagnostics == null) return;
        for (decls) |decl| {
            if (self.main_file) |mf| {
                if (decl.source_file) |sf| {
                    if (!std.mem.eql(u8, sf, mf)) continue;
                }
            }
            switch (decl.data) {
                .fn_decl => |fd| self.analyzeFnBody(fd.body),
                .const_decl => |cd| {
                    if (cd.value.data == .fn_decl) self.analyzeFnBody(cd.value.data.fn_decl.body);
                },
                else => {},
            }
        }
    }

    /// Analyze one function (or lambda) body as its own boundary — a fresh
    /// binding context and an empty proven set.
    fn analyzeFnBody(self: *Lowering, body: *const Node) void {
        var ctx = FlowCtx{
            .bindings = std.StringHashMap([]const u8).init(self.alloc),
            .err_vars = std.StringHashMap(void).init(self.alloc),
        };
        var proven = ProvenSet.empty;
        _ = self.flowWalk(body, &ctx, &proven);
    }

    /// Walk a block or single statement. Returns whether control always
    /// diverges (every path ends in return/raise/break/continue) — used by the
    /// caller to mark code after a branch unreachable.
    fn flowWalk(self: *Lowering, node: *const Node, ctx: *FlowCtx, proven: *ProvenSet) bool {
        switch (node.data) {
            .block => |b| {
                for (b.stmts) |s| if (self.flowStmt(s, ctx, proven)) return true;
                return false;
            },
            else => return self.flowStmt(node, ctx, proven),
        }
    }

    fn flowStmt(self: *Lowering, node: *const Node, ctx: *FlowCtx, proven: *ProvenSet) bool {
        switch (node.data) {
            .destructure_decl => |dd| {
                self.flowExpr(dd.value, ctx, proven.*);
                self.registerFailableDestructure(&dd, ctx);
                return false;
            },
            .var_decl => |vd| {
                if (vd.value) |v| self.flowExpr(v, ctx, proven.*);
                return false;
            },
            .const_decl => |cd| {
                self.flowExpr(cd.value, ctx, proven.*);
                return false;
            },
            .assignment => |a| {
                self.flowExpr(a.value, ctx, proven.*);
                self.flowExpr(a.target, ctx, proven.*);
                return false;
            },
            .multi_assign => |ma| {
                for (ma.values) |v| self.flowExpr(v, ctx, proven.*);
                return false;
            },
            .return_stmt => |r| {
                if (r.value) |v| self.flowExpr(v, ctx, proven.*);
                return true;
            },
            .raise_stmt => |rs| {
                self.flowExpr(rs.tag, ctx, proven.*);
                return true;
            },
            .break_expr, .continue_expr => return true,
            .if_expr => |ie| return self.flowIf(&ie, ctx, proven),
            .while_expr => |we| {
                self.flowExpr(we.condition, ctx, proven.*);
                var loop_proven = self.provenClone(proven.*);
                _ = self.flowWalk(we.body, ctx, &loop_proven);
                return false;
            },
            .for_expr => |fe| {
                self.flowExpr(fe.iterable, ctx, proven.*);
                if (fe.range_end) |re| self.flowExpr(re, ctx, proven.*);
                var loop_proven = self.provenClone(proven.*);
                _ = self.flowWalk(fe.body, ctx, &loop_proven);
                return false;
            },
            .match_expr => |me| return self.flowMatch(&me, ctx, proven),
            .push_stmt => |ps| {
                self.flowExpr(ps.context_expr, ctx, proven.*);
                var inner = self.provenClone(proven.*);
                _ = self.flowWalk(ps.body, ctx, &inner);
                return false;
            },
            .defer_stmt => |ds| {
                self.checkCleanupBody(ds.expr, "defer");
                self.flowExpr(ds.expr, ctx, proven.*);
                return false;
            },
            .onfail_stmt => |os| {
                self.checkCleanupBody(os.body, "onfail");
                self.flowExpr(os.body, ctx, proven.*);
                return false;
            },
            else => {
                self.flowExpr(node, ctx, proven.*);
                return false;
            },
        }
    }

    /// Path-sensitive `if`: refine the proven set on each branch, recurse, and
    /// join the reachable fall-through states by intersection.
    fn flowIf(self: *Lowering, ie: *const ast.IfExpr, ctx: *FlowCtx, proven: *ProvenSet) bool {
        self.flowExpr(ie.condition, ctx, proven.*);
        var then_proven = self.provenClone(proven.*);
        var else_proven = self.provenClone(proven.*);
        self.applyRefinement(ie.condition, true, ctx, &then_proven);
        self.applyRefinement(ie.condition, false, ctx, &else_proven);

        const then_div = self.flowWalk(ie.then_branch, ctx, &then_proven);
        var else_div = false;
        if (ie.else_branch) |eb| else_div = self.flowWalk(eb, ctx, &else_proven);

        // Reachable fall-through contributors: a branch that doesn't diverge,
        // plus the implicit (empty) else when there is no `else`.
        var contributors = std.ArrayList(ProvenSet).empty;
        if (!then_div) contributors.append(self.alloc, then_proven) catch {};
        if (ie.else_branch != null) {
            if (!else_div) contributors.append(self.alloc, else_proven) catch {};
        } else {
            contributors.append(self.alloc, else_proven) catch {};
        }
        if (contributors.items.len == 0) return true; // both branches diverge

        var result = self.provenClone(contributors.items[0]);
        for (contributors.items[1..]) |c| result = self.provenIntersect(result, c);
        proven.* = result;
        return false;
    }

    /// Refine the proven-null set for a branch taken when `cond` is `want_true`.
    /// A bare error-variable is truthy when it HOLDS an error, so its falsy edge
    /// proves absence; `!`, `&&`, `||` compose. Tag/equality compares prove
    /// nothing (error-set `==` compares tags, not presence).
    fn applyRefinement(self: *Lowering, cond: *const Node, want_true: bool, ctx: *FlowCtx, set: *ProvenSet) void {
        switch (cond.data) {
            .identifier => |id| {
                if (ctx.err_vars.contains(id.name) and !want_true) self.provenAdd(set, id.name);
            },
            .unary_op => |uop| {
                if (uop.op == .not) self.applyRefinement(uop.operand, !want_true, ctx, set);
            },
            .binary_op => |bop| {
                if (bop.op == .and_op and want_true) {
                    self.applyRefinement(bop.lhs, true, ctx, set);
                    self.applyRefinement(bop.rhs, true, ctx, set);
                } else if (bop.op == .or_op and !want_true) {
                    self.applyRefinement(bop.lhs, false, ctx, set);
                    self.applyRefinement(bop.rhs, false, ctx, set);
                }
            },
            else => {},
        }
    }

    fn flowMatch(self: *Lowering, me: *const ast.MatchExpr, ctx: *FlowCtx, proven: *ProvenSet) bool {
        self.flowExpr(me.subject, ctx, proven.*);
        for (me.arms) |arm| {
            var arm_proven = self.provenClone(proven.*);
            _ = self.flowWalk(arm.body, ctx, &arm_proven);
        }
        return false;
    }

    /// Check an expression for reads of a still-tainted value-slot variable and
    /// recurse into nested lambdas as their own boundaries. `proven` is by value
    /// — sub-expressions never publish proven-null facts back to the statement.
    fn flowExpr(self: *Lowering, node: *const Node, ctx: *FlowCtx, proven: ProvenSet) void {
        switch (node.data) {
            .identifier => |id| {
                if (ctx.bindings.get(id.name)) |err_var| {
                    if (!provenHas(proven, err_var)) {
                        if (self.diagnostics) |d| d.addFmt(.err, node.span, "value `{s}` from a failable can be used only where its error `{s}` is proven absent — guard the use with `if !{s} {{ … }}`, or return early with `if {s} {{ return; }}` before reading `{s}`", .{ id.name, err_var, err_var, err_var, id.name });
                    }
                }
            },
            .lambda => |lam| self.analyzeFnBody(lam.body),
            .binary_op => |b| {
                self.flowExpr(b.lhs, ctx, proven);
                // Short-circuit: the rhs of `&&` runs only when the lhs is true
                // (and `||`'s rhs only when the lhs is false), so refine the
                // proven-null set accordingly before checking it. This is what
                // makes `if !err && use(v)` legal.
                if (b.op == .and_op or b.op == .or_op) {
                    var rp = self.provenClone(proven);
                    self.applyRefinement(b.lhs, b.op == .and_op, ctx, &rp);
                    self.flowExpr(b.rhs, ctx, rp);
                } else {
                    self.flowExpr(b.rhs, ctx, proven);
                }
            },
            .chained_comparison => |cc| {
                for (cc.operands) |op| self.flowExpr(op, ctx, proven);
            },
            .unary_op => |u| self.flowExpr(u.operand, ctx, proven),
            .call => |c| {
                self.flowExpr(c.callee, ctx, proven);
                for (c.args) |a| self.flowExpr(a, ctx, proven);
            },
            .field_access => |fa| self.flowExpr(fa.object, ctx, proven),
            .index_expr => |ix| {
                self.flowExpr(ix.object, ctx, proven);
                self.flowExpr(ix.index, ctx, proven);
            },
            .slice_expr => |se| {
                self.flowExpr(se.object, ctx, proven);
                if (se.start) |s| self.flowExpr(s, ctx, proven);
                if (se.end) |e| self.flowExpr(e, ctx, proven);
            },
            .try_expr => |te| self.flowExpr(te.operand, ctx, proven),
            .catch_expr => |ce| {
                self.flowExpr(ce.operand, ctx, proven);
                self.flowExpr(ce.body, ctx, proven);
            },
            .force_unwrap => |fu| self.flowExpr(fu.operand, ctx, proven),
            .null_coalesce => |nc| {
                self.flowExpr(nc.lhs, ctx, proven);
                self.flowExpr(nc.rhs, ctx, proven);
            },
            .deref_expr => |de| self.flowExpr(de.operand, ctx, proven),
            .comptime_expr => |ce| self.flowExpr(ce.expr, ctx, proven),
            .insert_expr => |ie| self.flowExpr(ie.expr, ctx, proven),
            .spread_expr => |se| self.flowExpr(se.operand, ctx, proven),
            .struct_literal => |sl| {
                for (sl.field_inits) |fi| self.flowExpr(fi.value, ctx, proven);
            },
            .array_literal => |al| {
                for (al.elements) |el| self.flowExpr(el, ctx, proven);
            },
            .tuple_literal => |tl| {
                for (tl.elements) |el| self.flowExpr(el.value, ctx, proven);
            },
            .if_expr => |ie| {
                var tmp = self.provenClone(proven);
                _ = self.flowIf(&ie, ctx, &tmp);
            },
            .match_expr => |me| {
                var tmp = self.provenClone(proven);
                _ = self.flowMatch(&me, ctx, &tmp);
            },
            .block => |b| {
                var tmp = self.provenClone(proven);
                for (b.stmts) |s| if (self.flowStmt(s, ctx, &tmp)) break;
            },
            else => {},
        }
    }

    /// Register a `v…, err := failable()` destructure. Only a complete bare
    /// destructure (every slot bound, error slot a real name) creates taint —
    /// an omitted or `_`-bound error slot is already rejected by the discard
    /// check in `lowerDestructureDecl`, so it produces no proof obligation here.
    fn registerFailableDestructure(self: *Lowering, dd: *const ast.DestructureDecl, ctx: *FlowCtx) void {
        const ty = self.inferExprType(dd.value);
        if (self.errorChannelOf(ty) == null) return;
        if (ty.isBuiltin()) return;
        const ti = self.module.types.get(ty);
        if (ti != .tuple) return;
        const fields = ti.tuple.fields;
        if (dd.names.len != fields.len) return;
        const err_name = dd.names[fields.len - 1];
        if (std.mem.eql(u8, err_name, "_")) return;
        ctx.err_vars.put(err_name, {}) catch {};
        var i: usize = 0;
        while (i + 1 < dd.names.len) : (i += 1) {
            const vn = dd.names[i];
            if (std.mem.eql(u8, vn, "_")) continue;
            ctx.bindings.put(vn, err_name) catch {};
        }
    }

    /// E1.7: a `defer`/`onfail` body runs while the block is already exiting, so
    /// a bare failable call has nowhere to send its error. Reject any failable
    /// expression-statement that isn't absorbed locally by `catch` / `or value`
    /// / a destructure binding. (Parser already bans `try`/`raise`/`return`/
    /// `break`/`continue` here, so the only escape route left for a failable is
    /// local absorption.) The check is transitive through nested blocks, `if`,
    /// loops, match arms, and `catch` handlers, but stops at a nested closure
    /// (its own function boundary).
    fn checkCleanupBody(self: *Lowering, body: *const Node, kind: []const u8) void {
        self.checkCleanupNode(body, kind);
    }

    fn checkCleanupNode(self: *Lowering, node: *const Node, kind: []const u8) void {
        switch (node.data) {
            .block => |b| for (b.stmts) |s| self.checkCleanupNode(s, kind),
            .if_expr => |ie| {
                self.cleanupReject(ie.condition, kind);
                self.checkCleanupNode(ie.then_branch, kind);
                if (ie.else_branch) |eb| self.checkCleanupNode(eb, kind);
            },
            .while_expr => |we| {
                self.cleanupReject(we.condition, kind);
                self.checkCleanupNode(we.body, kind);
            },
            .for_expr => |fe| self.checkCleanupNode(fe.body, kind),
            .match_expr => |me| for (me.arms) |arm| self.checkCleanupNode(arm.body, kind),
            .push_stmt => |ps| self.checkCleanupNode(ps.body, kind),
            // A destructure binds the error slot → absorbed (explicit ownership).
            .destructure_decl => {},
            .var_decl => |vd| if (vd.value) |v| self.cleanupReject(v, kind),
            .const_decl => |cd| self.cleanupReject(cd.value, kind),
            .assignment => |a| self.cleanupReject(a.value, kind),
            // Closures are their own boundary; the parser-banned control-flow
            // exits are handled elsewhere; nested cleanup is independent.
            .lambda, .return_stmt, .raise_stmt, .break_expr, .continue_expr, .defer_stmt, .onfail_stmt => {},
            else => self.cleanupReject(node, kind),
        }
    }

    /// Reject `expr` if it is a bare (un-absorbed) failable in cleanup position.
    /// `catch` / `or value` strip the error channel (so `exprIsFailable` is
    /// false for them); only a still-failable expression has an unhandled error.
    fn cleanupReject(self: *Lowering, expr: *const Node, kind: []const u8) void {
        if (expr.data == .catch_expr) {
            // The operand is absorbed; the handler body still runs in cleanup.
            self.checkCleanupNode(expr.data.catch_expr.body, kind);
            return;
        }
        if (!self.exprIsFailable(expr)) return;
        if (self.diagnostics) |d| d.addFmt(.err, expr.span, "a bare failable call in a `{s}` body has nowhere to send its error — the block is already exiting; absorb it locally with `catch` or `or <value>`", .{kind});
    }

    /// On Android, the OS loads the .so via a Java-side Activity declared
    /// with `#jni_main #jni_class("...")`. The Java class drives the
    /// lifecycle (onCreate / onPause / etc.) and sx provides the native
    /// delegates bound via JNI name mangling. Without a `#jni_main` decl
    /// there's no entry point — the .so would load but Android has nothing
    /// to call into.
    fn checkRequiredEntryPoints(self: *Lowering) void {
        const tc = self.target_config orelse return;
        if (!tc.isAndroid()) return;

        var it = self.program_index.foreign_class_map.iterator();
        while (it.next()) |entry| {
            const fcd = entry.value_ptr.*;
            if (fcd.is_main and !fcd.is_foreign and fcd.runtime == .jni_class) return;
        }

        if (self.diagnostics) |diags| {
            diags.addFmt(.err, null,
                "target is Android but no `#jni_main` Activity declared. " ++
                "The OS launches a Java-side Activity that delegates lifecycle " ++
                "callbacks into sx — declare one like:\n\n" ++
                "    Bundle :: #foreign #jni_class(\"android/os/Bundle\") {{ }}\n\n" ++
                "    MyApp :: #jni_main #jni_class(\"co/example/MyApp\") {{\n" ++
                "        onCreate :: (self: *Self, b: *Bundle) {{ /* ... */ }}\n" ++
                "    }}", .{});
        }
    }

    /// Inject compile-time constants from target_config into comptime_constants.
    /// Called after scanDecls so that enum types (OperatingSystem, Architecture) are registered.
    fn injectComptimeConstants(self: *Lowering) void {
        const tc = self.target_config orelse return;

        // OS: OperatingSystem enum { macos; linux; windows; wasm; unknown; }
        const os_name_id = self.module.types.internString("OperatingSystem");
        if (self.module.types.findByName(os_name_id)) |os_ty| {
            const os_info = self.module.types.get(os_ty);
            if (os_info == .@"enum") {
                const tag: u32 = if (tc.isWasm())
                    self.findVariantIndex(os_info.@"enum".variants, "wasm")
                else if (tc.isWindows())
                    self.findVariantIndex(os_info.@"enum".variants, "windows")
                else if (tc.isAndroid())
                    self.findVariantIndex(os_info.@"enum".variants, "android")
                else if (tc.isLinux())
                    self.findVariantIndex(os_info.@"enum".variants, "linux")
                else if (tc.isIOS())
                    self.findVariantIndex(os_info.@"enum".variants, "ios")
                else if (tc.isMacOS())
                    self.findVariantIndex(os_info.@"enum".variants, "macos")
                else
                    self.findVariantIndex(os_info.@"enum".variants, "unknown");
                self.comptime_constants.put("OS", .{ .enum_tag = .{ .ty = os_ty, .tag = tag } }) catch {};
            }
        }

        // ARCH: Architecture enum { aarch64; x86_64; wasm32; wasm64; unknown; }
        const arch_name_id = self.module.types.internString("Architecture");
        if (self.module.types.findByName(arch_name_id)) |arch_ty| {
            const arch_info = self.module.types.get(arch_ty);
            if (arch_info == .@"enum") {
                const tag: u32 = if (tc.isWasm32())
                    self.findVariantIndex(arch_info.@"enum".variants, "wasm32")
                else if (tc.isWasm64())
                    self.findVariantIndex(arch_info.@"enum".variants, "wasm64")
                else if (tc.isAarch64())
                    self.findVariantIndex(arch_info.@"enum".variants, "aarch64")
                else if (tc.isX86_64())
                    self.findVariantIndex(arch_info.@"enum".variants, "x86_64")
                else
                    self.findVariantIndex(arch_info.@"enum".variants, "unknown");
                self.comptime_constants.put("ARCH", .{ .enum_tag = .{ .ty = arch_ty, .tag = tag } }) catch {};
            }
        }

        // POINTER_SIZE: s64 (4 for wasm32, 8 for wasm64 and other 64-bit targets)
        const ptr_size: i64 = if (tc.isWasm32()) 4 else 8;
        self.comptime_constants.put("POINTER_SIZE", .{ .int_val = ptr_size }) catch {};
    }

    fn findVariantIndex(self: *Lowering, variants: []const types.StringId, name: []const u8) u32 {
        const name_id = self.module.types.internString(name);
        for (variants, 0..) |v, i| {
            if (v == name_id) return @intCast(i);
        }
        return 0; // fallback to first variant
    }

    /// Lower functions that were deferred because they use type-category matching.
    /// At this point, main is fully lowered and all types are in the TypeTable.
    fn lowerDeferredTypeFns(self: *Lowering) void {
        if (self.deferred_type_fns.items.len == 0) return;
        self.processing_deferred = true;
        for (self.deferred_type_fns.items) |name| {
            self.lazyLowerFunction(name);
        }
        self.processing_deferred = false;
    }

    /// Lower a list of top-level declarations (used by irComptimeEval — non-lazy path).
    /// This preserves the old behavior for comptime evaluation contexts.
    pub fn lowerDecls(self: *Lowering, decls: []const *const Node) void {
        for (decls) |decl| {
            self.setCurrentSourceFile(decl.source_file);
            const is_imported = if (self.main_file) |mf|
                (if (decl.source_file) |sf| !std.mem.eql(u8, sf, mf) else false)
            else
                false;
            switch (decl.data) {
                .fn_decl => |fd| {
                    self.program_index.fn_ast_map.put(fd.name, &decl.data.fn_decl) catch {};
                    self.lowerFunction(&fd, fd.name, is_imported);
                },
                .const_decl => |cd| {
                    if (cd.value.data == .fn_decl) {
                        self.program_index.fn_ast_map.put(cd.name, &cd.value.data.fn_decl) catch {};
                        self.lowerFunction(&cd.value.data.fn_decl, cd.name, is_imported);
                    } else if (cd.value.data == .struct_decl) {
                        self.registerStructDecl(&cd.value.data.struct_decl);
                    } else if (cd.value.data == .enum_decl) {
                        _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map);
                    } else if (cd.value.data == .union_decl) {
                        _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map);
                    } else if (cd.value.data == .comptime_expr) {
                        self.lowerComptimeGlobal(cd.name, cd.value.data.comptime_expr.expr, cd.type_annotation);
                    }
                },
                .comptime_expr => |ct| {
                    self.lowerComptimeSideEffect(ct.expr);
                },
                .struct_decl => |sd| {
                    self.registerStructDecl(&sd);
                },
                .enum_decl => {
                    _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map);
                },
                .union_decl => {
                    _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map);
                },
                .error_set_decl => {
                    self.registerErrorSetDecl(decl);
                },
                .protocol_decl => {
                    self.registerProtocolDecl(&decl.data.protocol_decl);
                },
                .impl_block => {
                    self.protocolResolver().registerImplBlock(&decl.data.impl_block, is_imported, decl);
                },
                .foreign_class_decl => {
                    self.registerForeignClassDecl(&decl.data.foreign_class_decl);
                },
                .namespace_decl => |ns| {
                    self.registerNamespacedForeignClasses(ns);
                    if (self.main_file != null) {
                        self.lowerDecls(ns.decls);
                    }
                },
                else => {},
            }
        }
    }

    /// Detect whether `Context :: struct {...}` is declared anywhere in the
    /// program. Used to gate the implicit `__sx_ctx` param machinery: when
    /// `std.sx` is in the dep graph, `Context` is declared and every sx
    /// function gets the implicit param. Otherwise the program runs with a
    /// bare C ABI (no global Context, no implicit param, no FFI wrappers).
    fn detectContextDecl(decls: []const *const Node) bool {
        for (decls) |decl| {
            const found = switch (decl.data) {
                .struct_decl => |sd| std.mem.eql(u8, sd.name, "Context"),
                .const_decl => |cd|
                    std.mem.eql(u8, cd.name, "Context") and cd.value.data == .struct_decl,
                .namespace_decl => |ns| detectContextDecl(ns.decls),
                else => false,
            };
            if (found) return true;
        }
        return false;
    }

    /// Returns true if a sx function declaration should receive the
    /// implicit `__sx_ctx` parameter. False for foreign-libc bindings,
    /// #builtin / #compiler bodies, and C-conv functions (which keep
    /// their literal C ABI). Also false for OS-called entry points
    /// (`isExportedEntryName`): main and JNI hooks are invoked by the
    /// dyld / JVM with no `__sx_ctx` arg, so the visible signature must
    /// not include one. Their bodies are still sx code — they
    /// synthesise `&__sx_default_context` at entry and use it as their
    /// own `current_ctx_ref`. Full FFI-wrapper split (a separate
    /// `__sx_<name>_impl` with the ctx param) lands in Step 4 proper.
    fn funcWantsImplicitCtx(self: *const Lowering, fd: *const ast.FnDecl) bool {
        if (!self.implicit_ctx_enabled) return false;
        if (fd.call_conv == .c) return false;
        return switch (fd.body.data) {
            .foreign_expr, .builtin_expr, .compiler_expr => false,
            else => !isExportedEntryName(fd.name),
        };
    }

    /// Returns true if a fn-pointer of the given type carries an implicit
    /// `__sx_ctx` at LLVM slot 0. Default-conv sx fn-pointers do; C-conv
    /// (and any non-function type) does not.
    fn fnPtrTypeWantsCtx(self: *const Lowering, ty: TypeId) bool {
        if (!self.implicit_ctx_enabled) return false;
        if (ty.isBuiltin()) return false;
        const ti = self.module.types.get(ty);
        if (ti != .function) return false;
        return ti.function.call_conv != .c;
    }

    /// Pass 1: Scan declarations — register ASTs and extern stubs, but don't lower bodies.
    fn scanDecls(self: *Lowering, decls: []const *const Node) void {
        for (decls) |decl| {
            self.setCurrentSourceFile(decl.source_file);
            const is_imported = if (self.main_file) |mf|
                (if (decl.source_file) |sf| !std.mem.eql(u8, sf, mf) else false)
            else
                false;
            switch (decl.data) {
                .fn_decl => |fd| {
                    self.program_index.fn_ast_map.put(fd.name, &decl.data.fn_decl) catch {};
                    self.program_index.import_flags.put(fd.name, is_imported) catch {};
                    // Declare extern stub for all functions (bodies lowered lazily)
                    self.declareFunction(&fd, fd.name);
                },
                .const_decl => |cd| {
                    if (cd.value.data == .fn_decl) {
                        self.program_index.fn_ast_map.put(cd.name, &cd.value.data.fn_decl) catch {};
                        self.program_index.import_flags.put(cd.name, is_imported) catch {};
                        self.declareFunction(&cd.value.data.fn_decl, cd.name);
                    } else if (cd.value.data == .struct_decl) {
                        self.registerStructDecl(&cd.value.data.struct_decl);
                    } else if (cd.value.data == .enum_decl) {
                        // Register enum/tagged-union types in the type table
                        _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map);
                    } else if (cd.value.data == .union_decl) {
                        // Register plain union types in the type table
                        _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map);
                    } else if (cd.value.data == .type_expr or
                        cd.value.data == .pointer_type_expr or
                        cd.value.data == .many_pointer_type_expr or
                        cd.value.data == .array_type_expr or
                        cd.value.data == .slice_type_expr or
                        cd.value.data == .optional_type_expr or
                        cd.value.data == .function_type_expr)
                    {
                        // Type alias: MyFloat :: f64;  Ptr :: *u8;  Cb :: (s32) -> s32;
                        const target_ty = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map);
                        self.program_index.type_alias_map.put(cd.name, target_ty) catch {};
                    } else if (cd.value.data == .identifier) {
                        // Identifier-RHS alias: MyAlias :: MyInt;  WideAlias :: Wide;
                        // Chase through type_alias_map, then look up named types
                        // in the table. Forward references resolve lazily because
                        // the .identifier branch of resolveTypeArg also consults
                        // type_alias_map at use time.
                        const rhs_name = cd.value.data.identifier.name;
                        if (self.program_index.type_alias_map.get(rhs_name)) |chained| {
                            self.program_index.type_alias_map.put(cd.name, chained) catch {};
                        } else {
                            const name_id = self.module.types.internString(rhs_name);
                            if (self.module.types.findByName(name_id)) |tid| {
                                self.program_index.type_alias_map.put(cd.name, tid) catch {};
                            }
                        }
                    }
                    // Handle generic struct instantiation: Vec3 :: Vec(3, f32)
                    // Parser produces a .call node for these (not parameterized_type_expr)
                    if (cd.value.data == .call) {
                        const call_data = &cd.value.data.call;
                        const callee_name = switch (call_data.callee.data) {
                            .identifier => |id| id.name,
                            .field_access => |fa| fa.field,
                            else => "",
                        };
                        if (callee_name.len > 0) {
                            if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| {
                                const inst_id = self.instantiateGenericStruct(tmpl, call_data.args);
                                // Register under the alias name
                                const alias_name_id = self.module.types.internString(cd.name);
                                const inst_info = self.module.types.get(inst_id);
                                if (inst_info == .@"struct") {
                                    const alias_info: types.TypeInfo = .{ .@"struct" = .{
                                        .name = alias_name_id,
                                        .fields = inst_info.@"struct".fields,
                                    } };
                                    const alias_id = if (self.module.types.findByName(alias_name_id)) |existing| existing else self.module.types.intern(alias_info);
                                    self.module.types.update(alias_id, alias_info);
                                }
                            } else if (std.mem.eql(u8, callee_name, "Vector")) {
                                // Builtin type constructor — checked BEFORE
                                // the generic `fn_ast_map` branch because
                                // `Vector` IS in `fn_ast_map` (declared as a
                                // `#builtin` fn) but `instantiateTypeFunction`
                                // can't resolve it (no body). Use
                                // `resolveTypeCallWithBindings` which
                                // hard-codes the vector layout.
                                const result_ty = self.resolveTypeCallWithBindings(call_data);
                                if (result_ty != .void) {
                                    self.program_index.type_alias_map.put(cd.name, result_ty) catch {};
                                }
                            } else if (self.program_index.fn_ast_map.get(callee_name)) |fd| {
                                // Type-returning function: Foo :: Complex(u32)
                                if (fd.type_params.len > 0) {
                                    if (self.instantiateTypeFunction(cd.name, callee_name, fd, call_data.args)) |result_ty| {
                                        self.program_index.type_alias_map.put(cd.name, result_ty) catch {};
                                    }
                                }
                            }
                        }
                    } else if (cd.value.data == .parameterized_type_expr) {
                        // Type alias for generic struct (from type_bridge path)
                        const pt = &cd.value.data.parameterized_type_expr;
                        const base_name = if (std.mem.lastIndexOfScalar(u8, pt.name, '.')) |dot| pt.name[dot + 1 ..] else pt.name;
                        if (self.program_index.struct_template_map.getPtr(base_name)) |tmpl| {
                            const inst_id = self.instantiateGenericStruct(tmpl, pt.args);
                            const alias_name_id = self.module.types.internString(cd.name);
                            const inst_info = self.module.types.get(inst_id);
                            if (inst_info == .@"struct") {
                                const alias_info: types.TypeInfo = .{ .@"struct" = .{
                                    .name = alias_name_id,
                                    .fields = inst_info.@"struct".fields,
                                } };
                                const alias_id = if (self.module.types.findByName(alias_name_id)) |existing| existing else self.module.types.intern(alias_info);
                                self.module.types.update(alias_id, alias_info);
                            }
                        } else {
                            // Builtin parameterised type (Vector(N, T) etc) —
                            // resolve via type_bridge and register the result
                            // under the alias name so `Vec4` in expression
                            // position can `const_type(<vector tid>)`.
                            const result_ty = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map);
                            if (result_ty != .void and result_ty != .unresolved) {
                                self.program_index.type_alias_map.put(cd.name, result_ty) catch {};
                            }
                        }
                    }
                    // comptime_expr handled in Pass 2

                    // Typed value constants (`AF_INET :s32: 2`) are registered in
                    // pass 2 below — after the forward-alias fixpoint — so a
                    // forward identifier alias in the annotation resolves to its
                    // target instead of a fabricated stub (issue 0070). Untyped
                    // literal constants carry no annotation to resolve, so they
                    // stay here (their type comes from the literal / inference).
                    if (cd.type_annotation == null) {
                        // Untyped literal constants (e.g. UI_VERT_SRC :: #string GLSL...GLSL;)
                        switch (cd.value.data) {
                            .string_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .string }) catch {},
                            .int_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {},
                            .float_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .f64 }) catch {},
                            .bool_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .bool }) catch {},
                            // Complex constant expressions (e.g. COLOR_WHITE :: Color.{ r = 255, ... })
                            .struct_literal => {
                                const inferred_ty = self.inferExprType(cd.value);
                                self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = inferred_ty }) catch {};
                            },
                            else => {},
                        }
                    }
                },
                .struct_decl => |sd| {
                    self.registerStructDecl(&sd);
                },
                .enum_decl => {
                    // Register enum/tagged-union types in the type table
                    _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map);
                },
                .union_decl => {
                    // Register plain union types in the type table
                    _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map);
                },
                .error_set_decl => {
                    self.registerErrorSetDecl(decl);
                },
                .protocol_decl => {
                    self.registerProtocolDecl(&decl.data.protocol_decl);
                },
                .impl_block => {
                    self.protocolResolver().registerImplBlock(&decl.data.impl_block, is_imported, decl);
                },
                .foreign_class_decl => {
                    self.registerForeignClassDecl(&decl.data.foreign_class_decl);
                },
                .namespace_decl => |ns| {
                    self.registerNamespacedForeignClasses(ns);
                    if (self.main_file != null) {
                        self.scanDecls(ns.decls);
                    }
                },
                .ufcs_alias => |ua| {
                    self.program_index.ufcs_alias_map.put(ua.name, ua.target) catch {};
                },
                // Top-level globals are registered in a second pass (below),
                // after the forward-alias fixpoint, so a forward identifier
                // alias used as a global's type annotation resolves (issue 0070).
                .var_decl => {},
                else => {},
            }
        }
        self.resolveForwardIdentifierAliases(decls);
        // Pass 2: registrations that resolve a top-level type annotation run
        // after the alias fixpoint, so a forward identifier alias used as the
        // annotation resolves to its target (issue 0070).
        for (decls) |decl| {
            self.setCurrentSourceFile(decl.source_file);
            switch (decl.data) {
                .var_decl => self.registerTopLevelGlobal(&decl.data.var_decl),
                .const_decl => |cd| self.registerTypedModuleConst(&cd),
                else => {},
            }
        }
    }

    /// Register a typed module-level value constant (`AF_INET :s32: 2`). Run in
    /// scanDecls pass 2 (after `resolveForwardIdentifierAliases`) so a forward
    /// identifier alias in the annotation (`A :: B; B :: s32; K : A : 42;`)
    /// resolves to its target rather than a fabricated empty-struct stub, which
    /// would otherwise mistype the constant (issue 0070).
    fn registerTypedModuleConst(self: *Lowering, cd: *const ast.ConstDecl) void {
        const ta = cd.type_annotation orelse return;
        switch (cd.value.data) {
            .int_literal, .float_literal, .bool_literal, .string_literal, .undef_literal, .null_literal => {
                const ty = self.resolveType(ta);
                self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = ty }) catch {};
            },
            else => {},
        }
    }

    /// Register a top-level mutable global (e.g., `context : Context = ---;`).
    /// Run AFTER `resolveForwardIdentifierAliases` so a forward identifier alias
    /// in the type annotation (`A :: B; B :: s32; g : A = 7;`) resolves to its
    /// target instead of a fabricated empty-struct stub, which would otherwise
    /// give the global a type that mismatches its initializer at LLVM
    /// verification (issue 0070). Globals can't be named in a type position, so
    /// deferring them past type/alias registration introduces no ordering hazard.
    fn registerTopLevelGlobal(self: *Lowering, vd: *const ast.VarDecl) void {
        // Use self.resolveType so type aliases like `Handle :: u32;` resolve
        // to their target type (not a synthetic empty struct). When the
        // user omitted the annotation, infer from the initializer
        // expression; foreign globals with no annotation are diagnosed
        // because their type can't be inferred without an initializer.
        const var_ty: TypeId = if (vd.type_annotation) |ta|
            self.resolveType(ta)
        else if (vd.value) |val|
            self.inferExprType(val)
        else blk: {
            if (self.diagnostics) |d|
                d.addFmt(.err, null, "top-level var '{s}' has no type annotation and no initializer to infer from", .{vd.name});
            break :blk .void;
        };
        // Foreign globals reference a symbol defined in libSystem etc.
        // (`_NSConcreteStackBlock : *void #foreign;`). The C symbol
        // name is the optional override or the sx name itself.
        const sym_name = vd.foreign_name orelse vd.name;
        const name_id = self.module.types.internString(sym_name);
        const init_val = self.globalInitValue(vd, var_ty);
        const gid = self.module.addGlobal(.{
            .name = name_id,
            .ty = var_ty,
            .init_val = init_val,
            .is_const = false,
            .is_extern = vd.is_foreign,
        });
        self.program_index.global_names.put(vd.name, .{ .id = gid, .ty = var_ty }) catch {};
    }

    /// Serialize a top-level global's initializer into a static `ConstantValue`.
    /// Foreign globals (extern symbol) and value-less declarations carry no
    /// payload — they default to zero/extern at link, which is correct. An
    /// identifier initializer that names a module constant is materialized from
    /// the recorded constant (`K : A : 42; g : A = K;` → 42, issue 0071); a
    /// global initialized from an identifier that resolves to no usable constant
    /// is rejected with a diagnostic rather than silently zero-initialized — a
    /// global has no run site for a dynamic initializer.
    fn globalInitValue(self: *Lowering, vd: *const ast.VarDecl, var_ty: TypeId) ?inst_mod.ConstantValue {
        if (vd.is_foreign) return null;
        const v = vd.value orelse return null;
        return switch (v.data) {
            .undef_literal => .zeroinit,
            .null_literal => .null_val,
            .int_literal => |il| .{ .int = il.value },
            .bool_literal => |bl| .{ .boolean = bl.value },
            .float_literal => |fl| .{ .float = fl.value },
            .string_literal => |sl| .{ .string = self.module.types.internString(sl.raw) },
            .array_literal => |al| self.constArrayLiteral(al.elements),
            .struct_literal => |sl| self.constStructLiteral(&sl, var_ty),
            .identifier => |id| blk: {
                // A global initialized from a module constant copies the
                // constant's recorded value (typed module consts land in
                // `module_const_map` via `registerTypedModuleConst`, run in the
                // same pass-2 before this).
                if (self.program_index.module_const_map.get(id.name)) |ci| {
                    if (self.constExprValue(ci.value)) |cv| break :blk cv;
                }
                if (self.diagnostics) |d|
                    d.addFmt(.err, v.span, "global '{s}' must be initialized by a compile-time constant; '{s}' is not a usable constant here", .{ vd.name, id.name });
                break :blk null;
            },
            // Enum-literal shorthand globals (`OS : OperatingSystem = .unknown;`)
            // keep their established zero-init: it is load-bearing for
            // compile-time `inline if OS == .X` in the stdlib (issue 0071 scope
            // note). Carved out explicitly — not folded into a silent fallthrough.
            .enum_literal => null,
            // Any other initializer shape (`.field_access` on a const, a call, an
            // arithmetic expression, …) is not a static constant the compiler can
            // evaluate here. Diagnose loudly rather than emit a null payload that
            // silently zero-initializes the global (issues 0071/0072).
            else => blk: {
                if (self.diagnostics) |d|
                    d.addFmt(.err, v.span, "global '{s}' must be initialized by a compile-time constant", .{vd.name});
                break :blk null;
            },
        };
    }

    /// Resolve identifier-RHS type aliases whose target is declared LATER in the
    /// file. The forward scan above only registers an alias (`A :: B`) when `B`
    /// is already in `type_alias_map` / the `TypeTable`; a forward target isn't
    /// yet present, so `A` is left unregistered and its uses get falsely flagged
    /// as an unknown type (issue 0069). Re-resolve to a fixpoint now that every
    /// top-level name has been seen, so `A :: B; B :: s32;` converges the same as
    /// the ordered `B :: s32; A :: B;`. A value const is never an `.identifier`
    /// node (`NotAType :: 123` is an int literal), and an alias whose target is a
    /// value const still misses both lookups, so neither this pass nor issue 0068
    /// can register a non-type name.
    fn resolveForwardIdentifierAliases(self: *Lowering, decls: []const *const Node) void {
        var progressed = true;
        while (progressed) {
            progressed = false;
            for (decls) |decl| {
                const cd = switch (decl.data) {
                    .const_decl => |c| c,
                    else => continue,
                };
                if (cd.value.data != .identifier) continue;
                if (self.program_index.type_alias_map.contains(cd.name)) continue;
                const rhs_name = cd.value.data.identifier.name;
                if (self.program_index.type_alias_map.get(rhs_name)) |chained| {
                    self.program_index.type_alias_map.put(cd.name, chained) catch {};
                    progressed = true;
                } else {
                    const name_id = self.module.types.internString(rhs_name);
                    if (self.module.types.findByName(name_id)) |tid| {
                        self.program_index.type_alias_map.put(cd.name, tid) catch {};
                        progressed = true;
                    }
                }
            }
        }
    }

    /// Try to convert an array literal's elements into a compile-time ConstantValue.aggregate.
    /// Returns null if any element is not a compile-time constant.
    fn constArrayLiteral(self: *Lowering, elements: []const *const Node) ?inst_mod.ConstantValue {
        const vals = self.alloc.alloc(inst_mod.ConstantValue, elements.len) catch return null;
        for (elements, 0..) |elem, i| {
            vals[i] = self.constExprValue(elem) orelse return null;
        }
        return .{ .aggregate = vals };
    }

    /// Try to convert a single AST expression into a compile-time ConstantValue.
    /// Returns null if the expression is not constant-foldable here.
    fn constExprValue(self: *Lowering, expr: *const Node) ?inst_mod.ConstantValue {
        return switch (expr.data) {
            .int_literal => |il| .{ .int = il.value },
            .bool_literal => |bl| .{ .boolean = bl.value },
            .float_literal => |fl| .{ .float = fl.value },
            .string_literal => |sl| .{ .string = self.module.types.internString(sl.raw) },
            .undef_literal => .zeroinit,
            .unary_op => |uo| switch (uo.op) {
                .negate => switch (uo.operand.data) {
                    .int_literal => |il| .{ .int = -il.value },
                    .float_literal => |fl| .{ .float = -fl.value },
                    else => null,
                },
                else => null,
            },
            .array_literal => |al| self.constArrayLiteral(al.elements),
            else => null,
        };
    }

    /// Try to convert a struct literal into a compile-time ConstantValue.aggregate of the
    /// struct's fields in declaration order, filling missing fields from the struct's
    /// field defaults. Returns null if any value is not constant-foldable.
    fn constStructLiteral(self: *Lowering, sl: *const ast.StructLiteral, ty: TypeId) ?inst_mod.ConstantValue {
        if (ty.isBuiltin()) return null;
        const ti = self.module.types.get(ty);
        if (ti != .@"struct") return null;
        const struct_fields = ti.@"struct".fields;
        const struct_name = self.module.types.getString(ti.@"struct".name);
        const field_defaults: []const ?*const Node = self.struct_defaults_map.get(struct_name) orelse &.{};

        const has_names = sl.field_inits.len > 0 and sl.field_inits[0].name != null;

        const vals = self.alloc.alloc(inst_mod.ConstantValue, struct_fields.len) catch return null;
        for (struct_fields, 0..) |sf, fi| {
            const sf_name = self.module.types.getString(sf.name);
            const init_expr: ?*const Node = blk: {
                if (has_names) {
                    for (sl.field_inits) |init_pair| {
                        if (init_pair.name) |n| {
                            if (std.mem.eql(u8, n, sf_name)) break :blk init_pair.value;
                        }
                    }
                } else if (fi < sl.field_inits.len) {
                    break :blk sl.field_inits[fi].value;
                }
                if (fi < field_defaults.len) break :blk field_defaults[fi];
                break :blk null;
            };
            if (init_expr) |e| {
                vals[fi] = self.constExprValue(e) orelse return null;
            } else {
                vals[fi] = .zeroinit;
            }
        }
        return .{ .aggregate = vals };
    }

    /// Pass 2: Lower main function body and comptime side-effects.
    fn lowerMainAndComptime(self: *Lowering, decls: []const *const Node) void {
        for (decls) |decl| {
            switch (decl.data) {
                .const_decl => |cd| {
                    if (cd.value.data == .fn_decl) {
                        if (isExportedEntryName(cd.name)) {
                            self.lazyLowerFunction(cd.name);
                        }
                    } else if (cd.value.data == .comptime_expr) {
                        self.lowerComptimeGlobal(cd.name, cd.value.data.comptime_expr.expr, cd.type_annotation);
                    }
                },
                .fn_decl => |fd| {
                    if (isExportedEntryName(fd.name)) {
                        self.lazyLowerFunction(fd.name);
                    }
                },
                .comptime_expr => |ct| {
                    self.lowerComptimeSideEffect(ct.expr);
                },
                .namespace_decl => |ns| {
                    if (self.main_file != null) {
                        self.lowerMainAndComptime(ns.decls);
                    }
                },
                else => {},
            }
        }
    }

    /// Declare a function as an extern stub (signature only, no body).
    pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8) void {
        // Skip generic templates — they're monomorphized on demand, not declared as extern
        if (fd.type_params.len > 0) return;

        const ret_ty = self.resolveReturnType(fd);

        // Foreign declarations with a trailing variadic param map to the C
        // calling convention's `...` tail. Drop the variadic param from the
        // IR signature (it has no C-level slot) and set is_variadic.
        const is_foreign = fd.body.data == .foreign_expr;
        var is_variadic = false;
        var effective_params = fd.params;
        if (is_foreign and fd.params.len > 0 and fd.params[fd.params.len - 1].is_variadic) {
            is_variadic = true;
            effective_params = fd.params[0 .. fd.params.len - 1];
        }

        const wants_ctx = self.funcWantsImplicitCtx(fd);
        var params = std.ArrayList(Function.Param).empty;
        if (wants_ctx) {
            params.append(self.alloc, .{
                .name = self.module.types.internString("__sx_ctx"),
                .ty = self.module.types.ptrTo(.void),
            }) catch unreachable;
        }
        for (effective_params) |p| {
            const pty = self.resolveParamType(&p);
            params.append(self.alloc, .{
                .name = self.module.types.internString(p.name),
                .ty = pty,
            }) catch unreachable;
        }

        // `#foreign` declarations are external C symbols by definition —
        // promote them to callconv(.c) when the user didn't write it
        // explicitly. This keeps fn-ptr coercion type-safe: anything
        // typed by name as `(args) -> ret` of a `#foreign` decl can be
        // assigned to / passed as a `callconv(.c)` fn-pointer without a
        // call-convention mismatch.
        const cc: Function.CallingConvention = if (fd.call_conv == .c or is_foreign) .c else .default;

        // For #foreign with C name override, declare under C name and map sx name → C name
        if (is_foreign) {
            const fe = fd.body.data.foreign_expr;
            if (fe.c_name) |c_name| {
                const c_name_id = self.module.types.internString(c_name);
                const fid = self.builder.declareExtern(c_name_id, params.items, ret_ty);
                const func = self.module.getFunctionMut(fid);
                func.call_conv = cc;
                func.source_file = self.current_source_file;
                func.is_variadic = is_variadic;
                func.has_implicit_ctx = wants_ctx;
                self.foreign_name_map.put(name, c_name) catch {};
                return;
            }
        }

        const name_id = self.module.types.internString(name);
        const fid = self.builder.declareExtern(name_id, params.items, ret_ty);
        const func = self.module.getFunctionMut(fid);
        func.call_conv = cc;
        func.source_file = self.current_source_file;
        func.is_variadic = is_variadic;
        func.has_implicit_ctx = wants_ctx;
    }

    /// Check if a C-imported function is visible from the current source file.
    /// Returns true for non-C functions (always visible) or if no scoping info available.
    fn isCImportVisible(self: *Lowering, fn_name: []const u8) bool {
        const fd = self.program_index.fn_ast_map.get(fn_name) orelse return true;
        // Only restrict C import fn_decls: foreign_expr with no library_ref
        if (fd.body.data != .foreign_expr) return true;
        if (fd.body.data.foreign_expr.library_ref != null) return true;
        return self.isNameVisible(fn_name);
    }

    /// Non-transitive `#import` visibility check for top-level decls.
    ///
    /// `module_scopes[F]` holds ONLY the names authored in file F (plus its
    /// namespace aliases). Cross-module visibility is joined here at query
    /// time by walking each direct flat-import edge in `import_graph` — a
    /// name is visible from F when it's authored in F or in any module F
    /// directly `#import`s. Doing the join here (instead of pre-merging in
    /// `resolveImports`) lets cyclic imports like std.sx ↔ allocators.sx
    /// still resolve, since the cycle's skipped edge is still recorded in
    /// `import_graph` and the partner's scope is filled in by the time
    /// lowering queries it.
    ///
    /// Falls open when the scoping infrastructure isn't wired (comptime
    /// callers, directory imports without main_file, etc.). The caller is
    /// responsible for restricting the call to names that ARE known
    /// top-level decls; otherwise every local variable would be policed.
    fn isNameVisible(self: *Lowering, name: []const u8) bool {
        const scopes = self.program_index.module_scopes orelse return true;
        const source = self.current_source_file orelse return true;
        const own_scope = scopes.get(source) orelse return true;
        if (own_scope.contains(name)) return true;
        const graph = self.program_index.import_graph orelse return true;
        const direct = graph.get(source) orelse return true;
        var it = direct.iterator();
        while (it.next()) |kv| {
            const dep = scopes.get(kv.key_ptr.*) orelse continue;
            if (dep.contains(name)) return true;
        }
        return false;
    }

    /// Lazily lower a function body on demand. Called when lowerCall can't find
    /// the function and it exists in fn_ast_map.
    fn lazyLowerFunction(self: *Lowering, name: []const u8) void {
        // Already lowered?
        if (self.lowered_functions.contains(name)) return;

        // For sx-defined `#objc_class` methods, pin current_foreign_class
        // so `*Self` substitutions in resolveTypeWithBindings find the
        // state-struct type (M1.2 A.2b). The inline body-lowering path
        // below re-resolves param types, so the context must be set
        // BEFORE any resolveReturnType / resolveParamType call.
        const saved_fc_lazy = self.current_foreign_class;
        defer self.current_foreign_class = saved_fc_lazy;
        if (self.lookupObjcDefinedClassForMethod(name)) |fcd| {
            self.current_foreign_class = fcd;
        }
        // No AST? (builtins, foreign functions, or imported functions not in this file)
        const fd = self.program_index.fn_ast_map.get(name) orelse return;
        // Foreign declarations stay as extern stubs but need to be REGISTERED
        // in the current module so callers get a real FuncId. Without this,
        // a comptime-lowered function (e.g. `concat` from std.sx pulled into
        // a fresh ct_module via `evalComptimeString`) emits `.call` against a
        // FuncId that doesn't exist locally; the interp can't find the
        // foreign target and silently no-ops instead of dispatching to libc.
        if (fd.body.data == .foreign_expr) {
            if (self.resolveFuncByName(name) == null) {
                self.declareFunction(fd, name);
                self.lowered_functions.put(name, {}) catch {};
            }
            return;
        }
        // Builtins / #compiler bodies stay as compiler-handled — no extern stub needed.
        if (fd.body.data == .builtin_expr or fd.body.data == .compiler_expr) return;
        if (fd.type_params.len > 0) return; // generics handled by monomorphization (Step 3.13)

        // Defer functions with type-category matches until all types are registered.
        // any_to_string uses `if type == { case slice: ... }` which compiles a switch
        // with type tags from resolveTypeCategoryTags. This must happen AFTER main is
        // fully lowered so all types ([]s32, List__s32, etc.) are in the TypeTable.
        if (!self.processing_deferred and std.mem.eql(u8, name, "any_to_string")) {
            self.deferred_type_fns.append(self.alloc, name) catch {};
            return;
        }

        // Mark as lowered before lowering (prevents infinite recursion)
        self.lowered_functions.put(name, {}) catch {};

        // Save builder state (same pattern as lambda lowering)
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        const saved_scope = self.scope;
        const saved_defer_base = self.func_defer_base;
        const saved_block_terminated = self.block_terminated;
        const saved_force_block_value = self.force_block_value;
        const saved_source_file = self.current_source_file;
        // The `#jni_env` Ref stack is lexical within ONE function's instruction
        // stream — Refs from the caller don't dereference correctly in this
        // callee's body. Move the visible base to the current top so
        // omitted-env `#jni_call` in this fn doesn't accidentally pick up the
        // caller's Refs. Defer covers all the early-return paths below.
        const saved_jni_env_base = self.jni_env_stack_base;
        self.jni_env_stack_base = self.jni_env_stack.items.len;
        defer self.jni_env_stack_base = saved_jni_env_base;
        // Pack-fn mono state is lexical to the pack-fn body. A lazily
        // lowered callee may share a param NAME with the active pack
        // (e.g. `walk(args: []Any)` called from `probe(..$args)`); without
        // isolation, `lowerFieldAccess`'s `<pack_name>.len` intercept
        // folds the callee's `args.len` to the outer mono's arity and
        // bakes the constant into the IR. Same shape for the AST-node
        // and per-element-type maps. Null out for the duration of the
        // body lowering and restore on exit.
        const saved_pan = self.pack_arg_nodes;
        const saved_ppc = self.pack_param_count;
        const saved_pat = self.pack_arg_types;
        const saved_iri = self.inline_return_target;
        self.pack_arg_nodes = null;
        self.pack_param_count = null;
        self.pack_arg_types = null;
        self.inline_return_target = null;
        defer {
            self.pack_arg_nodes = saved_pan;
            self.pack_param_count = saved_ppc;
            self.pack_arg_types = saved_pat;
            self.inline_return_target = saved_iri;
        }
        self.func_defer_base = self.defer_stack.items.len;
        self.block_terminated = false;
        self.force_block_value = false;

        // Find the existing extern stub and replace it with a full body
        const name_id = self.module.types.internString(name);
        const ret_ty = self.resolveReturnType(fd);

        // Look up the existing function declaration (from scanDecls)
        var func_id: ?FuncId = null;
        for (self.module.functions.items, 0..) |func, i| {
            if (func.name == name_id) {
                func_id = FuncId.fromIndex(@intCast(i));
                break;
            }
        }

        if (func_id == null) {
            // Function not yet declared — create it fresh via lowerFunction
            self.lowerFunction(fd, name, false);
            // Restore builder state
            self.setCurrentSourceFile(saved_source_file);
            self.scope = saved_scope;
            self.func_defer_base = saved_defer_base;
            self.force_block_value = saved_force_block_value;
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
            return;
        }

        if (func_id) |fid| {
            // Re-use the existing function slot — switch builder to it
            self.builder.func = fid;
            const func = &self.module.functions.items[@intFromEnum(fid)];
            self.setCurrentSourceFile(func.source_file);
            if (!func.is_extern) {
                // Already promoted (e.g., via lowerComptimeDeps) — skip
                self.setCurrentSourceFile(saved_source_file);
                self.scope = saved_scope;
                self.func_defer_base = saved_defer_base;
                self.block_terminated = saved_block_terminated;
                self.force_block_value = saved_force_block_value;
                self.builder.func = saved_func;
                self.builder.current_block = saved_block;
                self.builder.inst_counter = saved_counter;
                return;
            }
            func.is_extern = false; // promote from extern stub to real function
            func.linkage = if (isExportedEntryName(name)) .external else .internal;
            if (fd.call_conv == .c) func.call_conv = .c;
            // Set inst_counter to param count (params occupy refs 0..N-1).
            // IR params = AST params + 1 if the function carries `__sx_ctx`
            // at slot 0.
            const ctx_slots: usize = if (func.has_implicit_ctx) 1 else 0;
            std.debug.assert(func.params.len == fd.params.len + ctx_slots);
            self.builder.inst_counter = @intCast(func.params.len);

            // Create entry block
            const entry_name = self.module.types.internString("entry");
            const entry = self.builder.appendBlock(entry_name, &.{});
            self.builder.switchToBlock(entry);

            // Create scope and bind params
            var scope = Scope.init(self.alloc, null);
            defer scope.deinit();
            self.scope = &scope;

            // The implicit `__sx_ctx` param (when present) lives at slot 0;
            // user params shift by one. `current_ctx_ref` is bound to slot 0
            // so call-site lowering can prepend it to every sx-to-sx call.
            // For OS-called entry points (main / JNI hooks), there's no
            // ctx param at all — we synthesise `&__sx_default_context` and
            // bind `current_ctx_ref` to its address so the body's sx-to-sx
            // calls have a sensible Context to forward.
            const wants_ctx = self.funcWantsImplicitCtx(fd);
            const saved_ctx_ref = self.current_ctx_ref;
            defer self.current_ctx_ref = saved_ctx_ref;
            const user_param_base: u32 = if (wants_ctx) 1 else 0;
            if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0);

            for (fd.params, 0..) |p, i| {
                const pty = self.resolveParamType(&p);
                const slot = self.builder.alloca(pty);
                const param_ref = Ref.fromIndex(@intCast(i + user_param_base));
                self.builder.store(slot, param_ref);
                scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
            }

            // Inbound entry points and callconv(.c) sx functions: bind
            // current_ctx_ref to the static default before any user code
            // runs. C-callable sx functions don't get a __sx_ctx param,
            // but their bodies may call ctx-aware sx functions / fn-ptrs
            // and need a real Context to forward.
            if (!wants_ctx and self.implicit_ctx_enabled) {
                if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| {
                    self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, self.module.types.ptrTo(.void));
                }
            }

            // Lower the function body (set target_type to return type for implicit returns)
            const saved_target = self.target_type;
            self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null;
            if (ret_ty != .void and ret_ty != .noreturn) {
                self.lowerValueBody(fd.body, ret_ty);
            } else {
                // void / noreturn: no value to return — lower as statements and
                // let `ensureTerminator` close the block (ret void / unreachable).
                self.lowerBlock(fd.body);
                self.ensureTerminator(ret_ty);
            }
            self.target_type = saved_target;

            self.builder.finalize();
        }

        // Restore builder state
        self.setCurrentSourceFile(saved_source_file);
        self.scope = saved_scope;
        self.func_defer_base = saved_defer_base;
        self.block_terminated = saved_block_terminated;
        self.force_block_value = saved_force_block_value;
        self.builder.func = saved_func;
        self.builder.current_block = saved_block;
        self.builder.inst_counter = saved_counter;
    }

    /// Lower a single function declaration.
    pub fn lowerFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8, is_imported: bool) void {
        // For sx-defined `#objc_class` methods (qualified `<Class>.<method>`),
        // set `current_foreign_class` so `*Self` substitutions through
        // `resolveTypeWithBindings` find the state-struct type (M1.2 A.2b).
        // Save+restore — function lowering can re-enter.
        const saved_fc = self.current_foreign_class;
        defer self.current_foreign_class = saved_fc;
        if (self.lookupObjcDefinedClassForMethod(name)) |fcd| {
            self.current_foreign_class = fcd;
        }

        const name_id = self.module.types.internString(name);
        const ret_ty = self.resolveReturnType(fd);

        const wants_ctx = self.funcWantsImplicitCtx(fd);

        // Build param list. `Function.init` borrows the slice (it does not
        // dupe), so this storage must outlive the local — build it in the
        // module's slice arena (freed at module deinit) rather than via
        // `self.alloc`, which would leak (Function.deinit never frees params).
        const param_alloc = self.module.slice_arena.allocator();
        var params = std.ArrayList(Function.Param).empty;
        if (wants_ctx) {
            params.append(param_alloc, .{
                .name = self.module.types.internString("__sx_ctx"),
                .ty = self.module.types.ptrTo(.void),
            }) catch unreachable;
        }
        for (fd.params) |p| {
            const pty = self.resolveParamType(&p);
            params.append(param_alloc, .{
                .name = self.module.types.internString(p.name),
                .ty = pty,
            }) catch unreachable;
        }

        // Check if the function body is a builtin or foreign declaration (no body needed)
        if (fd.body.data == .builtin_expr or fd.body.data == .foreign_expr or fd.body.data == .compiler_expr) {
            // Already declared by scanDecls/declareFunction (which handles #foreign renames)
            return;
        }

        // Skip generic functions (they have type parameters and are templates, not concrete)
        if (fd.type_params.len > 0) {
            const fid = self.builder.declareExtern(name_id, params.items, ret_ty);
            self.module.getFunctionMut(fid).has_implicit_ctx = wants_ctx;
            return;
        }

        // Imported functions: declare as extern (don't lower bodies from other files)
        if (is_imported) {
            const fid = self.builder.declareExtern(name_id, params.items, ret_ty);
            self.module.getFunctionMut(fid).has_implicit_ctx = wants_ctx;
            return;
        }

        const func_id = self.builder.beginFunction(
            name_id,
            params.items,
            ret_ty,
        );
        _ = func_id;
        self.builder.currentFunc().has_implicit_ctx = wants_ctx;

        // Set linkage. Default for fn defs is `internal` (LLVM DCE-friendly,
        // matches C `static`). isExportedEntryName lists the names the OS
        // loader calls — `main`, Android NativeActivity hooks — which must
        // stay externally visible.
        if (isExportedEntryName(name)) {
            self.builder.currentFunc().linkage = .external;
        }

        // Set calling convention
        if (fd.call_conv == .c) {
            self.builder.currentFunc().call_conv = .c;
        }

        // Create entry block
        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        // Create scope and bind params
        var scope = Scope.init(self.alloc, self.scope);
        defer scope.deinit();
        self.scope = &scope;
        defer self.scope = scope.parent;

        // Implicit `__sx_ctx` at slot 0 when funcWantsImplicitCtx is true;
        // user params shift by one. Bind `current_ctx_ref` for call-site
        // forwarding inside the body.
        const wants_ctx_lf = self.funcWantsImplicitCtx(fd);
        const saved_ctx_ref_lf = self.current_ctx_ref;
        defer self.current_ctx_ref = saved_ctx_ref_lf;
        const user_param_base_lf: u32 = if (wants_ctx_lf) 1 else 0;
        if (wants_ctx_lf) self.current_ctx_ref = Ref.fromIndex(0);

        for (fd.params, 0..) |p, i| {
            const pty = self.resolveParamType(&p);
            // Allocate stack slot for param, store initial value.
            // Refs 0..N-1 are reserved for function parameters by beginFunction.
            const slot = self.builder.alloca(pty);
            const param_ref = Ref.fromIndex(@intCast(i + user_param_base_lf));
            self.builder.store(slot, param_ref);
            scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
        }

        // Inbound entry points + callconv(.c) sx functions: bind
        // current_ctx_ref to &__sx_default_context. See companion comment
        // in `lowerFunction` for the same case.
        if (!wants_ctx_lf and self.implicit_ctx_enabled) {
            if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| {
                self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, self.module.types.ptrTo(.void));
            }
        }

        // Lower the function body, capturing the last expression's value for implicit return
        const saved_target = self.target_type;
        self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null;
        if (ret_ty != .void and ret_ty != .noreturn) {
            self.lowerValueBody(fd.body, ret_ty);
        } else {
            // void / noreturn: no value to return — lower as statements and
            // let `ensureTerminator` close the block (ret void / unreachable).
            self.lowerBlock(fd.body);
            self.ensureTerminator(ret_ty);
        }
        self.target_type = saved_target;

        self.builder.finalize();
    }

    // ── Statement lowering ──────────────────────────────────────────

    fn lowerBlock(self: *Lowering, node: *const Node) void {
        switch (node.data) {
            .block => |blk| {
                // Create a child scope for block-level variable shadowing
                var block_scope = Scope.init(self.alloc, self.scope);
                const saved_scope = self.scope;
                self.scope = &block_scope;
                const saved_defer_len = self.defer_stack.items.len;
                defer {
                    self.emitBlockDefers(saved_defer_len);
                    self.scope = saved_scope;
                    block_scope.deinit();
                }
                for (blk.stmts) |stmt| {
                    if (self.block_terminated) break;
                    self.lowerStmt(stmt);
                    // A bare `return`/`raise` mid-block terminates the current
                    // basic block but deliberately does NOT set `block_terminated`
                    // (that flag would leak past an `if cond { return }` merge
                    // block, skipping its trailing statements — see lowerReturn).
                    // Stop here so dead statements after the terminator aren't
                    // emitted into an already-closed block (invalid LLVM IR).
                    if (self.currentBlockHasTerminator()) break;
                }
            },
            else => {
                // Single expression as body (arrow functions)
                self.lowerStmt(node);
            },
        }
    }

    /// Lower an `inline if` branch — block body emits statements, expression returns value.
    fn lowerInlineBranch(self: *Lowering, node: *const Node) Ref {
        if (node.data == .block) {
            self.lowerBlock(node);
            // A `return` inside the branch terminates the current LLVM block; propagate
            // that up so the enclosing block lowering stops emitting fall-through.
            if (self.currentBlockHasTerminator()) {
                self.block_terminated = true;
                return .none;
            }
            return self.builder.constInt(0, .void);
        }
        return self.lowerExpr(node);
    }

    /// Lower a block and return the last expression's value (for implicit returns).
    fn lowerBlockValue(self: *Lowering, node: *const Node) ?Ref {
        // Set force_block_value so nested if-else expressions produce values
        const saved = self.force_block_value;
        self.force_block_value = true;
        defer self.force_block_value = saved;

        switch (node.data) {
            .block => |blk| {
                if (blk.stmts.len == 0) return null;
                // Create a child scope for block-level variable shadowing
                var block_scope = Scope.init(self.alloc, self.scope);
                const saved_scope = self.scope;
                self.scope = &block_scope;
                const saved_defer_len = self.defer_stack.items.len;
                defer {
                    self.emitBlockDefers(saved_defer_len);
                    self.scope = saved_scope;
                    block_scope.deinit();
                }
                // A block whose last statement is `;`-terminated (or not an
                // expression) discards its value: lower every statement as a
                // statement and yield nothing.
                if (!blk.produces_value) {
                    self.force_block_value = false;
                    for (blk.stmts) |stmt| {
                        if (self.block_terminated) return null;
                        self.lowerStmt(stmt);
                        if (self.currentBlockHasTerminator()) return null;
                    }
                    return null;
                }
                // Lower all statements except the last normally
                self.force_block_value = false; // don't force for non-last statements
                for (blk.stmts[0 .. blk.stmts.len - 1]) |stmt| {
                    if (self.block_terminated) return null;
                    self.lowerStmt(stmt);
                    // A bare `return`/`raise` mid-block closes the current basic
                    // block (without setting `block_terminated`); the remaining
                    // statements — including the value-expr — are dead.
                    if (self.currentBlockHasTerminator()) return null;
                }
                if (self.block_terminated) return null;
                // Last statement (no trailing `;`): its value is the block's.
                self.force_block_value = true;
                const last = blk.stmts[blk.stmts.len - 1];
                return self.tryLowerAsExpr(last);
            },
            else => {
                // Single expression as body (arrow functions)
                return self.tryLowerAsExpr(node);
            },
        }
    }

    /// Lower a value-returning function body and emit the implicit return.
    /// Emits a hard error when the body yields no value — its last statement is
    /// `;`-terminated (value discarded) or void — and the body doesn't already
    /// terminate via `return`/`raise`. Replaces the old silent default-return.
    fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
        const body_val = self.lowerBlockValue(body);
        if (self.currentBlockHasTerminator()) return;
        if (body_val) |val| {
            const val_ty = self.builder.getRefType(val);
            if (val_ty != .void) {
                const coerced = self.coerceToType(val, val_ty, ret_ty);
                self.builder.ret(coerced, ret_ty);
                return;
            }
        }
        // A PURE-failable function (`-> !` / `-> !Named`, whose entire return IS
        // the error channel) carries no success value — a void body is a normal
        // success exit, not a missing value. `ensureTerminator` emits the
        // error-slot-zero success return.
        if (self.errorChannelOf(ret_ty)) |chan| {
            if (chan == ret_ty) {
                self.ensureTerminator(ret_ty);
                return;
            }
        }
        if (self.diagnostics) |diags| {
            if (body.data == .block and body.data.block.discarded_semi != null) {
                diags.addFmt(.err, body.data.block.discarded_semi.?, "function returns '{s}' but the last expression's value is discarded by this `;` — drop the `;` to return it (or use an explicit `return`)", .{self.formatTypeName(ret_ty)});
            } else {
                const span = blk: {
                    if (body.data == .block) {
                        const stmts = body.data.block.stmts;
                        if (stmts.len > 0) break :blk stmts[stmts.len - 1].span;
                    }
                    break :blk body.span;
                };
                diags.addFmt(.err, span, "function returns '{s}' but its body produces no value — end it with a trailing expression (no `;`) or an explicit `return`", .{self.formatTypeName(ret_ty)});
            }
        }
        self.ensureTerminator(ret_ty);
    }

    /// Try to lower a node as an expression, returning its value.
    /// Statement nodes are lowered as statements (returning null).
    fn tryLowerAsExpr(self: *Lowering, node: *const Node) ?Ref {
        return switch (node.data) {
            .var_decl, .const_decl, .fn_decl, .return_stmt, .raise_stmt, .assignment, .defer_stmt, .push_stmt, .multi_assign, .destructure_decl => {
                self.lowerStmt(node);
                return null;
            },
            else => self.lowerExpr(node),
        };
    }

    fn lowerStmt(self: *Lowering, node: *const Node) void {
        // Stamp this statement's span onto its instructions (ERR E3.0); see
        // `lowerExpr`.
        const saved_span = self.builder.current_span;
        defer self.builder.current_span = saved_span;
        if (node.span.start != 0 or node.span.end != 0) self.builder.current_span = .{ .start = node.span.start, .end = node.span.end };
        switch (node.data) {
            .var_decl => |vd| self.lowerVarDecl(&vd),
            .const_decl => |cd| self.lowerConstDecl(&cd),
            .fn_decl => |fd| self.lowerLocalFnDecl(&fd),
            .return_stmt => |rs| self.lowerReturn(&rs),
            .raise_stmt => |rs| self.lowerRaise(&rs, node.span),
            .assignment => |asgn| self.lowerAssignment(&asgn),
            .defer_stmt => |ds| self.lowerDefer(&ds),
            .onfail_stmt => |ofs| self.lowerOnFail(&ofs, node.span),
            .push_stmt => |ps| self.lowerPush(&ps),
            .multi_assign => |ma| self.lowerMultiAssign(&ma),
            .destructure_decl => |dd| self.lowerDestructureDecl(&dd),
            .insert_expr => |ins| self.lowerInsertExpr(ins.expr),
            .block => self.lowerBlock(node),
            .jni_env_block => |eb| {
                // Compile-time stack push for lexical-direct env resolution
                // (2.16b — `#jni_call` in the same fn picks up env from
                // jni_env_stack directly, no TL read).
                //
                // Runtime TL save/set/restore (2.16c) for cross-function
                // helpers: callees in OTHER fns invoked from inside the
                // body read the slot via `sx_jni_env_tl_get`. Storage
                // lives in a separately-linked C helper (see
                // library/vendors/sx_jni_runtime/sx_jni_env_tl.c) so the
                // JIT doesn't need orc_rt for TLS.
                const env_ref = self.lowerExpr(eb.env);
                const fids = self.getJniEnvTlFids();
                const ptr_ty = self.module.types.ptrTo(.void);
                const saved_tl = self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
                const set_args = self.alloc.dupe(Ref, &.{env_ref}) catch unreachable;
                _ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = set_args } }, .void);
                self.jni_env_stack.append(self.alloc, env_ref) catch unreachable;
                self.lowerBlock(eb.body);
                _ = self.jni_env_stack.pop();
                const restore_args = self.alloc.dupe(Ref, &.{saved_tl}) catch unreachable;
                _ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void);
            },
            // Block-local type declarations
            .struct_decl => |sd| self.registerStructDecl(&sd),
            .enum_decl, .union_decl => {
                _ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map);
            },
            .error_set_decl => self.registerErrorSetDecl(node),
            .ufcs_alias => |ua| {
                self.program_index.ufcs_alias_map.put(ua.name, ua.target) catch {};
            },
            // Expression statement
            else => {
                _ = self.lowerExpr(node);
            },
        }
    }

    fn lowerVarDecl(self: *Lowering, vd: *const ast.VarDecl) void {
        if (vd.value) |val| {
            if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
                const ph = self.diagPackAsValue(val.data.identifier.name, val.span, .storage);
                // Bind the name to the placeholder so later uses don't cascade
                // into a second "unresolved" error after this one.
                if (self.scope) |scope| {
                    scope.put(vd.name, .{ .ref = ph, .ty = .unresolved, .is_alloca = false });
                }
                return;
            }
        }
        if (vd.type_annotation) |ta| {
            // Explicit type annotation — resolve type first, then lower value
            const ty = self.resolveType(ta);
            const slot = self.builder.alloca(ty);
            if (vd.value) |val| {
                // = --- (undef_literal) on tuple types: zero-initialize
                if (val.data == .undef_literal and !ty.isBuiltin()) {
                    const ti = self.module.types.get(ty);
                    if (ti == .tuple) {
                        var field_vals = std.ArrayList(Ref).empty;
                        defer field_vals.deinit(self.alloc);
                        for (ti.tuple.fields) |f| {
                            field_vals.append(self.alloc, self.builder.constInt(0, f)) catch unreachable;
                        }
                        const zero = self.builder.emit(.{
                            .tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
                        }, ty);
                        self.builder.store(slot, zero);
                        if (self.scope) |scope| {
                            scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
                        }
                        return;
                    }
                }
                const saved_target = self.target_type;
                const saved_fbv = self.force_block_value;
                self.target_type = ty;
                self.force_block_value = true;
                var ref = self.lowerExpr(val);
                self.target_type = saved_target;
                self.force_block_value = saved_fbv;
                // If target is optional and value isn't null, wrap with optional_wrap
                if (!ty.isBuiltin()) {
                    const ty_info = self.module.types.get(ty);
                    if (ty_info == .optional and val.data != .null_literal) {
                        ref = self.builder.optionalWrap(ref, ty);
                    } else if (ty_info == .slice) {
                        // Array → slice promotion: if value is an array, convert to slice
                        const ref_ty = self.builder.getRefType(ref);
                        if (!ref_ty.isBuiltin()) {
                            const ref_info = self.module.types.get(ref_ty);
                            if (ref_info == .array) {
                                ref = self.builder.emit(.{ .array_to_slice = .{ .operand = ref } }, ty);
                            }
                        }
                    } else if (self.getProtocolInfo(ty) != null) {
                        // Auto type erasure: concrete → protocol
                        const ref_ty = self.builder.getRefType(ref);
                        if (ref_ty != ty) {
                            ref = self.buildProtocolErasure(ref, val, ref_ty, ty);
                        }
                    }
                }
                // Coerce value to match target type (e.g. u8 → s64 widening)
                {
                    const ref_ty = self.builder.getRefType(ref);
                    if (ref_ty != ty and ref_ty != .void and ty != .void) {
                        ref = self.coerceToType(ref, ref_ty, ty);
                    }
                }
                self.builder.store(slot, ref);
            } else {
                // No value: zero-initialize or apply struct defaults
                const zero = self.buildDefaultValue(ty);
                self.builder.store(slot, zero);
            }
            if (self.scope) |scope| {
                scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
            }
        } else if (vd.value) |val| {
            // No type annotation — lower expr first, then get type from result.
            // This is critical for generic calls where the return type is only
            // known after monomorphization.
            const saved_fbv = self.force_block_value;
            self.force_block_value = true;
            const ref = self.lowerExpr(val);
            self.force_block_value = saved_fbv;
            const ty = self.builder.getRefType(ref);
            const slot = self.builder.alloca(ty);
            self.builder.store(slot, ref);
            if (self.scope) |scope| {
                scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
            }
        } else {
            const ty = TypeId.s64;
            const slot = self.builder.alloca(ty);
            self.builder.store(slot, self.zeroValue(ty));
            if (self.scope) |scope| {
                scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
            }
        }
    }

    /// Handle a bare fn_decl node as a local function declaration.
    /// The parser produces `fn_decl` (not `const_decl`) for `name :: (params) -> T { body }`.
    fn lowerLocalFnDecl(self: *Lowering, fd: *const ast.FnDecl) void {
        // Use mangled name for local functions to support block-scoped shadowing
        const name = if (self.scope) |scope| blk: {
            const mangled = std.fmt.allocPrint(self.alloc, "{s}__{d}", .{ fd.name, self.local_fn_counter }) catch fd.name;
            self.local_fn_counter += 1;
            scope.fn_names.put(fd.name, mangled) catch {};
            break :blk mangled;
        } else fd.name;
        self.program_index.fn_ast_map.put(name, fd) catch {};
        self.lazyLowerFunction(name);
    }

    fn lowerConstDecl(self: *Lowering, cd: *const ast.ConstDecl) void {
        // Handle local function declarations: fx :: (s:s3) -> s3 { ... }
        if (cd.value.data == .fn_decl) {
            const fd = &cd.value.data.fn_decl;
            // Use mangled name for local functions to support block-scoped shadowing
            const name = if (self.scope != null) blk: {
                const mangled = std.fmt.allocPrint(self.alloc, "{s}__{d}", .{ cd.name, self.local_fn_counter }) catch cd.name;
                self.local_fn_counter += 1;
                // Register the bare→mangled mapping in the current scope
                if (self.scope) |scope| {
                    scope.fn_names.put(cd.name, mangled) catch {};
                }
                break :blk mangled;
            } else cd.name;
            // Register in fn_ast_map so it can be resolved by lowerCall
            self.program_index.fn_ast_map.put(name, fd) catch {};
            // Lower the function body (saves/restores builder state)
            self.lazyLowerFunction(name);
            return;
        }

        // Handle local type declarations: MyType :: struct/union/enum { ... }
        if (cd.value.data == .struct_decl) {
            self.registerStructDecl(&cd.value.data.struct_decl);
            return;
        }
        if (cd.value.data == .enum_decl or cd.value.data == .union_decl) {
            _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map);
            return;
        }

        const ref = self.lowerExpr(cd.value);
        // If there's an explicit type annotation, use it. Otherwise, infer from the expression.
        const ty = if (cd.type_annotation) |ta|
            self.resolveType(ta)
        else
            self.builder.getRefType(ref);

        if (self.scope) |scope| {
            scope.put(cd.name, .{ .ref = ref, .ty = ty, .is_alloca = false });
        }
    }

    fn lowerReturn(self: *Lowering, rs: *const ast.ReturnStmt) void {
        if (rs.value) |val| {
            if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
                _ = self.diagPackAsValue(val.data.identifier.name, val.span, .return_value);
                return;
            }
        }
        // Set target_type to function return type so null_literal etc. get the right type.
        // When inlining a comptime body, the *inlined* fn's declared return type wins
        // over the caller's — otherwise `return 42` inside a `-> s64` body lowered into
        // a `-> s32` caller would coerce 42 to s32 before storing into the s64 slot.
        const old_target = self.target_type;
        const ret_ty_for_target: TypeId = if (self.inline_return_target) |iri|
            iri.ret_ty
        else if (self.builder.func) |fid|
            self.module.functions.items[@intFromEnum(fid)].ret
        else
            TypeId.s64;
        if (ret_ty_for_target != .void) self.target_type = ret_ty_for_target;
        // Evaluate return value first (before defers)
        const ret_val = if (rs.value) |val| self.lowerExpr(val) else null;
        self.target_type = old_target;

        // Inlined-comptime-body return: store into the slot the inliner
        // gave us and branch to the inliner's "return-done" basic block.
        // The branch is the basic block's terminator — so subsequent
        // dead code in the same block trips the LLVM verifier (the
        // SAME behaviour as a regular `return X;` followed by code).
        //
        // We DO NOT set `block_terminated = true`: that flag would
        // leak past structured control flow (e.g. an `if cond { return
        // X; }` whose merge block continues to subsequent statements)
        // and incorrectly skip the trailing statements. CFG-level
        // termination is what we actually want — let the basic-block
        // terminator do its job.
        if (self.inline_return_target) |iri| {
            if (ret_val) |ref| {
                const val_ty = self.builder.getRefType(ref);
                const coerced = if (val_ty != iri.ret_ty)
                    self.coerceToType(ref, val_ty, iri.ret_ty)
                else
                    ref;
                self.builder.store(iri.slot, coerced);
            }
            // Drain block-scoped defers up to the inlined-body base so
            // they fire on this return path the same as a real fn return.
            self.emitBlockDefers(self.func_defer_base);
            self.builder.br(iri.done_bb, &.{});
            return;
        }

        // Emit ALL pending defers for THIS function in LIFO order before the return
        self.emitBlockDefers(self.func_defer_base);

        if (ret_val) |ref| {
            const ret_ty = if (self.builder.func) |fid|
                self.module.functions.items[@intFromEnum(fid)].ret
            else
                TypeId.s64;
            if (ret_ty == .void) {
                // Void function — just return void (the value expression was evaluated for side effects)
                self.builder.retVoid();
            } else if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) {
                // Value-carrying failable `-> (T..., !)`: the user returns the
                // value part; the compiler appends the success error slot (0).
                self.lowerFailableSuccessReturn(ref, ret_ty, rs.value.?.span);
            } else {
                // Coerce return value to match function return type (e.g., ?s32 → s32)
                const val_ty = self.builder.getRefType(ref);
                const coerced = self.coerceToType(ref, val_ty, ret_ty);
                self.builder.ret(coerced, ret_ty);
            }
        } else {
            // A bare `return;` in a pure failable function (`-> !` / `-> !Named`,
            // whose return type IS the error set) is the success exit — the
            // error slot carries 0 ("no error"). Everything else is a void return.
            const ret_ty = if (self.builder.func) |fid|
                self.module.functions.items[@intFromEnum(fid)].ret
            else
                TypeId.void;
            if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .error_set) {
                self.builder.ret(self.builder.constInt(0, ret_ty), ret_ty);
            } else {
                self.builder.retVoid();
            }
        }
    }

    fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
        // Set target_type from LHS for RHS lowering (enum literals, struct literals, etc.)
        const old_target = self.target_type;
        if (asgn.target.data == .identifier) {
            var found_local = false;
            if (self.scope) |scope| {
                if (scope.lookup(asgn.target.data.identifier.name)) |binding| {
                    self.target_type = binding.ty;
                    found_local = true;
                }
            }
            if (!found_local) {
                if (self.program_index.global_names.get(asgn.target.data.identifier.name)) |gi| {
                    self.target_type = gi.ty;
                }
            }
        } else if (asgn.target.data == .index_expr) {
            // For array[i] = val, set target_type to the element type
            const elem_ty = self.getElementType(self.inferExprType(asgn.target.data.index_expr.object));
            if (elem_ty != .void) self.target_type = elem_ty;
        } else if (asgn.target.data == .field_access) {
            // For obj.field = val, set target_type to the field's type so RHS
            // sub-expressions (enum/struct literals, branch arms, xx casts) can
            // resolve against it. Skipped for forms that would forward the type
            // unchanged into method-call arg slots (`resolveCallParamTypes` can't
            // override target_type per-arg).
            const needs_target = switch (asgn.value.data) {
                .enum_literal, .struct_literal, .tuple_literal, .if_expr, .match_expr, .block, .unary_op, .binary_op => true,
                .call => |vc| vc.callee.data == .enum_literal,
                else => false,
            };
            if (needs_target) {
                const fa = asgn.target.data.field_access;
                const obj_ty_raw = self.inferExprType(fa.object);
                const obj_ty = if (!obj_ty_raw.isBuiltin()) blk: {
                    const pinfo = self.module.types.get(obj_ty_raw);
                    break :blk if (pinfo == .pointer) pinfo.pointer.pointee else obj_ty_raw;
                } else obj_ty_raw;
                if (!obj_ty.isBuiltin()) {
                    const field_name_id = self.module.types.internString(fa.field);
                    const struct_fields = self.getStructFields(obj_ty);
                    for (struct_fields) |f| {
                        if (f.name == field_name_id) {
                            self.target_type = f.ty;
                            break;
                        }
                    }
                }
            }
        }
        const val = self.lowerExpr(asgn.value);
        self.target_type = old_target;

        switch (asgn.target.data) {
            .identifier => |id| {
                var handled = false;
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        if (binding.is_alloca) {
                            handled = true;
                            if (asgn.op == .assign) {
                                // Coerce value to match binding type (e.g., f32 → ?f32, concrete → protocol)
                                var store_val = val;
                                const val_ty = self.builder.getRefType(val);
                                if (val_ty != binding.ty and val_ty != .void and binding.ty != .void) {
                                    store_val = self.coerceToType(val, val_ty, binding.ty);
                                }
                                self.builder.store(binding.ref, store_val);
                            } else {
                                // Compound assignment: load, op, store
                                const loaded = self.builder.load(binding.ref, binding.ty);
                                const result = self.emitCompoundOp(loaded, val, asgn.op, binding.ty);
                                self.builder.store(binding.ref, result);
                            }
                        }
                    }
                }
                // Fallback: global variable assignment
                if (!handled) {
                    if (self.program_index.global_names.get(id.name)) |gi| {
                        if (asgn.op == .assign) {
                            const val_ty = self.builder.getRefType(val);
                            const store_val = if (val_ty != gi.ty and val_ty != .void and gi.ty != .void)
                                self.coerceToType(val, val_ty, gi.ty)
                            else
                                val;
                            self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = store_val } }, .void);
                        } else {
                            // Compound assignment: load current value, apply op, store back
                            const loaded = self.builder.emit(.{ .global_get = gi.id }, gi.ty);
                            const result = self.emitCompoundOp(loaded, val, asgn.op, gi.ty);
                            self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = result } }, .void);
                        }
                    }
                }
            },
            .field_access => |fa| {
                // M2.2 — `obj.field = val` for an Obj-C `#property` field
                // dispatches via objc_msgSend `setField:`. Skip struct-
                // pointer / GEP entirely; receivers are opaque Obj-C ids.
                // Compound ops on properties are deferred (need load-via-
                // getter + op + store-via-setter — Month 4 ARC territory).
                if (asgn.op == .assign) {
                    if (self.lookupObjcPropertyOnPointer(fa.object, fa.field)) |prop| {
                        self.lowerObjcPropertySetter(fa.object, prop, val);
                        return;
                    }
                }
                // M1.2 A.3 — `self.field [op]= val` on a sx-defined Obj-C
                // class instance field (NOT a #property): write through
                // the __sx_state ivar. Handles plain assignment AND
                // compound ops (+=, -=, etc.) via storeOrCompound.
                if (self.lookupObjcDefinedStateFieldOnPointer(fa.object, fa.field)) |info| {
                    const obj_ref = self.lowerExpr(fa.object);
                    const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return;
                    const ptr_void = self.module.types.ptrTo(.void);
                    const field_addr = self.builder.emit(.{ .struct_gep = .{
                        .base = state_ptr,
                        .field_index = info.field_idx,
                        .base_type = info.state_ty,
                    } }, ptr_void);
                    self.storeOrCompound(field_addr, val, asgn.op, info.field_ty);
                    return;
                }

                var obj_ptr = self.lowerExprAsPtr(fa.object);
                var obj_ty = self.inferExprType(fa.object);
                // Auto-deref: if the object is a pointer field from a non-identifier
                // (i.e., result of structGep on a pointer slot), load the pointer value.
                if (fa.object.data != .identifier and !obj_ty.isBuiltin()) {
                    const pinfo = self.module.types.get(obj_ty);
                    if (pinfo == .pointer) {
                        obj_ptr = self.builder.load(obj_ptr, obj_ty);
                        obj_ty = pinfo.pointer.pointee;
                    }
                }

                // Special .len/.ptr handling only for slices, strings, arrays — NOT structs
                const is_special_container = obj_ty == .string or (if (!obj_ty.isBuiltin()) blk: {
                    const obj_info = self.module.types.get(obj_ty);
                    break :blk obj_info == .slice or obj_info == .array or obj_info == .vector;
                } else false);

                if (is_special_container and std.mem.eql(u8, fa.field, "len")) {
                    const gep = self.builder.structGepTyped(obj_ptr, 1, .s64, obj_ty);
                    self.storeOrCompound(gep, val, asgn.op, .s64);
                } else if (is_special_container and std.mem.eql(u8, fa.field, "ptr")) {
                    const gep = self.builder.structGepTyped(obj_ptr, 0, .s64, obj_ty);
                    self.storeOrCompound(gep, val, asgn.op, .s64);
                } else {
                    const field_name_id = self.module.types.internString(fa.field);

                    // Check if this is a union field assignment
                    if (!obj_ty.isBuiltin()) {
                        const type_info = self.module.types.get(obj_ty);
                        const union_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (type_info) {
                            .@"union" => |u| u.fields,
                            .tagged_union => |u| u.fields,
                            else => null,
                        };
                        if (union_fields) |fields| {
                            for (fields, 0..) |f, i| {
                                if (f.name == field_name_id) {
                                    const gep = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = @intCast(i), .base_type = obj_ty } }, self.module.types.ptrTo(f.ty));
                                    const src_ty = self.builder.getRefType(val);
                                    const coerced = self.coerceToType(val, src_ty, f.ty);
                                    self.storeOrCompound(gep, coerced, asgn.op, f.ty);
                                    return;
                                }
                                // Check promoted fields from anonymous struct variants
                                if (!f.ty.isBuiltin()) {
                                    const fi = self.module.types.get(f.ty);
                                    if (fi == .@"struct") {
                                        for (fi.@"struct".fields, 0..) |sf, si| {
                                            if (sf.name == field_name_id) {
                                                // GEP into union payload area, then into the struct field
                                                const union_gep = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = @intCast(i), .base_type = obj_ty } }, self.module.types.ptrTo(f.ty));
                                                const field_gep = self.builder.structGepTyped(union_gep, @intCast(si), sf.ty, f.ty);
                                                const src_ty = self.builder.getRefType(val);
                                                const coerced = self.coerceToType(val, src_ty, sf.ty);
                                                self.storeOrCompound(field_gep, coerced, asgn.op, sf.ty);
                                                return;
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }

                    // Tuple field element: `t.0 = v` / `t.x = v` — indexed by
                    // position (numeric) or by name, not via `getStructFields`
                    // (a tuple's elements aren't named-struct fields). Mirrors
                    // the read path's tuple handling.
                    if (!obj_ty.isBuiltin()) {
                        const ti = self.module.types.get(obj_ty);
                        if (ti == .tuple) {
                            const tup = ti.tuple;
                            var elem_idx: ?usize = null;
                            if (std.fmt.parseInt(usize, fa.field, 10)) |n| {
                                if (n < tup.fields.len) elem_idx = n;
                            } else |_| {
                                if (tup.names) |names| {
                                    for (names, 0..) |nm, i| {
                                        if (nm == field_name_id and i < tup.fields.len) {
                                            elem_idx = i;
                                            break;
                                        }
                                    }
                                }
                            }
                            if (elem_idx) |idx| {
                                const elem_ty = tup.fields[idx];
                                const gep = self.builder.structGepTyped(obj_ptr, @intCast(idx), self.module.types.ptrTo(elem_ty), obj_ty);
                                const src_ty = self.builder.getRefType(val);
                                const coerced = self.coerceToType(val, src_ty, elem_ty);
                                self.storeOrCompound(gep, coerced, asgn.op, elem_ty);
                                return;
                            }
                        }
                    }

                    const struct_fields = self.getStructFields(obj_ty);
                    var field_idx: u32 = 0;
                    var field_ty: TypeId = .unresolved;
                    for (struct_fields, 0..) |f, i| {
                        if (f.name == field_name_id) {
                            field_idx = @intCast(i);
                            field_ty = f.ty;
                            break;
                        }
                    }
                    // Wrap in ptrTo so the store handler sees *field_ty (consistent
                    // with index_gep which uses ptrTo(elem_ty)). Without this, a
                    // [*]BigNode field makes the store handler extract BigNode as the
                    // target type, storing element-sized bytes instead of a pointer.
                    const gep_ty = self.module.types.ptrTo(field_ty);
                    const gep = self.builder.structGepTyped(obj_ptr, field_idx, gep_ty, obj_ty);
                    // Coerce value to field type — use the lowered value's actual type
                    // (not inferExprType, which can re-read target_type after restore).
                    const src_ty = self.builder.getRefType(val);
                    const coerced = self.coerceToType(val, src_ty, field_ty);
                    self.storeOrCompound(gep, coerced, asgn.op, field_ty);
                }
            },
            .index_expr => |ie| {
                const idx = self.lowerExpr(ie.index);
                const obj_ty = self.inferExprType(ie.object);
                const elem_ty = self.getElementType(obj_ty);
                const ptr_ty = self.module.types.ptrTo(elem_ty);
                // For fixed-size array assignment targets, use the alloca pointer directly
                // so that the store modifies the original variable (not a loaded copy).
                const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
                const obj_alloca = if (is_array) self.getExprAlloca(ie.object) else null;
                if (obj_alloca) |alloca_ref| {
                    // Array alloca: single-index GEP with element stride
                    const gep = self.builder.emit(.{ .index_gep = .{ .lhs = alloca_ref, .rhs = idx } }, ptr_ty);
                    self.storeOrCompound(gep, val, asgn.op, elem_ty);
                } else if (is_array) {
                    // Array in a struct field or other composite: get pointer to array in-place
                    const obj_ptr = self.lowerExprAsPtr(ie.object);
                    const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj_ptr, .rhs = idx } }, ptr_ty);
                    self.storeOrCompound(gep, val, asgn.op, elem_ty);
                } else {
                    // Pointer/slice: load the pointer value and GEP
                    const obj = self.lowerExpr(ie.object);
                    const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj, .rhs = idx } }, ptr_ty);
                    self.storeOrCompound(gep, val, asgn.op, elem_ty);
                }
            },
            .deref_expr => |de| {
                const ptr = self.lowerExpr(de.operand);
                if (asgn.op == .assign) {
                    const pointee_ty = blk: {
                        const ptr_ty = self.inferExprType(de.operand);
                        if (!ptr_ty.isBuiltin()) {
                            const info = self.module.types.get(ptr_ty);
                            if (info == .pointer) break :blk info.pointer.pointee;
                        }
                        break :blk ptr_ty;
                    };
                    const val_ty = self.builder.getRefType(val);
                    const store_val = if (val_ty != pointee_ty and val_ty != .void and pointee_ty != .void)
                        self.coerceToType(val, val_ty, pointee_ty)
                    else
                        val;
                    self.builder.store(ptr, store_val);
                } else {
                    const pointee_ty = self.inferExprType(de.operand);
                    const elem_ty = blk: {
                        if (!pointee_ty.isBuiltin()) {
                            const info = self.module.types.get(pointee_ty);
                            if (info == .pointer) break :blk info.pointer.pointee;
                        }
                        break :blk pointee_ty;
                    };
                    self.storeOrCompound(ptr, val, asgn.op, elem_ty);
                }
            },
            else => {
                _ = self.emitError("assignment_target", asgn.target.span);
            },
        }
    }

    /// Get the pointer (alloca ref) for an lvalue expression, without loading.
    fn lowerExprAsPtr(self: *Lowering, node: *const Node) Ref {
        switch (node.data) {
            .identifier => |id| {
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        if (binding.is_alloca) {
                            // If the variable IS a pointer (e.g., p: *Vec2), load it
                            // to get the actual pointer value for GEP/store operations
                            if (!binding.ty.isBuiltin()) {
                                const info = self.module.types.get(binding.ty);
                                if (info == .pointer) {
                                    return self.builder.load(binding.ref, binding.ty);
                                }
                            }
                            return binding.ref;
                        }
                    }
                }
            },
            .field_access => |fa| {
                var obj_ptr = self.lowerExprAsPtr(fa.object);
                var obj_ty = self.inferExprType(fa.object);
                // Auto-deref for chained pointer field access:
                // When fa.object is a field_access or index_expr, lowerExprAsPtr returns
                // a structGep/pointer to the slot. If the slot holds a pointer type,
                // we need to load the pointer value before GEPing into the pointee struct.
                // (Identifiers are already loaded by the identifier handler in lowerExprAsPtr.)
                if (fa.object.data != .identifier and !obj_ty.isBuiltin()) {
                    const info = self.module.types.get(obj_ty);
                    if (info == .pointer) {
                        obj_ptr = self.builder.load(obj_ptr, obj_ty);
                        obj_ty = info.pointer.pointee;
                    }
                }
                const struct_fields = self.getStructFields(obj_ty);
                const field_name_id = self.module.types.internString(fa.field);
                for (struct_fields, 0..) |f, i| {
                    if (f.name == field_name_id) {
                        return self.builder.structGepTyped(obj_ptr, @intCast(i), f.ty, obj_ty);
                    }
                }
                return self.builder.structGepTyped(obj_ptr, 0, .s64, obj_ty);
            },
            .index_expr => |ie| {
                const idx = self.lowerExpr(ie.index);
                const obj_ty = self.inferExprType(ie.object);
                const elem_ty = self.getElementType(obj_ty);
                const ptr_ty = self.module.types.ptrTo(elem_ty);
                // For fixed-size arrays, use the alloca so GEP addresses the original memory
                const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
                const base = if (is_array)
                    (self.getExprAlloca(ie.object) orelse self.lowerExprAsPtr(ie.object))
                else
                    self.lowerExpr(ie.object);
                return self.builder.emit(.{ .index_gep = .{ .lhs = base, .rhs = idx } }, ptr_ty);
            },
            .deref_expr => |de| {
                return self.lowerExpr(de.operand);
            },
            else => {},
        }
        // Fallback: lower as expression (may produce a value, not pointer)
        return self.lowerExpr(node);
    }

    /// Store a value to a GEP, handling both plain and compound assignment.
    fn storeOrCompound(self: *Lowering, gep: Ref, val: Ref, op: ast.Assignment.Op, ty: TypeId) void {
        if (op == .assign) {
            const val_ty = self.builder.getRefType(val);
            const store_val = if (val_ty != ty and val_ty != .void and ty != .void)
                self.coerceToType(val, val_ty, ty)
            else
                val;
            self.builder.store(gep, store_val);
        } else {
            const loaded = self.builder.load(gep, ty);
            const result = self.emitCompoundOp(loaded, val, op, ty);
            self.builder.store(gep, result);
        }
    }

    fn emitCompoundOp(self: *Lowering, lhs: Ref, rhs: Ref, op: ast.Assignment.Op, ty: TypeId) Ref {
        return switch (op) {
            .add_assign => self.builder.add(lhs, rhs, ty),
            .sub_assign => self.builder.sub(lhs, rhs, ty),
            .mul_assign => self.builder.mul(lhs, rhs, ty),
            .div_assign => self.builder.div(lhs, rhs, ty),
            .mod_assign => self.builder.emit(.{ .mod = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .and_assign => self.builder.emit(.{ .bit_and = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .or_assign => self.builder.emit(.{ .bit_or = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .xor_assign => self.builder.emit(.{ .bit_xor = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .shl_assign => self.builder.emit(.{ .shl = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .shr_assign => self.builder.emit(.{ .shr = .{ .lhs = lhs, .rhs = rhs } }, ty),
            else => self.emitError("compound_assign", null),
        };
    }

    // ── Expression lowering ─────────────────────────────────────────

    fn lowerExpr(self: *Lowering, node: *const Node) Ref {
        // Stamp this node's source span onto the instructions it emits (ERR
        // E3.0 — feeds DWARF line-info + comptime frame resolution). Save/
        // restore so a parent's later emits keep the parent's span after a
        // child lowers. Skip the empty default so synthetic nodes don't reset
        // a meaningful enclosing span to offset 0.
        const saved_span = self.builder.current_span;
        defer self.builder.current_span = saved_span;
        if (node.span.start != 0 or node.span.end != 0) self.builder.current_span = .{ .start = node.span.start, .end = node.span.end };
        return switch (node.data) {
            // Bare `$<pack>` in expression position → an `[]Type` slice
            // value where each element is a `const_type(arg_types[i])`.
            // Per `Type → .any` mapping in type_bridge, the IR slice
            // type is `[]Any`; the interp stores raw `.type_tag` Values
            // (NOT Any-boxed) so `args[i]` reads back as a Type value
            // directly. Step 4 final slice — lets builder fns walk the
            // whole pack at interp time.
            .comptime_pack_ref => |cpr| blk: {
                // `$<name>` is overloaded in expression position:
                //   - Inside a pack-fn mono (or a `tryPackImplMatch`
                //     impl mono), `name` is a pack binding → slice of
                //     element types (`[]Type` lowered as `[]Any`).
                //   - Inside an impl mono whose impl pattern bound a
                //     single-type generic (`$R: Type` in
                //     `Closure(..$args) -> $R`), `name` is in
                //     `type_bindings` → single `const_type(R)` value.
                // Pack arg types are checked first (the slice form),
                // then pack_bindings (the impl-mono mirror), then
                // type_bindings (single-type binding); only if all
                // miss is it a real "outside an active binding" error.
                if (self.pack_arg_types) |pat| {
                    if (pat.get(cpr.pack_name)) |arg_tys| {
                        break :blk self.buildPackSliceValue(arg_tys);
                    }
                }
                if (self.pack_bindings) |pb| {
                    if (pb.get(cpr.pack_name)) |arg_tys| {
                        break :blk self.buildPackSliceValue(arg_tys);
                    }
                }
                if (self.type_bindings) |tb| {
                    if (tb.get(cpr.pack_name)) |ty| {
                        break :blk self.builder.constType(ty);
                    }
                }
                if (self.diagnostics) |diags| {
                    diags.addFmt(.err, node.span, "pack reference ${s} used outside an active pack binding", .{cpr.pack_name});
                }
                break :blk self.builder.constNull(self.module.types.sliceOf(.any));
            },
            // Pack-index in expression position: `$<pack>[<lit>]` →
            // `const_type(arg_types[index])`. Yields a comptime-only
            // Type value (`Value.type_tag(TypeId)` in the interp).
            // OOB / no-active-pack-binding → focused diagnostic; the
            // emitted Ref is a const_type(.void) placeholder so the
            // verifier downstream catches misuse rather than silently
            // succeeding with .void.
            .pack_index_type_expr => |pi| blk: {
                if (self.pack_arg_types) |pat| {
                    if (pat.get(pi.pack_name)) |arg_tys| {
                        if (pi.index < arg_tys.len) {
                            break :blk self.builder.constType(arg_tys[pi.index]);
                        }
                        if (self.diagnostics) |diags| {
                            diags.addFmt(.err, node.span, "pack-index value ${s}[{}] out of bounds: '{s}' has {} element{s}", .{
                                pi.pack_name, pi.index, pi.pack_name, arg_tys.len,
                                if (arg_tys.len == 1) @as([]const u8, "") else @as([]const u8, "s"),
                            });
                        }
                        break :blk self.builder.constType(.void);
                    }
                }
                if (self.diagnostics) |diags| {
                    diags.addFmt(.err, node.span, "pack-index value ${s}[{}] used outside an active pack binding", .{
                        pi.pack_name, pi.index,
                    });
                }
                break :blk self.builder.constType(.void);
            },
            .int_literal => |lit| {
                // If target is a float type, emit as float literal
                if (self.target_type) |tt| {
                    if (tt == .f32 or tt == .f64) {
                        return self.builder.constFloat(@floatFromInt(lit.value), tt);
                    }
                }
                const ty = if (self.target_type) |tt| blk: {
                    break :blk if (self.isIntEx(tt)) tt else .s64;
                } else .s64;
                return self.builder.constInt(lit.value, ty);
            },
            .float_literal => |lit| {
                const fty: TypeId = if (self.target_type) |tt| (if (tt == .f32 or tt == .f64) tt else .f64) else .f64;
                return self.builder.constFloat(lit.value, fty);
            },
            .bool_literal => |lit| self.builder.constBool(lit.value),
            .string_literal => |lit| blk: {
                const str = if (lit.is_raw)
                    lit.raw
                else
                    unescape.unescapeString(self.alloc, lit.raw) catch lit.raw;
                const sid = self.module.types.internString(str);
                break :blk self.builder.constString(sid);
            },
            .null_literal => self.builder.constNull(self.target_type orelse .void),
            .undef_literal => self.builder.constUndef(self.target_type orelse .void),

            .identifier => |id| blk: {
                // A bare pack name in value position has no runtime
                // representation (Decision 1). Projections (`xs.len`, `xs[i]`,
                // `xs.value`) are field/index nodes handled elsewhere, so a bare
                // `xs` reaching here is always a pack-as-value misuse.
                if (self.isPackName(id.name)) {
                    break :blk self.diagPackAsValue(id.name, node.span, .generic);
                }
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        if (binding.is_alloca) {
                            break :blk self.builder.load(binding.ref, binding.ty);
                        }
                        break :blk binding.ref;
                    }
                }
                // Check compile-time constants (OS, ARCH, POINTER_SIZE) before globals
                if (self.comptime_constants.get(id.name)) |cv| {
                    switch (cv) {
                        .int_val => |iv| break :blk self.builder.constInt(iv, .s64),
                        .enum_tag => |et| break :blk self.builder.constInt(@intCast(et.tag), et.ty),
                    }
                }
                // `context` resolves to a load through the lowering's
                // current `__sx_ctx` pointer. Every sx function (and
                // every `push Context.{...}` body) sets `current_ctx_ref`
                // to a `*Context` it owns, so this is one indirection.
                if (std.mem.eql(u8, id.name, "context")) {
                    if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) {
                        break :blk self.diagnoseMissingContext("the `context` identifier");
                    }
                    const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
                        break :blk self.diagnoseMissingContext("the `context` identifier");
                    };
                    break :blk self.builder.load(self.current_ctx_ref, ctx_ty);
                }
                // Check globals (#run constants)
                if (self.program_index.global_names.get(id.name)) |gi| {
                    break :blk self.builder.emit(.{ .global_get = gi.id }, gi.ty);
                }
                // Check module-level value constants (e.g. AF_INET :s32: 2)
                if (self.program_index.module_const_map.get(id.name)) |ci| {
                    if (!self.isNameVisible(id.name)) {
                        if (self.diagnostics) |d|
                            d.addFmt(.err, node.span, "'{s}' is not visible; #import the module that declares it", .{id.name});
                        break :blk self.emitError(id.name, node.span);
                    }
                    break :blk self.emitModuleConst(ci);
                }
                // Check if it's a function name — produce function pointer reference
                // Resolve mangled name for block-local functions
                const eff_fn_name = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
                if (self.program_index.fn_ast_map.contains(eff_fn_name)) {
                    // Visibility check only for user-typed bare names (id.name
                    // == eff_fn_name) without a UFCS alias. Mangled local-
                    // scope names and UFCS rewrites are compiler indirections
                    // and stay exempt.
                    if (std.mem.eql(u8, eff_fn_name, id.name) and
                        self.program_index.ufcs_alias_map.get(id.name) == null and
                        !self.isNameVisible(eff_fn_name))
                    {
                        if (self.diagnostics) |d|
                            d.addFmt(.err, node.span, "'{s}' is not visible; #import the module that declares it", .{eff_fn_name});
                        break :blk self.emitError(eff_fn_name, node.span);
                    }
                    // Type-as-value: if target is Any (Type variable), produce a type name string
                    if (self.target_type == .any) {
                        const fd = self.program_index.fn_ast_map.get(eff_fn_name).?;
                        const fn_type_str = self.formatFnTypeString(fd);
                        const sid = self.module.types.internString(fn_type_str);
                        const str = self.builder.constString(sid);
                        break :blk self.builder.boxAny(str, .string);
                    }
                    if (!self.lowered_functions.contains(eff_fn_name)) {
                        self.lazyLowerFunction(eff_fn_name);
                    }
                    if (self.resolveFuncByName(eff_fn_name)) |fid| {
                        // Auto-promote bare function → closure when target_type is closure
                        if (self.target_type) |tt| {
                            if (!tt.isBuiltin()) {
                                const tt_info = self.module.types.get(tt);
                                if (tt_info == .closure) {
                                    const tramp_id = self.createBareFnTrampoline(fid, tt_info.closure);
                                    break :blk self.builder.closureCreate(tramp_id, Ref.none, tt);
                                }
                                // Coercing a bare fn name to a fn-pointer
                                // type — the call_conv must match. A
                                // default-conv sx fn assigned to a
                                // callconv(.c) slot (e.g. passed to
                                // pthread_create) would otherwise crash at
                                // runtime when the C caller doesn't supply
                                // the implicit __sx_ctx arg.
                                if (tt_info == .function) {
                                    const func_cc = self.module.functions.items[@intFromEnum(fid)].call_conv;
                                    if (func_cc != tt_info.function.call_conv) {
                                        if (self.diagnostics) |d| {
                                            const want_cc = if (tt_info.function.call_conv == .c) "callconv(.c)" else "default sx convention";
                                            const have_cc = if (func_cc == .c) "callconv(.c)" else "default sx convention";
                                            d.addFmt(.err, node.span, "call-convention mismatch: '{s}' is declared with {s} but the target type expects {s}", .{ eff_fn_name, have_cc, want_cc });
                                        }
                                        break :blk self.emitPlaceholder(eff_fn_name);
                                    }
                                }
                                // NOTE: `xx <sx_fn> : *void` (e.g.
                                // `class_addMethod(_, _, xx my_imp, _)`)
                                // is intentionally NOT diagnosed here.
                                // Manually-constructed Closure values
                                // legitimately store default-conv sx fns
                                // into a `*void` slot for sx-side dispatch
                                // through the closure trampoline ABI. The
                                // compiler can't distinguish C-side vs
                                // sx-side use from the cast alone.
                                // examples/50-smoke.sx has both shapes.
                            }
                        }
                        break :blk self.builder.emit(.{ .func_ref = fid }, .s64);
                    }
                }
                // Type-as-value: a name that resolves to a TypeId
                // (primitive, alias, registered struct/enum/union,
                // generic-struct instantiation) evaluates to a
                // `const_type` in expression position. Works for
                // direct assignment to a `Type`-typed slot
                // (`x: Type = Vec4`), comparison (`x == Vec4`), and
                // pack-arg / Any context (boxing happens at the
                // consumer).
                const ty = blk_ty: {
                    if (self.type_bindings) |tb| {
                        if (tb.get(id.name)) |t| break :blk_ty t;
                    }
                    if (self.program_index.type_alias_map.get(id.name)) |t| break :blk_ty t;
                    if (type_bridge.resolveTypePrimitive(id.name)) |t| break :blk_ty t;
                    const name_id = self.module.types.internString(id.name);
                    if (self.module.types.findByName(name_id)) |t| break :blk_ty t;
                    break :blk_ty TypeId.void;
                };
                if (ty != .void) {
                    break :blk self.builder.constType(ty);
                }
                // Unknown identifier
                break :blk self.emitError(id.name, node.span);
            },

            .binary_op => |bop| self.lowerBinaryOp(&bop),

            .unary_op => |uop| blk: {
                // `xx <pack>` with a slice target materializes the comptime
                // pack into a runtime `[]elem` (issue 0053). Must run before the
                // operand is lowered (a bare pack name otherwise hits the
                // pack-as-value error).
                if (uop.op == .xx and uop.operand.data == .identifier and self.isPackName(uop.operand.data.identifier.name)) {
                    const pname = uop.operand.data.identifier.name;
                    if (self.target_type) |tt| {
                        if (!tt.isBuiltin() and self.module.types.get(tt) == .slice) {
                            break :blk self.lowerPackToSlice(pname, tt);
                        }
                    }
                    break :blk self.diagPackAsValue(pname, node.span, .generic);
                }
                // address_of(index_expr) → emit index_gep (pointer to element) instead of index_get + addr_of
                if (uop.op == .address_of and uop.operand.data == .index_expr) {
                    const ie = &uop.operand.data.index_expr;
                    const idx = self.lowerExpr(ie.index);
                    const obj_ty = self.inferExprType(ie.object);
                    const elem_ty = self.getElementType(obj_ty);
                    const ptr_ty = self.module.types.ptrTo(elem_ty);
                    // For array targets, use the alloca directly so the pointer is persistent
                    const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
                    const base = if (is_array) (self.getExprAlloca(ie.object) orelse self.lowerExpr(ie.object)) else self.lowerExpr(ie.object);
                    break :blk self.builder.emit(.{ .index_gep = .{ .lhs = base, .rhs = idx } }, ptr_ty);
                }
                // address_of(field_access) → use lowerExprAsPtr for GEP chain
                // Handles all cases: pointer-based, index-based, nested field access
                if (uop.op == .address_of and uop.operand.data == .field_access) {
                    const inner_ty = self.inferExprType(uop.operand);
                    const ptr_ty = self.module.types.ptrTo(inner_ty);
                    const ptr = self.lowerExprAsPtr(uop.operand);
                    break :blk self.builder.emit(.{ .addr_of = .{ .operand = ptr } }, ptr_ty);
                }
                // address_of(identifier) → return alloca directly (pointer to variable)
                if (uop.op == .address_of and uop.operand.data == .identifier) {
                    const id_name = uop.operand.data.identifier.name;
                    if (self.scope) |scope| {
                        if (scope.lookup(id_name)) |binding| {
                            if (binding.is_alloca) {
                                const ptr_ty = self.module.types.ptrTo(binding.ty);
                                break :blk self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, ptr_ty);
                            }
                        }
                    }
                    // address_of(global) → emit global_addr (pointer to global, not load)
                    if (self.program_index.global_names.get(id_name)) |gi| {
                        const ptr_ty = self.module.types.ptrTo(gi.ty);
                        break :blk self.builder.emit(.{ .global_addr = gi.id }, ptr_ty);
                    }
                }
                const operand = self.lowerExpr(uop.operand);
                break :blk switch (uop.op) {
                    .negate => self.builder.emit(.{ .neg = .{ .operand = operand } }, self.inferExprType(uop.operand)),
                    .not => self.builder.emit(.{ .bool_not = .{ .operand = operand } }, .bool),
                    .bit_not => self.builder.emit(.{ .bit_not = .{ .operand = operand } }, self.inferExprType(uop.operand)),
                    .xx => self.lowerXX(operand, uop.operand),
                    .address_of => blk2: {
                        const inner_ty = self.inferExprType(uop.operand);
                        const ptr_ty = self.module.types.ptrTo(inner_ty);
                        break :blk2 self.builder.emit(.{ .addr_of = .{ .operand = operand } }, ptr_ty);
                    },
                };
            },

            .if_expr => |ie| self.lowerIfExpr(&ie),
            .match_expr => |me| self.lowerMatch(&me),
            .while_expr => |we| self.lowerWhile(&we),
            .for_expr => |fe| self.lowerFor(&fe),
            .break_expr => self.lowerBreak(),
            .continue_expr => self.lowerContinue(),
            .call => |c| self.lowerCall(&c),
            .ffi_intrinsic_call => |fic| self.lowerFfiIntrinsicCall(&fic),
            .field_access => |fa| self.lowerFieldAccess(&fa, node.span),
            .struct_literal => |sl| self.lowerStructLiteral(&sl, node.span),
            .array_literal => |al| self.lowerArrayLiteral(&al),
            .index_expr => |ie| self.lowerIndexExpr(&ie),
            .slice_expr => |se| self.lowerSliceExpr(&se),
            .lambda => |lam| self.lowerLambda(&lam),
            .force_unwrap => |fu| self.lowerForceUnwrap(&fu),
            .null_coalesce => |nc| self.lowerNullCoalesce(&nc),
            .deref_expr => |de| self.lowerDerefExpr(&de),
            .enum_literal => |el| self.lowerEnumLiteral(&el),
            .comptime_expr => |ct| self.lowerInlineComptime(ct.expr),
            .insert_expr => |ins| blk: {
                break :blk self.lowerInsertExprValue(ins.expr);
            },
            .tuple_literal => |tl| self.lowerTupleLiteral(&tl),
            .spread_expr => self.emitError("spread_expr", node.span),
            .chained_comparison => |cc| self.lowerChainedComparison(&cc),

            // `#jni_env(env) { body }` in expression position — the block's
            // value becomes the env-scope's value. Save→set→body-value→restore.
            .jni_env_block => |eb| blk: {
                const env_ref = self.lowerExpr(eb.env);
                const fids = self.getJniEnvTlFids();
                const ptr_ty = self.module.types.ptrTo(.void);
                const saved_tl = self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
                const set_args = self.alloc.dupe(Ref, &.{env_ref}) catch unreachable;
                _ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = set_args } }, .void);
                self.jni_env_stack.append(self.alloc, env_ref) catch unreachable;
                const value = self.lowerBlockValue(eb.body) orelse self.builder.constInt(0, .void);
                _ = self.jni_env_stack.pop();
                const restore_args = self.alloc.dupe(Ref, &.{saved_tl}) catch unreachable;
                _ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void);
                break :blk value;
            },

            // Statements that can appear in expression position
            .block => |blk| blk: {
                // Create a child scope for block-level variable shadowing
                var block_scope = Scope.init(self.alloc, self.scope);
                const saved_scope = self.scope;
                self.scope = &block_scope;
                const saved_defer_len = self.defer_stack.items.len;
                defer {
                    self.emitBlockDefers(saved_defer_len);
                    self.scope = saved_scope;
                    block_scope.deinit();
                }
                // This block sits in value position (lowerExpr is reached only
                // for value contexts — statement blocks go through lowerBlock).
                // If its last expression's value is discarded by a `;`, the
                // surrounding expression has no value to use: report it.
                if (!blk.produces_value and blk.discarded_semi != null) {
                    if (self.diagnostics) |diags| {
                        diags.addFmt(.err, blk.discarded_semi.?, "this block is used as a value but its last expression's value is discarded by this `;` — drop the `;`", .{});
                    }
                }
                // A block in expression position yields its last statement's
                // value only when it produces one (no trailing `;`); otherwise
                // it runs as statements and evaluates to void.
                if (blk.produces_value and blk.stmts.len > 0) {
                    for (blk.stmts[0 .. blk.stmts.len - 1]) |stmt| {
                        self.lowerStmt(stmt);
                    }
                    break :blk self.tryLowerAsExpr(blk.stmts[blk.stmts.len - 1]) orelse
                        self.builder.constInt(0, .void);
                }
                for (blk.stmts) |stmt| {
                    self.lowerStmt(stmt);
                }
                break :blk self.builder.constInt(0, .void);
            },

            // type_expr can appear as a variable reference when the name collides
            // with a builtin type name (e.g. s2, u8). Check scope first.
            .type_expr => |te| blk: {
                if (self.scope) |scope| {
                    if (scope.lookup(te.name)) |binding| {
                        if (binding.is_alloca) {
                            break :blk self.builder.load(binding.ref, binding.ty);
                        }
                        break :blk binding.ref;
                    }
                }
                if (self.program_index.global_names.get(te.name)) |gi| {
                    break :blk self.builder.emit(.{ .global_get = gi.id }, gi.ty);
                }
                // Type literal in expression position → first-class
                // `const_type` Value (i64 = TypeId.index()). Makes
                // `t : Type = f64;` store a real TypeId; lets
                // `t == f64` icmp at runtime against the same TypeId.
                if (self.isKnownTypeName(te.name)) {
                    const ty = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map);
                    break :blk self.builder.constType(ty);
                }
                break :blk self.emitError(te.name, node.span);
            },

            .try_expr => |te| self.lowerTry(te.operand, node.span),
            .catch_expr => |ce| self.lowerCatch(&ce, node.span),
            .caller_location => self.lowerCallerLocation(node),
            else => self.emitError("unknown_expr", node.span),
        };
    }

    /// If `node` names a `for xs: (*x)` by-ref capture (an `*elem`), returns
    /// the element (pointee) type so a value-position use can auto-deref it.
    fn refCapturePointee(self: *Lowering, node: *const Node) ?TypeId {
        if (node.data != .identifier) return null;
        const scope = self.scope orelse return null;
        const binding = scope.lookup(node.data.identifier.name) orelse return null;
        if (!binding.is_ref_capture or binding.ty.isBuiltin()) return null;
        const info = self.module.types.get(binding.ty);
        return if (info == .pointer) info.pointer.pointee else null;
    }

    fn lowerBinaryOp(self: *Lowering, bop: *const ast.BinaryOp) Ref {
        // Short-circuit: `a and b` → if a then b else false
        if (bop.op == .and_op) {
            const lhs = self.lowerExpr(bop.lhs);
            const rhs_bb = self.freshBlock("and.rhs");
            const merge_bb = self.freshBlockWithParams("and.merge", &.{.bool});
            const false_val = self.builder.constBool(false);
            self.builder.condBr(lhs, rhs_bb, &.{}, merge_bb, &.{false_val});
            self.builder.switchToBlock(rhs_bb);
            const rhs = self.lowerExpr(bop.rhs);
            self.builder.br(merge_bb, &.{rhs});
            self.builder.switchToBlock(merge_bb);
            return self.builder.blockParam(merge_bb, 0, .bool);
        }
        // Short-circuit: `a or b` → if a then true else b
        if (bop.op == .or_op) {
            // A failable `or` (value-terminator or chain) routes to the error-
            // handling lowering, not the optional/boolean unwrap below. Detected
            // structurally (a `try`-chain's value type is non-failable `T`, so a
            // type-only `exprIsFailable(lhs)` would miss nested chains).
            if (self.orIsFailableChain(bop)) {
                return self.lowerFailableOr(bop);
            }
            const lhs = self.lowerExpr(bop.lhs);
            const rhs_bb = self.freshBlock("or.rhs");
            const merge_bb = self.freshBlockWithParams("or.merge", &.{.bool});
            const true_val = self.builder.constBool(true);
            self.builder.condBr(lhs, merge_bb, &.{true_val}, rhs_bb, &.{});
            self.builder.switchToBlock(rhs_bb);
            const rhs = self.lowerExpr(bop.rhs);
            self.builder.br(merge_bb, &.{rhs});
            self.builder.switchToBlock(merge_bb);
            return self.builder.blockParam(merge_bb, 0, .bool);
        }

        // Type-literal comparison fold: when both sides are type-shaped
        // AST nodes (`s64`, `*u8`, `?T`, `[3]f64`, etc.) OR resolve to
        // a static TypeId at lower time (`type_of(x)` for any
        // statically-typed `x`), resolve each and emit a `const_bool`.
        // Same semantic as `type_eq(A, B)` but using the standard `==`
        // operator — the user's intuition. Without the fold, both
        // sides lower as `const_type` undef-i64 and the runtime icmp
        // returns garbage.
        if (bop.op == .eq or bop.op == .neq) {
            if (self.isStaticTypeRef(bop.lhs) and self.isStaticTypeRef(bop.rhs)) {
                const lhs_ty = self.resolveTypeArg(bop.lhs);
                const rhs_ty = self.resolveTypeArg(bop.rhs);
                const eq_result = lhs_ty == rhs_ty;
                return self.builder.constBool(if (bop.op == .eq) eq_result else !eq_result);
            }
        }

        // Any-shaped `==` (e.g. `t == s64` where `t: Type`): both
        // operands are 16-byte `{tag, value}` aggregates. LLVM
        // doesn't accept `icmp` on aggregates directly. Decompose
        // via `unbox_any` (which extracts the value field at
        // `.s64`) and compare the i64s. Tag fields are stable
        // across compilations of the same source so value-only
        // identity is enough.
        if (bop.op == .eq or bop.op == .neq) {
            const lhs_ty = self.inferExprType(bop.lhs);
            const rhs_ty = self.inferExprType(bop.rhs);
            if (lhs_ty == .any and rhs_ty == .any) {
                const lhs = self.lowerExpr(bop.lhs);
                const rhs = self.lowerExpr(bop.rhs);
                const lhs_val = self.builder.emit(.{ .unbox_any = .{ .operand = lhs } }, .s64);
                const rhs_val = self.builder.emit(.{ .unbox_any = .{ .operand = rhs } }, .s64);
                if (bop.op == .eq) {
                    return self.builder.emit(.{ .cmp_eq = .{ .lhs = lhs_val, .rhs = rhs_val } }, .bool);
                } else {
                    return self.builder.emit(.{ .cmp_ne = .{ .lhs = lhs_val, .rhs = rhs_val } }, .bool);
                }
            }
        }

        // Special case: optional == null / optional != null
        if (bop.op == .eq or bop.op == .neq) {
            const lhs_is_null = bop.lhs.data == .null_literal;
            const rhs_is_null = bop.rhs.data == .null_literal;
            if (lhs_is_null or rhs_is_null) {
                const opt_node = if (rhs_is_null) bop.lhs else bop.rhs;
                const opt_ty = self.inferExprType(opt_node);
                if (!opt_ty.isBuiltin()) {
                    const info = self.module.types.get(opt_ty);
                    if (info == .optional) {
                        const opt_val = self.lowerExpr(opt_node);
                        const has = self.builder.emit(.{ .optional_has_value = .{ .operand = opt_val } }, .bool);
                        // == null → !has_value, != null → has_value
                        return if (bop.op == .eq) self.builder.emit(.{ .bool_not = .{ .operand = has } }, .bool) else has;
                    }
                }
            }
        }

        // Error-set equality: an error-set value compares only with an
        // `error.X` tag literal or another error-set value. Comparing to a raw
        // integer is a type error (coerce with `xx`). `e == error.X` resolves
        // X against e's set and validates membership.
        if (bop.op == .eq or bop.op == .neq) {
            if (self.tryLowerErrorSetEquality(bop)) |result| return result;
        }

        // Set target_type for null literals to match the other operand's type.
        // This ensures null gets the same LLVM type as the value being compared.
        if (bop.op == .eq or bop.op == .neq) {
            const null_on_rhs = bop.rhs.data == .null_literal;
            const null_on_lhs = bop.lhs.data == .null_literal;
            if (null_on_rhs or null_on_lhs) {
                var other_ty = if (null_on_rhs) self.inferExprType(bop.lhs) else self.inferExprType(bop.rhs);
                // Lower the non-null side first when its type isn't statically
                // inferable, and take the null's type from the lowered value —
                // never a guess.
                var pre_lowered: ?Ref = null;
                if (other_ty == .unresolved) {
                    pre_lowered = self.lowerExpr(if (null_on_rhs) bop.lhs else bop.rhs);
                    other_ty = self.builder.getRefType(pre_lowered.?);
                }
                if (other_ty != .void and other_ty != .unresolved) {
                    const saved_tt = self.target_type;
                    self.target_type = other_ty;
                    const lv = if (null_on_lhs or pre_lowered == null) self.lowerExpr(bop.lhs) else pre_lowered.?;
                    const rv = if (null_on_rhs or pre_lowered == null) self.lowerExpr(bop.rhs) else pre_lowered.?;
                    self.target_type = saved_tt;
                    const cmp_op: inst_mod.Op = if (bop.op == .eq) .{ .cmp_eq = .{ .lhs = lv, .rhs = rv } } else .{ .cmp_ne = .{ .lhs = lv, .rhs = rv } };
                    return self.builder.emit(cmp_op, .bool);
                }
            }
        }
        var lhs = self.lowerExpr(bop.lhs);
        // A `for xs: (*x)` capture is a pointer; in a value position (here, an
        // operand) it auto-derefs to the element.
        const lhs_ref_pointee = self.refCapturePointee(bop.lhs);
        if (lhs_ref_pointee) |p| lhs = self.builder.load(lhs, p);
        // Set target_type from LHS so enum literals on RHS resolve correctly.
        // When the LHS isn't statically inferable (e.g. `#objc_call(...)`), use
        // the lowered operand's concrete type rather than a guess.
        const lhs_ty = blk: {
            if (lhs_ref_pointee) |p| break :blk p;
            const it = self.inferExprType(bop.lhs);
            break :blk if (it == .unresolved) self.builder.getRefType(lhs) else it;
        };
        const saved_tt = self.target_type;
        if (lhs_ty != .void) {
            if (!lhs_ty.isBuiltin()) {
                const lhs_info = self.module.types.get(lhs_ty);
                if (lhs_info == .@"enum" or lhs_info == .@"union" or lhs_info == .tagged_union) {
                    self.target_type = lhs_ty;
                }
            } else if (lhs_ty == .f32 or lhs_ty == .f64) {
                self.target_type = lhs_ty;
            }
        }
        var rhs = self.lowerExpr(bop.rhs);
        const rhs_ref_pointee = self.refCapturePointee(bop.rhs);
        if (rhs_ref_pointee) |p| rhs = self.builder.load(rhs, p);
        self.target_type = saved_tt;
        // Infer result type from LHS operand (covers float, bool, etc.)
        var ty = lhs_ty;

        // Promote int×float → float (e.g., s64 * f32 → f32)
        // Only for scalar int LHS — don't affect vectors or structs.
        {
            const rhs_inferred = rhs_ref_pointee orelse self.inferExprType(bop.rhs);
            const l_int = isInt(ty);
            const r_float = (rhs_inferred == .f32 or rhs_inferred == .f64);
            if (l_int and r_float) {
                ty = rhs_inferred;
            }
        }

        // Auto-unwrap optional operands for arithmetic/comparison
        if (!ty.isBuiltin()) {
            const info = self.module.types.get(ty);
            if (info == .optional) {
                ty = info.optional.child;
                lhs = self.builder.emit(.{ .optional_unwrap = .{ .operand = lhs } }, ty);
            }
        }
        const rhs_ty = rhs_ref_pointee orelse self.inferExprType(bop.rhs);
        if (!rhs_ty.isBuiltin()) {
            const rhs_info = self.module.types.get(rhs_ty);
            if (rhs_info == .optional) {
                rhs = self.builder.emit(.{ .optional_unwrap = .{ .operand = rhs } }, rhs_info.optional.child);
            }
        }

        // String comparison: use str_eq/str_ne (memcmp-based) instead of pointer comparison
        if (ty == .string and (bop.op == .eq or bop.op == .neq)) {
            return if (bop.op == .eq)
                self.builder.emit(.{ .str_eq = .{ .lhs = lhs, .rhs = rhs } }, .bool)
            else
                self.builder.emit(.{ .str_ne = .{ .lhs = lhs, .rhs = rhs } }, .bool);
        }

        // Tuple operators
        if (!ty.isBuiltin()) {
            const lhs_info = self.module.types.get(ty);
            if (lhs_info == .tuple) {
                return self.lowerTupleOp(bop, lhs, rhs, ty);
            }
        }
        // Tuple membership: value in (tuple)
        if (bop.op == .in_op) {
            const rhs_ty_raw = self.inferExprType(bop.rhs);
            if (!rhs_ty_raw.isBuiltin()) {
                const rhs_info_raw = self.module.types.get(rhs_ty_raw);
                if (rhs_info_raw == .tuple) {
                    return self.lowerTupleMembership(lhs, rhs, rhs_info_raw.tuple);
                }
            }
        }

        // Reject scalar ops on incompatible operand types (e.g.
        // `s64 + string`, `s64 < string`, `s64 & string`). The result type
        // `ty` is derived from the LHS, so without this the op lowers as
        // `<op> : <lhs>` and either reinterprets the RHS bytes (arithmetic
        // / bitwise → garbage) or feeds mismatched LLVM types to `icmp`
        // (ordering → verifier failure).
        {
            const group: enum { none, arith, ordering, bitwise } = switch (bop.op) {
                .add, .sub, .mul, .div, .mod => .arith,
                .lt, .lte, .gt, .gte => .ordering,
                .bit_and, .bit_or, .bit_xor, .shl, .shr => .bitwise,
                else => .none,
            };
            if (group != .none) {
                const eff_rhs_ty = blk: {
                    if (rhs_ty == .unresolved) break :blk self.builder.getRefType(rhs);
                    if (!rhs_ty.isBuiltin()) {
                        const ri = self.module.types.get(rhs_ty);
                        if (ri == .optional) break :blk ri.optional.child;
                    }
                    break :blk rhs_ty;
                };
                const ok = switch (group) {
                    .arith => self.isArithOperand(ty) and self.isArithOperand(eff_rhs_ty),
                    .ordering => self.isOrderingOperand(ty) and self.isOrderingOperand(eff_rhs_ty),
                    .bitwise => self.isBitwiseOperand(ty) and self.isBitwiseOperand(eff_rhs_ty),
                    .none => true,
                };
                if (!ok) {
                    if (self.diagnostics) |diags| {
                        diags.addFmt(.err, bop.lhs.span, "cannot apply '{s}' to operands of type '{s}' and '{s}'", .{
                            binOpSymbol(bop.op), self.formatTypeName(ty), self.formatTypeName(eff_rhs_ty),
                        });
                    }
                    return self.emitPlaceholder("operand-type-mismatch");
                }
            }
        }

        return switch (bop.op) {
            .add => self.builder.add(lhs, rhs, ty),
            .sub => self.builder.sub(lhs, rhs, ty),
            .mul => self.builder.mul(lhs, rhs, ty),
            .div => self.builder.div(lhs, rhs, ty),
            .mod => self.builder.emit(.{ .mod = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .eq => self.builder.cmpEq(lhs, rhs),
            .neq => self.builder.emit(.{ .cmp_ne = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            .lt => self.builder.cmpLt(lhs, rhs),
            .lte => self.builder.emit(.{ .cmp_le = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            .gt => self.builder.cmpGt(lhs, rhs),
            .gte => self.builder.emit(.{ .cmp_ge = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            .and_op => self.builder.emit(.{ .bool_and = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            .or_op => self.builder.emit(.{ .bool_or = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            .bit_and => self.builder.emit(.{ .bit_and = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .bit_or => self.builder.emit(.{ .bit_or = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .bit_xor => self.builder.emit(.{ .bit_xor = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .shl => self.builder.emit(.{ .shl = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .shr => self.builder.emit(.{ .shr = .{ .lhs = lhs, .rhs = rhs } }, ty),
            .in_op => self.emitError("in_op", bop.lhs.span),
        };
    }

    /// Handle tuple binary ops: concat (+), repeat (*), comparison (==, !=, <, <=, >, >=)
    fn lowerTupleOp(self: *Lowering, bop: *const ast.BinaryOp, lhs: Ref, rhs: Ref, lhs_ty: TypeId) Ref {
        const lhs_info = self.module.types.get(lhs_ty);
        const lhs_fields = lhs_info.tuple.fields;

        switch (bop.op) {
            .add => {
                // Tuple concatenation: (a, b) + (c, d) → (a, b, c, d)
                const rhs_ty = self.inferExprType(bop.rhs);
                const rhs_fields = if (!rhs_ty.isBuiltin()) blk: {
                    const ri = self.module.types.get(rhs_ty);
                    break :blk if (ri == .tuple) ri.tuple.fields else &[_]TypeId{};
                } else &[_]TypeId{};

                var all_fields = std.ArrayList(TypeId).empty;
                defer all_fields.deinit(self.alloc);
                var all_vals = std.ArrayList(Ref).empty;
                defer all_vals.deinit(self.alloc);

                for (lhs_fields, 0..) |f, i| {
                    all_fields.append(self.alloc, f) catch unreachable;
                    all_vals.append(self.alloc, self.builder.structGet(lhs, @intCast(i), f)) catch unreachable;
                }
                for (rhs_fields, 0..) |f, i| {
                    all_fields.append(self.alloc, f) catch unreachable;
                    all_vals.append(self.alloc, self.builder.structGet(rhs, @intCast(i), f)) catch unreachable;
                }

                const result_ty = self.module.types.intern(.{ .tuple = .{
                    .fields = self.alloc.dupe(TypeId, all_fields.items) catch unreachable,
                    .names = null,
                } });
                const owned = self.alloc.dupe(Ref, all_vals.items) catch unreachable;
                return self.builder.emit(.{ .tuple_init = .{ .fields = owned } }, result_ty);
            },
            .mul => {
                // Tuple repeat: (a, b) * 3 → (a, b, a, b, a, b)
                const count: usize = switch (bop.rhs.data) {
                    .int_literal => |il| @intCast(@as(u64, @bitCast(il.value))),
                    else => 1,
                };

                var all_fields = std.ArrayList(TypeId).empty;
                defer all_fields.deinit(self.alloc);
                var all_vals = std.ArrayList(Ref).empty;
                defer all_vals.deinit(self.alloc);

                for (0..count) |_| {
                    for (lhs_fields, 0..) |f, i| {
                        all_fields.append(self.alloc, f) catch unreachable;
                        all_vals.append(self.alloc, self.builder.structGet(lhs, @intCast(i), f)) catch unreachable;
                    }
                }

                const result_ty = self.module.types.intern(.{ .tuple = .{
                    .fields = self.alloc.dupe(TypeId, all_fields.items) catch unreachable,
                    .names = null,
                } });
                const owned = self.alloc.dupe(Ref, all_vals.items) catch unreachable;
                return self.builder.emit(.{ .tuple_init = .{ .fields = owned } }, result_ty);
            },
            .eq, .neq => {
                // Element-wise equality (or single-element tuple vs scalar)
                const rhs_is_tuple = blk: {
                    const rt = self.inferExprType(bop.rhs);
                    if (!rt.isBuiltin()) {
                        break :blk self.module.types.get(rt) == .tuple;
                    }
                    break :blk false;
                };
                if (!rhs_is_tuple and lhs_fields.len == 1) {
                    // Single-element tuple vs scalar: unwrap and compare
                    const lf = self.builder.structGet(lhs, 0, lhs_fields[0]);
                    const eq = self.builder.cmpEq(lf, rhs);
                    return if (bop.op == .neq) self.builder.emit(.{ .bool_not = .{ .operand = eq } }, .bool) else eq;
                }
                var result = self.builder.constBool(true);
                for (lhs_fields, 0..) |f, i| {
                    const lf = self.builder.structGet(lhs, @intCast(i), f);
                    const rf = self.builder.structGet(rhs, @intCast(i), f);
                    const eq = self.builder.cmpEq(lf, rf);
                    result = self.builder.emit(.{ .bool_and = .{ .lhs = result, .rhs = eq } }, .bool);
                }
                return if (bop.op == .neq) self.builder.emit(.{ .bool_not = .{ .operand = result } }, .bool) else result;
            },
            .lt, .lte, .gt, .gte => {
                // Lexicographic comparison
                return self.lowerTupleLexCompare(bop.op, lhs, rhs, lhs_fields);
            },
            else => return self.builder.constInt(0, .s64),
        }
    }

    fn lowerTupleLexCompare(self: *Lowering, op: ast.BinaryOp.Op, lhs: Ref, rhs: Ref, fields: []const TypeId) Ref {
        // Lexicographic comparison using boolean logic.
        // (a0,a1) < (b0,b1) = (a0 < b0) || (a0 == b0 && a1 < b1)
        // (a0,a1) <= (b0,b1) = (a0 < b0) || (a0 == b0 && a1 <= b1)
        if (fields.len == 0) return self.builder.constBool(op == .lte or op == .gte);

        const n = fields.len;
        // Start with the last field using the actual op
        const lf_last = self.builder.structGet(lhs, @intCast(n - 1), fields[n - 1]);
        const rf_last = self.builder.structGet(rhs, @intCast(n - 1), fields[n - 1]);
        var result = switch (op) {
            .lt => self.builder.cmpLt(lf_last, rf_last),
            .lte => self.builder.emit(.{ .cmp_le = .{ .lhs = lf_last, .rhs = rf_last } }, .bool),
            .gt => self.builder.cmpGt(lf_last, rf_last),
            .gte => self.builder.emit(.{ .cmp_ge = .{ .lhs = lf_last, .rhs = rf_last } }, .bool),
            else => unreachable,
        };

        // Work backwards: result = (a[i] < b[i]) || (a[i] == b[i] && result)
        if (n > 1) {
            var i: usize = n - 1;
            while (i > 0) {
                i -= 1;
                const lf = self.builder.structGet(lhs, @intCast(i), fields[i]);
                const rf = self.builder.structGet(rhs, @intCast(i), fields[i]);
                const strict = if (op == .lt or op == .lte) self.builder.cmpLt(lf, rf) else self.builder.cmpGt(lf, rf);
                const eq = self.builder.cmpEq(lf, rf);
                const eq_and_rest = self.builder.emit(.{ .bool_and = .{ .lhs = eq, .rhs = result } }, .bool);
                result = self.builder.emit(.{ .bool_or = .{ .lhs = strict, .rhs = eq_and_rest } }, .bool);
            }
        }
        return result;
    }

    fn lowerTupleMembership(self: *Lowering, value: Ref, tuple: Ref, tuple_info: anytype) Ref {
        // value in (a, b, c) → value == a || value == b || value == c
        var result = self.builder.constBool(false);
        for (tuple_info.fields, 0..) |f, i| {
            const elem = self.builder.structGet(tuple, @intCast(i), f);
            const eq = self.builder.cmpEq(value, elem);
            result = self.builder.emit(.{ .bool_or = .{ .lhs = result, .rhs = eq } }, .bool);
        }
        return result;
    }

    // ── Control flow ────────────────────────────────────────────────

    fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref {
        // inline if: evaluate condition at compile time, only lower taken branch
        if (ie.is_comptime) {
            if (self.evalComptimeCondition(ie.condition)) |is_true| {
                if (is_true) {
                    return self.lowerInlineBranch(ie.then_branch);
                } else if (ie.else_branch) |eb| {
                    return self.lowerInlineBranch(eb);
                }
                return self.builder.constInt(0, .void);
            }
            // Condition couldn't be evaluated — fall through to runtime
        }

        // Check for constant-bool conditions (e.g., is_flags(T) → false) to avoid dead-code LLVM errors
        if (self.tryConstBoolCondition(ie.condition)) |is_true| {
            if (is_true) {
                // Condition always true: only lower then-branch
                if ((ie.is_inline or self.force_block_value) and ie.else_branch != null) {
                    return self.lowerExpr(ie.then_branch);
                }
                self.lowerBlock(ie.then_branch);
                // If then-branch terminated (return/break), mark block as dead
                if (self.currentBlockHasTerminator()) {
                    self.block_terminated = true;
                    return .none;
                }
                return self.builder.constInt(0, .void);
            } else {
                // Condition always false: only lower else-branch (if any)
                if (ie.else_branch) |eb| {
                    if (ie.is_inline or self.force_block_value) {
                        return self.lowerExpr(eb);
                    }
                    self.lowerBlock(eb);
                    if (self.currentBlockHasTerminator()) {
                        self.block_terminated = true;
                        return .none;
                    }
                }
                return self.builder.constInt(0, .void);
            }
        }

        // Optional binding: `if val := expr { ... }`
        // Clear target_type so the ternary's result type doesn't leak into the condition
        // (e.g., `if x != 0 then 1.0 else 2.0` — the `0` must be s64, not f32)
        const saved_cond_target = self.target_type;
        self.target_type = null;
        const opt_val = self.lowerExpr(ie.condition);
        self.target_type = saved_cond_target;
        const cond = if (ie.binding_name != null) blk: {
            // The condition is an optional — emit has_value check
            break :blk self.builder.emit(.{ .optional_has_value = .{ .operand = opt_val } }, .bool);
        } else opt_val;
        const has_else = ie.else_branch != null;
        // If-else produces a value when inline OR when in value position (force_block_value)
        var is_value = (ie.is_inline or self.force_block_value) and has_else;

        // Infer result type from then branch for value if-exprs
        // If then_branch is null/void, try else_branch (e.g., `if cond then null else val`)
        var result_type: TypeId = if (is_value) blk: {
            var t = self.inferExprType(ie.then_branch);
            if ((t == .void or t == .unresolved) and ie.else_branch != null) {
                t = self.inferExprType(ie.else_branch.?);
            }
            // Branch type not statically inferable (e.g. `null` / a bare enum
            // literal) — use the contextually expected type rather than a guess.
            if (t == .unresolved) {
                if (self.target_type) |tt| t = tt;
            }
            break :blk t;
        } else .void;

        // A value-position if/else whose branches yield no value (both are
        // `;`-terminated / void blocks) is really a statement-if — lowering it
        // as a value would build a `phi void`. Demote it.
        if (is_value and result_type == .void) {
            is_value = false;
            result_type = .void;
        }

        const then_bb = self.freshBlock("if.then");
        const else_bb: ?BlockId = if (has_else) self.freshBlock("if.else") else null;
        const merge_params: []const TypeId = if (is_value) &.{result_type} else &.{};
        const merge_bb = self.freshBlockWithParams("if.merge", merge_params);

        // Conditional branch
        self.builder.condBr(
            cond,
            then_bb,
            &.{},
            if (else_bb) |eb| eb else merge_bb,
            &.{},
        );

        // Then branch
        self.builder.switchToBlock(then_bb);
        // If binding: unwrap the optional and bind to the name
        if (ie.binding_name) |bind_name| {
            const opt_ty = self.inferExprType(ie.condition);
            const inner_ty = if (!opt_ty.isBuiltin()) blk: {
                const info = self.module.types.get(opt_ty);
                break :blk if (info == .optional) info.optional.child else opt_ty;
            } else opt_ty;
            const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = opt_val } }, inner_ty);
            const slot = self.builder.alloca(inner_ty);
            self.builder.store(slot, unwrapped);
            if (self.scope) |scope| {
                scope.put(bind_name, .{ .ref = slot, .ty = inner_ty, .is_alloca = true });
            }
        }
        // Set target_type so null/undef in branches get the right type
        const saved_target = self.target_type;
        if (is_value and result_type != .void) self.target_type = result_type;
        if (is_value) {
            var v = self.lowerExpr(ie.then_branch);
            if (!self.currentBlockHasTerminator()) {
                const v_ty = self.builder.getRefType(v);
                if (v_ty != result_type and v_ty != .void and result_type != .void) {
                    v = self.coerceToType(v, v_ty, result_type);
                }
                self.builder.br(merge_bb, &.{v});
            }
        } else {
            self.lowerBlock(ie.then_branch);
            if (!self.currentBlockHasTerminator()) {
                self.builder.br(merge_bb, &.{});
            }
        }

        // Else branch
        if (has_else) {
            self.builder.switchToBlock(else_bb.?);
            if (is_value) {
                var v = self.lowerExpr(ie.else_branch.?);
                if (!self.currentBlockHasTerminator()) {
                    const v_ty = self.builder.getRefType(v);
                    if (v_ty != result_type and v_ty != .void and result_type != .void) {
                        v = self.coerceToType(v, v_ty, result_type);
                    }
                    self.builder.br(merge_bb, &.{v});
                }
            } else {
                self.lowerBlock(ie.else_branch.?);
                if (!self.currentBlockHasTerminator()) {
                    self.builder.br(merge_bb, &.{});
                }
            }
        }
        self.target_type = saved_target;

        // Continue at merge
        self.builder.switchToBlock(merge_bb);
        if (is_value) {
            return self.builder.blockParam(merge_bb, 0, result_type);
        }
        return self.builder.constInt(0, .void);
    }

    /// Try to evaluate an AST condition as a compile-time constant bool.
    /// Returns true/false if the condition is known at compile time, null otherwise.
    fn tryConstBoolCondition(self: *Lowering, node: *const Node) ?bool {
        switch (node.data) {
            .bool_literal => |bl| return bl.value,
            .call => |c| {
                if (c.callee.data == .identifier) {
                    const cname = c.callee.data.identifier.name;
                    if (std.mem.eql(u8, cname, "is_flags")) {
                        // Resolve the type arg to check if it's actually a flags enum
                        if (c.args.len > 0) {
                            const ty = self.resolveTypeArg(c.args[0]);
                            if (!ty.isBuiltin()) {
                                const info = self.module.types.get(ty);
                                if (info == .@"enum") return info.@"enum".is_flags;
                            }
                        }
                        return false;
                    }
                    if (std.mem.eql(u8, cname, "type_eq") and c.args.len >= 2) {
                        const a = self.resolveTypeArg(c.args[0]);
                        const b = self.resolveTypeArg(c.args[1]);
                        return a == b;
                    }
                    if (std.mem.eql(u8, cname, "has_impl") and c.args.len >= 2) {
                        const ty = self.resolveTypeArg(c.args[1]);
                        return self.computeHasImpl(c.args[0], ty);
                    }
                }
            },
            else => {},
        }
        return null;
    }

    /// Shared implementation for the `has_impl(P, T)` builtin and its
    /// `tryConstBoolCondition` arm. The protocol expression is either:
    /// - Plain `Hash` (identifier / type_expr) → walks
    ///   `protocol_thunk_map["Hash\x00<T>"]`.
    /// - Parameterised `Into(Block)` (call) → walks `param_impl_map`
    ///   keyed by `"<P>\x00<arg_mangled>\x00<T_mangled>"`.
    /// Returns false on any malformed protocol-arg shape (caller
    /// reports a diagnostic if it wants).
    fn computeHasImpl(self: *Lowering, proto_node: *const Node, ty: TypeId) bool {
        switch (proto_node.data) {
            .identifier => |id| return self.protocolResolver().hasImplPlain(id.name, ty),
            .type_expr => |te| return self.protocolResolver().hasImplPlain(te.name, ty),
            .call => |c| {
                const p_name: []const u8 = switch (c.callee.data) {
                    .identifier => |id| id.name,
                    .type_expr => |te| te.name,
                    else => return false,
                };
                // Resolve protocol type args. Each goes through
                // `resolveTypeArg` so type aliases / generics / pack-
                // indexed types all work as protocol args.
                var arg_mangles = std.ArrayList(u8).empty;
                defer arg_mangles.deinit(self.alloc);
                for (c.args, 0..) |a, i| {
                    if (i > 0) arg_mangles.append(self.alloc, 0) catch return false;
                    const aty = self.resolveTypeArg(a);
                    arg_mangles.appendSlice(self.alloc, self.mangleTypeName(aty)) catch return false;
                }
                const ty_mangled = self.mangleTypeName(ty);
                const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}\x00{s}", .{
                    p_name, arg_mangles.items, ty_mangled,
                }) catch return false;
                return self.param_impl_map.contains(key);
            },
            else => return false,
        }
    }

    /// Evaluate a compile-time condition for `inline if`.
    /// Handles: `ident == .variant`, `ident != .variant`, `ident == int`, `ident != int`.
    fn evalComptimeCondition(self: *Lowering, node: *const Node) ?bool {
        if (node.data != .binary_op) return null;
        const bo = &node.data.binary_op;
        if (bo.op != .eq and bo.op != .neq) return null;

        // LHS must be an identifier that's in comptime_constants
        const name = switch (bo.lhs.data) {
            .identifier => |id| id.name,
            else => return null,
        };
        const cv = self.comptime_constants.get(name) orelse return null;

        switch (cv) {
            .enum_tag => |et| {
                // RHS must be an enum literal (.variant)
                const variant_name = switch (bo.rhs.data) {
                    .enum_literal => |el| el.name,
                    else => return null,
                };
                // Look up variant index in the enum type
                const enum_info = self.module.types.get(et.ty);
                if (enum_info != .@"enum") return null;
                const variant_idx = self.findVariantIndex(enum_info.@"enum".variants, variant_name);
                const result = et.tag == variant_idx;
                return if (bo.op == .eq) result else !result;
            },
            .int_val => |iv| {
                // RHS must be an integer literal
                const rhs_val: i64 = switch (bo.rhs.data) {
                    .int_literal => |il| il.value,
                    else => return null,
                };
                const result = iv == rhs_val;
                return if (bo.op == .eq) result else !result;
            },
        }
    }

    /// Evaluate a compile-time match expression for `inline if ... == { case ... }`.
    /// Returns the body of the matching arm, or null if the match can't be resolved.
    fn evalComptimeMatch(self: *Lowering, me: *const ast.MatchExpr) ?*const Node {
        // Subject must be a comptime constant identifier
        const name = switch (me.subject.data) {
            .identifier => |id| id.name,
            else => return null,
        };
        const cv = self.comptime_constants.get(name) orelse return null;

        switch (cv) {
            .enum_tag => |et| {
                const enum_info = self.module.types.get(et.ty);
                if (enum_info != .@"enum") return null;
                for (me.arms) |arm| {
                    if (arm.pattern == null) continue; // default arm
                    const variant_name = switch (arm.pattern.?.data) {
                        .enum_literal => |el| el.name,
                        else => continue,
                    };
                    const variant_idx = self.findVariantIndex(enum_info.@"enum".variants, variant_name);
                    if (et.tag == variant_idx) return arm.body;
                }
                // No match — try default arm
                for (me.arms) |arm| {
                    if (arm.pattern == null) return arm.body;
                }
                return null;
            },
            .int_val => |iv| {
                for (me.arms) |arm| {
                    if (arm.pattern == null) continue;
                    const rhs_val: i64 = switch (arm.pattern.?.data) {
                        .int_literal => |il| il.value,
                        else => continue,
                    };
                    if (iv == rhs_val) return arm.body;
                }
                for (me.arms) |arm| {
                    if (arm.pattern == null) return arm.body;
                }
                return null;
            },
        }
    }

    fn lowerWhile(self: *Lowering, we: *const ast.WhileExpr) Ref {
        const header_bb = self.freshBlock("while.hdr");
        const body_bb = self.freshBlock("while.body");
        const exit_bb = self.freshBlock("while.exit");

        // Branch to header
        self.builder.br(header_bb, &.{});

        // Header: evaluate condition
        self.builder.switchToBlock(header_bb);
        const cond = self.lowerExpr(we.condition);
        self.builder.condBr(cond, body_bb, &.{}, exit_bb, &.{});

        // Body
        self.builder.switchToBlock(body_bb);

        // Save and set loop targets
        const old_break = self.break_target;
        const old_continue = self.continue_target;
        self.break_target = exit_bb;
        self.continue_target = header_bb;
        defer {
            self.break_target = old_break;
            self.continue_target = old_continue;
        }

        self.lowerBlock(we.body);
        if (!self.currentBlockHasTerminator()) {
            self.builder.br(header_bb, &.{});
        }

        // Continue at exit
        self.builder.switchToBlock(exit_bb);
        return self.builder.constInt(0, .void);
    }

    /// View a `List(T)`-like struct (`{ items: [*]T, len, … }`) as its backing
    /// `items` pointer + element type + `len`, so `for list: (x)` iterates the
    /// elements. Null for anything that isn't such a struct.
    fn listView(self: *Lowering, value: Ref, ty: TypeId) ?struct { data: Ref, data_ty: TypeId, len: Ref } {
        if (ty.isBuiltin()) return null;
        const info = self.module.types.get(ty);
        if (info != .@"struct") return null;
        const items_id = self.module.types.internString("items");
        const len_id = self.module.types.internString("len");
        var items_idx: ?u32 = null;
        var items_ty: TypeId = .unresolved;
        var len_idx: ?u32 = null;
        for (info.@"struct".fields, 0..) |f, i| {
            if (f.name == items_id and !f.ty.isBuiltin() and self.module.types.get(f.ty) == .many_pointer) {
                items_idx = @intCast(i);
                items_ty = f.ty;
            } else if (f.name == len_id) {
                len_idx = @intCast(i);
            }
        }
        if (items_idx == null or len_idx == null) return null;
        return .{
            .data = self.builder.emit(.{ .struct_get = .{ .base = value, .field_index = items_idx.? } }, items_ty),
            .data_ty = items_ty,
            .len = self.builder.emit(.{ .struct_get = .{ .base = value, .field_index = len_idx.? } }, .s64),
        };
    }

    fn lowerFor(self: *Lowering, fe: *const ast.ForExpr) Ref {
        if (fe.range_end) |end_node| {
            if (fe.is_inline) return self.lowerInlineRangeFor(fe, end_node);
            return self.lowerRuntimeRangeFor(fe, end_node);
        }
        // Collection-form `for xs : (x)` over a pack: a pack has no runtime
        // value to iterate (Decision 1) — point the user at `inline for`.
        if (fe.iterable.data == .identifier and self.isPackName(fe.iterable.data.identifier.name)) {
            return self.diagPackAsValue(fe.iterable.data.identifier.name, fe.iterable.span, .runtime_iter);
        }

        // Lower iterable + resolve its static type.
        var iterable = self.lowerExpr(fe.iterable);
        var iterable_ty = self.inferExprType(fe.iterable);

        // `*List` / `*[]T` etc. — deref to the collection value.
        const ptr_info = if (iterable_ty.isBuiltin()) null else self.module.types.get(iterable_ty);
        if (ptr_info != null and ptr_info.? == .pointer) {
            iterable = self.builder.load(iterable, ptr_info.?.pointer.pointee);
            iterable_ty = ptr_info.?.pointer.pointee;
        }

        // A `List(T)`-like struct iterates its `items[0..len]`; arrays/slices
        // use their intrinsic length.
        var len: Ref = undefined;
        if (self.listView(iterable, iterable_ty)) |lv| {
            iterable = lv.data;
            iterable_ty = lv.data_ty;
            len = lv.len;
        } else {
            len = self.builder.emit(.{ .length = .{ .operand = iterable } }, .s64);
        }

        // Create index variable
        const idx_slot = self.builder.alloca(.s64);
        const zero = self.builder.constInt(0, .s64);
        self.builder.store(idx_slot, zero);

        const header_bb = self.freshBlock("for.hdr");
        const body_bb = self.freshBlock("for.body");
        const inc_bb = self.freshBlock("for.inc");
        const exit_bb = self.freshBlock("for.exit");

        self.builder.br(header_bb, &.{});

        // Header: compare index < length
        self.builder.switchToBlock(header_bb);
        const idx_val = self.builder.load(idx_slot, .s64);
        const cmp = self.builder.cmpLt(idx_val, len);
        self.builder.condBr(cmp, body_bb, &.{}, exit_bb, &.{});

        // Body
        self.builder.switchToBlock(body_bb);

        // Bind element — resolve element type from iterable. `for xs: (*x)`
        // binds a pointer into the collection (no per-element copy); `(x)`
        // binds a value copy.
        const elem_ty = self.getElementType(iterable_ty);
        const bind_ty = if (fe.capture_by_ref) self.module.types.ptrTo(elem_ty) else elem_ty;
        const elem = if (fe.capture_by_ref) blk: {
            // A slice value carries its backing pointer, so GEP on it writes
            // through. An array is a value — GEP needs its storage (alloca) or
            // mutations would hit a copy.
            const is_array = !iterable_ty.isBuiltin() and self.module.types.get(iterable_ty) == .array;
            const base = if (is_array) (self.getExprAlloca(fe.iterable) orelse iterable) else iterable;
            break :blk self.builder.emit(.{ .index_gep = .{ .lhs = base, .rhs = idx_val } }, bind_ty);
        } else self.builder.emit(.{ .index_get = .{ .lhs = iterable, .rhs = idx_val } }, bind_ty);

        var body_scope = Scope.init(self.alloc, self.scope);
        const old_scope = self.scope;
        self.scope = &body_scope;

        body_scope.put(fe.capture_name, .{ .ref = elem, .ty = bind_ty, .is_alloca = false, .is_ref_capture = fe.capture_by_ref });

        // Bind index if requested
        if (fe.index_name) |iname| {
            body_scope.put(iname, .{ .ref = idx_val, .ty = .s64, .is_alloca = false });
        }

        // Save and set loop targets
        const old_break = self.break_target;
        const old_continue = self.continue_target;
        self.break_target = exit_bb;
        self.continue_target = inc_bb; // continue → increment, not header

        self.lowerBlock(fe.body);

        self.break_target = old_break;
        self.continue_target = old_continue;
        self.scope = old_scope;
        body_scope.deinit();

        // Fall through to increment block
        if (!self.currentBlockHasTerminator()) {
            self.builder.br(inc_bb, &.{});
        }

        // Increment block: increment index and jump back to header
        self.builder.switchToBlock(inc_bb);
        {
            const cur_idx = self.builder.load(idx_slot, .s64);
            const one = self.builder.constInt(1, .s64);
            const next_idx = self.builder.add(cur_idx, one, .s64);
            self.builder.store(idx_slot, next_idx);
            self.builder.br(header_bb, &.{});
        }

        // Continue at exit
        self.builder.switchToBlock(exit_bb);
        return self.builder.constInt(0, .void);
    }

    /// Runtime counting loop `for start..end (i) { }` — `i` (optional) is the
    /// cursor, `end` is exclusive. Lowers to the same header/inc/exit shape as
    /// the collection form, minus the element fetch.
    fn lowerRuntimeRangeFor(self: *Lowering, fe: *const ast.ForExpr, end_node: *Node) Ref {
        const start = self.lowerExpr(fe.iterable);
        const end = self.lowerExpr(end_node);

        const idx_slot = self.builder.alloca(.s64);
        self.builder.store(idx_slot, start);

        const header_bb = self.freshBlock("for.hdr");
        const body_bb = self.freshBlock("for.body");
        const inc_bb = self.freshBlock("for.inc");
        const exit_bb = self.freshBlock("for.exit");

        self.builder.br(header_bb, &.{});

        self.builder.switchToBlock(header_bb);
        const idx_val = self.builder.load(idx_slot, .s64);
        const cmp = self.builder.cmpLt(idx_val, end);
        self.builder.condBr(cmp, body_bb, &.{}, exit_bb, &.{});

        self.builder.switchToBlock(body_bb);
        var body_scope = Scope.init(self.alloc, self.scope);
        const old_scope = self.scope;
        self.scope = &body_scope;
        if (fe.capture_name.len > 0) {
            body_scope.put(fe.capture_name, .{ .ref = idx_val, .ty = .s64, .is_alloca = false });
        }

        const old_break = self.break_target;
        const old_continue = self.continue_target;
        self.break_target = exit_bb;
        self.continue_target = inc_bb;

        self.lowerBlock(fe.body);

        self.break_target = old_break;
        self.continue_target = old_continue;
        self.scope = old_scope;
        body_scope.deinit();

        if (!self.currentBlockHasTerminator()) {
            self.builder.br(inc_bb, &.{});
        }

        self.builder.switchToBlock(inc_bb);
        {
            const cur_idx = self.builder.load(idx_slot, .s64);
            const one = self.builder.constInt(1, .s64);
            const next_idx = self.builder.add(cur_idx, one, .s64);
            self.builder.store(idx_slot, next_idx);
            self.builder.br(header_bb, &.{});
        }

        self.builder.switchToBlock(exit_bb);
        return self.builder.constInt(0, .void);
    }

    /// Comptime-unrolled `inline for start..end (i) { }`. `start`/`end` must be
    /// comptime-known. The body is lowered `end - start` times with the cursor
    /// bound as an `int_val` comptime constant, so `xs[i]` over a pack
    /// substitutes the concrete per-position argument each iteration.
    fn lowerInlineRangeFor(self: *Lowering, fe: *const ast.ForExpr, end_node: *Node) Ref {
        const start = self.evalComptimeInt(fe.iterable) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, fe.iterable.span, "inline for: range start is not a compile-time integer", .{});
            return self.builder.constInt(0, .void);
        };
        const end = self.evalComptimeInt(end_node) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, end_node.span, "inline for: range end is not a compile-time integer", .{});
            return self.builder.constInt(0, .void);
        };

        var i: i64 = start;
        while (i < end) : (i += 1) {
            var body_scope = Scope.init(self.alloc, self.scope);
            const old_scope = self.scope;
            self.scope = &body_scope;

            // Bind the cursor both as a runtime value (constInt, for uses like
            // `print(i)`) and as a comptime constant (for `xs[i]` substitution).
            var had_prev = false;
            var prev: ComptimeValue = undefined;
            if (fe.capture_name.len > 0) {
                body_scope.put(fe.capture_name, .{ .ref = self.builder.constInt(i, .s64), .ty = .s64, .is_alloca = false });
                if (self.comptime_constants.get(fe.capture_name)) |p| {
                    had_prev = true;
                    prev = p;
                }
                self.comptime_constants.put(fe.capture_name, .{ .int_val = i }) catch {};
            }

            self.lowerBlock(fe.body);

            if (fe.capture_name.len > 0) {
                if (had_prev) {
                    self.comptime_constants.put(fe.capture_name, prev) catch {};
                } else {
                    _ = self.comptime_constants.remove(fe.capture_name);
                }
            }

            self.scope = old_scope;
            body_scope.deinit();

            if (self.currentBlockHasTerminator()) break;
        }

        return self.builder.constInt(0, .void);
    }

    /// Evaluate a node to a comptime integer: literal, comptime-constant
    /// identifier, or `<pack>.len` (resolves to the monomorphised arity).
    fn evalComptimeInt(self: *Lowering, node: *const Node) ?i64 {
        switch (node.data) {
            .int_literal => |lit| return lit.value,
            .identifier => |id| {
                if (self.comptime_constants.get(id.name)) |cv| {
                    switch (cv) {
                        .int_val => |iv| return iv,
                        else => return null,
                    }
                }
                return null;
            },
            .field_access => |fa| {
                if (self.pack_param_count) |ppc| {
                    if (fa.object.data == .identifier and std.mem.eql(u8, fa.field, "len")) {
                        if (ppc.get(fa.object.data.identifier.name)) |n| {
                            return @as(i64, @intCast(n));
                        }
                    }
                }
                return null;
            },
            else => return null,
        }
    }

    fn lowerMatch(self: *Lowering, me: *const ast.MatchExpr) Ref {
        // inline if match: evaluate at compile time, only lower the matching arm
        if (me.is_comptime) {
            if (self.evalComptimeMatch(me)) |arm_body| {
                return self.lowerInlineBranch(arm_body);
            }
            // Couldn't evaluate — fall through to runtime
        }

        const is_type_match = isTypeCategoryMatch(me);
        var subject = self.lowerExpr(me.subject);
        var subject_ty = self.inferExprType(me.subject);
        // A pointer subject (e.g. a `for xs: (*x)` element capture) — deref to
        // the pointed-to union/enum so tag/payload extraction works.
        if (!subject_ty.isBuiltin()) {
            const sinfo = self.module.types.get(subject_ty);
            if (sinfo == .pointer and !sinfo.pointer.pointee.isBuiltin()) {
                const pinfo = self.module.types.get(sinfo.pointer.pointee);
                if (pinfo == .tagged_union or pinfo == .@"enum") {
                    subject = self.builder.load(subject, sinfo.pointer.pointee);
                    subject_ty = sinfo.pointer.pointee;
                }
            }
        }
        const is_optional_match = blk: {
            if (!subject_ty.isBuiltin()) {
                const info = self.module.types.get(subject_ty);
                break :blk info == .optional;
            }
            break :blk false;
        };
        // An error-set subject (`catch e == { case .X: ... }` / `if e == { ... }`):
        // the value IS its u32 tag id, and `case .X` matches the global tag id
        // of `X`. Used by ERR E1.5's catch match-body form.
        const is_error_set_match = blk: {
            if (!subject_ty.isBuiltin()) {
                break :blk self.module.types.get(subject_ty) == .error_set;
            }
            break :blk false;
        };

        // Determine if the match produces a value (has non-void arms)
        // For type-category matches (inside any_to_string), only produce value when force_block_value
        // For regular enum/optional matches, always produce value if arms are non-void
        var inferred_result = self.inferMatchResultType(me);
        // Arms not statically inferable (bare enum literals etc.): only a
        // value-position match (`force_block_value`) needs a concrete result —
        // use the contextually expected type. A statement match with non-value
        // arms is a side-effect (void); don't let a leaked `target_type` turn
        // it into a value match.
        if (inferred_result == .unresolved) {
            inferred_result = if (self.force_block_value) (self.target_type orelse .unresolved) else .void;
        }
        const is_value = if (is_type_match) self.force_block_value else (self.force_block_value or (inferred_result != .void and inferred_result != .unresolved));
        const result_type: TypeId = if (is_value) inferred_result else .void;
        // A fully-diverging match (`result_type == .noreturn` — every arm
        // `return`s / `raise`s / etc.) produces no value, so it builds no
        // merge phi; its arms terminate and the merge block is unreachable.
        const has_value_merge = is_value and result_type != .void and result_type != .noreturn;
        const merge_params: []const TypeId = if (has_value_merge) &.{result_type} else &.{};
        const merge_bb = self.freshBlockWithParams("match.merge", merge_params);

        // Build arm blocks
        var default_bb: ?BlockId = null;
        var arm_blocks = std.ArrayList(BlockId).empty;
        defer arm_blocks.deinit(self.alloc);
        for (me.arms) |_| {
            arm_blocks.append(self.alloc, self.freshBlock("match.arm")) catch unreachable;
        }

        // Build case list and pre-collect type tags per arm
        var cases = std.ArrayList(inst_mod.SwitchBranch.Case).empty;
        defer cases.deinit(self.alloc);
        var arm_tag_values = std.ArrayList([]const u64).empty;
        defer arm_tag_values.deinit(self.alloc);

        for (me.arms, 0..) |arm, i| {
            if (arm.pattern == null) {
                default_bb = arm_blocks.items[i];
                arm_tag_values.append(self.alloc, &.{}) catch unreachable;
                continue;
            }
            const pat = arm.pattern.?;

            if (is_type_match) {
                // Type-category match: resolve category name to tag values
                const name = switch (pat.data) {
                    .identifier => |id| id.name,
                    .type_expr => |te| te.name,
                    else => "",
                };
                const tag_values = self.resolveTypeCategoryTags(name);
                arm_tag_values.append(self.alloc, tag_values) catch unreachable;
                for (tag_values) |tag| {
                    cases.append(self.alloc, .{
                        .value = @intCast(tag),
                        .target = arm_blocks.items[i],
                        .args = &.{},
                    }) catch unreachable;
                }
            } else if (is_optional_match) {
                // Optional match: .some → 1 (has_value=true), .none → 0
                arm_tag_values.append(self.alloc, &.{}) catch unreachable;
                const pat_name = switch (pat.data) {
                    .enum_literal => |el| el.name,
                    .identifier => |id| id.name,
                    else => "",
                };
                const case_val: u64 = if (std.mem.eql(u8, pat_name, "some")) 1 else 0;
                cases.append(self.alloc, .{
                    .value = @intCast(case_val),
                    .target = arm_blocks.items[i],
                    .args = &.{},
                }) catch unreachable;
            } else {
                // Enum/value match: resolve variant name to actual tag value
                arm_tag_values.append(self.alloc, &.{}) catch unreachable;
                const case_val: u64 = blk: {
                    const pat_name = switch (pat.data) {
                        .enum_literal => |el| el.name,
                        .identifier => |id| id.name,
                        .int_literal => |il| break :blk @intCast(il.value),
                        .bool_literal => |bl| break :blk @as(u64, if (bl.value) 1 else 0),
                        else => break :blk @as(u64, @intCast(i)),
                    };
                    // Look up variant value in the subject's type
                    if (!subject_ty.isBuiltin()) {
                        const ty_info = self.module.types.get(subject_ty);
                        if (ty_info == .tagged_union) {
                            for (ty_info.tagged_union.fields, 0..) |f, vi| {
                                const vname = self.module.types.strings.get(f.name);
                                if (std.mem.eql(u8, vname, pat_name)) {
                                    if (ty_info.tagged_union.explicit_tag_values) |vals| {
                                        if (vi < vals.len) break :blk @intCast(@as(u64, @bitCast(vals[vi])));
                                    }
                                    break :blk @intCast(vi);
                                }
                            }
                            if (self.diagnostics) |diags| {
                                const ty_name = self.formatTypeName(subject_ty);
                                diags.addFmt(.err, pat.span, "no variant '{s}' on type '{s}'", .{ pat_name, ty_name });
                            }
                        } else if (ty_info == .@"enum") {
                            for (ty_info.@"enum".variants, 0..) |v, vi| {
                                const vname = self.module.types.strings.get(v);
                                if (std.mem.eql(u8, vname, pat_name)) {
                                    if (ty_info.@"enum".explicit_values) |vals| {
                                        if (vi < vals.len) break :blk @intCast(@as(u64, @bitCast(vals[vi])));
                                    }
                                    break :blk @intCast(vi);
                                }
                            }
                            if (self.diagnostics) |diags| {
                                const ty_name = self.formatTypeName(subject_ty);
                                diags.addFmt(.err, pat.span, "no variant '{s}' on type '{s}'", .{ pat_name, ty_name });
                            }
                        } else if (ty_info == .error_set) {
                            // `case .X` matches the global tag id of `X`.
                            break :blk @intCast(self.module.types.internTag(pat_name));
                        }
                    }
                    break :blk @intCast(i);
                };
                cases.append(self.alloc, .{
                    .value = @intCast(case_val),
                    .target = arm_blocks.items[i],
                    .args = &.{},
                }) catch unreachable;
            }
        }

        // If no default arm, create an unreachable default
        if (default_bb == null) {
            default_bb = self.freshBlock("match.unr");
        }

        // Switch on the subject (for type match, subject is either a
        // bare TypeId (s64) or an Any-shaped Type value — unbox in the
        // latter case so the switch sees the i64 type id).
        const tag = if (is_type_match) tag_blk: {
            if (subject_ty == .any) {
                break :tag_blk self.builder.emit(.{ .unbox_any = .{ .operand = subject } }, .s64);
            }
            break :tag_blk subject;
        } else if (is_optional_match) self.builder.emit(.{ .optional_has_value = .{ .operand = subject } }, .bool) else if (is_error_set_match) subject else blk: {
            // Determine actual tag type from union info (e.g. u32 for SDL_Event)
            const tag_ty: TypeId = tt: {
                if (!subject_ty.isBuiltin()) {
                    const ty_info = self.module.types.get(subject_ty);
                    if (ty_info == .tagged_union) break :tt ty_info.tagged_union.tag_type;
                }
                break :tt .s32;
            };
            break :blk self.builder.enumTag(subject, tag_ty);
        };
        self.builder.switchBr(tag, cases.items, default_bb.?, &.{});

        // Lower each arm's body
        for (me.arms, 0..) |arm, i| {
            self.builder.switchToBlock(arm_blocks.items[i]);

            // For type-match arms with empty tag lists, the arm is unreachable
            // (no switch case targets it). Skip lowering to avoid invalid IR
            // from runtime cast/dispatch with no matching types.
            if (is_type_match and arm.pattern != null and arm_tag_values.items[i].len == 0) {
                self.builder.emitUnreachable();
                continue;
            }

            var arm_scope = Scope.init(self.alloc, self.scope);
            const old_scope = self.scope;
            self.scope = &arm_scope;

            if (arm.capture) |capture_name| {
                if (is_optional_match) {
                    // For optional match, unwrap the optional value
                    const opt_info = self.module.types.get(subject_ty);
                    const child_ty = if (opt_info == .optional) opt_info.optional.child else .s64;
                    const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = subject } }, child_ty);
                    arm_scope.put(capture_name, .{ .ref = unwrapped, .ty = child_ty, .is_alloca = false });
                } else {
                    // Resolve actual variant index and payload type from the subject's type
                    var variant_idx: u32 = @intCast(i);
                    var payload_ty: TypeId = .unresolved;
                    if (arm.pattern) |arm_pat| {
                        const pat_name = switch (arm_pat.data) {
                            .enum_literal => |el| el.name,
                            .identifier => |id| id.name,
                            else => "",
                        };
                        if (!subject_ty.isBuiltin()) {
                            const ty_info = self.module.types.get(subject_ty);
                            if (ty_info == .tagged_union) {
                                for (ty_info.tagged_union.fields, 0..) |f, vi| {
                                    const vname = self.module.types.strings.get(f.name);
                                    if (std.mem.eql(u8, vname, pat_name)) {
                                        variant_idx = @intCast(vi);
                                        payload_ty = f.ty;
                                        break;
                                    }
                                }
                            }
                        }
                    }
                    const payload = self.builder.emit(.{ .enum_payload = .{
                        .base = subject,
                        .field_index = variant_idx,
                    } }, payload_ty);
                    arm_scope.put(capture_name, .{ .ref = payload, .ty = payload_ty, .is_alloca = false });
                }
            }

            // Set match arm context for runtime type dispatch
            const saved_match_tags = self.current_match_tags;
            if (is_type_match) {
                self.current_match_tags = arm_tag_values.items[i];
            }

            if (has_value_merge) {
                // Lower the arm body against the merge's result type so literals
                // (and negated literals) in the arm pick the right width — the
                // phi operands must all match `result_type` (issue 0066).
                const saved_arm_target = self.target_type;
                self.target_type = result_type;
                const maybe_v = self.lowerBlockValue(arm.body);
                self.target_type = saved_arm_target;
                self.current_match_tags = saved_match_tags;
                self.scope = old_scope;
                arm_scope.deinit();
                // Only materialize a value + branch to the merge when the arm
                // body did NOT diverge. A diverging arm (e.g. `return x`) has
                // already terminated its block; emitting the fallback const
                // here would land AFTER the terminator (the issue-0057 bug).
                if (!self.currentBlockHasTerminator()) {
                    var v = maybe_v orelse if (result_type == .string or !result_type.isBuiltin())
                        self.builder.constUndef(result_type)
                    else
                        self.builder.constInt(0, result_type);
                    const v_ty = self.builder.getRefType(v);
                    v = self.coerceToType(v, v_ty, result_type);
                    self.builder.br(merge_bb, &.{v});
                }
            } else {
                self.lowerBlock(arm.body);
                self.current_match_tags = saved_match_tags;
                self.scope = old_scope;
                arm_scope.deinit();
                if (!self.currentBlockHasTerminator()) {
                    self.builder.br(merge_bb, &.{});
                }
            }
        }

        // Emit default block if no explicit else arm
        if (default_bb != null) {
            var found_default = false;
            for (me.arms) |arm| {
                if (arm.pattern == null) { found_default = true; break; }
            }
            if (!found_default) {
                self.builder.switchToBlock(default_bb.?);
                if (is_type_match) {
                    // For type-category matches, unrecognized tags should skip to merge
                    // (e.g., optional types not covered by any_to_string categories)
                    if (has_value_merge) {
                        const default_val = self.builder.constUndef(result_type);
                        self.builder.br(merge_bb, &.{default_val});
                    } else {
                        self.builder.br(merge_bb, &.{});
                    }
                } else {
                    // For non-exhaustive matches (union/enum with unhandled variants),
                    // fall through to merge instead of unreachable
                    const is_exhaustive = blk: {
                        if (!subject_ty.isBuiltin()) {
                            const ty_info = self.module.types.get(subject_ty);
                            if (ty_info == .tagged_union) {
                                break :blk cases.items.len >= ty_info.tagged_union.fields.len;
                            } else if (ty_info == .@"enum") {
                                break :blk cases.items.len >= ty_info.@"enum".variants.len;
                            }
                        }
                        break :blk false;
                    };
                    if (is_exhaustive) {
                        self.builder.emitUnreachable();
                    } else if (has_value_merge) {
                        const default_val = self.builder.constUndef(result_type);
                        self.builder.br(merge_bb, &.{default_val});
                    } else {
                        self.builder.br(merge_bb, &.{});
                    }
                }
            }
        }

        self.builder.switchToBlock(merge_bb);
        if (has_value_merge) {
            return self.builder.blockParam(merge_bb, 0, result_type);
        }
        return self.builder.constInt(0, .void);
    }

    fn lowerBreak(self: *Lowering) Ref {
        if (self.break_target) |target| {
            self.builder.br(target, &.{});
        }
        return Ref.none;
    }

    fn lowerContinue(self: *Lowering) Ref {
        if (self.continue_target) |target| {
            self.builder.br(target, &.{});
        }
        return Ref.none;
    }

    // ── Struct/enum/union ops ───────────────────────────────────────

    fn lowerStructLiteral(self: *Lowering, sl: *const ast.StructLiteral, span: ast.Span) Ref {
        // Check for tagged enum construction: .Variant.{ payload_fields }
        // This happens when type_expr is an enum_literal and target_type is a union
        if (sl.type_expr) |te| {
            if (te.data == .enum_literal) {
                const variant_name = te.data.enum_literal.name;
                const union_ty = self.target_type orelse .unresolved;
                if (!union_ty.isBuiltin()) {
                    const union_info = self.module.types.get(union_ty);
                    if (union_info == .tagged_union) {
                        return self.lowerTaggedEnumLiteral(sl, variant_name, union_ty, union_info.tagged_union, span);
                    }
                }
            }
        }

        // `.{ name = ... }` against a tagged-union target_type. Reject:
        // the only valid construction forms are `.variant(payload)` and
        // `.variant.{ field, ... }`. Falling through would lower the
        // user's values straight into the `(tag, payload_bytes)` slot
        // pair and emit IR that LLVM later rejects.
        if (sl.type_expr == null and sl.struct_name == null) {
            const tu_ty = self.target_type orelse .unresolved;
            if (!tu_ty.isBuiltin()) {
                const tu_info = self.module.types.get(tu_ty);
                if (tu_info == .tagged_union) {
                    if (sl.field_inits.len > 0 and sl.field_inits[0].name != null) {
                        const first_name = sl.field_inits[0].name.?;
                        if (self.diagnostics) |diags| {
                            const ty_name = self.formatTypeName(tu_ty);
                            if (self.findTaggedVariant(tu_info.tagged_union, first_name) != null) {
                                diags.addFmt(
                                    .err,
                                    span,
                                    "cannot construct tagged union '{s}' from `.{{ {s} = ... }}`; use `.{s}(...)` or `.{s}.{{ ... }}`",
                                    .{ ty_name, first_name, first_name, first_name },
                                );
                            } else {
                                self.emitBadVariant(tu_ty, tu_info.tagged_union, first_name, span);
                            }
                        }
                        return self.builder.enumInit(0, Ref.none, tu_ty);
                    }
                }
            }
        }

        const ty: TypeId = if (sl.struct_name) |name| blk: {
            const name_id = self.module.types.internString(name);
            break :blk self.module.types.findByName(name_id) orelse
                self.module.types.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } });
        } else if (sl.type_expr) |te|
            // Generic struct literal: Pair(s32).{ ... } — resolve type from type_expr
            self.resolveTypeWithBindings(te)
        else self.target_type orelse .unresolved;

        // Get struct field types for coercion and ordering
        const struct_fields = self.getStructFields(ty);

        // Look up field defaults from AST
        const struct_name_for_defaults = if (sl.struct_name) |n| n else if (!ty.isBuiltin()) blk: {
            const ti = self.module.types.get(ty);
            break :blk if (ti == .@"struct") self.module.types.getString(ti.@"struct".name) else @as(?[]const u8, null);
        } else @as(?[]const u8, null);
        const field_defaults: []const ?*const Node = if (struct_name_for_defaults) |sn|
            (self.struct_defaults_map.get(sn) orelse &.{})
        else
            &.{};

        // Check if any field_init has a name (named literal)
        const has_names = sl.field_inits.len > 0 and sl.field_inits[0].name != null;

        if (has_names and struct_fields.len > 0) {
            // Named literal: reorder fields to match struct declaration order
            // First, lower all field values in source order (to preserve evaluation order)
            var lowered = std.ArrayList(struct { val: Ref, name: []const u8, node: *const Node }).empty;
            defer lowered.deinit(self.alloc);
            for (sl.field_inits) |fi| {
                const saved_tt = self.target_type;
                // Set target_type to the field's declared type so array literals
                // know if the target is a vector, etc.
                if (fi.name) |fname| {
                    for (struct_fields) |sf| {
                        if (std.mem.eql(u8, self.module.types.getString(sf.name), fname)) {
                            self.target_type = sf.ty;
                            break;
                        }
                    }
                }
                const val = self.lowerExpr(fi.value);
                self.target_type = saved_tt;
                lowered.append(self.alloc, .{
                    .val = val,
                    .name = fi.name orelse "",
                    .node = fi.value,
                }) catch unreachable;
            }

            // Build fields in declaration order
            var fields = std.ArrayList(Ref).empty;
            defer fields.deinit(self.alloc);
            for (struct_fields, 0..) |sf, fi| {
                const sf_name = self.module.types.getString(sf.name);
                // Find the matching lowered value
                var found = false;
                for (lowered.items) |l| {
                    if (std.mem.eql(u8, l.name, sf_name)) {
                        var val = l.val;
                        const src_ty = self.builder.getRefType(val);
                        val = self.coerceToType(val, src_ty, sf.ty);
                        fields.append(self.alloc, val) catch unreachable;
                        found = true;
                        break;
                    }
                }
                if (!found) {
                    // Field not specified — use default if available, else zero
                    if (fi < field_defaults.len) {
                        if (field_defaults[fi]) |default_expr| {
                            const saved_tt = self.target_type;
                            self.target_type = sf.ty;
                            const val = self.lowerExpr(default_expr);
                            self.target_type = saved_tt;
                            fields.append(self.alloc, val) catch unreachable;
                        } else {
                            fields.append(self.alloc, self.zeroValue(sf.ty)) catch unreachable;
                        }
                    } else {
                        fields.append(self.alloc, self.zeroValue(sf.ty)) catch unreachable;
                    }
                }
            }

            const result = self.builder.structInit(fields.items, ty);
            if (sl.init_block) |ib| {
                return self.lowerInitBlock(result, ty, ib);
            }
            return result;
        }

        // Positional literal: use source order
        var fields = std.ArrayList(Ref).empty;
        defer fields.deinit(self.alloc);

        for (sl.field_inits, 0..) |fi, i| {
            var val = self.lowerExpr(fi.value);
            // Coerce field value to match struct field type
            if (i < struct_fields.len) {
                const src_ty = self.inferExprType(fi.value);
                val = self.coerceToType(val, src_ty, struct_fields[i].ty);
            }
            fields.append(self.alloc, val) catch unreachable;
        }

        // Pad missing fields with defaults or zeroes
        if (fields.items.len < struct_fields.len) {
            for (struct_fields[fields.items.len..], fields.items.len..) |sf, fi| {
                if (fi < field_defaults.len) {
                    if (field_defaults[fi]) |default_expr| {
                        const saved_tt = self.target_type;
                        self.target_type = sf.ty;
                        const val = self.lowerExpr(default_expr);
                        self.target_type = saved_tt;
                        fields.append(self.alloc, val) catch unreachable;
                        continue;
                    }
                }
                fields.append(self.alloc, self.zeroValue(sf.ty)) catch unreachable;
            }
        }

        const result = self.builder.structInit(fields.items, ty);

        // Lower init block if present
        if (sl.init_block) |ib| {
            return self.lowerInitBlock(result, ty, ib);
        }

        return result;
    }

    /// Lower an init block: store struct value to alloca, bind `self`, execute block, reload.
    fn lowerInitBlock(self: *Lowering, struct_val: Ref, ty: TypeId, ib: *const Node) Ref {
        // Store struct value to a temporary alloca
        const ptr_ty = self.module.types.ptrTo(ty);
        const slot = self.builder.alloca(ty);
        self.builder.store(slot, struct_val);

        // Create a nested scope with `self` bound to the alloca pointer
        var init_scope = Scope.init(self.alloc, self.scope);
        defer init_scope.deinit();
        const saved_scope = self.scope;
        self.scope = &init_scope;

        // `self` is the pointer to the struct (not an alloca itself — it IS the pointer value)
        init_scope.put("self", .{ .ref = slot, .ty = ptr_ty, .is_alloca = false });

        // Lower the init block body
        self.lowerBlock(ib);

        // Restore scope
        self.scope = saved_scope;

        // Load and return the (possibly modified) struct value
        return self.builder.load(slot, ty);
    }

    /// Get the field list for a struct TypeId, or empty if not a struct.
    pub fn getStructFields(self: *Lowering, ty: TypeId) []const types.TypeInfo.StructInfo.Field {
        if (ty.isBuiltin()) return &.{};
        var resolved = ty;
        const info = self.module.types.get(resolved);
        // Dereference pointer types to get to the underlying struct
        if (info == .pointer) {
            resolved = info.pointer.pointee;
            if (resolved.isBuiltin()) return &.{};
            const inner = self.module.types.get(resolved);
            return switch (inner) {
                .@"struct" => |s| s.fields,
                else => &.{},
            };
        }
        return switch (info) {
            .@"struct" => |s| s.fields,
            else => &.{},
        };
    }

    /// If a method's first param expects a pointer (*T) but we're passing T by value,
    /// swap the first arg with the alloca address (implicit address-of).
    fn fixupMethodReceiver(self: *Lowering, method_args: *std.ArrayList(Ref), func: *const Function, obj_node: *const Node, obj_ty: TypeId) void {
        // Skip the implicit __sx_ctx param when inspecting the receiver slot.
        const skip: usize = if (func.has_implicit_ctx) 1 else 0;
        if (func.params.len <= skip) return;
        const first_param_ty = func.params[skip].ty;
        // Check if first param expects a pointer
        if (!first_param_ty.isBuiltin()) {
            const pi = self.module.types.get(first_param_ty);
            if (pi == .pointer) {
                // If obj is already a pointer type, it's already correct (no addr_of needed)
                if (!obj_ty.isBuiltin()) {
                    const oi = self.module.types.get(obj_ty);
                    if (oi == .pointer) return; // already a pointer
                }
                // Method expects *T — pass the address of the receiver (value type in alloca)
                if (obj_node.data == .identifier) {
                    if (self.scope) |scope| {
                        if (scope.lookup(obj_node.data.identifier.name)) |binding| {
                            if (binding.is_alloca) {
                                const ptr_ty = self.module.types.ptrTo(binding.ty);
                                method_args.items[0] = self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, ptr_ty);
                                return;
                            }
                        }
                    }
                }
                // Field access: obj.field.method() → GEP to field, pass pointer directly.
                // This avoids copying the struct value (mutations through *T must be visible).
                if (obj_node.data == .field_access) {
                    const gep_ref = self.lowerExprAsPtr(obj_node);
                    // GEP returns a pointer in LLVM but its IR type is the field value type.
                    // Wrap with addr_of (no-op in LLVM) to set the IR type to *T,
                    // preventing coerceCallArgs from doing a spurious alloca+store.
                    const ptr_ty = self.module.types.ptrTo(obj_ty);
                    method_args.items[0] = self.builder.emit(.{ .addr_of = .{ .operand = gep_ref } }, ptr_ty);
                    return;
                }
                // General case: alloca+store the value and pass the alloca pointer
                {
                    const slot = self.builder.alloca(obj_ty);
                    self.builder.store(slot, method_args.items[0]);
                    method_args.items[0] = slot;
                }
            } else {
                // Method expects a value `T` but the receiver is a `*T` (e.g. a
                // `for xs: (*x)` by-ref capture) — deref to pass the value.
                if (!obj_ty.isBuiltin()) {
                    const oi = self.module.types.get(obj_ty);
                    if (oi == .pointer and oi.pointer.pointee == first_param_ty) {
                        method_args.items[0] = self.builder.load(method_args.items[0], first_param_ty);
                    }
                }
            }
        }
    }

    /// Get the name of a struct type (dereferencing pointers). Returns null for non-struct types.
    fn getStructTypeName(self: *Lowering, ty: TypeId) ?[]const u8 {
        if (ty.isBuiltin()) {
            // Map builtin types to their names for method resolution (e.g., s64.eq)
            return builtinTypeName(ty);
        }
        var resolved = ty;
        const info = self.module.types.get(resolved);
        if (info == .pointer) {
            resolved = info.pointer.pointee;
            if (resolved.isBuiltin()) return builtinTypeName(resolved);
        }
        const ri = self.module.types.get(resolved);
        return switch (ri) {
            .@"struct" => |s| self.module.types.getString(s.name),
            else => null,
        };
    }

    fn builtinTypeName(ty: TypeId) ?[]const u8 {
        return switch (ty) {
            .s8 => "s8",
            .s16 => "s16",
            .s32 => "s32",
            .s64 => "s64",
            .u8 => "u8",
            .u16 => "u16",
            .u32 => "u32",
            .u64 => "u64",
            .f32 => "f32",
            .f64 => "f64",
            .bool => "bool",
            .string => "string",
            else => null,
        };
    }

    /// Resolve the type of a named field on a given type.
    fn resolveFieldType(self: *Lowering, ty: TypeId, field: []const u8) TypeId {
        if (std.mem.eql(u8, field, "len")) return .s64;
        if (std.mem.eql(u8, field, "ptr")) {
            const elem_ty = self.getElementType(ty);
            return self.module.types.manyPtrTo(elem_ty);
        }
        const field_name_id = self.module.types.internString(field);
        // Check union fields + promoted fields
        if (!ty.isBuiltin()) {
            const info = self.module.types.get(ty);
            const u_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (info) {
                .@"union" => |u| u.fields,
                .tagged_union => |u| u.fields,
                else => null,
            };
            if (u_fields) |ufields| {
                for (ufields) |f| {
                    if (f.name == field_name_id) return f.ty;
                    // Check promoted fields from anonymous struct variants
                    if (!f.ty.isBuiltin()) {
                        const fi = self.module.types.get(f.ty);
                        if (fi == .@"struct") {
                            for (fi.@"struct".fields) |sf| {
                                if (sf.name == field_name_id) return sf.ty;
                            }
                        }
                    }
                }
            }
        }
        // Check tuple fields
        if (!ty.isBuiltin()) {
            const ti = self.module.types.get(ty);
            if (ti == .tuple) {
                const tuple = ti.tuple;
                // Try named fields
                if (tuple.names) |names| {
                    for (names, 0..) |name_id, i| {
                        if (name_id == field_name_id) return tuple.fields[i];
                    }
                }
                // Try numeric index
                const idx = std.fmt.parseInt(usize, field, 10) catch {
                    return .unresolved;
                };
                if (idx < tuple.fields.len) return tuple.fields[idx];
                return .unresolved;
            }
        }
        const struct_fields = self.getStructFields(ty);
        for (struct_fields) |f| {
            if (f.name == field_name_id) return f.ty;
        }
        return .unresolved;
    }

    fn lowerFieldAccess(self: *Lowering, fa: *const ast.FieldAccess, span: ast.Span) Ref {
        // `error.X` — an error-tag literal. The `error` keyword in expression
        // position parses as identifier "error" (E0.2), so `error.X` is a
        // field access we intercept here. `error` is reserved, so this is
        // unambiguous (no struct/pack can be named `error`).
        if (fa.object.data == .identifier and std.mem.eql(u8, fa.object.data.identifier.name, "error")) {
            return self.lowerErrorTagLiteral(fa.field, span);
        }

        // Pack-arity intercept: `<pack_name>.len` in a pack-fn mono's
        // body resolves to the comptime-known N. The mono doesn't
        // materialise the `[]Any` slice that the inline path used, so
        // `args` isn't in scope as a value.
        if (self.pack_param_count) |ppc| {
            if (fa.object.data == .identifier and std.mem.eql(u8, fa.field, "len")) {
                if (ppc.get(fa.object.data.identifier.name)) |n| {
                    return self.builder.constInt(@as(i64, @intCast(n)), .s64);
                }
            }
        }

        // Pack value projection: `xs.<m>` where `<m>` is a (zero-arg) method of
        // the pack's constraint protocol projects it over every element →
        // a tuple `(xs[0].<m>(), …, xs[N-1].<m>())`. (`xs.len` handled above.)
        if (self.pack_constraint) |pcon| {
            if (fa.object.data == .identifier) {
                if (pcon.get(fa.object.data.identifier.name)) |proto| {
                    if (self.lookupProtocolField(proto, fa.field) != null) {
                        return self.lowerPackValueProjection(fa.object.data.identifier.name, fa.field, span);
                    }
                }
            }
        }

        // Interface-only enforcement (Decision): a member access on a
        // constrained pack element `xs[i].<m>` may only name a method of the
        // constraint protocol — not an arbitrary concrete field. Checked here,
        // on the `xs[i]` (index_expr) base, BEFORE substitution erases the
        // "constrained to P" context. Protocol method CALLS go through the call
        // path; a method name passes this check (it's in the protocol).
        if (self.pack_constraint) |pcon| {
            if (fa.object.data == .index_expr and fa.object.data.index_expr.object.data == .identifier) {
                const base_name = fa.object.data.index_expr.object.data.identifier.name;
                if (pcon.get(base_name)) |proto| {
                    if (self.lookupProtocolField(proto, fa.field) == null) {
                        if (self.diagnostics) |diags| {
                            diags.addFmt(.err, span, "'{s}' is not part of protocol '{s}' — a pack element exposes only the protocol's interface", .{ fa.field, proto });
                        }
                        return self.builder.constInt(0, .void);
                    }
                }
            }
        }

        // Check for struct constant access: Struct.CONST
        if (fa.object.data == .identifier) {
            const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fa.object.data.identifier.name, fa.field }) catch fa.field;
            if (self.struct_const_map.get(qualified)) |info| {
                return self.lowerStructConstant(info);
            }
        }

        // M1.3 — `obj.class` on any Obj-C-class pointer lowers to
        // `object_getClass(obj)`. Sugar; the receiver is opaque so
        // we don't auto-deref. Returns `Class` (alias for *void;
        // typed Class(T) parameterization is M1.1.b).
        if (std.mem.eql(u8, fa.field, "class")) {
            const expr_ty = self.inferExprType(fa.object);
            if (self.isObjcClassPointer(expr_ty)) {
                const obj_ref = self.lowerExpr(fa.object);
                const ptr_void = self.module.types.ptrTo(.void);
                const get_class_fid = self.ensureCRuntimeDecl("object_getClass", &.{ptr_void}, ptr_void);
                const args = self.alloc.alloc(Ref, 1) catch unreachable;
                args[0] = obj_ref;
                return self.builder.emit(.{ .call = .{ .callee = get_class_fid, .args = args } }, ptr_void);
            }
        }

        // M2.2 — `obj.field` where `field` is declared with `#property`
        // on a foreign Obj-C class lowers as `[obj field]` (the synthesized
        // getter). Receiver stays opaque — no auto-deref.
        if (self.lookupObjcPropertyOnPointer(fa.object, fa.field)) |prop| {
            return self.lowerObjcPropertyGetter(fa.object, prop, fa.field, span);
        }

        // M1.2 A.3 — `self.field` (or `obj.field`) on a *sx-defined-class
        // pointer for a plain instance field (NOT a #property) lowers as
        // `object_getIvar(obj, load(__<Cls>_state_ivar))` + struct_gep on
        // the state struct + load. The receiver is the opaque Obj-C id
        // (matching Apple's `self` semantics); the state lives in the
        // hidden `__sx_state` ivar.
        if (self.lookupObjcDefinedStateFieldOnPointer(fa.object, fa.field)) |info| {
            return self.lowerObjcDefinedStateFieldRead(fa.object, info);
        }

        var obj = self.lowerExpr(fa.object);
        var obj_ty = self.inferExprType(fa.object);

        // Auto-deref: if the object is a pointer to a struct, load through it
        if (!obj_ty.isBuiltin()) {
            const ptr_info = self.module.types.get(obj_ty);
            if (ptr_info == .pointer) {
                const pointee = ptr_info.pointer.pointee;
                obj = self.builder.load(obj, pointee);
                obj_ty = pointee;
            }
        }

        // Special fields on slices/strings (NOT structs with .len/.ptr fields)
        if (std.mem.eql(u8, fa.field, "len") or std.mem.eql(u8, fa.field, "ptr")) {
            // Only use length/data_ptr for slice, string, array, vector types
            const is_special = obj_ty == .string or (if (!obj_ty.isBuiltin()) blk: {
                const info = self.module.types.get(obj_ty);
                break :blk info == .slice or info == .array or info == .vector;
            } else false);

            if (is_special) {
                if (std.mem.eql(u8, fa.field, "len")) {
                    return self.builder.emit(.{ .length = .{ .operand = obj } }, .s64);
                }
                {
                    const elem_ty = self.getElementType(obj_ty);
                    const mp_ty = self.module.types.manyPtrTo(elem_ty);
                    return self.builder.emit(.{ .data_ptr = .{ .operand = obj } }, mp_ty);
                }
            }
        }

        // Optional chaining: p?.field
        if (fa.is_optional) {
            return self.lowerOptionalChain(obj, fa, span);
        }

        return self.lowerFieldAccessOnType(obj, obj_ty, fa.field, span);
    }

    /// Lower each pack element to a Ref: `pack_name[i]` when `method` is null,
    /// or `pack_name[i].method()` when given. Synthesizes the index/field/call
    /// AST per element and lowers it (substitution turns `xs[i]` into the
    /// concrete arg; UFCS dispatches the method). Caller owns the returned slice.
    fn lowerPackElems(self: *Lowering, pack_name: []const u8, method: ?[]const u8, span: ast.Span) []Ref {
        const n: u32 = if (self.pack_param_count) |ppc| (ppc.get(pack_name) orelse 0) else 0;
        var refs = std.ArrayList(Ref).empty;
        var i: u32 = 0;
        while (i < n) : (i += 1) {
            const id_node = self.alloc.create(Node) catch break;
            id_node.* = .{ .span = span, .data = .{ .identifier = .{ .name = pack_name } } };
            const idx_node = self.alloc.create(Node) catch break;
            idx_node.* = .{ .span = span, .data = .{ .int_literal = .{ .value = @intCast(i) } } };
            const index_node = self.alloc.create(Node) catch break;
            index_node.* = .{ .span = span, .data = .{ .index_expr = .{ .object = id_node, .index = idx_node } } };
            var elem_node = index_node;
            if (method) |m| {
                const fa_node = self.alloc.create(Node) catch break;
                fa_node.* = .{ .span = span, .data = .{ .field_access = .{ .object = index_node, .field = m } } };
                const call_node = self.alloc.create(Node) catch break;
                call_node.* = .{ .span = span, .data = .{ .call = .{ .callee = fa_node, .args = &.{} } } };
                elem_node = call_node;
            }
            refs.append(self.alloc, self.lowerExpr(elem_node)) catch break;
        }
        return refs.toOwnedSlice(self.alloc) catch &.{};
    }

    /// Value-position pack projection `xs.<method>`: call the (zero-arg)
    /// protocol method on each element and collect the results into a tuple
    /// `(xs[0].<method>(), …, xs[N-1].<method>())`. N=0 yields the empty tuple.
    fn lowerPackValueProjection(self: *Lowering, pack_name: []const u8, method: []const u8, span: ast.Span) Ref {
        const refs = self.lowerPackElems(pack_name, method, span);
        defer self.alloc.free(refs);
        var tys = std.ArrayList(TypeId).empty;
        defer tys.deinit(self.alloc);
        for (refs) |r| tys.append(self.alloc, self.builder.getRefType(r)) catch {};
        const tuple_ty = self.module.types.intern(.{ .tuple = .{
            .fields = self.alloc.dupe(TypeId, tys.items) catch return self.builder.constInt(0, .void),
            .names = null,
        } });
        const owned = self.alloc.dupe(Ref, refs) catch return self.builder.constInt(0, .void);
        return self.builder.emit(.{ .tuple_init = .{ .fields = owned } }, tuple_ty);
    }

    /// If `operand` is a pack spread — `..xs` (bare pack) or `..xs.method`
    /// (per-element projection) — return the per-element Refs to splice into a
    /// call's positional args. Null when it's not a pack spread (e.g. a runtime
    /// slice `..arr`, handled by the slice-variadic path). Caller owns the slice.
    fn packSpreadRefs(self: *Lowering, operand: *const Node, span: ast.Span) ?[]Ref {
        const ppc = self.pack_param_count orelse return null;
        switch (operand.data) {
            .identifier => |id| {
                if (ppc.contains(id.name)) return self.lowerPackElems(id.name, null, span);
            },
            .field_access => |fa| {
                if (fa.object.data == .identifier and ppc.contains(fa.object.data.identifier.name)) {
                    return self.lowerPackElems(fa.object.data.identifier.name, fa.field, span);
                }
            },
            else => {},
        }
        return null;
    }

    /// Lower a struct-level constant value (e.g., Phys.GRAVITY).
    fn lowerStructConstant(self: *Lowering, info: StructConstInfo) Ref {
        const val_node = info.value;
        return switch (val_node.data) {
            .int_literal => |lit| self.builder.constInt(lit.value, info.ty orelse .s64),
            .float_literal => |lit| self.builder.constFloat(lit.value, info.ty orelse .f64),
            .bool_literal => |lit| self.builder.constBool(lit.value),
            .string_literal => |lit| self.builder.constString(self.module.types.internString(lit.raw)),
            else => self.lowerExpr(val_node),
        };
    }

    /// Lower optional chaining: `p?.field` where p is ?T
    /// Produces ?FieldType: some(unwrap(p).field) if p has value, else null
    /// If FieldType is already optional (?U), flattens to ?U (no double wrapping)
    fn lowerOptionalChain(self: *Lowering, obj: Ref, fa: *const ast.FieldAccess, span: ast.Span) Ref {
        const obj_ty = self.inferExprType(fa.object);
        // Get the inner (non-optional) type
        const inner_ty = if (!obj_ty.isBuiltin()) blk: {
            const info = self.module.types.get(obj_ty);
            break :blk if (info == .optional) info.optional.child else obj_ty;
        } else obj_ty;

        // Get the field type on the inner type
        const field_ty = self.resolveFieldType(inner_ty, fa.field);
        // If field is already optional, flatten (don't double-wrap)
        const field_already_optional = if (!field_ty.isBuiltin()) self.module.types.get(field_ty) == .optional else false;
        const result_ty = if (field_already_optional) field_ty else self.module.types.optionalOf(field_ty);

        // Check if optional has value
        const has_val = self.builder.emit(.{ .optional_has_value = .{ .operand = obj } }, .bool);

        // Create blocks
        const some_bb = self.freshBlock("chain.some");
        const none_bb = self.freshBlock("chain.none");
        const merge_bb = self.freshBlockWithParams("chain.merge", &.{result_ty});

        self.builder.condBr(has_val, some_bb, &.{}, none_bb, &.{});

        // Some: unwrap, access field (already ?FieldType if flattened, else wrap)
        self.builder.switchToBlock(some_bb);
        const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = obj } }, inner_ty);
        const field_val = self.lowerFieldAccessOnType(unwrapped, inner_ty, fa.field, span);
        const some_result = if (field_already_optional) field_val else self.builder.emit(.{ .optional_wrap = .{ .operand = field_val } }, result_ty);
        self.builder.br(merge_bb, &.{some_result});

        // None: produce null optional
        self.builder.switchToBlock(none_bb);
        const none_result = self.builder.constNull(result_ty);
        self.builder.br(merge_bb, &.{none_result});

        // Merge
        self.builder.switchToBlock(merge_bb);
        return self.builder.blockParam(merge_bb, 0, result_ty);
    }

    /// Field access on a known type (shared by regular field access and optional chaining)
    fn lowerFieldAccessOnType(self: *Lowering, obj: Ref, obj_ty: TypeId, field: []const u8, span: ast.Span) Ref {
        const field_name_id = self.module.types.internString(field);

        // Check if it's a union type
        if (!obj_ty.isBuiltin()) {
            const info = self.module.types.get(obj_ty);
            switch (info) {
                .tagged_union => |u| {
                    // .tag → extract the enum tag value with the correct tag type
                    if (std.mem.eql(u8, field, "tag")) {
                        return self.builder.emit(.{ .enum_tag = .{ .operand = obj } }, u.tag_type);
                    }
                    // Tagged union — use enum_payload
                    for (u.fields, 0..) |f, i| {
                        if (f.name == field_name_id) {
                            return self.builder.emit(.{ .enum_payload = .{ .base = obj, .field_index = @intCast(i) } }, f.ty);
                        }
                    }
                    // Check promoted fields from anonymous struct variants
                    for (u.fields) |f| {
                        if (!f.ty.isBuiltin()) {
                            const field_info = self.module.types.get(f.ty);
                            if (field_info == .@"struct") {
                                for (field_info.@"struct".fields, 0..) |sf, si| {
                                    if (sf.name == field_name_id) {
                                        const reinterpreted = self.builder.emit(.{ .union_get = .{ .base = obj, .field_index = 0 } }, f.ty);
                                        return self.builder.structGet(reinterpreted, @intCast(si), sf.ty);
                                    }
                                }
                            }
                        }
                    }
                },
                .@"union" => |u| {
                    // Untagged union — use union_get to reinterpret bytes
                    for (u.fields, 0..) |f, i| {
                        if (f.name == field_name_id) {
                            return self.builder.emit(.{ .union_get = .{ .base = obj, .field_index = @intCast(i) } }, f.ty);
                        }
                    }
                    // Check promoted fields from anonymous struct variants
                    for (u.fields) |f| {
                        if (!f.ty.isBuiltin()) {
                            const field_info = self.module.types.get(f.ty);
                            if (field_info == .@"struct") {
                                for (field_info.@"struct".fields, 0..) |sf, si| {
                                    if (sf.name == field_name_id) {
                                        const reinterpreted = self.builder.emit(.{ .union_get = .{ .base = obj, .field_index = 0 } }, f.ty);
                                        return self.builder.structGet(reinterpreted, @intCast(si), sf.ty);
                                    }
                                }
                            }
                        }
                    }
                },
                else => {},
            }
        }

        // Vector field access: .x/.y/.z/.w → index 0/1/2/3
        if (!obj_ty.isBuiltin()) {
            const vinfo = self.module.types.get(obj_ty);
            if (vinfo == .vector) {
                const vidx: u32 = if (std.mem.eql(u8, field, "x") or std.mem.eql(u8, field, "r")) 0 else if (std.mem.eql(u8, field, "y") or std.mem.eql(u8, field, "g")) 1 else if (std.mem.eql(u8, field, "z") or std.mem.eql(u8, field, "b")) 2 else if (std.mem.eql(u8, field, "w") or std.mem.eql(u8, field, "a")) 3 else 0;
                return self.builder.structGet(obj, vidx, vinfo.vector.element);
            }
        }

        // Closure field access: .fn_ptr → field 0, .env → field 1
        if (!obj_ty.isBuiltin()) {
            const cinfo = self.module.types.get(obj_ty);
            if (cinfo == .closure) {
                if (std.mem.eql(u8, field, "fn_ptr")) {
                    const fn_ptr_ty = self.module.types.ptrTo(.void);
                    return self.builder.structGet(obj, 0, fn_ptr_ty);
                } else if (std.mem.eql(u8, field, "env")) {
                    const env_ty = self.module.types.ptrTo(.void);
                    return self.builder.structGet(obj, 1, env_ty);
                }
            }
        }

        // Tuple field access: .0, .1, etc. or named fields
        if (!obj_ty.isBuiltin()) {
            const tinfo = self.module.types.get(obj_ty);
            if (tinfo == .tuple) {
                const tuple = tinfo.tuple;
                // Try named fields first
                if (tuple.names) |names| {
                    for (names, 0..) |name_id, i| {
                        if (name_id == field_name_id) {
                            return self.builder.structGet(obj, @intCast(i), tuple.fields[i]);
                        }
                    }
                }
                // Try numeric index (e.g., "0", "1")
                const idx = std.fmt.parseInt(u32, field, 10) catch {
                    return self.emitFieldError(obj_ty, field, span);
                };
                if (idx < tuple.fields.len) {
                    return self.builder.structGet(obj, idx, tuple.fields[idx]);
                }
                return self.emitFieldError(obj_ty, field, span);
            }
        }

        // Resolve struct field index and type
        const struct_fields = self.getStructFields(obj_ty);
        for (struct_fields, 0..) |f, i| {
            if (f.name == field_name_id) {
                return self.builder.structGet(obj, @intCast(i), f.ty);
            }
        }

        return self.emitFieldError(obj_ty, field, span);
    }

    fn lowerEnumLiteral(self: *Lowering, el: *const ast.EnumLiteral) Ref {
        const target = self.target_type orelse .unresolved;
        const tag = self.resolveVariantValue(target, el.name);
        return self.builder.enumInit(tag, Ref.none, target);
    }

    /// Lower an `error.X` tag literal to its global tag id (a `u32`). When the
    /// destination context (`target_type`) is a named error set, the value is
    /// typed as that set and `X`'s membership is validated; otherwise the value
    /// is the raw `u32` global tag id (per the spec's context rule).
    fn lowerErrorTagLiteral(self: *Lowering, tag_name: []const u8, span: ast.Span) Ref {
        const tag_id = self.module.types.internTag(tag_name);
        if (self.target_type) |t| {
            if (!t.isBuiltin()) {
                const info = self.module.types.get(t);
                if (info == .error_set) {
                    // The bare-`!` inferred placeholder (reserved name "!") accepts
                    // any tag — its members aren't known until the whole-program SCC
                    // pass (E1.4) folds in every raised tag. Skip membership for it.
                    if (!std.mem.eql(u8, self.module.types.getString(info.error_set.name), "!")) {
                        var in_set = false;
                        for (info.error_set.tags) |member| {
                            if (member == tag_id) {
                                in_set = true;
                                break;
                            }
                        }
                        if (!in_set) {
                            if (self.diagnostics) |diags| {
                                diags.addFmt(.err, span, "error tag 'error.{s}' is not in error set '{s}'", .{ tag_name, self.module.types.getString(info.error_set.name) });
                            }
                        }
                    }
                    return self.builder.constInt(@as(i64, @intCast(tag_id)), t);
                }
            }
        }
        return self.builder.constInt(@as(i64, @intCast(tag_id)), .u32);
    }

    /// Lower a tagged enum construction: .Variant.{ field_inits }
    /// The struct literal provides the payload fields; we wrap them in an enum_init.
    fn lowerTaggedEnumLiteral(
        self: *Lowering,
        sl: *const ast.StructLiteral,
        variant_name: []const u8,
        union_ty: TypeId,
        union_info: types.TypeInfo.TaggedUnionInfo,
        span: ast.Span,
    ) Ref {
        if (self.findTaggedVariant(union_info, variant_name) == null) {
            self.emitBadVariant(union_ty, union_info, variant_name, span);
            return self.builder.enumInit(0, Ref.none, union_ty);
        }

        const tag = self.resolveVariantValue(union_ty, variant_name);
        const name_id = self.module.types.internString(variant_name);

        // Find the payload type for this variant
        var payload_ty: TypeId = .void;
        for (union_info.fields) |f| {
            if (f.name == name_id) {
                payload_ty = f.ty;
                break;
            }
        }

        if (payload_ty == .void or sl.field_inits.len == 0) {
            // No payload or no fields — just tag
            return self.builder.enumInit(tag, Ref.none, union_ty);
        }

        // Lower the payload as a struct init of the payload type
        const saved_tt = self.target_type;
        self.target_type = payload_ty;
        const payload_fields = self.getStructFields(payload_ty);

        var fields = std.ArrayList(Ref).empty;
        defer fields.deinit(self.alloc);

        for (sl.field_inits, 0..) |fi, i| {
            if (i < payload_fields.len) {
                const saved_inner = self.target_type;
                self.target_type = payload_fields[i].ty;
                var val = self.lowerExpr(fi.value);
                self.target_type = saved_inner;
                const src_ty = self.inferExprType(fi.value);
                val = self.coerceToType(val, src_ty, payload_fields[i].ty);
                fields.append(self.alloc, val) catch unreachable;
            } else {
                fields.append(self.alloc, self.lowerExpr(fi.value)) catch unreachable;
            }
        }

        // Pad missing payload fields with zeroes
        if (fields.items.len < payload_fields.len) {
            for (payload_fields[fields.items.len..]) |sf| {
                fields.append(self.alloc, self.zeroValue(sf.ty)) catch unreachable;
            }
        }

        const payload = self.builder.structInit(fields.items, payload_ty);
        self.target_type = saved_tt;

        return self.builder.enumInit(tag, payload, union_ty);
    }

    fn findTaggedVariant(
        self: *Lowering,
        union_info: types.TypeInfo.TaggedUnionInfo,
        variant_name: []const u8,
    ) ?usize {
        const name_id = self.module.types.internString(variant_name);
        for (union_info.fields, 0..) |f, i| {
            if (f.name == name_id) return i;
        }
        return null;
    }

    fn emitBadVariant(
        self: *Lowering,
        union_ty: TypeId,
        union_info: types.TypeInfo.TaggedUnionInfo,
        variant_name: []const u8,
        span: ast.Span,
    ) void {
        const diags = self.diagnostics orelse return;
        const ty_name = self.formatTypeName(union_ty);
        var list: std.ArrayList(u8) = .empty;
        for (union_info.fields, 0..) |f, i| {
            if (i > 0) list.appendSlice(self.alloc, ", ") catch return;
            list.appendSlice(self.alloc, self.module.types.getString(f.name)) catch return;
        }
        diags.addFmt(
            .err,
            span,
            "'{s}' is not a variant of '{s}' (variants are: {s})",
            .{ variant_name, ty_name, list.items },
        );
    }

    /// Resolve a variant name to its runtime value (flags: power-of-2, regular: index).
    fn resolveVariantValue(self: *Lowering, ty: TypeId, variant_name: []const u8) u32 {
        if (ty.isBuiltin()) return 0;
        const info = self.module.types.get(ty);
        const name_id = self.module.types.internString(variant_name);
        switch (info) {
            .@"enum" => |e| {
                for (e.variants, 0..) |v, i| {
                    if (v == name_id) {
                        if (e.explicit_values) |vals| {
                            if (i < vals.len) return @intCast(@as(u64, @bitCast(vals[i])));
                        }
                        return @intCast(i);
                    }
                }
            },
            .tagged_union => |u| {
                for (u.fields, 0..) |f, i| {
                    if (f.name == name_id) {
                        if (u.explicit_tag_values) |vals| {
                            if (i < vals.len) return @intCast(@as(u64, @bitCast(vals[i])));
                        }
                        return @intCast(i);
                    }
                }
            },
            else => {},
        }
        return 0;
    }

    /// Resolve a variant name to its tag index within an enum or union type.
    pub fn resolveVariantIndex(self: *Lowering, ty: TypeId, variant_name: []const u8) u32 {
        if (ty.isBuiltin()) return 0;
        const info = self.module.types.get(ty);
        const name_id = self.module.types.internString(variant_name);
        switch (info) {
            .tagged_union => |u| {
                for (u.fields, 0..) |f, i| {
                    if (f.name == name_id) return @intCast(i);
                }
            },
            .@"enum" => |e| {
                for (e.variants, 0..) |v, i| {
                    if (v == name_id) return @intCast(i);
                }
            },
            else => {},
        }
        return 0;
    }

    fn lowerArrayLiteral(self: *Lowering, al: *const ast.ArrayLiteral) Ref {
        var elems = std.ArrayList(Ref).empty;
        defer elems.deinit(self.alloc);

        // Determine element type: explicit type_expr > target_type > inference
        var elem_ty: TypeId = .unresolved;
        var from_target = false;
        var is_vector = false;

        // First, check explicit type annotation on the literal (e.g. Vector(3,f32).[1,2,3])
        if (al.type_expr) |te| {
            const resolved = self.resolveArrayLiteralType(te);
            if (resolved != .unresolved) {
                if (!resolved.isBuiltin()) {
                    const info = self.module.types.get(resolved);
                    switch (info) {
                        .array => |a| {
                            elem_ty = a.element;
                            from_target = true;
                        },
                        .vector => |v| {
                            elem_ty = v.element;
                            from_target = true;
                            is_vector = true;
                        },
                        .slice => |s| {
                            elem_ty = s.element;
                            from_target = true;
                        },
                        else => {},
                    }
                }
            }
        }

        if (!from_target) {
            if (self.target_type) |tt| {
                if (!tt.isBuiltin()) {
                    const info = self.module.types.get(tt);
                    switch (info) {
                        .array => |a| {
                            elem_ty = a.element;
                            from_target = true;
                        },
                        .slice => |s| {
                            elem_ty = s.element;
                            from_target = true;
                        },
                        .vector => |v| {
                            elem_ty = v.element;
                            from_target = true;
                            is_vector = true;
                        },
                        else => {},
                    }
                }
            }
        }
        if (!from_target and al.elements.len > 0) {
            const inferred = self.inferExprType(al.elements[0]);
            if (inferred != .void) elem_ty = inferred;
        }

        for (al.elements) |elem| {
            const old_tt = self.target_type;
            self.target_type = elem_ty;
            const val = self.lowerExpr(elem);
            self.target_type = old_tt;
            elems.append(self.alloc, val) catch unreachable;
        }

        const result_ty = if (is_vector)
            self.module.types.vectorOf(elem_ty, @intCast(al.elements.len))
        else
            self.module.types.arrayOf(elem_ty, @intCast(al.elements.len));
        return self.builder.structInit(elems.items, result_ty);
    }

    /// Resolve the type annotation on an array literal (e.g. Vector(3,f32).[...]).
    /// Handles call nodes (Vector(3,f32)), parameterized_type_expr, and identifier/type_expr.
    fn resolveArrayLiteralType(self: *Lowering, te: *const Node) TypeId {
        switch (te.data) {
            .call => |cl| {
                // Vector(3, f32) or Module.Vector(3, f32)
                const callee_name = switch (cl.callee.data) {
                    .identifier => |id| id.name,
                    .field_access => |fa| fa.field,
                    else => return .unresolved,
                };
                if (std.mem.eql(u8, callee_name, "Vector")) {
                    if (cl.args.len == 2) {
                        const length: u32 = switch (cl.args[0].data) {
                            .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))),
                            else => 0,
                        };
                        const elem = self.resolveTypeWithBindings(cl.args[1]);
                        if (length > 0) return self.module.types.vectorOf(elem, length);
                    }
                }
                // Try as generic struct
                if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| {
                    return self.instantiateGenericStruct(tmpl, cl.args);
                }
                return .unresolved;
            },
            .parameterized_type_expr => |pt| return self.resolveParameterizedWithBindings(&pt),
            .identifier => |id| {
                const name_id = self.module.types.internString(id.name);
                return self.module.types.findByName(name_id) orelse .unresolved;
            },
            .type_expr => return type_bridge.resolveAstType(te, &self.module.types, &self.program_index.type_alias_map),
            .field_access => |fa| {
                // Module.Type — try to resolve the field as a type name
                const name_id = self.module.types.internString(fa.field);
                return self.module.types.findByName(name_id) orelse .unresolved;
            },
            else => return .unresolved,
        }
    }

    fn lowerIndexExpr(self: *Lowering, ie: *const ast.IndexExpr) Ref {
        // Pack-arg substitution: `args[<int_literal>]` inside a body
        // whose enclosing comptime call bound `args` as a pack name.
        // Lowering the i-th call-site arg directly gives the concrete
        // call-arg type — bypasses the `[]Any` slice boxing that would
        // otherwise lose the type. Non-literal indices fall through to
        // the standard slice indexing path.
        if (self.packArgNodeAt(ie)) |arg_node| {
            return self.lowerExpr(arg_node);
        }
        // Out-of-bounds pack indexing: object IS a pack name + index
        // IS a comptime int literal but exceeds the pack arity. Emit
        // a focused diagnostic so the user gets "pack index 2 out of
        // bounds" instead of the generic "unresolved 'args'" that the
        // fall-through scope-lookup would produce.
        if (self.diagPackIndexOOB(ie)) {
            return self.builder.constInt(0, .s64);
        }
        // Runtime index into a comptime-only pack (Decision 1): a pack has no
        // runtime representation, so the index must be a compile-time constant.
        // A runtime index is a hard error — clearer than the "unresolved
        // '<pack>'" the slice-index fall-through would otherwise produce.
        if (self.pack_param_count) |ppc| {
            if (ie.object.data == .identifier) {
                const pname = ie.object.data.identifier.name;
                if (ppc.contains(pname) and self.comptimeIndexOf(ie.index) == null) {
                    if (self.diagnostics) |diags| {
                        diags.addFmt(.err, ie.index.span, "pack '{s}' must be indexed by a compile-time constant — a pack is comptime-only and has no runtime value", .{pname});
                    }
                    return self.builder.constInt(0, .s64);
                }
            }
        }
        const obj = self.lowerExpr(ie.object);
        const idx = self.lowerExpr(ie.index);
        // Infer element type from the object's slice/array type
        const obj_ty = self.inferExprType(ie.object);
        const elem_ty = self.getElementType(obj_ty);
        return self.builder.emit(.{ .index_get = .{ .lhs = obj, .rhs = idx } }, elem_ty);
    }

    /// Detect `<pack_name>[<int_literal>]` where the literal exceeds
    /// the pack arity (or is negative). Emits a diagnostic and
    /// returns true; caller skips the standard indexing path and
    /// returns a placeholder Ref. Returns false for non-pack bases,
    /// non-literal indices, or in-range indices.
    fn diagPackIndexOOB(self: *Lowering, ie: *const ast.IndexExpr) bool {
        const ppc = self.pack_param_count orelse return false;
        if (ie.object.data != .identifier) return false;
        const pack_name = ie.object.data.identifier.name;
        const n = ppc.get(pack_name) orelse return false;
        // Any comptime index (int literal or a comptime-constant cursor) that's
        // out of range — runtime indices are handled by the caller's
        // must-be-comptime check.
        const raw: i64 = self.comptimeIndexOf(ie.index) orelse return false;
        if (raw >= 0 and @as(u32, @intCast(raw)) < n) return false;
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, ie.index.span, "pack index {} out of bounds: '{s}' has {} element{s}", .{
                raw, pack_name, n, if (n == 1) @as([]const u8, "") else @as([]const u8, "s"),
            });
        }
        return true;
    }

    /// Returns the call-site arg AST node when `ie` matches
    /// `<pack_name>[<comptime_int_literal>]` with the pack name bound
    /// in the active `pack_arg_nodes` map and the index in range.
    /// Otherwise null — caller falls back to standard slice indexing.
    pub fn packArgNodeAt(self: *Lowering, ie: *const ast.IndexExpr) ?*const Node {
        const pan = self.pack_arg_nodes orelse return null;
        if (ie.object.data != .identifier) return null;
        const arg_nodes = pan.get(ie.object.data.identifier.name) orelse return null;
        const raw: i64 = self.comptimeIndexOf(ie.index) orelse return null;
        if (raw < 0) return null;
        const i: usize = @intCast(raw);
        if (i >= arg_nodes.len) return null;
        return arg_nodes[i];
    }

    /// Resolve an index expression to a comptime-known integer: a literal,
    /// or an identifier bound to an `int_val` in `comptime_constants` (e.g.
    /// the cursor of an `inline for 0..N (i)` unroll). Otherwise null.
    pub fn comptimeIndexOf(self: *Lowering, index: *const Node) ?i64 {
        switch (index.data) {
            .int_literal => |lit| return lit.value,
            .identifier => |id| {
                if (self.comptime_constants.get(id.name)) |cv| {
                    switch (cv) {
                        .int_val => |iv| return iv,
                        else => return null,
                    }
                }
                return null;
            },
            else => return null,
        }
    }

    const PackValueKind = enum { storage, call_arg, return_value, runtime_iter, generic };

    /// `xs` is a pack name used where a runtime value is required. A pack is
    /// comptime-only (Decision 1), so this is an error — with a context-tailored
    /// suggestion for how to express the intent instead.
    fn diagPackAsValue(self: *Lowering, name: []const u8, span: ast.Span, kind: PackValueKind) Ref {
        if (self.diagnostics) |d| {
            const id = d.addFmtId(.err, span, "pack '{s}' has no runtime value — a pack is comptime-only and can't be used as a value here", .{name});
            switch (kind) {
                .storage => d.addHelpFmt(id, span, null, "to store it, materialize a tuple: `(..{s})`", .{name}),
                .call_arg => d.addHelpFmt(id, span, null, "to pass it to a `[]Any`/`[]P` parameter, materialize it with `xx {s}`", .{name}),
                .return_value => d.addHelpFmt(id, span, null, "to return it, return a tuple `(..{s})` and make the return type that tuple", .{name}),
                .runtime_iter => d.addHelpFmt(id, span, null, "to iterate at comptime use `inline for 0..{s}.len (i)`; for a runtime loop declare it as `..{s}: []P` (a protocol slice) instead of a pack", .{ name, name }),
                .generic => d.addHelpFmt(id, span, null, "materialize a tuple `(..{s})` to store it, or `xx {s}` to convert it to an expected `[]Any`/`[]P` slice", .{ name, name }),
            }
        }
        return self.emitPlaceholder(name);
    }

    /// True when `name` is a pack parameter bound in the current mono body.
    fn isPackName(self: *Lowering, name: []const u8) bool {
        const ppc = self.pack_param_count orelse return false;
        return ppc.contains(name);
    }

    /// `xx <pack>` with a slice target: materialize the comptime pack into a
    /// runtime `[]elem` by lowering each element node and boxing (`[]Any`) or
    /// `xx`-erasing (`[]P`) it into a stack `[N]elem`, then return the slice.
    /// This is the explicit pack→slice bridge (issue 0053).
    fn lowerPackToSlice(self: *Lowering, pack_name: []const u8, slice_ty: TypeId) Ref {
        const arg_nodes = (self.pack_arg_nodes orelse return self.builder.constInt(0, .unresolved)).get(pack_name) orelse
            return self.builder.constInt(0, .unresolved);
        const elem_ty = self.module.types.get(slice_ty).slice.element;
        const is_any = elem_ty == .any;
        const elem_is_protocol = blk: {
            if (elem_ty.isBuiltin()) break :blk false;
            const ei = self.module.types.get(elem_ty);
            break :blk ei == .@"struct" and ei.@"struct".is_protocol;
        };
        const slice_slot = self.builder.alloca(slice_ty);
        const ptr_gep = self.builder.structGepTyped(slice_slot, 0, self.module.types.ptrTo(elem_ty), slice_ty);
        const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, slice_ty);
        if (arg_nodes.len == 0) {
            self.builder.store(ptr_gep, self.builder.constNull(self.module.types.ptrTo(elem_ty)));
            self.builder.store(len_gep, self.builder.constInt(0, .s64));
            return self.builder.load(slice_slot, slice_ty);
        }
        const array_ty = self.module.types.arrayOf(elem_ty, @intCast(arg_nodes.len));
        const array_slot = self.builder.alloca(array_ty);
        for (arg_nodes, 0..) |arg, i| {
            var val = self.lowerExpr(arg);
            var source_ty = self.inferExprType(arg);
            if (source_ty == .unresolved) source_ty = self.builder.getRefType(val);
            if (is_any) {
                if (source_ty != .any) val = self.builder.boxAny(val, source_ty);
            } else if (elem_is_protocol) {
                if (source_ty != elem_ty) val = self.buildProtocolErasure(val, arg, source_ty, elem_ty);
            }
            const ep = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = self.builder.constInt(@intCast(i), .s64) } }, self.module.types.ptrTo(elem_ty));
            self.builder.store(ep, val);
        }
        const data_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = self.builder.constInt(0, .s64) } }, self.module.types.ptrTo(elem_ty));
        self.builder.store(ptr_gep, data_ptr);
        self.builder.store(len_gep, self.builder.constInt(@intCast(arg_nodes.len), .s64));
        return self.builder.load(slice_slot, slice_ty);
    }

    fn lowerSliceExpr(self: *Lowering, se: *const ast.SliceExpr) Ref {
        const obj = self.lowerExpr(se.object);
        const lo = if (se.start) |s| self.lowerExpr(s) else self.builder.constInt(0, .s64);
        const hi = if (se.end) |e| self.lowerExpr(e) else self.builder.emit(.{ .length = .{ .operand = obj } }, .s64);
        // Infer result slice type from the object
        const obj_ty = self.inferExprType(se.object);
        // Subslice of string stays string (same {ptr, i64} layout, correct type category)
        if (obj_ty == .string) {
            return self.builder.emit(.{ .subslice = .{ .base = obj, .lo = lo, .hi = hi } }, .string);
        }
        const elem_ty = self.getElementType(obj_ty);
        const slice_ty = if (elem_ty != .void) self.module.types.sliceOf(elem_ty) else self.module.types.sliceOf(.u8);
        return self.builder.emit(.{ .subslice = .{ .base = obj, .lo = lo, .hi = hi } }, slice_ty);
    }

    fn lowerTupleLiteral(self: *Lowering, tl: *const ast.TupleLiteral) Ref {
        var elems = std.ArrayList(Ref).empty;
        defer elems.deinit(self.alloc);
        var field_type_ids = std.ArrayList(TypeId).empty;
        defer field_type_ids.deinit(self.alloc);
        var name_ids = std.ArrayList(types.StringId).empty;
        defer name_ids.deinit(self.alloc);
        var has_names = false;

        // A tuple_init's element values must match its field types exactly
        // (LLVM `insertvalue` does no implicit conversion). When a contextual
        // target tuple of matching arity is in scope (annotation, assignment
        // LHS, call/return slot), its field types drive element lowering so an
        // ambient scalar `target_type` (e.g. the enclosing fn's int return
        // type) can't narrow an element below its field width. Otherwise each
        // element's type is inferred independently.
        // A pack-spread element `(..xs)` / `(..xs.method)` expands to N fields,
        // so element-count ≠ field-count and a contextual target tuple can't be
        // aligned by index — infer field types from the expanded refs instead.
        var has_spread = false;
        for (tl.elements) |elem| {
            if (elem.value.data == .spread_expr) has_spread = true;
        }

        // Contextual target tuple field types. Without a spread we require
        // exact arity (existing behavior); with a spread we index positionally
        // by output position (so `(..sources)` into a `(VL(T0), …)` field coerces
        // / erases each spliced element to its slot's type).
        var target_fields: ?[]const TypeId = null;
        if (self.target_type) |tt| {
            if (!tt.isBuiltin()) {
                const tinfo = self.module.types.get(tt);
                if (tinfo == .tuple and (has_spread or tinfo.tuple.fields.len == tl.elements.len)) {
                    target_fields = tinfo.tuple.fields;
                }
            }
        }

        const saved_target = self.target_type;
        var out_idx: usize = 0;
        for (tl.elements) |elem| {
            // Pack-spread element → splice its per-element values as fields.
            if (elem.value.data == .spread_expr) {
                const sp_operand = elem.value.data.spread_expr.operand;
                if (self.packSpreadRefs(sp_operand, elem.value.span)) |refs| {
                    defer self.alloc.free(refs);
                    // Element AST nodes (for protocol-erasure lvalue/name fallback)
                    // when the spread is a bare pack name.
                    const elem_nodes: ?[]const *const Node = if (sp_operand.data == .identifier and self.pack_arg_nodes != null)
                        self.pack_arg_nodes.?.get(sp_operand.data.identifier.name)
                    else
                        null;
                    for (refs, 0..) |r, ri| {
                        var val = r;
                        var vty = self.builder.getRefType(r);
                        if (target_fields) |tf| {
                            if (out_idx < tf.len and tf[out_idx] != vty and tf[out_idx] != .void) {
                                const want = tf[out_idx];
                                const node = if (elem_nodes) |ens| (if (ri < ens.len) ens[ri] else elem.value) else elem.value;
                                val = self.coerceOrErase(r, vty, want, node);
                                vty = want;
                            }
                        }
                        elems.append(self.alloc, val) catch unreachable;
                        field_type_ids.append(self.alloc, vty) catch unreachable;
                        name_ids.append(self.alloc, self.module.types.internString("")) catch unreachable;
                        out_idx += 1;
                    }
                    continue;
                }
                // Not a pack spread (e.g. tuple-value spread) — not yet handled.
                _ = self.lowerExpr(elem.value); // surfaces the spread_expr diagnostic
                continue;
            }
            const field_ty = if (target_fields) |tf| (if (out_idx < tf.len) tf[out_idx] else self.inferExprType(elem.value)) else self.inferExprType(elem.value);
            self.target_type = field_ty;
            var val = self.lowerExpr(elem.value);
            self.target_type = saved_target;
            const val_ty = self.builder.getRefType(val);
            if (val_ty != field_ty and val_ty != .void) {
                val = self.coerceToType(val, val_ty, field_ty);
            }
            elems.append(self.alloc, val) catch unreachable;
            field_type_ids.append(self.alloc, field_ty) catch unreachable;
            if (elem.name) |name| {
                name_ids.append(self.alloc, self.module.types.internString(name)) catch unreachable;
                has_names = true;
            } else {
                name_ids.append(self.alloc, self.module.types.internString("")) catch unreachable;
            }
            out_idx += 1;
        }

        // Reuse the contextual target tuple type when it drove lowering so the
        // value's type identity (incl. field names) matches the destination
        // slot; otherwise build the tuple type from the inferred fields.
        const tuple_ty = if (target_fields != null and self.target_type != null)
            self.target_type.?
        else
            self.module.types.intern(.{ .tuple = .{
                .fields = self.alloc.dupe(TypeId, field_type_ids.items) catch unreachable,
                .names = if (has_names) self.alloc.dupe(types.StringId, name_ids.items) catch unreachable else null,
            } });

        const owned = self.alloc.dupe(Ref, elems.items) catch unreachable;
        return self.builder.emit(.{ .tuple_init = .{ .fields = owned } }, tuple_ty);
    }

    fn lowerDerefExpr(self: *Lowering, de: *const ast.DerefExpr) Ref {
        const ptr = self.lowerExpr(de.operand);
        // Resolve pointee type from the pointer type.
        const ptr_ty = self.inferExprType(de.operand);
        if (!ptr_ty.isBuiltin()) {
            const info = self.module.types.get(ptr_ty);
            if (info == .pointer) {
                return self.builder.emit(.{ .deref = .{ .operand = ptr } }, info.pointer.pointee);
            }
        }
        // Operand isn't a pointer — `.*` is invalid. Diagnose here instead of
        // emitting a `.deref` with an `.unresolved` result type, which would
        // otherwise slip through to emit_llvm's "unresolved type reached LLVM
        // emission" panic with no source location.
        if (self.diagnostics) |d| {
            d.addFmt(.err, de.operand.span, "cannot dereference with `.*`: '{s}' is not a pointer", .{self.formatTypeName(ptr_ty)});
        }
        return ptr;
    }

    fn lowerForceUnwrap(self: *Lowering, fu: *const ast.ForceUnwrap) Ref {
        const val = self.lowerExpr(fu.operand);
        const inner_ty = self.resolveOptionalInner(self.inferExprType(fu.operand));
        return self.builder.optionalUnwrap(val, inner_ty);
    }

    fn lowerNullCoalesce(self: *Lowering, nc: *const ast.NullCoalesce) Ref {
        const lhs = self.lowerExpr(nc.lhs);
        const inner_ty = self.resolveOptionalInner(self.inferExprType(nc.lhs));

        // Short-circuit: only evaluate RHS if LHS is null.
        // IMPORTANT: optional_unwrap must be in the "has value" branch,
        // not before the condBr — the interpreter errors on unwrapping null.
        const has_val = self.builder.emit(.{ .optional_has_value = .{ .operand = lhs } }, .bool);

        const then_bb = self.freshBlock("nc.has");
        const rhs_bb = self.freshBlock("nc.rhs");
        const merge_bb = self.freshBlockWithParams("nc.merge", &.{inner_ty});

        // If has value, go to then_bb to unwrap; else go to rhs_bb
        self.builder.condBr(has_val, then_bb, &.{}, rhs_bb, &.{});

        // Then block: unwrap LHS and branch to merge
        self.builder.switchToBlock(then_bb);
        const unwrapped = self.builder.optionalUnwrap(lhs, inner_ty);
        self.builder.br(merge_bb, &.{unwrapped});

        // RHS block: evaluate fallback and branch to merge
        self.builder.switchToBlock(rhs_bb);
        var rhs = self.lowerExpr(nc.rhs);
        const rhs_ty = self.builder.getRefType(rhs);
        if (rhs_ty != inner_ty and rhs_ty != .void and inner_ty != .void) {
            rhs = self.coerceToType(rhs, rhs_ty, inner_ty);
        }
        self.builder.br(merge_bb, &.{rhs});

        // Continue at merge
        self.builder.switchToBlock(merge_bb);
        return self.builder.blockParam(merge_bb, 0, inner_ty);
    }

    fn resolveOptionalInner(self: *Lowering, ty: TypeId) TypeId {
        if (!ty.isBuiltin()) {
            const info = self.module.types.get(ty);
            if (info == .optional) return info.optional.child;
        }
        return .unresolved;
    }

    // ── FFI intrinsics (#objc_call / #jni_call / #jni_static_call) ─

    /// Intern an Obj-C selector string into a module-scoped `SEL*` slot.
    /// First call creates the global; subsequent calls return the same
    /// `GlobalId`. emit_llvm.zig walks `module.objc_selector_cache` and
    /// synthesizes a constructor that populates each slot via
    /// `sel_registerName` exactly once at module load.
    ///
    /// Slot name matches clang's convention: `OBJC_SELECTOR_REFERENCES_<sel>`
    /// with `:` replaced by `_` to keep the symbol name valid.
    fn internObjcSelector(self: *Lowering, sel_str: []const u8) inst_mod.GlobalId {
        if (self.module.lookupObjcSelector(sel_str)) |gid| return gid;

        // Mangle selector: replace colons with underscores. Apple's
        // toolchain does the same (foo:bar: → foo_bar_).
        var mangled = std.ArrayList(u8).empty;
        defer mangled.deinit(self.alloc);
        mangled.appendSlice(self.alloc, "OBJC_SELECTOR_REFERENCES_") catch unreachable;
        for (sel_str) |ch| {
            mangled.append(self.alloc, if (ch == ':') '_' else ch) catch unreachable;
        }
        const slot_name = self.module.types.internString(mangled.items);
        const vptr_ty = self.module.types.ptrTo(.void);
        const gid = self.module.addGlobal(.{
            .name = slot_name,
            .ty = vptr_ty,
            .init_val = .null_val,
            .is_extern = false,
            .is_const = false,
        });
        self.module.appendObjcSelector(sel_str, gid);
        return gid;
    }

    /// Intern an Obj-C class name into a module-scoped `Class*` slot.
    /// First call creates the global; subsequent calls return the same
    /// `GlobalId`. emit_llvm.zig walks `module.objc_class_cache` and
    /// synthesizes a constructor that populates each slot via
    /// `objc_getClass` exactly once at module load.
    ///
    /// Slot name matches clang's convention: `OBJC_CLASSLIST_REFERENCES_<Cls>`.
    fn internObjcClassObject(self: *Lowering, class_name: []const u8) inst_mod.GlobalId {
        if (self.module.lookupObjcClass(class_name)) |gid| return gid;

        var mangled = std.ArrayList(u8).empty;
        defer mangled.deinit(self.alloc);
        mangled.appendSlice(self.alloc, "OBJC_CLASSLIST_REFERENCES_") catch unreachable;
        mangled.appendSlice(self.alloc, class_name) catch unreachable;
        const slot_name = self.module.types.internString(mangled.items);
        const vptr_ty = self.module.types.ptrTo(.void);
        const gid = self.module.addGlobal(.{
            .name = slot_name,
            .ty = vptr_ty,
            .init_val = .null_val,
            .is_extern = false,
            .is_const = false,
        });
        self.module.appendObjcClass(class_name, gid);
        return gid;
    }

    /// Lazily declare `sel_registerName(name: *u8) -> *void` as an extern.
    /// Cached per Lowering instance so multiple `#objc_call` sites share
    /// one declaration.
    fn getSelRegisterNameFid(self: *Lowering) FuncId {
        if (self.sel_register_name_fid) |fid| return fid;
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        const name_str = self.module.types.internString("name");
        const ptr_ty = self.module.types.ptrTo(.u8);
        params.append(self.alloc, .{ .name = name_str, .ty = ptr_ty }) catch unreachable;
        const fn_name = self.module.types.internString("sel_registerName");
        const ret_ty = self.module.types.ptrTo(.void);
        const fid = self.builder.declareExtern(fn_name, params.toOwnedSlice(self.alloc) catch unreachable, ret_ty);
        const func = self.module.getFunctionMut(fid);
        func.call_conv = .c;
        self.sel_register_name_fid = fid;
        return fid;
    }

    /// Lower `#objc_call(T)(recv, "sel:", args...)` to:
    ///   %sel = call ptr @sel_registerName(<"sel:">)
    ///   %ret = call <ABI(T)> @objc_msgSend(recv, %sel, args...)
    /// For Phase 1.3 only the (void return, no extra args) form is
    /// fully wired. Extra arities + non-void returns will land in
    /// subsequent phase-1 steps.
    fn lowerFfiIntrinsicCall(self: *Lowering, fic: *const ast.FfiIntrinsicCall) Ref {
        if (fic.kind == .jni_call or fic.kind == .jni_static_call) {
            return self.lowerJniCall(fic);
        }

        if (fic.args.len < 2) {
            if (self.diagnostics) |d| {
                d.add(.err, "#objc_call requires at least a receiver and a selector", null);
            }
            return Ref.none;
        }

        // Resolve the return type from the syntactic slot.
        const ret_ty = self.resolveType(fic.return_type);

        if (fic.args.len < 2) {
            if (self.diagnostics) |d| {
                d.add(.err, "#objc_call requires at least a receiver and a selector", null);
            }
            return Ref.none;
        }

        // Receiver expression.
        const recv = self.lowerExpr(fic.args[0]);

        // Selector. Literal selectors get interned into a module-
        // scoped `SEL*` slot — emit_llvm.zig tags the slot into
        // `__DATA,__objc_selrefs` so dyld populates it at load time
        // (matches clang's `@selector(...)` lowering exactly).
        // Non-literal selectors keep the per-call `sel_registerName`
        // fallback.
        const sel_arg_node = fic.args[1];
        const vptr_ty = self.module.types.ptrTo(.void);
        const sel = blk: {
            if (sel_arg_node.data == .string_literal) {
                const raw = sel_arg_node.data.string_literal.raw;
                const slot_gid = self.internObjcSelector(raw);
                const slot_ptr = self.builder.emit(.{ .global_addr = slot_gid }, self.module.types.ptrTo(vptr_ty));
                break :blk self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty);
            }
            const sel_ref = self.lowerExpr(sel_arg_node);
            const sel_fid = self.getSelRegisterNameFid();
            var sel_args = std.ArrayList(Ref).empty;
            sel_args.append(self.alloc, sel_ref) catch unreachable;
            const sel_owned = sel_args.toOwnedSlice(self.alloc) catch unreachable;
            break :blk self.builder.emit(.{ .call = .{ .callee = sel_fid, .args = sel_owned } }, vptr_ty);
        };

        // Additional args after recv + selector.
        var extra = std.ArrayList(Ref).empty;
        var ai: usize = 2;
        while (ai < fic.args.len) : (ai += 1) {
            extra.append(self.alloc, self.lowerExpr(fic.args[ai])) catch unreachable;
        }
        const extra_owned = extra.toOwnedSlice(self.alloc) catch unreachable;

        return self.builder.emit(.{ .objc_msg_send = .{
            .recv = recv,
            .sel = sel,
            .args = extra_owned,
        } }, ret_ty);
    }

    fn lowerJniCall(self: *Lowering, fic: *const ast.FfiIntrinsicCall) Ref {
        // env is always implicit: lexical-direct from the enclosing `#jni_env(env)`
        // block (2.16b, cheap), else the thread-local slot the block populated
        // at runtime (2.16c, one TL load per call). Surface form is uniform:
        //   #jni_call(T)(target, "name", "sig", method-args...)        (≥3 args)
        if (fic.args.len < 3) {
            if (self.diagnostics) |d| {
                d.add(.err, "#jni_call requires target, method name, and signature", null);
            }
            return Ref.none;
        }

        const ret_ty = self.resolveType(fic.return_type);

        const env_ref = if (self.jni_env_stack.items.len > self.jni_env_stack_base)
            self.jni_env_stack.items[self.jni_env_stack.items.len - 1]
        else blk: {
            const fids = self.getJniEnvTlFids();
            const ptr_ty = self.module.types.ptrTo(.void);
            break :blk self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
        };

        const target_idx: usize = 0;
        const name_idx: usize = 1;
        const sig_idx: usize = 2;
        const first_method_arg_idx: usize = 3;

        const target_ref = self.lowerExpr(fic.args[target_idx]);
        const name_node = fic.args[name_idx];
        const sig_node = fic.args[sig_idx];
        const name_ref = self.lowerExpr(name_node);
        const sig_ref = self.lowerExpr(sig_node);

        // Capture the (name, sig) literal content when both args are
        // string literals — emit_llvm uses this as the intern key for
        // the shared `jclass`/`jmethodID` slot pair (step 1.17).
        const cache_key: ?inst_mod.CacheKey = if (name_node.data == .string_literal and sig_node.data == .string_literal)
            inst_mod.CacheKey{
                .name_str = name_node.data.string_literal.raw,
                .sig_str = sig_node.data.string_literal.raw,
            }
        else
            null;

        var extra = std.ArrayList(Ref).empty;
        var ai: usize = first_method_arg_idx;
        while (ai < fic.args.len) : (ai += 1) {
            extra.append(self.alloc, self.lowerExpr(fic.args[ai])) catch unreachable;
        }
        const extra_owned = extra.toOwnedSlice(self.alloc) catch unreachable;

        return self.builder.emit(.{ .jni_msg_send = .{
            .env = env_ref,
            .target = target_ref,
            .name = name_ref,
            .sig = sig_ref,
            .args = extra_owned,
            .is_static = fic.kind == .jni_static_call,
            .cache_key = cache_key,
        } }, ret_ty);
    }

    /// Lower an `inst.method(args)` call where `inst`'s type is a foreign-class
    /// alias declared by `#jni_class("...") { ... }` (or its parallel forms).
    /// JNI runtimes lower directly to `jni_msg_send` with a descriptor derived
    /// from the method's sx signature; Obj-C / Swift runtimes are deferred to
    /// Phase 3/4 and currently surface a clear diagnostic.
    fn lowerForeignMethodCall(
        self: *Lowering,
        fcd: *const ast.ForeignClassDecl,
        method_name: []const u8,
        target: Ref,
        method_args: []const Ref,
        span: ast.Span,
    ) Ref {
        // M2.3 — walk the `#extends` chain when the method isn't
        // declared directly on this fcd. The dispatch target stays
        // the original receiver — objc_msgSend's runtime walks the
        // class hierarchy by isa, so we just need to find ANY
        // ancestor that declared the method (for the selector
        // mangling + signature info). The receiver-class fcd is
        // still used for `*Self` substitution at the dispatch site
        // — the inherited method's *Self should resolve to the
        // child receiver, not the parent.
        const found = self.findForeignMethodInChain(fcd, method_name) orelse {
            if (self.diagnostics) |d| {
                d.addFmt(.err, span, "no method '{s}' on foreign class '{s}' (or any `#extends` ancestor)", .{ method_name, fcd.name });
            }
            return Ref.none;
        };
        const method = found.method;

        // Obj-C instance dispatch (Phase 3 step 3.0 + M1.2 A.7).
        // `inst.method(args)` on an `#objc_class` / `#objc_protocol`
        // receiver derives a selector from the sx method name (default
        // mangling: split on `_`, each piece becomes a keyword with a
        // trailing `:`; niladic stays verbatim) and lowers to
        // `objc_msg_send`. Both foreign and sx-defined classes flow
        // through the same path — sx-defined classes have their IMPs
        // registered at module-init (M1.2 A.4b.iii) so `objc_msgSend`
        // finds them. The Swift runtimes still bail — Phase 4.
        if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) {
            return self.lowerObjcMethodCall(fcd, method, target, method_args, span);
        }
        if (!fcd.is_foreign) {
            if (self.diagnostics) |d| {
                d.addFmt(.err, span, "sx-defined classes on non-Obj-C runtimes can't yet be dispatched into (class '{s}', runtime '{s}')", .{ fcd.name, @tagName(fcd.runtime) });
            }
            return Ref.none;
        }
        if (fcd.runtime != .jni_class and fcd.runtime != .jni_interface) {
            if (self.diagnostics) |d| {
                d.addFmt(.err, span, "method calls on '{s}' runtime not yet supported (Phase 3/4)", .{@tagName(fcd.runtime)});
            }
            return Ref.none;
        }

        if (self.jni_env_stack.items.len == 0) {
            if (self.diagnostics) |d| {
                d.addFmt(.err, span, "method call on '{s}' requires an enclosing '#jni_env' scope", .{fcd.name});
            }
            return Ref.none;
        }
        const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1];

        // Build a ClassRegistry snapshot so descriptor derivation can
        // resolve `*Foo` cross-class refs to their foreign paths.
        var registry = jni_descriptor.ClassRegistry.init(self.alloc);
        defer registry.deinit();
        var it = self.program_index.foreign_class_map.iterator();
        while (it.next()) |entry| {
            registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {};
        }

        const desc_str = jni_descriptor.deriveMethod(self.alloc, .{
            .enclosing_path = fcd.foreign_path,
            .classes = &registry,
        }, method) catch |err| {
            if (self.diagnostics) |d| {
                d.addFmt(.err, span, "JNI descriptor derivation failed for '{s}.{s}': {s}", .{ fcd.name, method.name, @errorName(err) });
            }
            return Ref.none;
        };

        const name_sid = self.module.types.internString(method_name);
        const name_ref = self.builder.constString(name_sid);
        const sig_sid = self.module.types.internString(desc_str);
        const sig_ref = self.builder.constString(sig_sid);

        const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void;

        // Reject return types the JNI emit path can't dispatch — emit_llvm's
        // Call<T>Method switch only covers void / bool / s32 / s64 / f32 / f64
        // / pointer-returning. Anything else (s8 / s16 / u8 / u16 / aggregates)
        // would silently lower to LLVMGetUndef and produce wrong arguments at
        // the call site (chess Android touch shipped broken because s32→s32+
        // f32 returns hit the undef path before .f32 was wired up).
        if (!isJniReturnTypeSupported(&self.module.types, ret_ty)) {
            if (self.diagnostics) |d| {
                d.addFmt(.err, span, "JNI method '{s}.{s}' returns '{s}', which isn't supported by the JNI call-method lowering yet — only void/bool/s32/s64/f32/f64 and pointers are wired up", .{ fcd.name, method.name, self.module.types.typeName(ret_ty) });
            }
            return Ref.none;
        }

        const cache_key: inst_mod.CacheKey = .{
            .name_str = method_name,
            .sig_str = desc_str,
        };

        const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable;
        return self.builder.emit(.{ .jni_msg_send = .{
            .env = env_ref,
            .target = target,
            .name = name_ref,
            .sig = sig_ref,
            .args = args_owned,
            .is_static = method.is_static,
            .cache_key = cache_key,
        } }, ret_ty);
    }

    /// Resolve the Obj-C selector for a foreign-class method, honoring
    /// any `#selector("...")` override on the declaration. When an
    /// override is present the selector string is the user's literal;
    /// `keyword_count` is the `:` count in the literal (so callers can
    /// still cross-check arity, downgrading the diagnostic to a
    /// warning). When no override exists, the default mangling rule
    /// runs:
    ///   - niladic: name verbatim (`length` → `length`).
    ///   - arity ≥ 1: split the sx name on `_`; each piece becomes a
    ///     keyword with a trailing `:` (`addObject` → `addObject:`,
    ///     `combine_and` → `combine:and:`).
    fn deriveObjcSelector(self: *Lowering, method: ast.ForeignMethodDecl, arity: usize) struct { sel: []const u8, keyword_count: usize, is_override: bool } {
        if (method.selector_override) |sel| {
            var colons: usize = 0;
            for (sel) |ch| {
                if (ch == ':') colons += 1;
            }
            return .{ .sel = sel, .keyword_count = colons, .is_override = true };
        }
        if (arity == 0) {
            return .{ .sel = method.name, .keyword_count = 0, .is_override = false };
        }
        // Each `_` in the sx name becomes a `:` (one-byte-for-one), plus
        // one trailing `:` regardless of how many pieces. Piece count
        // = (number of `_`) + 1.
        var pieces: usize = 1;
        for (method.name) |ch| {
            if (ch == '_') pieces += 1;
        }
        const out = self.alloc.alloc(u8, method.name.len + 1) catch unreachable;
        for (method.name, 0..) |ch, i| {
            out[i] = if (ch == '_') ':' else ch;
        }
        out[method.name.len] = ':';
        return .{ .sel = out, .keyword_count = pieces, .is_override = false };
    }

    /// Derive an Obj-C type-encoding string for a synthesized IMP
    /// signature (M1.2 A.1). Apple's runtime accepts these strings on
    /// `class_addMethod(cls, sel, imp, types)`; the encoding tells the
    /// runtime the IMP's argument layout for KVC, NSCoder, and reflective
    /// dispatch.
    ///
    /// Layout: `<ret> @ : <param0> <param1> ...`. The `@` slot is the
    /// receiver (self); `:` is `_cmd`. Caller passes user-declared params
    /// AFTER stripping `self`.
    ///
    /// Single-character encodings (the common case):
    ///   v=void  B=bool  c=s8/BOOL  s=s16  i=s32  q=s64
    ///   C=u8    S=u16   I=u32      Q=u64  f=f32  d=f64
    ///   @=id    #=Class :=SEL      *=C string  ^v=void* / generic ptr
    ///
    /// Foreign-class pointers (`*UIView` etc.) encode as `@` (object
    /// pointer). Other pointers fall to `^v` — the encoding is metadata,
    /// not ABI, so being conservative here is safe. Pass-by-value
    /// structs encode as `{Name=field0field1...}`; nested structs
    /// recurse with cycle-break via `ObjcEncodingStack`. Tagged-union /
    /// array / vector / function shapes BAIL loudly via diagnostics
    /// rather than silently mis-encoding (per CLAUDE.md rejected-
    /// patterns rule).
    ///
    /// Returns an allocator-owned slice; caller frees via `self.alloc`.
    pub fn objcTypeEncodingFromSignature(
        self: *Lowering,
        return_ty: TypeId,
        param_tys: []const TypeId,
        span: ?ast.Span,
    ) ![]const u8 {
        var out = std.ArrayList(u8).empty;
        errdefer out.deinit(self.alloc);

        var stack: ObjcEncodingStack = .{};
        try self.appendObjcEncoding(&out, return_ty, span, &stack);
        try out.append(self.alloc, '@'); // self
        try out.append(self.alloc, ':'); // _cmd
        for (param_tys) |pty| {
            try self.appendObjcEncoding(&out, pty, span, &stack);
        }

        return try out.toOwnedSlice(self.alloc);
    }

    /// Tracks struct TypeIds currently being emitted so a struct field of
    /// `*Self` (or a transitive pointee that cycles back) emits the
    /// abbreviated `{Name}` form instead of recursing forever. Bounded to
    /// `cap` — well above any realistic Obj-C struct nesting depth.
    const ObjcEncodingStack = struct {
        const cap = 16;
        items: [cap]TypeId = undefined,
        len: u8 = 0,

        fn push(self: *ObjcEncodingStack, tid: TypeId) bool {
            if (self.len >= cap) return false;
            self.items[self.len] = tid;
            self.len += 1;
            return true;
        }

        fn pop(self: *ObjcEncodingStack) void {
            std.debug.assert(self.len > 0);
            self.len -= 1;
        }

        fn contains(self: *const ObjcEncodingStack, tid: TypeId) bool {
            var i: usize = 0;
            while (i < self.len) : (i += 1) {
                if (self.items[i] == tid) return true;
            }
            return false;
        }
    };

    fn appendObjcEncoding(
        self: *Lowering,
        out: *std.ArrayList(u8),
        ty: TypeId,
        span: ?ast.Span,
        stack: *ObjcEncodingStack,
    ) !void {
        const info = self.module.types.get(ty);
        switch (info) {
            .void => try out.append(self.alloc, 'v'),
            .bool => try out.append(self.alloc, 'B'),
            .signed => |bits| {
                const ch: u8 = switch (bits) {
                    8 => 'c',
                    16 => 's',
                    32 => 'i',
                    64 => 'q',
                    else => return self.bailObjcEncoding(span, "signed integer with non-standard bit width", bits),
                };
                try out.append(self.alloc, ch);
            },
            .unsigned => |bits| {
                const ch: u8 = switch (bits) {
                    8 => 'C',
                    16 => 'S',
                    32 => 'I',
                    64 => 'Q',
                    else => return self.bailObjcEncoding(span, "unsigned integer with non-standard bit width", bits),
                };
                try out.append(self.alloc, ch);
            },
            .f32 => try out.append(self.alloc, 'f'),
            .f64 => try out.append(self.alloc, 'd'),
            // sx-target arm64 — pointer-sized aliases match s64/u64.
            .isize => try out.append(self.alloc, 'q'),
            .usize => try out.append(self.alloc, 'Q'),
            .pointer => |p| {
                // Pointer to a foreign Obj-C class (or sx-defined #objc_class)
                // encodes as `@`. Anything else falls to `^v` — generic
                // pointer; the runtime treats it as opaque.
                const pointee_info = self.module.types.get(p.pointee);
                const is_objc_obj = blk: {
                    if (pointee_info != .@"struct") break :blk false;
                    const name = self.module.types.getString(pointee_info.@"struct".name);
                    break :blk self.program_index.foreign_class_map.get(name) != null;
                };
                if (is_objc_obj) {
                    try out.append(self.alloc, '@');
                } else {
                    try out.appendSlice(self.alloc, "^v");
                }
            },
            .many_pointer => |mp| {
                // `[*]u8` is the canonical C-string carrier — encode as `*`.
                // Other element types fall to generic `^v`.
                const el = self.module.types.get(mp.element);
                if (el == .unsigned and el.unsigned == 8) {
                    try out.append(self.alloc, '*');
                } else {
                    try out.appendSlice(self.alloc, "^v");
                }
            },
            .optional => |o| {
                // sx's `?T` is a nullable T. At the Obj-C ABI boundary
                // nullability is just "this pointer may be null" — the
                // wire-level encoding is the same as T. Unwrap and
                // recurse. (Same goes for `?*UIView` etc. — the
                // underlying pointer kind drives the encoding char.)
                return self.appendObjcEncoding(out, o.child, span, stack);
            },
            .@"struct" => |s| {
                // Pass-by-value struct argument or return: Apple's
                // encoding is `{Name=field0field1...}`. A struct
                // already on the encoding stack (i.e. transitively
                // referenced through a struct field — extremely rare
                // since sx structs don't recurse by value) gets the
                // abbreviated `{Name}` form. Recursion through
                // POINTERS is fine because `.pointer` collapses to
                // `^v` regardless of pointee shape.
                const name = self.module.types.getString(s.name);
                try out.append(self.alloc, '{');
                try out.appendSlice(self.alloc, name);
                if (stack.contains(ty)) {
                    try out.append(self.alloc, '}');
                    return;
                }
                if (!stack.push(ty)) {
                    return self.bailObjcEncoding(span, "Obj-C struct encoding nested deeper than supported", ObjcEncodingStack.cap);
                }
                defer stack.pop();
                try out.append(self.alloc, '=');
                for (s.fields) |f| {
                    try self.appendObjcEncoding(out, f.ty, span, stack);
                }
                try out.append(self.alloc, '}');
            },
            else => return self.bailObjcEncoding(span, "type kind not yet supported by Obj-C encoding", @intFromEnum(std.meta.activeTag(info))),
        }
    }

    fn bailObjcEncoding(self: *Lowering, span: ?ast.Span, reason: []const u8, detail: anytype) anyerror {
        if (self.diagnostics) |d| {
            d.addFmt(.err, span, "cannot derive Obj-C type encoding: {s} (detail={any})", .{ reason, detail });
        }
        return error.ObjcEncodingUnsupported;
    }

    /// Resolve a foreign-class member type, substituting `Self` (and `*Self`)
    /// with the foreign class's own struct type. Without this substitution
    /// chained calls like `Cls.alloc().init()` see the inner result as a
    /// fictitious `Self` struct and the next dispatch lookup fails.
    fn resolveForeignClassMemberType(
        self: *Lowering,
        fcd: *const ast.ForeignClassDecl,
        type_node: *const ast.Node,
    ) TypeId {
        if (type_node.data == .type_expr and std.mem.eql(u8, type_node.data.type_expr.name, "Self")) {
            return self.foreignClassStructType(fcd);
        }
        if (type_node.data == .pointer_type_expr) {
            const pt = type_node.data.pointer_type_expr;
            if (pt.pointee_type.data == .type_expr and std.mem.eql(u8, pt.pointee_type.data.type_expr.name, "Self")) {
                return self.module.types.ptrTo(self.foreignClassStructType(fcd));
            }
        }
        return self.resolveType(type_node);
    }

    pub fn resolveForeignMethodReturnType(
        self: *Lowering,
        fcd: *const ast.ForeignClassDecl,
        method: ast.ForeignMethodDecl,
    ) TypeId {
        const rt = method.return_type orelse return .void;
        return self.resolveForeignClassMemberType(fcd, rt);
    }

    fn foreignClassStructType(self: *Lowering, fcd: *const ast.ForeignClassDecl) TypeId {
        const name_id = self.module.types.internString(fcd.name);
        if (self.module.types.findByName(name_id)) |existing| return existing;
        return self.module.types.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } });
    }

    /// Build (and cache) the hidden sx-state struct type for an sx-defined
    /// `#objc_class`. The state struct is what the runtime's `__sx_state`
    /// ivar points at — separate from the Obj-C object itself, which stays
    /// opaque. Layout (M1.2 A.2):
    ///
    ///   __<ClassName>State {
    ///       user_field_0,
    ///       user_field_1,
    ///       ...
    ///   }
    ///
    /// M1.2 A.5 will prepend `__sx_allocator: Allocator` so `-dealloc`
    /// can free through the per-instance allocator and method bodies can
    /// access `self.allocator`. For A.2 the struct holds only the
    /// user-declared fields — sufficient for the body lowering +
    /// `self.field` access work in A.2/A.3. Field-by-name resolution
    /// stays correct across the future repositioning.
    ///
    /// Foreign-class members other than `.field` are ignored here —
    /// methods / `#extends` / `#implements` don't contribute to the
    /// state layout.
    pub fn objcDefinedStateStructType(self: *Lowering, fcd: *const ast.ForeignClassDecl) TypeId {
        const state_name = std.fmt.allocPrint(self.alloc, "__{s}State", .{fcd.name}) catch unreachable;
        defer self.alloc.free(state_name); // internString copies; the temp isn't needed after.
        const name_id = self.module.types.internString(state_name);
        if (self.module.types.findByName(name_id)) |existing| return existing;

        // The interned struct's `fields` slice lives for the module's lifetime;
        // allocate it (and the building ArrayList) in the module arena so it's
        // freed at module deinit rather than leaking through `self.alloc`.
        const field_alloc = self.module.slice_arena.allocator();
        var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
        // M4.0: prepend __sx_allocator at field index 0 — captured at +alloc
        // time, read at -dealloc time to free the state struct through the
        // same allocator. Lookup by name (the existing by-name resolution in
        // emitObjcDefinedClassPropertyImps + lookupObjcDefinedStateFieldOnPointer)
        // naturally finds user fields at their post-shift indices.
        if (self.objcStateAllocatorType()) |allocator_ty| {
            fields.append(field_alloc, .{
                .name = self.module.types.internString("__sx_allocator"),
                .ty = allocator_ty,
            }) catch unreachable;
        }
        for (fcd.members) |m| {
            switch (m) {
                .field => |f| {
                    const f_name_id = self.module.types.internString(f.name);
                    const f_ty = self.resolveType(f.field_type);
                    fields.append(field_alloc, .{ .name = f_name_id, .ty = f_ty }) catch unreachable;
                },
                else => {},
            }
        }
        return self.module.types.intern(.{ .@"struct" = .{
            .name = name_id,
            .fields = fields.toOwnedSlice(field_alloc) catch unreachable,
        } });
    }

    /// Return the `Allocator` protocol TypeId (the value-shape used in
    /// Context.allocator). Falls back to null if Context isn't registered
    /// yet (early-init paths); callers omit the field in that case.
    fn objcStateAllocatorType(self: *Lowering) ?TypeId {
        const ctx_name = self.module.types.internString("Context");
        const ctx_ty = self.module.types.findByName(ctx_name) orelse return null;
        const ctx_info = self.module.types.get(ctx_ty);
        if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) return null;
        return ctx_info.@"struct".fields[0].ty;
    }

    /// Lower `inst.method(args)` on an `#objc_class` / `#objc_protocol`
    /// receiver. The selector is derived by `deriveObjcSelector`; arity
    /// is validated against the keyword count produced by the mangling
    /// (excluding self). Dispatch then runs through `objc_msg_send`,
    /// sharing the cached-SEL slot path with explicit `#objc_call`.
    fn lowerObjcMethodCall(
        self: *Lowering,
        fcd: *const ast.ForeignClassDecl,
        method: ast.ForeignMethodDecl,
        target: Ref,
        method_args: []const Ref,
        span: ast.Span,
    ) Ref {
        const arity = method_args.len;
        const derived = self.deriveObjcSelector(method, arity);

        // Arity validation: the keyword count (number of `:` in the
        // selector) must equal the number of args passed at the call
        // site. For methods using the default mangling rule, a mismatch
        // is an error because the user can fix the sx-side name. For
        // `#selector("...")` overrides, the user has deliberately
        // chosen the selector — downgrade to a warning so the build
        // proceeds, but still surface the typo case (Obj-C's runtime
        // doesn't validate colon-vs-arg, so this is the last defense).
        if (arity > 0 and derived.keyword_count != arity) {
            if (self.diagnostics) |d| {
                if (derived.is_override) {
                    d.addFmt(
                        .warn,
                        span,
                        "Obj-C selector \"{s}\" (override for '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string",
                        .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity },
                    );
                } else {
                    d.addFmt(
                        .err,
                        span,
                        "Obj-C selector for '{s}.{s}' has {} keyword(s) but the call passes {} argument(s); split the sx method name on '_' so it produces exactly {} keyword(s), or override with `#selector(\"...\")`",
                        .{ fcd.name, method.name, derived.keyword_count, arity, arity },
                    );
                    return Ref.none;
                }
            }
        }

        const ret_ty = self.resolveForeignMethodReturnType(fcd, method);

        // Cache the SEL slot per (selector-string, module) like
        // `#objc_call` does. The mangling produces the literal selector
        // string; we don't need a runtime sel_registerName call at the
        // dispatch site because the global initializer already does it.
        const vptr_ty = self.module.types.ptrTo(.void);
        const slot_gid = self.internObjcSelector(derived.sel);
        const slot_ptr = self.builder.emit(.{ .global_addr = slot_gid }, self.module.types.ptrTo(vptr_ty));
        const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty);

        const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable;
        return self.builder.emit(.{ .objc_msg_send = .{
            .recv = target,
            .sel = sel,
            .args = args_owned,
        } }, ret_ty);
    }

    /// Lower `Cls.static_method(args)` on an `#objc_class` /
    /// `#objc_protocol` alias. Loads the class object through the
    /// module-scoped cached slot (populated by `objc_getClass` at
    /// module-init) and dispatches `objc_msg_send` with the same
    /// selector mangling as instance methods (Phase 3.0).
    fn lowerObjcStaticCall(
        self: *Lowering,
        fcd: *const ast.ForeignClassDecl,
        method: ast.ForeignMethodDecl,
        method_args: []const Ref,
        span: ast.Span,
    ) Ref {
        const arity = method_args.len;
        const derived = self.deriveObjcSelector(method, arity);

        if (arity > 0 and derived.keyword_count != arity) {
            if (self.diagnostics) |d| {
                if (derived.is_override) {
                    d.addFmt(
                        .warn,
                        span,
                        "Obj-C selector \"{s}\" (override for static call '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string",
                        .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity },
                    );
                } else {
                    d.addFmt(
                        .err,
                        span,
                        "Obj-C selector for static call '{s}.{s}' has {} keyword(s) but the call passes {} argument(s); split the sx method name on '_' so it produces exactly {} keyword(s), or override with `#selector(\"...\")`",
                        .{ fcd.name, method.name, derived.keyword_count, arity, arity },
                    );
                    return Ref.none;
                }
            }
        }

        const ret_ty = self.resolveForeignMethodReturnType(fcd, method);

        const vptr_ty = self.module.types.ptrTo(.void);

        // Load the class object from its module-scoped cached slot.
        // `objc_getClass(<name>)` runs once at module-init via the
        // constructor emit_llvm synthesizes (see `emitObjcClassInit`).
        const class_slot_gid = self.internObjcClassObject(fcd.foreign_path);
        const class_slot_ptr = self.builder.emit(.{ .global_addr = class_slot_gid }, self.module.types.ptrTo(vptr_ty));
        const class_obj = self.builder.emit(.{ .load = .{ .operand = class_slot_ptr } }, vptr_ty);

        // M4.0b: intercept `Cls.alloc()` for sx-defined classes — emit the
        // inline alloc-and-init sequence using the caller's `context.allocator`
        // instead of going through `objc_msgSend` (which would land in the
        // +alloc IMP and use `__sx_default_context.allocator`). This honors
        // a surrounding `push Context.{ allocator = ... }`.
        if (!fcd.is_foreign and
            fcd.runtime == .objc_class and
            method_args.len == 0 and
            std.mem.eql(u8, method.name, "alloc"))
        {
            const ctx_addr = if (self.current_ctx_ref != Ref.none)
                self.current_ctx_ref
            else blk: {
                // Fallback: no current ctx (e.g. compiler-internal callers).
                // Use the default context — same as the IMP would.
                const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse {
                    if (self.diagnostics) |d| {
                        d.addFmt(.err, span, "Cls.alloc() on sx-defined class '{s}': no current context and __sx_default_context missing", .{fcd.name});
                    }
                    return Ref.none;
                };
                break :blk self.builder.emit(.{ .global_addr = default_ctx_gi.id }, vptr_ty);
            };
            const instance = self.emitObjcDefinedAllocAndInit(fcd, class_obj, ctx_addr) orelse return Ref.none;
            // class_createInstance returns *void; bitcast to the method's
            // declared return type (typically `*<Cls>` or `?*<Cls>`) so
            // downstream `let f := Cls.alloc();` binds f at the right type
            // (lowerVarDecl reads the Ref's IR type when no annotation is
            // present). coerceToType is a no-op for ptr→ptr; we need an
            // explicit bitcast IR op to retype the Ref.
            if (ret_ty == vptr_ty) return instance;
            // Optional-wrapped returns (e.g. `-> ?*Cls`): emit optional_wrap.
            if (!ret_ty.isBuiltin()) {
                const ret_info = self.module.types.get(ret_ty);
                if (ret_info == .optional) {
                    const inner = ret_info.optional.child;
                    const cast = if (inner == vptr_ty)
                        instance
                    else
                        self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = inner } }, inner);
                    return self.builder.optionalWrap(cast, ret_ty);
                }
            }
            return self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = ret_ty } }, ret_ty);
        }

        // Load the SEL from its slot.
        const sel_slot_gid = self.internObjcSelector(derived.sel);
        const sel_slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty));
        const sel = self.builder.emit(.{ .load = .{ .operand = sel_slot_ptr } }, vptr_ty);

        const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable;
        return self.builder.emit(.{ .objc_msg_send = .{
            .recv = class_obj,
            .sel = sel,
            .args = args_owned,
        } }, ret_ty);
    }

    /// Lower `Alias.new(args)` where `Alias` is a foreign-class identifier
    /// with `static new :: (...) -> *Self;` — JNI constructor dispatch:
    /// `FindClass + GetMethodID("<init>", "(args)V") + NewObject(env,
    /// clazz, mid, args...)`. Returns the new jobject.
    ///
    /// Non-`new` static methods aren't supported via this path yet — the
    /// user can use `#jni_static_call(T)(class, "name", sig, args...)`
    /// for those. Constructor is the common case for #jni_main bodies
    /// that need to instantiate Android classes (SurfaceView, etc.).
    fn lowerForeignStaticCall(
        self: *Lowering,
        fcd: *const ast.ForeignClassDecl,
        method: ast.ForeignMethodDecl,
        method_args: []const Ref,
        span: ast.Span,
    ) Ref {
        // Obj-C static dispatch (Phase 3 step 3.1). `Cls.static_method(args)`
        // on an `#objc_class` alias loads the class object through a
        // module-scoped cached slot (populated once per module via
        // `objc_getClass`) and dispatches with the derived selector.
        if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) {
            return self.lowerObjcStaticCall(fcd, method, method_args, span);
        }
        if (fcd.runtime != .jni_class and fcd.runtime != .jni_interface) {
            if (self.diagnostics) |d| d.addFmt(.err, span, "static calls on '{s}' runtime not yet supported (Phase 3/4)", .{@tagName(fcd.runtime)});
            return Ref.none;
        }
        if (!std.mem.eql(u8, method.name, "new")) {
            if (self.diagnostics) |d| d.addFmt(.err, span, "static foreign-class call '{s}.{s}' not yet supported via `Alias.method()` syntax \u{2014} only `new` is wired today; use `#jni_static_call` directly for other static methods", .{ fcd.name, method.name });
            return Ref.none;
        }

        if (self.jni_env_stack.items.len <= self.jni_env_stack_base) {
            if (self.diagnostics) |d| d.addFmt(.err, span, "constructor `{s}.new(...)` requires an enclosing `#jni_env` scope (or `#jni_main` body)", .{fcd.name});
            return Ref.none;
        }
        const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1];

        // Build class registry snapshot for `*Foo` cross-class refs.
        var registry = jni_descriptor.ClassRegistry.init(self.alloc);
        defer registry.deinit();
        var it = self.program_index.foreign_class_map.iterator();
        while (it.next()) |entry| {
            registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {};
        }

        // For `new`, the JNI descriptor's return position is `V` (the
        // constructor returns void; the new jobject comes back from
        // `NewObject` itself). Patch the AST by overriding return_type
        // to null during derivation.
        const m_for_desc: ast.ForeignMethodDecl = .{
            .name = method.name,
            .params = method.params,
            .param_names = method.param_names,
            .return_type = null,
            .is_static = method.is_static,
            .jni_descriptor_override = method.jni_descriptor_override,
            .body = method.body,
        };

        const descriptor = jni_descriptor.deriveMethod(self.alloc, .{
            .enclosing_path = fcd.foreign_path,
            .classes = &registry,
        }, m_for_desc) catch |err| {
            if (self.diagnostics) |d| d.addFmt(.err, span, "JNI descriptor derivation failed for '{s}.new': {s}", .{ fcd.name, @errorName(err) });
            return Ref.none;
        };

        // sx-side return type is `*Self` — resolve to a pointer to the
        // foreign-class struct type so method dispatch on the new
        // jobject works (`view := SurfaceView.new(ctx); view.getHolder()`).
        // At LLVM level still ptr; the sx type table is what method
        // resolution consults.
        const self_struct_name = self.module.types.internString(fcd.name);
        const self_struct_id = if (self.module.types.findByName(self_struct_name)) |existing|
            existing
        else blk: {
            const info: types.TypeInfo = .{ .@"struct" = .{ .name = self_struct_name, .fields = &.{} } };
            break :blk self.module.types.intern(info);
        };
        const ret_ty = self.module.types.ptrTo(self_struct_id);

        const name_sid = self.module.types.internString("<init>");
        const name_ref = self.builder.constString(name_sid);
        const sig_sid = self.module.types.internString(descriptor);
        const sig_ref = self.builder.constString(sig_sid);

        const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable;
        return self.builder.emit(.{ .jni_msg_send = .{
            .env = env_ref,
            .target = Ref.none, // unused for ctor — class is resolved via parent_class_path
            .name = name_ref,
            .sig = sig_ref,
            .args = args_owned,
            .is_static = false,
            .is_constructor = true,
            .parent_class_path = self.alloc.dupe(u8, fcd.foreign_path) catch fcd.foreign_path,
            .cache_key = null,
        } }, ret_ty);
    }

    /// Lower `super.method(args)` inside a `#jni_main` / sx-defined
    /// `#jni_class` bodied method. Resolves the parent class from the
    /// enclosing fcd's `#extends` clause (default `android.app.Activity`)
    /// and emits a `JniMsgSend` with `is_nonvirtual=true`, which
    /// emit_llvm expands into a `FindClass(parent) + GetMethodID +
    /// CallNonvirtual<T>Method` chain.
    ///
    /// Signature derivation: when `method_name` matches the enclosing
    /// method's name (the common case — `super.onCreate(b)` from inside
    /// `onCreate :: (self, b)` override), the enclosing method's
    /// signature is reused. Other method names require the parent class
    /// to be declared via `#foreign #jni_class` so the signature can be
    /// looked up.
    fn lowerSuperCall(
        self: *Lowering,
        method_name: []const u8,
        method_args: []const Ref,
        span: ast.Span,
    ) Ref {
        const fcd = self.current_foreign_class orelse {
            if (self.diagnostics) |d| d.addFmt(.err, span, "'super' is only valid inside a `#jni_class` method body", .{});
            return Ref.none;
        };

        // Resolve parent foreign_path from the fcd's `#extends`. Default to
        // android.app.Activity to match the jni_java_emit default.
        var parent_path: []const u8 = "android/app/Activity";
        for (fcd.members) |m| switch (m) {
            .extends => |alias| {
                if (self.program_index.foreign_class_map.get(alias)) |parent_fcd| {
                    parent_path = parent_fcd.foreign_path;
                } else {
                    parent_path = alias;
                }
                break;
            },
            else => {},
        };

        // Resolve method signature. Same-name fast path reuses the
        // enclosing method's descriptor; cross-method super calls require
        // the parent class to be declared via `#foreign #jni_class`.
        var descriptor: []const u8 = "";
        var resolved_method: ?ast.ForeignMethodDecl = null;
        if (self.current_foreign_method) |em| {
            if (std.mem.eql(u8, em.name, method_name)) {
                resolved_method = em;
            }
        }
        if (resolved_method == null) {
            const parent_fcd = blk: for (fcd.members) |m| switch (m) {
                .extends => |alias| if (self.program_index.foreign_class_map.get(alias)) |pf| break :blk pf else continue,
                else => {},
            } else null;
            if (parent_fcd) |pf| {
                for (pf.members) |pm| switch (pm) {
                    .method => |pmd| if (std.mem.eql(u8, pmd.name, method_name)) {
                        resolved_method = pmd;
                        break;
                    },
                    else => {},
                };
            }
        }
        const method = resolved_method orelse {
            if (self.diagnostics) |d| d.addFmt(.err, span, "no method '{s}' found for `super.{s}(...)` — declare the parent class via `#foreign #jni_class` to make cross-method super calls available", .{ method_name, method_name });
            return Ref.none;
        };

        // Derive descriptor against the parent path (used as enclosing_path
        // for `*Self` resolution).
        var registry = jni_descriptor.ClassRegistry.init(self.alloc);
        defer registry.deinit();
        var it = self.program_index.foreign_class_map.iterator();
        while (it.next()) |entry| {
            registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {};
        }
        descriptor = jni_descriptor.deriveMethod(self.alloc, .{
            .enclosing_path = parent_path,
            .classes = &registry,
        }, method) catch |err| {
            if (self.diagnostics) |d| d.addFmt(.err, span, "super-call descriptor derivation failed for '{s}.{s}': {s}", .{ parent_path, method_name, @errorName(err) });
            return Ref.none;
        };

        // env from the lexical stack (pushed by synthesizeJniMainStub).
        if (self.jni_env_stack.items.len <= self.jni_env_stack_base) {
            if (self.diagnostics) |d| d.addFmt(.err, span, "`super.{s}(...)` requires an enclosing `#jni_main` method scope (env is unavailable)", .{method_name});
            return Ref.none;
        }
        const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1];

        // `self` is the first param of the synthesized `Java_*` fn. Bound
        // in scope as `self` by synthesizeJniMainStub.
        const self_binding = if (self.scope) |s| s.lookup("self") else null;
        const self_ref = if (self_binding) |b| (if (b.is_alloca) self.builder.load(b.ref, b.ty) else b.ref) else Ref.none;

        const name_sid = self.module.types.internString(method_name);
        const name_ref = self.builder.constString(name_sid);
        const sig_sid = self.module.types.internString(descriptor);
        const sig_ref = self.builder.constString(sig_sid);

        const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void;

        const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable;
        return self.builder.emit(.{ .jni_msg_send = .{
            .env = env_ref,
            .target = self_ref,
            .name = name_ref,
            .sig = sig_ref,
            .args = args_owned,
            .is_static = false,
            .is_nonvirtual = true,
            .parent_class_path = self.alloc.dupe(u8, parent_path) catch parent_path,
            .cache_key = null, // per-call FindClass + GetMethodID; caching is a follow-up
        } }, ret_ty);
    }

    // ── Calls ───────────────────────────────────────────────────────

    fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
        // Expand default parameter values for bare identifier callees:
        // when the caller omits trailing positional args, fill them in
        // from the callee's `param: T = expr` declarations.
        var c = c_in;
        if (self.expandCallDefaults(c)) |expanded| c = expanded;
        // Check reflection builtins first (before lowering args — some args are type names, not values)
        if (c.callee.data == .identifier) {
            if (self.tryLowerReflectionCall(c.callee.data.identifier.name, c)) |ref| return ref;
        }

        // Check for runtime dispatch pattern BEFORE lowering args.
        // lowerRuntimeDispatchCall handles its own arg lowering, and pre-lowering
        // cast(type) val would produce a dead `call_builtin cast : void`.
        if (c.callee.data == .identifier) {
            const id_name = c.callee.data.identifier.name;
            const eff_name = blk: {
                const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name;
                if (self.program_index.ufcs_alias_map.get(id_name)) |target| {
                    break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
                }
                break :blk scoped;
            };
            // C-import visibility: deny calls to C fn_decls not in the caller's module scope
            if (!self.isCImportVisible(eff_name)) {
                if (self.diagnostics) |d|
                    d.addFmt(.err, c.callee.span, "C function '{s}' not visible; add #import for the module that declares it", .{eff_name});
                return Ref.none;
            }
            // Non-transitive `#import` visibility check. Apply only when the
            // user-typed name resolved as-is to a top-level fn — local-scope
            // mangling (eff_name != id_name) and UFCS alias rewriting are
            // compiler indirections and stay exempt.
            if (std.mem.eql(u8, eff_name, id_name) and
                self.program_index.ufcs_alias_map.get(id_name) == null and
                self.program_index.fn_ast_map.contains(eff_name) and
                !self.isNameVisible(eff_name))
            {
                if (self.diagnostics) |d|
                    d.addFmt(.err, c.callee.span, "'{s}' is not visible; #import the module that declares it", .{eff_name});
                return Ref.none;
            }
            if (self.program_index.fn_ast_map.get(eff_name)) |fd| {
                if (self.current_match_tags) |tags| {
                    if (tags.len > 0 and self.hasCastWithRuntimeType(c)) {
                        return self.lowerRuntimeDispatchCall(fd, eff_name, c, tags);
                    }
                }
            }
        }

        // Handle closure(fn_or_lambda) — wrap bare functions into closures
        if (c.callee.data == .identifier and std.mem.eql(u8, c.callee.data.identifier.name, "closure")) {
            if (c.args.len >= 1) {
                const arg = c.args[0];
                // If argument is a bare function name, create a proper closure from it
                if (arg.data == .identifier) {
                    const fn_name = arg.data.identifier.name;
                    if (!self.lowered_functions.contains(fn_name)) {
                        self.lazyLowerFunction(fn_name);
                    }
                    if (self.resolveFuncByName(fn_name)) |fid| {
                        const func = &self.module.functions.items[@intFromEnum(fid)];
                        // Build closure type from user-visible params only —
                        // skip the implicit __sx_ctx param.
                        var param_types_list = std.ArrayList(TypeId).empty;
                        defer param_types_list.deinit(self.alloc);
                        const skip: usize = if (func.has_implicit_ctx) 1 else 0;
                        for (func.params[skip..]) |p| {
                            param_types_list.append(self.alloc, p.ty) catch unreachable;
                        }
                        const closure_ty = self.module.types.closureType(param_types_list.items, func.ret);
                        const closure_info = self.module.types.get(closure_ty).closure;
                        const tramp_id = self.createBareFnTrampoline(fid, closure_info);
                        return self.builder.closureCreate(tramp_id, Ref.none, closure_ty);
                    }
                }
                // Lambda or other expression — already produces closure_create
                return self.lowerExpr(arg);
            }
        }

        // Early detection of comptime-expanded calls (e.g. print) — skip arg evaluation
        // since lowerComptimeCall re-evaluates args from AST (avoiding double evaluation)
        if (c.callee.data == .identifier) {
            const early_name = blk: {
                const id_name = c.callee.data.identifier.name;
                const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name;
                if (self.program_index.ufcs_alias_map.get(id_name)) |target| {
                    break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
                }
                break :blk scoped;
            };
            if (self.program_index.fn_ast_map.get(early_name)) |fd| {
                if (isPackFn(fd)) {
                    // Protocol packs (`..xs: P`) and comptime type-packs
                    // (`..$args`) both monomorphize per call shape.
                    return self.lowerPackFnCall(fd, c);
                }
                if (hasComptimeParams(fd)) {
                    return self.lowerComptimeCall(fd, c);
                }
                // Early detection of generic function calls — skip arg lowering for type params
                // because lowerGenericCall resolves type params from AST nodes, not lowered refs.
                // Only if the name is NOT shadowed by a local variable (closure, fn ptr, etc.)
                const shadowed = if (self.scope) |scope| scope.lookup(c.callee.data.identifier.name) != null else false;
                if (fd.type_params.len > 0 and !shadowed) {
                    // Types are explicit when call args match param count (e.g., are_equal(Point, p1, p2))
                    // Types are inferred when call args < param count (e.g., are_equal(p1, p2))
                    const types_explicit = c.args.len == fd.params.len;
                    var lowered_args = std.ArrayList(Ref).empty;
                    defer lowered_args.deinit(self.alloc);
                    for (c.args, 0..) |arg, ai| {
                        // Skip type param args only when types are passed explicitly
                        if (types_explicit and ai < fd.params.len and isTypeParamDecl(&fd.params[ai], fd.type_params)) {
                            lowered_args.append(self.alloc, Ref.none) catch unreachable;
                        } else {
                            const saved_target = self.target_type;
                            lowered_args.append(self.alloc, self.lowerExpr(arg)) catch unreachable;
                            self.target_type = saved_target;
                        }
                    }
                    return self.lowerGenericCall(fd, early_name, c, lowered_args.items);
                }
            }
        }

        // Lower args (with target type propagation for xx conversions)
        var args = std.ArrayList(Ref).empty;
        defer args.deinit(self.alloc);
        // Try to resolve param types for target_type context
        const param_types = self.resolveCallParamTypes(c);
        // For enum_literal callees (.Variant(payload)), resolve the payload target type
        // from the union field type so struct literal fields get proper coercion
        var enum_payload_ty: ?TypeId = null;
        if (c.callee.data == .enum_literal) {
            const target = self.target_type orelse .unresolved;
            if (!target.isBuiltin()) {
                const info = self.module.types.get(target);
                if (info == .tagged_union) {
                    const tag = self.resolveVariantIndex(target, c.callee.data.enum_literal.name);
                    if (tag < info.tagged_union.fields.len) {
                        enum_payload_ty = info.tagged_union.fields[tag].ty;
                    }
                }
            }
        }
        for (c.args, 0..) |arg, ai| {
            if (arg.data == .spread_expr) {
                // Pack spread `..xs` / `..xs.method` → expand to N positional
                // args here. A runtime-slice spread (`..arr`) is left as a
                // placeholder for the slice-variadic path (packVariadicCallArgs).
                if (self.packSpreadRefs(arg.data.spread_expr.operand, arg.span)) |elems| {
                    defer self.alloc.free(elems);
                    for (elems) |e| args.append(self.alloc, e) catch unreachable;
                    continue;
                }
                args.append(self.alloc, Ref.none) catch unreachable;
                continue;
            }
            const saved_target = self.target_type;
            if (ai < param_types.len) {
                self.target_type = param_types[ai];
            }
            if (enum_payload_ty) |ept| {
                if (ai == 0) self.target_type = ept;
            }
            // Implicit address-of: when param expects *T and arg is an identifier
            // with an alloca of type T, pass the alloca pointer directly (reference
            // semantics, so mutations through the pointer are visible to the caller).
            if (ai < param_types.len and arg.data == .identifier) {
                const pt = param_types[ai];
                if (!pt.isBuiltin()) {
                    const pti = self.module.types.get(pt);
                    if (pti == .pointer) {
                        if (self.scope) |scope| {
                            if (scope.lookup(arg.data.identifier.name)) |binding| {
                                // Only apply when the binding type matches the pointee type
                                if (binding.is_alloca and binding.ty == pti.pointer.pointee) {
                                    const ptr_ty = self.module.types.ptrTo(binding.ty);
                                    args.append(self.alloc, self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, ptr_ty)) catch unreachable;
                                    self.target_type = saved_target;
                                    continue;
                                }
                            }
                        }
                    }
                }
            }
            // Implicit address-of for compound lvalues (field access / index /
            // deref): when the param expects `*T` and the arg is an addressable
            // lvalue of type `T`, pass the lvalue's real address (GEP) — same
            // reference semantics as the identifier case above. Without this the
            // arg would be loaded into a temporary and the callee would mutate a
            // throwaway copy (silent data loss — e.g. `make_move(self.board, m)`).
            if (ai < param_types.len and (arg.data == .field_access or arg.data == .index_expr or arg.data == .deref_expr)) {
                const pt = param_types[ai];
                if (!pt.isBuiltin()) {
                    const pti = self.module.types.get(pt);
                    if (pti == .pointer and self.inferExprType(arg) == pti.pointer.pointee) {
                        // `lowerExprAsPtr` yields the lvalue's address, typed
                        // either as `*T` already (index/deref) or as the pointee
                        // `T` (a field "place" ref); normalize to `*T` — exactly
                        // what `@field_access` does.
                        const place = self.lowerExprAsPtr(arg);
                        const place_ty = self.builder.getRefType(place);
                        const ref: ?Ref = if (place_ty == pt)
                            place
                        else if (place_ty == pti.pointer.pointee)
                            self.builder.emit(.{ .addr_of = .{ .operand = place } }, pt)
                        else
                            null;
                        if (ref) |r| {
                            args.append(self.alloc, r) catch unreachable;
                            self.target_type = saved_target;
                            continue;
                        }
                    }
                }
            }
            const val = self.lowerExpr(arg);
            self.target_type = saved_target;
            // Passing a `*T` where a `T` value is expected — a by-reference loop
            // capture (`for xs: (*m)`), a `*T` parameter, or any pointer local —
            // otherwise slips through to LLVM as an opaque "call parameter type
            // does not match function signature" verifier error. Flag it at the
            // call site with a `.*` fix-it.
            if (ai < param_types.len) {
                const vt = self.builder.getRefType(val);
                const vti = self.module.types.get(vt);
                if (vti == .pointer and vti.pointer.pointee == param_types[ai]) {
                    if (self.diagnostics) |d| {
                        const tn = self.formatTypeName(param_types[ai]);
                        if (arg.data == .identifier) {
                            const nm = arg.data.identifier.name;
                            const lead: []const u8 = if (self.refCapturePointee(arg) != null) "by-reference loop capture" else "argument";
                            const fix = std.fmt.allocPrint(self.alloc, "{s}.*", .{nm}) catch nm;
                            const pid = d.addFmtId(.err, arg.span, "{s} '{s}' has type '*{s}', but '{s}' is expected here", .{ lead, nm, tn, tn });
                            d.addHelpFmt(pid, arg.span, fix, "dereference it to pass the value: `{s}`", .{fix});
                        } else {
                            const pid = d.addFmtId(.err, arg.span, "this argument has type '*{s}', but '{s}' is expected here", .{ tn, tn });
                            d.addHelpFmt(pid, arg.span, null, "dereference it with `.*` to pass the value", .{});
                        }
                    }
                }
            }
            args.append(self.alloc, val) catch unreachable;
        }

        switch (c.callee.data) {
            .identifier => |id| {
                // Resolve local function name (bare → mangled) and UFCS aliases
                const func_name = blk: {
                    // First try scope lookup for mangled local fn names
                    const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
                    // Then try UFCS alias on bare name
                    if (self.program_index.ufcs_alias_map.get(id.name)) |target| {
                        // Resolve the alias target through scope too (target may be mangled)
                        break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
                    }
                    break :blk scoped;
                };

                // Handle cast(TargetType, val) — emit conversion instructions
                // Only for compile-time known types (type_expr or known type names)
                if (std.mem.eql(u8, id.name, "cast") and c.args.len >= 2) {
                    const type_arg = c.args[0];
                    const is_static_type = blk: {
                        if (type_arg.data == .type_expr) break :blk true;
                        if (type_arg.data == .identifier) {
                            const tname = type_arg.data.identifier.name;
                            // Check if it's a known type name (not a runtime variable)
                            if (type_bridge.resolveTypePrimitive(tname) != null) break :blk true;
                            if (self.type_bindings) |bindings| {
                                if (bindings.get(tname) != null) break :blk true;
                            }
                            // Check if it's a registered struct/enum type name
                            const name_id = self.module.types.internString(tname);
                            if (self.module.types.findByName(name_id) != null) break :blk true;
                        }
                        break :blk false;
                    };
                    if (is_static_type) {
                        const dst_ty = self.resolveTypeArg(c.args[0]);
                        const val = args.items[1]; // already lowered
                        const src_ty = self.inferExprType(c.args[1]);
                        // Unbox Any → concrete type
                        if (src_ty == .any) {
                            return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty);
                        }
                        return self.coerceToType(val, src_ty, dst_ty);
                    }
                    // Runtime cast — fall through to builtin handling
                }
                // Check builtins first (these are handled natively by interpreter and emitter)
                if (resolveBuiltin(id.name)) |bid| {
                    const ret_ty: TypeId = switch (bid) {
                        .size_of, .align_of => .s64,
                        .sqrt, .sin, .cos, .floor => blk: {
                            // Math builtins: return type matches argument type ($T -> T)
                            if (c.args.len > 0) {
                                const arg_ty = self.inferExprType(c.args[0]);
                                if (arg_ty == .f32) break :blk TypeId.f32;
                            }
                            break :blk TypeId.f64;
                        },
                        else => .void,
                    };
                    return self.builder.callBuiltin(bid, args.items, ret_ty);
                }
                // Check scope first: local variables (closures, fn ptrs) shadow global functions
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        if (!binding.ty.isBuiltin()) {
                            const ty_info = self.module.types.get(binding.ty);
                            if (ty_info == .closure) {
                                const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref;
                                // Closure trampolines carry `__sx_ctx` at
                                // slot 0; emit_llvm's `call_closure` builds
                                // the call as [ctx, env, user_args], so we
                                // prepend ctx here. args[0] becomes ctx.
                                const owned = if (self.implicit_ctx_enabled) blk: {
                                    const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
                                    arr[0] = self.current_ctx_ref;
                                    @memcpy(arr[1..], args.items);
                                    break :blk arr;
                                } else self.alloc.dupe(Ref, args.items) catch unreachable;
                                const ret_ty = ty_info.closure.ret;
                                return self.builder.emit(.{ .call_closure = .{ .callee = callee_ref, .args = owned } }, ret_ty);
                            }
                        }
                    }
                }
                // Check for comptime-expanded or generic functions
                if (self.program_index.fn_ast_map.get(func_name)) |fd| {
                    if (hasComptimeParams(fd)) {
                        return self.lowerComptimeCall(fd, c);
                    }
                    if (fd.type_params.len > 0) {
                        // Runtime dispatch already handled above (before arg lowering)
                        return self.lowerGenericCall(fd, func_name, c, args.items);
                    }
                }
                // Check for #compiler free functions
                if (self.program_index.fn_ast_map.get(func_name)) |fd_check| {
                    if (fd_check.body.data == .compiler_expr) {
                        const ret_ty = if (fd_check.return_type) |rt| type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map) else TypeId.void;
                        return self.builder.compilerCall(func_name, args.items, ret_ty);
                    }
                }

                // Look up declared/extern function — try lazy lowering if not yet lowered
                {
                    // First attempt: function may already be declared (from scanDecls)
                    // but not yet lowered. Try lazy lowering if needed.
                    if (self.program_index.fn_ast_map.contains(func_name) and !self.lowered_functions.contains(func_name)) {
                        self.lazyLowerFunction(func_name);
                    }
                    if (self.resolveFuncByName(func_name)) |fid| {
                        const func = &self.module.functions.items[@intFromEnum(fid)];
                        const ret_ty = func.ret;
                        const params = func.params;
                        // Pack variadic args into a slice if the function has a variadic param
                        if (self.program_index.fn_ast_map.get(func_name)) |fd| {
                            self.packVariadicCallArgs(fd, c, &args);
                        }
                        const final_args = self.prependCtxIfNeeded(func, args.items);
                        // Coerce arguments to match parameter types
                        self.coerceCallArgs(final_args, params);
                        if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
                        return self.builder.call(fid, final_args, ret_ty);
                    }
                }
                // May be a variable holding a function pointer (non-closure)
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref;
                        const ret_ty = if (!binding.ty.isBuiltin()) blk: {
                            const bti = self.module.types.get(binding.ty);
                            break :blk if (bti == .function) bti.function.ret else .s64;
                        } else .s64;
                        var final_args = std.ArrayList(Ref).empty;
                        defer final_args.deinit(self.alloc);
                        if (self.fnPtrTypeWantsCtx(binding.ty)) {
                            final_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
                        }
                        final_args.appendSlice(self.alloc, args.items) catch unreachable;
                        const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable;
                        return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, ret_ty);
                    }
                }
                // May be a global variable holding a function pointer
                if (self.program_index.global_names.get(id.name)) |gi| {
                    if (!gi.ty.isBuiltin()) {
                        const gti = self.module.types.get(gi.ty);
                        if (gti == .function) {
                            const callee_ref = self.builder.emit(.{ .global_get = gi.id }, gi.ty);
                            // Coerce args to match fn-ptr param types (including implicit address-of)
                            for (args.items, 0..) |*arg, ai| {
                                if (ai < gti.function.params.len) {
                                    const dst_ty = gti.function.params[ai];
                                    const src_ty = self.inferExprType(c.args[ai]);
                                    // Implicit address-of: passing T where *T expected
                                    if (!dst_ty.isBuiltin()) {
                                        const dti = self.module.types.get(dst_ty);
                                        if (dti == .pointer and dti.pointer.pointee == src_ty and src_ty != .void) {
                                            // For identifier args, pass the alloca directly (reference semantics)
                                            if (c.args[ai].data == .identifier) {
                                                if (self.scope) |scope| {
                                                    if (scope.lookup(c.args[ai].data.identifier.name)) |binding| {
                                                        if (binding.is_alloca) {
                                                            arg.* = self.builder.emit(.{ .addr_of = .{ .operand = binding.ref } }, dst_ty);
                                                            continue;
                                                        }
                                                    }
                                                }
                                            }
                                            // For other expressions, copy semantics
                                            const slot = self.builder.alloca(src_ty);
                                            self.builder.store(slot, arg.*);
                                            arg.* = slot;
                                            continue;
                                        }
                                    }
                                    arg.* = self.coerceToType(arg.*, src_ty, dst_ty);
                                }
                            }
                            var final_args = std.ArrayList(Ref).empty;
                            defer final_args.deinit(self.alloc);
                            if (self.fnPtrTypeWantsCtx(gi.ty)) {
                                final_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
                            }
                            final_args.appendSlice(self.alloc, args.items) catch unreachable;
                            const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable;
                            return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, gti.function.ret);
                        }
                    }
                }
                // Unresolved function call
                return self.emitError(id.name, c.callee.span);
            },
            .field_access => |fa| {
                // `super.method(args)` from inside a `#jni_main` (or any
                // sx-defined `#jni_class`) bodied method. Dispatch via
                // CallNonvirtual<T>Method against the parent class
                // resolved from the enclosing fcd's `#extends` clause.
                if (fa.object.data == .identifier and
                    std.mem.eql(u8, fa.object.data.identifier.name, "super"))
                {
                    return self.lowerSuperCall(fa.field, args.items, c.callee.span);
                }

                // `Alias.method(args)` where Alias is a foreign-class
                // identifier and `method` is a `static` member — JNI
                // dispatch via FindClass + GetStaticMethodID + CallStatic*,
                // OR (for `new`) via FindClass + GetMethodID("<init>") +
                // NewObject. Falls through to existing paths when no match.
                if (fa.object.data == .identifier) {
                    const alias = fa.object.data.identifier.name;
                    if (self.program_index.foreign_class_map.get(alias)) |fcd| {
                        for (fcd.members) |m| switch (m) {
                            .method => |md| if (md.is_static and std.mem.eql(u8, md.name, fa.field)) {
                                return self.lowerForeignStaticCall(fcd, md, args.items, c.callee.span);
                            },
                            else => {},
                        };
                    }
                }

                // Type constructor call: Sx(f32).user(0.5) — obj is a call that returns a type
                if (fa.object.data == .call) {
                    const inner_call = &fa.object.data.call;
                    if (inner_call.callee.data == .identifier) {
                        const inner_name = inner_call.callee.data.identifier.name;
                        const resolved = if (self.scope) |scope| (scope.lookupFn(inner_name) orelse inner_name) else inner_name;

                        // Generic struct static method: Animated(Size).make(...)
                        if (self.program_index.struct_template_map.getPtr(resolved)) |tmpl| {
                            const inst_ty = self.instantiateGenericStruct(tmpl, inner_call.args);
                            const inst_name = self.formatTypeName(inst_ty);
                            // Look up template method, monomorphize, and call
                            if (self.struct_instance_template.get(inst_name)) |tmpl_name| {
                                const tmpl_qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ tmpl_name, fa.field }) catch fa.field;
                                if (self.program_index.fn_ast_map.get(tmpl_qualified)) |fd| {
                                    if (self.struct_instance_bindings.getPtr(inst_name)) |bindings| {
                                        const mangled = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ inst_name, fa.field }) catch fa.field;
                                        if (!self.lowered_functions.contains(mangled)) {
                                            self.monomorphizeFunction(fd, mangled, bindings);
                                        }
                                        if (self.resolveFuncByName(mangled)) |fid| {
                                            const func = &self.module.functions.items[@intFromEnum(fid)];
                                            const final_args = self.prependCtxIfNeeded(func, args.items);
                                            self.coerceCallArgs(final_args, func.params);
                                            return self.builder.call(fid, final_args, func.ret);
                                        }
                                    }
                                }
                            }
                        }

                        if (self.program_index.fn_ast_map.get(resolved)) |fd| {
                            if (fd.type_params.len > 0) {
                                // Try instantiate as type function
                                if (self.instantiateTypeFunction(inner_name, inner_name, fd, inner_call.args)) |result_ty| {
                                    const type_info = self.module.types.get(result_ty);
                                    if (type_info == .tagged_union) {
                                        // Qualified enum construction: Type.variant(payload)
                                        const tag = self.resolveVariantIndex(result_ty, fa.field);
                                        var payload = if (args.items.len > 0) args.items[0] else Ref.none;
                                        if (!payload.isNone()) {
                                            const fields = type_info.tagged_union.fields;
                                            if (tag < fields.len) {
                                                const field_ty = fields[tag].ty;
                                                if (field_ty != .void) {
                                                    const payload_ty = self.inferExprType(c.args[0]);
                                                    if (field_ty != payload_ty) {
                                                        payload = self.coerceToType(payload, payload_ty, field_ty);
                                                    }
                                                }
                                            }
                                        }
                                        return self.builder.enumInit(tag, payload, result_ty);
                                    }
                                    if (type_info == .@"enum") {
                                        const tag = self.resolveVariantIndex(result_ty, fa.field);
                                        return self.builder.enumInit(tag, Ref.none, result_ty);
                                    }
                                }
                            }
                        }
                    }
                }

                // Namespace-qualified call (e.g. `std.print`) vs method / UFCS
                // call on a value (`recv.method`). This boundary decides whether
                // the receiver is prepended, so it MUST agree with the call
                // plan's `free_fn_ufcs` (prepends) vs `namespace_fn` (does not)
                // classification — source it from the single definition in
                // `CallResolver` rather than re-deriving it here.
                const is_namespace = !self.callResolver().objectIsValue(fa.object);

                if (is_namespace) {
                    // Namespace call: module.func(args) — don't prepend object
                    const func_name = fa.field;
                    // Also try qualified name: Namespace.method (for struct methods)
                    const ns_name: ?[]const u8 = switch (fa.object.data) {
                        .identifier => |id| id.name,
                        .type_expr => |te| te.name,
                        else => null,
                    };
                    const qualified_name = if (ns_name) |n|
                        std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ n, fa.field }) catch func_name
                    else
                        func_name;
                    // Check for comptime-expanded or generic functions (try both names)
                    const effective_name = if (self.program_index.fn_ast_map.get(qualified_name) != null) qualified_name else func_name;
                    if (self.program_index.fn_ast_map.get(effective_name)) |fd| {
                        if (hasComptimeParams(fd)) {
                            return self.lowerComptimeCall(fd, c);
                        }
                        if (fd.type_params.len > 0) {
                            return self.lowerGenericCall(fd, effective_name, c, args.items);
                        }
                    }
                    if (self.program_index.fn_ast_map.contains(effective_name) and !self.lowered_functions.contains(effective_name)) {
                        self.lazyLowerFunction(effective_name);
                    }
                    if (self.resolveFuncByName(effective_name)) |fid| {
                        const func = &self.module.functions.items[@intFromEnum(fid)];
                        const ret_ty = func.ret;
                        const params = func.params;
                        if (self.program_index.fn_ast_map.get(effective_name)) |fd| {
                            self.packVariadicCallArgs(fd, c, &args);
                        }
                        const final_args = self.prependCtxIfNeeded(func, args.items);
                        self.coerceCallArgs(final_args, params);
                        if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
                        return self.builder.call(fid, final_args, ret_ty);
                    }
                    // Check if this is Type.variant(payload) — qualified enum construction
                    if (ns_name) |type_name| {
                        const type_name_id = self.module.types.internString(type_name);
                        if (self.module.types.findByName(type_name_id)) |union_ty| {
                            const type_info = self.module.types.get(union_ty);
                            if (type_info == .tagged_union) {
                                const tag = self.resolveVariantIndex(union_ty, func_name);
                                var payload = if (args.items.len > 0) args.items[0] else Ref.none;
                                // Coerce payload to match field type
                                if (!payload.isNone()) {
                                    const fields = type_info.tagged_union.fields;
                                    if (tag < fields.len) {
                                        const field_ty = fields[tag].ty;
                                        const payload_ty = self.inferExprType(c.args[0]);
                                        if (field_ty != payload_ty) {
                                            payload = self.coerceToType(payload, payload_ty, field_ty);
                                        }
                                    }
                                }
                                return self.builder.enumInit(tag, payload, union_ty);
                            }
                            if (type_info == .@"enum") {
                                const tag = self.resolveVariantIndex(union_ty, func_name);
                                return self.builder.enumInit(tag, Ref.none, union_ty);
                            }
                        }
                    }
                    return self.emitError(func_name, c.callee.span);
                }

                // Method call: obj.method(args) → prepend obj (or &obj for *Self receivers)
                // For ptr.*.method(): pass the pointer directly instead of loading + re-addressing.
                // This ensures mutations through self: *T are visible after the call.
                var obj_ty: TypeId = undefined;
                var obj: Ref = undefined;
                var effective_obj_node: *const Node = fa.object;
                if (fa.object.data == .deref_expr) {
                    effective_obj_node = fa.object.data.deref_expr.operand;
                    obj_ty = self.inferExprType(effective_obj_node);
                    obj = self.lowerExpr(effective_obj_node);
                } else {
                    obj_ty = self.inferExprType(fa.object);
                    obj = self.lowerExpr(fa.object);
                }

                // Check if field is a closure type — call as closure, not method
                if (!obj_ty.isBuiltin()) {
                    const field_name_id = self.module.types.internString(fa.field);
                    const struct_fields = self.getStructFields(obj_ty);
                    for (struct_fields, 0..) |f, fi| {
                        if (f.name == field_name_id and !f.ty.isBuiltin()) {
                            const fti = self.module.types.get(f.ty);
                            if (fti == .closure) {
                                // structGet requires an aggregate value; if obj is *T, load through it first.
                                var agg = obj;
                                const oi = self.module.types.get(obj_ty);
                                if (oi == .pointer) {
                                    agg = self.builder.load(obj, oi.pointer.pointee);
                                }
                                const closure_val = self.builder.structGet(agg, @intCast(fi), f.ty);
                                // Prepend ctx for sx-side closure call ABI.
                                const owned = if (self.implicit_ctx_enabled) blk: {
                                    const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
                                    arr[0] = self.current_ctx_ref;
                                    @memcpy(arr[1..], args.items);
                                    break :blk arr;
                                } else self.alloc.dupe(Ref, args.items) catch unreachable;
                                return self.builder.emit(.{ .call_closure = .{ .callee = closure_val, .args = owned } }, fti.closure.ret);
                            }
                        }
                    }
                }

                // Check if receiver is a protocol type → dispatch through vtable/fn_ptrs
                if (self.getProtocolInfo(obj_ty)) |proto_info| {
                    return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, obj_ty);
                }

                // Check if receiver is `?Protocol` — for sentinel-shaped
                // optionals (Protocol has ctx as first ptr field, and a
                // null ctx is the "none" state) the unwrap is a no-op
                // structurally. Treat the optional value as the protocol
                // value and dispatch. Calling a method on a null protocol
                // is undefined (same as derefing a null pointer); user
                // guards with `if x != null` first.
                if (!obj_ty.isBuiltin()) {
                    const opt_info = self.module.types.get(obj_ty);
                    if (opt_info == .optional) {
                        const pay_ty = opt_info.optional.child;
                        if (self.getProtocolInfo(pay_ty)) |proto_info| {
                            return self.emitProtocolDispatch(obj, proto_info, fa.field, args.items, pay_ty);
                        }
                    }
                }

                var method_args = std.ArrayList(Ref).empty;
                defer method_args.deinit(self.alloc);
                method_args.append(self.alloc, obj) catch unreachable;
                for (args.items) |a| {
                    method_args.append(self.alloc, a) catch unreachable;
                }

                // Foreign-class DSL: `inst.method(args)` where `inst`'s
                // type is an alias declared by `#jni_class("...") { ... }`
                // (or its parallel forms). Routes to the JNI dispatch
                // shape, descriptor derived from the sx signature.
                const struct_name = self.getStructTypeName(obj_ty);
                if (struct_name) |sname_for_foreign| {
                    if (self.program_index.foreign_class_map.get(sname_for_foreign)) |fcd| {
                        return self.lowerForeignMethodCall(fcd, fa.field, obj, args.items, c.callee.span);
                    }
                }

                // Try to resolve the method by struct type name
                if (struct_name) |sname| {
                    // Try direct qualified name: StructName.method
                    const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, fa.field }) catch fa.field;

                    // Generic #compiler method dispatch
                    if (self.program_index.fn_ast_map.get(qualified)) |method_fd| {
                        if (method_fd.body.data == .compiler_expr) {
                            const ret_ty = if (method_fd.return_type) |rt|
                                type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map)
                            else
                                .void;
                            return self.builder.compilerCall(qualified, method_args.items, ret_ty);
                        }
                    }

                    // Check for generic struct template method
                    if (self.struct_instance_template.get(sname)) |tmpl_name| {
                        // This is an instantiated generic struct — look up template method
                        const tmpl_qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ tmpl_name, fa.field }) catch fa.field;
                        if (self.program_index.fn_ast_map.get(tmpl_qualified)) |fd| {
                            // Get the stored type bindings for this instance
                            if (self.struct_instance_bindings.getPtr(sname)) |bindings| {
                                // Monomorphize the method with the struct's type bindings
                                const mangled = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, fa.field }) catch fa.field;
                                if (!self.lowered_functions.contains(mangled)) {
                                    self.monomorphizeFunction(fd, mangled, bindings);
                                }
                                if (self.resolveFuncByName(mangled)) |fid| {
                                    const func = &self.module.functions.items[@intFromEnum(fid)];
                                    const ret_ty = func.ret;
                                    const params = func.params;
                                    self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
                                    self.appendDefaultArgs(fd, &method_args);
                                    const final_args = self.prependCtxIfNeeded(func, method_args.items);
                                    self.coerceCallArgs(final_args, params);
                                    return self.builder.call(fid, final_args, ret_ty);
                                }
                            }
                        }
                    }

                    // Generic method on a non-template struct: `obj.method($T, ...)`
                    // or inferred form `obj.method(val)` where val's type pins $T.
                    if (self.program_index.fn_ast_map.get(qualified)) |gen_fd| {
                        if (gen_fd.type_params.len > 0 and gen_fd.body.data != .compiler_expr) {
                            // Effective AST args: prepend receiver so positions
                            // line up with fd.params (which has self at index 0).
                            var eff_args = std.ArrayList(*const Node).empty;
                            defer eff_args.deinit(self.alloc);
                            eff_args.append(self.alloc, effective_obj_node) catch unreachable;
                            for (c.args) |a| eff_args.append(self.alloc, a) catch unreachable;

                            var gbindings = self.genericResolver().buildTypeBindings(gen_fd, eff_args.items);
                            defer gbindings.deinit();

                            const gmangled = self.genericResolver().mangleGenericName(qualified, gen_fd, &gbindings);
                            if (!self.lowered_functions.contains(gmangled)) {
                                self.monomorphizeFunction(gen_fd, gmangled, &gbindings);
                            }
                            if (self.resolveFuncByName(gmangled)) |gfid| {
                                const gfunc = &self.module.functions.items[@intFromEnum(gfid)];
                                const gret_ty = gfunc.ret;
                                const gparams = gfunc.params;
                                // Strip type-decl slots from method_args. method_args[0] is the
                                // receiver (corresponds to fd.params[0] = self, never a type decl).
                                // Walk fd.params[1..], advance arg_idx through method_args[1..].
                                var gvalue_args = std.ArrayList(Ref).empty;
                                defer gvalue_args.deinit(self.alloc);
                                gvalue_args.append(self.alloc, method_args.items[0]) catch unreachable;
                                const types_explicit = method_args.items.len == gen_fd.params.len;
                                var arg_idx: usize = 1;
                                for (gen_fd.params[1..]) |p| {
                                    if (isTypeParamDecl(&p, gen_fd.type_params)) {
                                        if (types_explicit) arg_idx += 1;
                                        continue;
                                    }
                                    if (arg_idx < method_args.items.len) {
                                        gvalue_args.append(self.alloc, method_args.items[arg_idx]) catch unreachable;
                                    }
                                    arg_idx += 1;
                                }
                                self.fixupMethodReceiver(&gvalue_args, gfunc, effective_obj_node, obj_ty);
                                const final_args = self.prependCtxIfNeeded(gfunc, gvalue_args.items);
                                self.coerceCallArgs(final_args, gparams);
                                return self.builder.call(gfid, final_args, gret_ty);
                            }
                        }
                    }

                    // Try non-generic qualified method
                    if (self.program_index.fn_ast_map.get(qualified)) |fd| {
                        if (!self.lowered_functions.contains(qualified)) {
                            self.lazyLowerFunction(qualified);
                        }
                        _ = fd;
                    }
                    if (self.resolveFuncByName(qualified)) |fid| {
                        const func = &self.module.functions.items[@intFromEnum(fid)];
                        const ret_ty = func.ret;
                        const params = func.params;
                        const has_ctx = func.has_implicit_ctx;
                        self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
                        // Note: coerceCallArgs can trigger protocol thunk creation
                        // (module.addFunction), invalidating func pointer.
                        // Use pre-extracted params/ret_ty (+ has_ctx) instead of
                        // func.* after this.
                        const final_args = blk: {
                            if (!has_ctx) break :blk method_args.items;
                            const new_args = self.alloc.alloc(Ref, method_args.items.len + 1) catch break :blk method_args.items;
                            new_args[0] = self.current_ctx_ref;
                            @memcpy(new_args[1..], method_args.items);
                            break :blk new_args;
                        };
                        self.coerceCallArgs(final_args, params);
                        return self.builder.call(fid, final_args, ret_ty);
                    }
                }

                // Try to resolve as bare function name (free-function UFCS:
                // `recv.fn(args)` → `fn(recv, args)`). Lazily lower the body —
                // a function reached ONLY via UFCS would otherwise be declared
                // but never emitted (issue 0063: undefined symbol at link).
                if (self.program_index.fn_ast_map.get(fa.field)) |_| {
                    if (!self.lowered_functions.contains(fa.field)) {
                        self.lazyLowerFunction(fa.field);
                    }
                }
                if (self.resolveFuncByName(fa.field)) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    const ret_ty = func.ret;
                    const params = func.params;
                    // Same implicit address-of as a struct-defined method: if the
                    // free function's first param is `*T` and the receiver is a
                    // value `T`, pass its address instead of a by-value copy
                    // (issue 0063).
                    self.fixupMethodReceiver(&method_args, func, effective_obj_node, obj_ty);
                    const final_args = self.prependCtxIfNeeded(func, method_args.items);
                    self.coerceCallArgs(final_args, params);
                    return self.builder.call(fid, final_args, ret_ty);
                }
                return self.emitError(fa.field, c.callee.span);
            },
            .enum_literal => |el| {
                const target_opt: ?TypeId = self.target_type;

                // Try struct-method dispatch first: .{...}.method() where target is a struct
                if (target_opt) |tgt| {
                    if (!tgt.isBuiltin()) {
                        const target_info = self.module.types.get(tgt);
                        if (target_info == .@"struct") {
                            const struct_name = self.module.types.typeName(tgt);
                            const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ struct_name, el.name }) catch el.name;
                            if (self.program_index.fn_ast_map.get(qualified)) |fd| {
                                if (fd.type_params.len > 0) {
                                    return self.lowerGenericCall(fd, qualified, c, args.items);
                                }
                                if (!self.lowered_functions.contains(qualified)) {
                                    self.lazyLowerFunction(qualified);
                                }
                            }
                            if (self.resolveFuncByName(qualified)) |fid| {
                                const func = &self.module.functions.items[@intFromEnum(fid)];
                                const ret_ty = func.ret;
                                const params = func.params;
                                const final_args = self.prependCtxIfNeeded(func, args.items);
                                self.coerceCallArgs(final_args, params);
                                return self.builder.call(fid, final_args, ret_ty);
                            }
                        }
                    }
                }

                // .Variant(payload) — tagged enum construction. Requires target to be a tagged union.
                const target = blk: {
                    if (target_opt) |tgt| {
                        if (!tgt.isBuiltin() and self.module.types.get(tgt) == .tagged_union) break :blk tgt;
                    }
                    if (self.diagnostics) |diags| {
                        diags.addFmt(.err, c.callee.span, "cannot infer enum type for '.{s}' \u{2014} use an explicit type or assign to a typed variable", .{el.name});
                    }
                    return self.emitPlaceholder(el.name);
                };
                const tag = self.resolveVariantIndex(target, el.name);
                var payload = if (args.items.len > 0) args.items[0] else Ref.none;
                // Coerce payload to match the field type
                if (!payload.isNone() and !target.isBuiltin()) {
                    const info = self.module.types.get(target);
                    if (info == .tagged_union) {
                        const fields = info.tagged_union.fields;
                        if (tag < fields.len) {
                            const field_ty = fields[tag].ty;
                            const payload_ty = self.inferExprType(c.args[0]);
                            if (field_ty != payload_ty) {
                                payload = self.coerceToType(payload, payload_ty, field_ty);
                            }
                        }
                    }
                }
                return self.builder.enumInit(tag, payload, target);
            },
            else => {
                // Indirect call through expression
                const callee_ref = self.lowerExpr(c.callee);
                const owned = self.alloc.dupe(Ref, args.items) catch unreachable;
                return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, .s64);
            },
        }
    }

    /// Emit a diagnostic for code that needs `Context` (allocator
    /// protocol, `push Context.{...}`, the `context` identifier) when
    /// the program hasn't registered the type — i.e. doesn't transitively
    /// import `modules/std.sx`. Returns a placeholder Ref so the lowering
    /// can keep going and surface any additional errors.
    fn diagnoseMissingContext(self: *Lowering, what: []const u8) Ref {
        if (self.diagnostics) |d| {
            const span = ast.Span{ .start = 0, .end = 0 };
            d.addFmt(.err, span, "{s} requires the Context type — add `#import \"modules/std.sx\";` (or a module that imports it)", .{what});
        }
        return self.emitPlaceholder("missing-context");
    }

    /// Emit `context.allocator.alloc(size)` dispatch — used by internal
    /// compiler-driven heap copies (e.g. the `xx value` protocol-erasure
    /// path in `buildProtocolValue`). Routes through whatever allocator is
    /// currently installed in `context`, so a surrounding
    /// `push Context.{ allocator = my_alloc, ... }` actually backs every
    /// allocation including the ones the compiler inserts.
    ///
    /// If `Context` isn't registered (the program doesn't import std.sx),
    /// emits a diagnostic and returns a placeholder. We deliberately do
    /// NOT fall back to a direct libc malloc — that was the silent escape
    /// hatch that bit us through the implicit-context refactor (see the
    /// "Silent unimplemented arms" REJECTED PATTERN in CLAUDE.md).
    fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref {
        if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) {
            return self.diagnoseMissingContext("heap allocation");
        }
        const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
            return self.diagnoseMissingContext("heap allocation");
        };
        const ctx_ty_info = self.module.types.get(ctx_ty);
        if (ctx_ty_info != .@"struct" or ctx_ty_info.@"struct".fields.len < 1) {
            return self.diagnoseMissingContext("heap allocation");
        }
        const allocator_ty = ctx_ty_info.@"struct".fields[0].ty;
        const ctx = self.builder.load(self.current_ctx_ref, ctx_ty);
        const allocator = self.builder.structGet(ctx, 0, allocator_ty);
        // #inline Allocator protocol layout: { ctx, alloc_fn_ptr, dealloc_fn_ptr }.
        // field 0 = receiver ctx, field 1 = alloc fn-ptr.
        const alloc_ctx = self.builder.structGet(allocator, 0, void_ptr_ty);
        const fn_ptr = self.builder.structGet(allocator, 1, void_ptr_ty);
        // Allocator thunks are sx-side and carry the implicit __sx_ctx at
        // slot 0. Forward our caller's current_ctx_ref so the thunk's body
        // (and the concrete alloc method it forwards to) has a real
        // Context to thread on.
        const args = if (self.implicit_ctx_enabled)
            self.alloc.dupe(Ref, &.{ self.current_ctx_ref, alloc_ctx, size_ref }) catch unreachable
        else
            self.alloc.dupe(Ref, &.{ alloc_ctx, size_ref }) catch unreachable;
        return self.builder.emit(.{ .call_indirect = .{
            .callee = fn_ptr,
            .args = args,
        } }, void_ptr_ty);
    }

    /// Emit a call to a foreign-declared function looked up by name.
    /// Used for the compiler-internal byte-copy in the protocol-erasure
    /// heap path and the closure env-copy path, both of which need
    /// libc `memcpy` after the `#builtin` form was dropped.
    fn callForeign(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref {
        const fid = self.resolveFuncByName(name) orelse @panic("foreign symbol missing — std.sx not imported?");
        return self.builder.call(fid, args, ret_ty);
    }

    /// Prepend the caller's current `__sx_ctx` to `args` when the callee
    /// has the implicit context param. Returns either the original `args`
    /// (when no prepend is needed) or a newly-allocated slice with ctx at
    /// slot 0. The returned slice is mutable so callers can pass it
    /// straight into `coerceCallArgs`. Direct callers that built the args
    /// themselves with __sx_ctx already prepended (protocol thunks, FFI
    /// wrappers in Step 4) should NOT call this — they already manage
    /// slot 0.
    fn prependCtxIfNeeded(self: *Lowering, callee: *const Function, args: []Ref) []Ref {
        if (!callee.has_implicit_ctx) return args;
        const new_args = self.alloc.alloc(Ref, args.len + 1) catch return args;
        new_args[0] = self.current_ctx_ref;
        @memcpy(new_args[1..], args);
        return new_args;
    }

    pub fn resolveFuncByName(self: *Lowering, name: []const u8) ?FuncId {
        // Check foreign name map first (e.g., "c_abs" → "abs")
        const effective_name = self.foreign_name_map.get(name) orelse name;
        const name_id = self.module.types.internString(effective_name);
        for (self.module.functions.items, 0..) |func, i| {
            if (func.name == name_id) return FuncId.fromIndex(@intCast(i));
        }
        return null;
    }

    pub fn resolveBuiltin(name: []const u8) ?inst_mod.BuiltinId {
        const builtins = .{
            // Note: "print" is NOT here — it's a comptime-expanded function, not a simple builtin
            .{ "out", inst_mod.BuiltinId.out },
            .{ "sqrt", inst_mod.BuiltinId.sqrt },
            .{ "sin", inst_mod.BuiltinId.sin },
            .{ "cos", inst_mod.BuiltinId.cos },
            .{ "floor", inst_mod.BuiltinId.floor },
            .{ "size_of", inst_mod.BuiltinId.size_of },
            .{ "align_of", inst_mod.BuiltinId.align_of },
            .{ "cast", inst_mod.BuiltinId.cast },
        };
        inline for (builtins) |entry| {
            if (std.mem.eql(u8, name, entry[0])) return entry[1];
        }
        return null;
    }

    // ── Lambda/closure ────────────────────────────────────────────

    const CaptureInfo = struct {
        name: []const u8,
        ty: TypeId,
        ref: Ref, // alloca or value ref in the parent scope
        is_alloca: bool,
    };

    fn lowerLambda(self: *Lowering, lam: *const ast.Lambda) Ref {
        // Lower the lambda body as a new anonymous function
        var buf: [64]u8 = undefined;
        const name = std.fmt.bufPrint(&buf, "__lambda_{d}", .{self.block_counter}) catch "__lambda";
        self.block_counter += 1;

        // Collect lambda param names for exclusion from captures
        var param_names = std.StringHashMap(void).init(self.alloc);
        defer param_names.deinit();
        for (lam.params) |p| {
            param_names.put(p.name, {}) catch {};
        }

        // Pre-scan lambda body AST for free variables (captures)
        var captures = std.ArrayList(CaptureInfo).empty;
        defer captures.deinit(self.alloc);
        self.collectCaptures(lam.body, &param_names, &captures);

        // Deduplicate captures
        var seen = std.StringHashMap(void).init(self.alloc);
        defer seen.deinit();
        var deduped = std.ArrayList(CaptureInfo).empty;
        defer deduped.deinit(self.alloc);
        for (captures.items) |cap| {
            if (!seen.contains(cap.name)) {
                seen.put(cap.name, {}) catch {};
                deduped.append(self.alloc, cap) catch {};
            }
        }
        const capture_list = deduped.items;

        // Build env struct type if there are captures
        var env_struct_ty: TypeId = .void;
        if (capture_list.len > 0) {
            const env_field_data = self.alloc.alloc(types.TypeInfo.StructInfo.Field, capture_list.len) catch unreachable;
            for (capture_list, 0..) |cap, i| {
                var nbuf: [32]u8 = undefined;
                const fname = std.fmt.bufPrint(&nbuf, "cap_{d}", .{i}) catch "cap";
                env_field_data[i] = .{
                    .name = self.module.types.internString(fname),
                    .ty = cap.ty,
                };
            }
            const env_name = std.fmt.bufPrint(&buf, "__env_{d}", .{self.block_counter}) catch "__env";
            const env_name_id = self.module.types.internString(env_name);
            env_struct_ty = self.module.types.intern(.{ .@"struct" = .{
                .name = env_name_id,
                .fields = env_field_data,
            } });
        }

        // Save current builder state
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        const saved_scope = self.scope;

        // Build param list. Convention when implicit_ctx is enabled:
        //   slot 0 = __sx_ctx: *void
        //   slot 1 = env: *void
        //   slot 2+ = user params
        // Without implicit_ctx, env is slot 0 and user params follow.
        var params = std.ArrayList(Function.Param).empty;
        const env_ptr_ty = self.module.types.ptrTo(.void);
        const lambda_wants_ctx = self.implicit_ctx_enabled and lam.call_conv != .c;
        if (lambda_wants_ctx) {
            params.append(self.alloc, .{
                .name = self.module.types.internString("__sx_ctx"),
                .ty = env_ptr_ty,
            }) catch unreachable;
        }
        params.append(self.alloc, .{
            .name = self.module.types.internString("env"),
            .ty = env_ptr_ty,
        }) catch unreachable;
        // Get target closure param types for inference (from Closure(T1, T2) -> R annotations)
        const target_closure_params: ?[]const TypeId = if (self.target_type) |tt| blk: {
            if (!tt.isBuiltin()) {
                const tti = self.module.types.get(tt);
                if (tti == .closure) break :blk tti.closure.params;
                // Unwrap ?Closure(...) → Closure(...)
                if (tti == .optional) {
                    const inner = tti.optional.child;
                    if (!inner.isBuiltin()) {
                        const inner_info = self.module.types.get(inner);
                        if (inner_info == .closure) break :blk inner_info.closure.params;
                    }
                }
            }
            break :blk null;
        } else null;
        // User params follow the ctx (optional) + env slots in `params`.
        const user_param_base: usize = (if (lambda_wants_ctx) @as(usize, 1) else 0) + 1;
        for (lam.params, 0..) |p, pi| {
            const pty: TypeId = blk: {
                // Unannotated lambda params take their type positionally from
                // the target `Closure(T0, …)` signature. Resolve them here so
                // `resolveParamType` (which would diagnose a missing annotation)
                // is only called for params that carry one.
                if (p.type_expr.data == .inferred_type) {
                    if (target_closure_params != null and pi < target_closure_params.?.len) {
                        break :blk target_closure_params.?[pi];
                    }
                    if (self.diagnostics) |d| {
                        d.addFmt(.err, p.type_expr.span, "cannot infer type of lambda parameter '{s}'; annotate it or use the lambda where a closure type is expected", .{p.name});
                    }
                    break :blk .unresolved;
                }
                break :blk self.resolveParamType(&p);
            };
            params.append(self.alloc, .{
                .name = self.module.types.internString(p.name),
                .ty = pty,
            }) catch unreachable;
        }

        const ret_ty = blk: {
            if (lam.return_type) |rt| {
                break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map);
            }
            // Use target closure return type if available — but only when it's
            // a resolved type. An `.unresolved` ret comes from an unbound
            // generic (`Closure(..) -> $R`); fall through to infer it from the
            // body so the concrete return drives `$R` inference at the call site.
            if (self.target_type) |tt| {
                if (!tt.isBuiltin()) {
                    const tti = self.module.types.get(tt);
                    if (tti == .closure and tti.closure.ret != .unresolved) break :blk tti.closure.ret;
                    // Unwrap ?Closure(...) → Closure(...)
                    if (tti == .optional) {
                        const inner = tti.optional.child;
                        if (!inner.isBuiltin()) {
                            const inner_info = self.module.types.get(inner);
                            if (inner_info == .closure and inner_info.closure.ret != .unresolved) break :blk inner_info.closure.ret;
                        }
                    }
                }
            }
            // Arrow lambda without explicit return type — infer from body expression
            // Temporarily bind params in scope so inferExprType can resolve param types
            var temp_scope = Scope.init(self.alloc, self.scope);
            const saved = self.scope;
            self.scope = &temp_scope;
            for (lam.params, 0..) |p, i| {
                const pty = params.items[user_param_base + i].ty;
                temp_scope.put(p.name, .{ .ref = @enumFromInt(0), .ty = pty, .is_alloca = false });
            }
            const inferred = self.inferExprType(lam.body);
            self.scope = saved;
            temp_scope.deinit();
            break :blk inferred;
        };
        const name_id = self.module.types.internString(name);
        const func_id = self.builder.beginFunction(name_id, params.items, ret_ty);
        if (lam.call_conv == .c) {
            self.module.getFunctionMut(func_id).call_conv = .c;
        }
        self.builder.currentFunc().has_implicit_ctx = lambda_wants_ctx;

        // Param-slot layout: ctx at 0 (if present), env at ctx_slots,
        // user args at ctx_slots+1.
        const lambda_ctx_slots: u32 = if (lambda_wants_ctx) 1 else 0;
        const env_param_idx: u32 = lambda_ctx_slots;
        const user_param_base_lam: u32 = lambda_ctx_slots + 1;

        // Save + rebind current_ctx_ref so the body's sx-to-sx calls
        // forward the trampoline's own ctx (slot 0).
        const saved_ctx_ref_lam = self.current_ctx_ref;
        defer self.current_ctx_ref = saved_ctx_ref_lam;
        if (lambda_wants_ctx) self.current_ctx_ref = Ref.fromIndex(0);

        // Create entry block
        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        // Create scope WITHOUT parent — captures are bound from env, not parent scope
        var lambda_scope = Scope.init(self.alloc, null);
        self.scope = &lambda_scope;

        // Bind captures from env struct (at env_param_idx)
        if (capture_list.len > 0) {
            const env_param_ref = Ref.fromIndex(env_param_idx);
            // Alloca env struct locally so struct_gep can resolve the type
            const env_local = self.builder.alloca(env_struct_ty);
            // Compute env size
            const env_byte_size_inner = self.computeEnvSize(capture_list);
            const env_size_val = self.builder.constInt(@intCast(env_byte_size_inner), .s64);
            // memcpy(local_alloca, env_param, size)
            _ = self.callForeign("memcpy", &.{ env_local, env_param_ref, env_size_val }, self.module.types.ptrTo(.void));

            for (capture_list, 0..) |cap, i| {
                // GEP into env struct to get field pointer
                const field_ptr = self.builder.structGepTyped(env_local, @intCast(i), self.module.types.ptrTo(cap.ty), env_struct_ty);
                // Load the captured value into a local alloca
                const loaded = self.builder.load(field_ptr, cap.ty);
                const slot = self.builder.alloca(cap.ty);
                self.builder.store(slot, loaded);
                lambda_scope.put(cap.name, .{ .ref = slot, .ty = cap.ty, .is_alloca = true });
            }
        }

        // Also need parent scope for function lookups (but not variable lookups)
        // Set up fn_names from parent scope chain
        {
            var s: ?*Scope = saved_scope;
            while (s) |scope| {
                var it = scope.fn_names.iterator();
                while (it.next()) |e| {
                    if (!lambda_scope.fn_names.contains(e.key_ptr.*)) {
                        lambda_scope.fn_names.put(e.key_ptr.*, e.value_ptr.*) catch {};
                    }
                }
                s = scope.parent;
            }
        }

        // Bind params (user args start at user_param_base_lam, shifted past ctx + env).
        // Use the signature types computed above (`params`), which already
        // applied contextual typing from the target closure to untyped params —
        // `resolveParamType` alone would drop it and default each to s64.
        for (lam.params, 0..) |p, i| {
            const pty = params.items[user_param_base + i].ty;
            const slot = self.builder.alloca(pty);
            const param_ref = Ref.fromIndex(user_param_base_lam + @as(u32, @intCast(i)));
            self.builder.store(slot, param_ref);
            lambda_scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
        }

        // Lower body — capture last expression as return value. The
        // `in_lambda_body` flag scopes the lambda-specific `raise`-not-failable
        // hint; save/restore so a lambda nested inside a regular function (or a
        // lambda inside a lambda) restores the enclosing context.
        const saved_in_lambda = self.in_lambda_body;
        self.in_lambda_body = true;
        if (ret_ty != .void) {
            if (self.lowerBlockValue(lam.body)) |val| {
                if (!self.currentBlockHasTerminator()) {
                    const val_ty = self.builder.getRefType(val);
                    // A value-carrying failable arrow lambda (`-> (T, !) => expr`)
                    // yields the bare success value; the compiler appends the
                    // no-error slot (0) — same as a `return v` in a block body.
                    if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) {
                        self.lowerFailableSuccessReturn(val, ret_ty, lam.body.span);
                    } else {
                        const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val;
                        self.builder.ret(coerced, ret_ty);
                    }
                }
            }
        } else {
            self.lowerBlock(lam.body);
        }
        self.in_lambda_body = saved_in_lambda;
        self.ensureTerminator(ret_ty);
        self.builder.finalize();

        // Restore builder state
        self.scope = saved_scope;
        lambda_scope.deinit();
        self.builder.func = saved_func;
        self.builder.current_block = saved_block;
        self.builder.inst_counter = saved_counter;
        // Restore the caller's `current_ctx_ref` BEFORE we emit the env
        // alloc/memcpy below — those run in the caller's scope, and
        // `allocViaContext` reads `current_ctx_ref` to find the
        // installed allocator. Without this, the env_heap dispatch
        // would still see `Ref.fromIndex(0)` (the lambda's own ctx
        // param), which doesn't exist in the caller's frame and
        // silently routes through the default context instead of any
        // surrounding `push Context.{ allocator = ... }`.
        self.current_ctx_ref = saved_ctx_ref_lam;

        // Closure flowing into a BARE function-pointer slot (`(T) -> U`, no env):
        // the slot is called without the closure env arg, so the closure fn can't
        // be passed directly. For a capture-free closure whose return type matches
        // the slot, emit an adapter with the bare ABI. Reject the cases the bare
        // ABI can't represent: a capturing closure (env has nowhere to live), and
        // a failable closure into a non-failable slot (foreign code can't observe
        // the error channel — ERR E5.1 FFI-boundary rule).
        if (self.target_type) |tt| {
            if (!tt.isBuiltin() and self.module.types.get(tt) == .function) {
                const slot_ret = self.module.types.get(tt).function.ret;
                const widen_ok = self.errorChannelOf(slot_ret) != null and self.errorChannelOf(ret_ty) == null and self.failableSuccessType(slot_ret) == ret_ty;
                if (capture_list.len > 0) {
                    if (self.diagnostics) |d| d.addFmt(.err, lam.body.span, "a capturing closure cannot be passed as a bare function pointer; declare the parameter type as `Closure(...)` so its environment is carried", .{});
                } else if (ret_ty == slot_ret or widen_ok) {
                    // Matching ABI, or a non-failable closure widening into a
                    // failable slot (∅ ⊆ slot set) — the adapter wraps {value, 0}.
                    const adapter = self.createClosureToBareFnAdapter(func_id, self.module.types.get(tt).function, ret_ty, lam.body.span);
                    return self.builder.emit(.{ .func_ref = adapter }, tt);
                } else if (self.errorChannelOf(ret_ty) != null and self.errorChannelOf(slot_ret) == null) {
                    if (self.diagnostics) |d| d.addFmt(.err, lam.body.span, "failable closure cannot be assigned to a non-failable function-type slot; foreign code can't observe the error channel — handle the error in a wrapper closure that absorbs it", .{});
                } else if (self.diagnostics) |d| {
                    d.addFmt(.err, lam.body.span, "closure return type does not match the function-type slot", .{});
                }
            }
        }

        // Create proper closure type (user-visible params only — skip ctx + env).
        const skip_count: usize = if (lambda_wants_ctx) 2 else 1;
        var param_types_list = std.ArrayList(TypeId).empty;
        for (params.items[skip_count..]) |p| {
            param_types_list.append(self.alloc, p.ty) catch unreachable;
        }
        const closure_ty = self.module.types.closureType(param_types_list.items, ret_ty);

        // Build env and closure in the caller's scope
        if (capture_list.len > 0) {
            // Alloca env struct on stack (so struct_gep can resolve the type)
            const env_local = self.builder.alloca(env_struct_ty);

            // Store captured values into env struct fields
            for (capture_list, 0..) |cap, i| {
                const gep = self.builder.structGepTyped(env_local, @intCast(i), self.module.types.ptrTo(cap.ty), env_struct_ty);
                const val = if (cap.is_alloca)
                    self.builder.load(cap.ref, cap.ty)
                else
                    cap.ref;
                self.builder.store(gep, val);
            }

            // Copy env to heap (so it outlives the stack frame).
            // Route through `context.allocator.alloc` rather than calling
            // libc malloc directly so closures respect a surrounding
            // `push Context.{ allocator = ... }` and a tracker / arena
            // counts the env allocation alongside everything else.
            const env_byte_size = self.computeEnvSize(capture_list);
            const env_size = self.builder.constInt(@intCast(env_byte_size), .s64);
            const ptr_void = self.module.types.ptrTo(.void);
            const env_heap = self.allocViaContext(env_size, ptr_void);
            // memcpy(heap, stack_alloca, size)
            _ = self.callForeign("memcpy", &.{ env_heap, env_local, env_size }, ptr_void);

            return self.builder.closureCreate(func_id, env_heap, closure_ty);
        } else {
            return self.builder.closureCreate(func_id, Ref.none, closure_ty);
        }
    }

    /// Create a trampoline function that wraps a bare function for closure auto-promotion.
    /// The trampoline has signature `(env: *void, args...) -> ret` and simply calls the
    /// bare function with `(args...)`, ignoring the env parameter.
    fn createBareFnTrampoline(self: *Lowering, bare_func_id: FuncId, closure_info: types.TypeInfo.ClosureInfo) FuncId {
        // Build trampoline params: [__sx_ctx]? + env + closure params.
        // When the program uses Context, every sx-side trampoline carries
        // the implicit ctx at slot 0 and forwards it to the wrapped
        // function (which is also sx-side and expects it at slot 0).
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        defer params.deinit(self.alloc);
        const void_ptr_ty = self.module.types.ptrTo(.void);
        const wants_ctx = self.implicit_ctx_enabled;
        if (wants_ctx) {
            params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr_ty }) catch unreachable;
        }
        const env_name = self.module.types.internString("env");
        params.append(self.alloc, .{ .name = env_name, .ty = void_ptr_ty }) catch unreachable;
        for (closure_info.params, 0..) |pty, i| {
            var buf: [32]u8 = undefined;
            const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg";
            params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable;
        }

        // Generate unique trampoline name
        const bare_func = self.module.functions.items[bare_func_id.index()];
        const bare_name = self.module.types.getString(bare_func.name);
        var name_buf: [128]u8 = undefined;
        const tramp_name = std.fmt.bufPrint(&name_buf, "__tramp_{s}", .{bare_name}) catch "__tramp";
        const tramp_name_id = self.module.types.internString(tramp_name);

        // Save builder state
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;

        // Create function
        const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable;
        var func = inst_mod.Function.init(tramp_name_id, owned_params, closure_info.ret);
        func.has_implicit_ctx = wants_ctx;
        const func_id = self.module.addFunction(func);
        self.builder.func = func_id;
        self.builder.inst_counter = @intCast(owned_params.len); // params occupy refs 0..N-1
        const entry_name = self.module.types.internString("entry");
        const entry_block = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry_block);

        // Build call args: forward [__sx_ctx]? + user_params (skip env).
        // Trampoline slots: 0=ctx (if present), {0|1}=env, then user args.
        const ctx_slots: usize = if (wants_ctx) 1 else 0;
        const user_arg_start: u32 = @intCast(ctx_slots + 1); // skip ctx + env
        var call_args = std.ArrayList(Ref).empty;
        defer call_args.deinit(self.alloc);
        if (wants_ctx and bare_func.has_implicit_ctx) {
            call_args.append(self.alloc, Ref.fromIndex(0)) catch unreachable; // forward our ctx
        }
        for (closure_info.params, 0..) |_, i| {
            call_args.append(self.alloc, Ref.fromIndex(user_arg_start + @as(u32, @intCast(i)))) catch unreachable;
        }
        const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable;
        const result = self.builder.emit(.{ .call = .{ .callee = bare_func_id, .args = owned_args } }, closure_info.ret);

        // Return result (or void)
        if (closure_info.ret != .void) {
            self.builder.ret(result, closure_info.ret);
        } else {
            self.builder.retVoid();
        }
        self.builder.finalize();

        // Restore builder state
        self.builder.func = saved_func;
        self.builder.current_block = saved_block;
        self.builder.inst_counter = saved_counter;

        return func_id;
    }

    /// Adapter for coercing a closure into a BARE function-pointer slot
    /// (`(T) -> U`, no env). The closure's underlying function has signature
    /// `[ctx?] + env + user-params`, but a bare fn-ptr slot is *called* without
    /// the env arg — so the closure fn can't be used directly (the env slot
    /// would swallow the first user arg). This adapter carries the bare ABI
    /// (`[ctx?] + user-params`) and forwards to the closure fn with a null env.
    /// Only sound for capture-free closures (a null env is correct iff the body
    /// reads no captures); the caller rejects capturing closures.
    ///
    /// When `closure_ret` differs from `fn_info.ret`, this is the ∅-widening
    /// case (a non-failable closure into a failable slot): the closure returns
    /// the success value and the adapter wraps it into the slot's `{value, 0}`
    /// failable tuple (ERR E5.1 non-failable→failable widening).
    fn createClosureToBareFnAdapter(self: *Lowering, closure_func_id: FuncId, fn_info: types.TypeInfo.FunctionInfo, closure_ret: TypeId, span: ast.Span) FuncId {
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        defer params.deinit(self.alloc);
        const void_ptr_ty = self.module.types.ptrTo(.void);
        const wants_ctx = self.implicit_ctx_enabled;
        if (wants_ctx) {
            params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr_ty }) catch unreachable;
        }
        for (fn_info.params, 0..) |pty, i| {
            var buf: [32]u8 = undefined;
            const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg";
            params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable;
        }

        const closure_func = self.module.functions.items[closure_func_id.index()];
        const closure_name = self.module.types.getString(closure_func.name);
        var name_buf: [128]u8 = undefined;
        const adapter_name = std.fmt.bufPrint(&name_buf, "__cl2fn_{s}", .{closure_name}) catch "__cl2fn";
        const adapter_name_id = self.module.types.internString(adapter_name);

        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;

        const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable;
        var func = inst_mod.Function.init(adapter_name_id, owned_params, fn_info.ret);
        func.has_implicit_ctx = wants_ctx;
        const func_id = self.module.addFunction(func);
        self.builder.func = func_id;
        self.builder.inst_counter = @intCast(owned_params.len);
        const entry_name = self.module.types.internString("entry");
        const entry_block = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry_block);

        // Forward [ctx?] + null env + user params to the closure fn.
        const ctx_slots: usize = if (wants_ctx) 1 else 0;
        var call_args = std.ArrayList(Ref).empty;
        defer call_args.deinit(self.alloc);
        if (wants_ctx) call_args.append(self.alloc, Ref.fromIndex(0)) catch unreachable;
        call_args.append(self.alloc, self.builder.constNull(void_ptr_ty)) catch unreachable;
        for (fn_info.params, 0..) |_, i| {
            call_args.append(self.alloc, Ref.fromIndex(@intCast(ctx_slots + i))) catch unreachable;
        }
        const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable;
        const result = self.builder.emit(.{ .call = .{ .callee = closure_func_id, .args = owned_args } }, closure_ret);
        if (closure_ret == fn_info.ret) {
            if (fn_info.ret != .void) {
                self.builder.ret(result, fn_info.ret);
            } else {
                self.builder.retVoid();
            }
        } else {
            // ∅-widening: closure returns the success value; wrap `{value, 0}`
            // into the slot's failable tuple.
            self.lowerFailableSuccessReturn(result, fn_info.ret, span);
        }
        self.builder.finalize();

        self.builder.func = saved_func;
        self.builder.current_block = saved_block;
        self.builder.inst_counter = saved_counter;
        return func_id;
    }

    /// Walk an AST node and collect free variable references (identifiers that are
    /// in the current scope but not in lambda params).
    fn collectCaptures(self: *Lowering, node: *const Node, param_names: *std.StringHashMap(void), captures: *std.ArrayList(CaptureInfo)) void {
        switch (node.data) {
            .identifier => |id| {
                // Skip lambda params
                if (param_names.contains(id.name)) return;
                // Skip function names
                if (self.program_index.fn_ast_map.contains(id.name)) return;
                // Skip type names
                if (self.program_index.struct_template_map.contains(id.name)) return;
                // Check if it's a variable in the parent scope
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        captures.append(self.alloc, .{
                            .name = id.name,
                            .ty = binding.ty,
                            .ref = binding.ref,
                            .is_alloca = binding.is_alloca,
                        }) catch {};
                    }
                }
            },
            .binary_op => |bo| {
                self.collectCaptures(bo.lhs, param_names, captures);
                self.collectCaptures(bo.rhs, param_names, captures);
            },
            .unary_op => |uo| {
                self.collectCaptures(uo.operand, param_names, captures);
            },
            .call => |cl| {
                self.collectCaptures(cl.callee, param_names, captures);
                for (cl.args) |arg| {
                    self.collectCaptures(arg, param_names, captures);
                }
            },
            .block => |blk| {
                for (blk.stmts) |stmt| {
                    self.collectCaptures(stmt, param_names, captures);
                }
            },
            .if_expr => |ie| {
                self.collectCaptures(ie.condition, param_names, captures);
                self.collectCaptures(ie.then_branch, param_names, captures);
                if (ie.else_branch) |eb| self.collectCaptures(eb, param_names, captures);
            },
            .while_expr => |we| {
                self.collectCaptures(we.condition, param_names, captures);
                self.collectCaptures(we.body, param_names, captures);
            },
            .return_stmt => |rs| {
                if (rs.value) |v| self.collectCaptures(v, param_names, captures);
            },
            .var_decl => |vd| {
                if (vd.value) |v| self.collectCaptures(v, param_names, captures);
                // Register the local var name so it's not captured
                param_names.put(vd.name, {}) catch {};
            },
            .const_decl => |cd| {
                self.collectCaptures(cd.value, param_names, captures);
                param_names.put(cd.name, {}) catch {};
            },
            .assignment => |a| {
                self.collectCaptures(a.target, param_names, captures);
                self.collectCaptures(a.value, param_names, captures);
            },
            .destructure_decl => |dd| {
                self.collectCaptures(dd.value, param_names, captures);
                for (dd.names) |name| {
                    param_names.put(name, {}) catch {};
                }
            },
            .field_access => |fa| {
                self.collectCaptures(fa.object, param_names, captures);
            },
            .index_expr => |ie| {
                self.collectCaptures(ie.object, param_names, captures);
                self.collectCaptures(ie.index, param_names, captures);
            },
            .struct_literal => |sl| {
                for (sl.field_inits) |fi| {
                    self.collectCaptures(fi.value, param_names, captures);
                }
            },
            .array_literal => |al| {
                for (al.elements) |elem| {
                    self.collectCaptures(elem, param_names, captures);
                }
            },
            .lambda => |inner_lam| {
                // For nested lambdas, the inner lambda captures from our scope too
                // But its own params should be excluded
                var inner_params = std.StringHashMap(void).init(self.alloc);
                defer inner_params.deinit();
                // Copy current param_names
                var it = param_names.iterator();
                while (it.next()) |e| {
                    inner_params.put(e.key_ptr.*, {}) catch {};
                }
                for (inner_lam.params) |p| {
                    inner_params.put(p.name, {}) catch {};
                }
                self.collectCaptures(inner_lam.body, &inner_params, captures);
            },
            .match_expr => |me| {
                self.collectCaptures(me.subject, param_names, captures);
                for (me.arms) |arm| {
                    self.collectCaptures(arm.body, param_names, captures);
                }
            },
            .null_coalesce => |nc| {
                self.collectCaptures(nc.lhs, param_names, captures);
                self.collectCaptures(nc.rhs, param_names, captures);
            },
            .deref_expr => |de| {
                self.collectCaptures(de.operand, param_names, captures);
            },
            .for_expr => |fe| {
                self.collectCaptures(fe.iterable, param_names, captures);
                // Register capture name as local so it's not captured
                param_names.put(fe.capture_name, {}) catch {};
                self.collectCaptures(fe.body, param_names, captures);
            },
            .slice_expr => |se| {
                self.collectCaptures(se.object, param_names, captures);
                if (se.start) |s| self.collectCaptures(s, param_names, captures);
                if (se.end) |e| self.collectCaptures(e, param_names, captures);
            },
            .tuple_literal => |tl| {
                for (tl.elements) |elem| {
                    self.collectCaptures(elem.value, param_names, captures);
                }
            },
            .force_unwrap => |fu| {
                self.collectCaptures(fu.operand, param_names, captures);
            },
            .chained_comparison => |cc| {
                for (cc.operands) |op| {
                    self.collectCaptures(op, param_names, captures);
                }
            },
            .defer_stmt => |ds| {
                self.collectCaptures(ds.expr, param_names, captures);
            },
            .ffi_intrinsic_call => |fic| {
                self.collectCaptures(fic.return_type, param_names, captures);
                for (fic.args) |arg| {
                    self.collectCaptures(arg, param_names, captures);
                }
            },
            else => {},
        }
    }

    /// Compute the byte size of the env struct based on captured value types.
    fn computeEnvSize(self: *Lowering, capture_list: []const CaptureInfo) usize {
        // Must match LLVM's struct layout: fields are aligned to their natural alignment
        var offset: usize = 0;
        var max_align: usize = 1;
        for (capture_list) |cap| {
            const field_size = self.typeSizeBytes(cap.ty);
            const field_align = self.typeAlignBytes(cap.ty);
            if (field_align > max_align) max_align = field_align;
            // Align offset to field alignment
            offset = (offset + field_align - 1) & ~(field_align - 1);
            offset += field_size;
        }
        // Align total to max field alignment (matches LLVM's struct alignment)
        return (offset + max_align - 1) & ~(max_align - 1);
    }

    /// Byte size of an IR type matching LLVM's type layout.
    fn typeSizeBytes(self: *Lowering, ty: TypeId) usize {
        return self.module.types.typeSizeBytes(ty);
    }

    fn typeAlignBytes(self: *Lowering, ty: TypeId) usize {
        return self.module.types.typeAlignBytes(ty);
    }

    fn resolveReturnType2(self: *Lowering, rt: ?*const Node) TypeId {
        if (rt) |r| return type_bridge.resolveAstType(r, &self.module.types, &self.program_index.type_alias_map);
        return .void;
    }

    // ── Chained comparison ──────────────────────────────────────────

    fn lowerChainedComparison(self: *Lowering, cc: *const ast.ChainedComparison) Ref {
        // a < b < c → (a < b) and (b < c)
        // Pre-lower all operands so shared ones (e.g., b) aren't evaluated twice.
        if (cc.operands.len < 2 or cc.ops.len == 0) {
            return self.builder.constBool(true);
        }

        var refs = std.ArrayList(Ref).empty;
        defer refs.deinit(self.alloc);
        for (cc.operands) |op| {
            refs.append(self.alloc, self.lowerExpr(op)) catch unreachable;
        }

        var result = self.emitCmp(refs.items[0], refs.items[1], cc.ops[0]);

        var i: usize = 1;
        while (i < cc.ops.len) : (i += 1) {
            const next_cmp = self.emitCmp(refs.items[i], refs.items[i + 1], cc.ops[i]);
            result = self.builder.emit(.{ .bool_and = .{ .lhs = result, .rhs = next_cmp } }, .bool);
        }

        return result;
    }

    fn emitCmp(self: *Lowering, lhs: Ref, rhs: Ref, op: ast.BinaryOp.Op) Ref {
        return switch (op) {
            .eq => self.builder.cmpEq(lhs, rhs),
            .neq => self.builder.emit(.{ .cmp_ne = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            .lt => self.builder.cmpLt(lhs, rhs),
            .lte => self.builder.emit(.{ .cmp_le = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            .gt => self.builder.cmpGt(lhs, rhs),
            .gte => self.builder.emit(.{ .cmp_ge = .{ .lhs = lhs, .rhs = rhs } }, .bool),
            else => self.builder.constBool(false),
        };
    }

    // ── Defer/Push/MultiAssign ──────────────────────────────────────

    fn lowerDefer(self: *Lowering, ds: *const ast.DeferStmt) void {
        // Push deferred expression onto the stack — emitted at every block exit, LIFO.
        self.defer_stack.append(self.alloc, .{ .body = ds.expr, .is_onfail = false }) catch {};
    }

    /// `onfail [e] BODY` (ERR E1.7) — cleanup that runs only when an error
    /// leaves the enclosing block. Recorded on the shared cleanup stack;
    /// emitted (interleaved with defers, reverse) at error exits by
    /// `emitErrorCleanup`, and discarded — never run — on a success exit.
    fn lowerOnFail(self: *Lowering, ofs: *const ast.OnFailStmt, span: ast.Span) void {
        // `onfail` is only meaningful inside a failable function — a
        // non-failable function never error-exits, so it could never fire.
        const ret_ty = self.effectiveReturnType() orelse {
            self.diagOnFailNotFailable(span);
            return;
        };
        if (self.errorChannelOf(ret_ty) == null) {
            self.diagOnFailNotFailable(span);
            return;
        }
        self.defer_stack.append(self.alloc, .{ .body = ofs.body, .is_onfail = true, .binding = ofs.binding }) catch {};
    }

    fn diagOnFailNotFailable(self: *Lowering, span: ast.Span) void {
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, span, "`onfail` is only valid inside a failable function (a return type with `!` or `!Named`) — use `defer` for unconditional cleanup", .{});
        }
    }

    /// Emit cleanups from saved_len..current in reverse (LIFO) order on a
    /// SUCCESS exit: only `defer` entries run; `onfail` entries are skipped
    /// (and discarded by the truncation). Truncates the stack to saved_len.
    fn emitBlockDefers(self: *Lowering, saved_len: usize) void {
        // Guard: if stack was already drained (e.g., by a return that emitted all defers)
        if (saved_len > self.defer_stack.items.len) return;
        if (self.currentBlockHasTerminator()) {
            // Block already terminated (e.g., by return) — cleanups were already emitted
            self.defer_stack.shrinkRetainingCapacity(saved_len);
            return;
        }
        const stack = self.defer_stack.items;
        var i = stack.len;
        while (i > saved_len) {
            i -= 1;
            if (!stack[i].is_onfail) self.lowerCleanupBody(stack[i].body);
        }
        self.defer_stack.shrinkRetainingCapacity(saved_len);
    }

    /// Run a `defer`/`onfail` cleanup body for its side effects (void context).
    /// A braced body lowers as statements (NOT as a value) so a trailing-`;`
    /// last expression is fine here — cleanup bodies never yield a value.
    fn lowerCleanupBody(self: *Lowering, body: *const Node) void {
        if (body.data == .block) self.lowerBlock(body) else _ = self.lowerExpr(body);
    }

    /// Emit cleanups from `base`..current in reverse order on an ERROR exit
    /// (raise / try-propagation): BOTH `defer` and `onfail` entries run,
    /// interleaved in reverse declaration order. `err_tag` is the in-flight
    /// error tag, bound to each `onfail e`'s binding. Does not truncate — the
    /// terminating `ret` + the unwinding block-scope `emitBlockDefers` (which
    /// then see the terminator and skip) leave the stack consistent.
    fn emitErrorCleanup(self: *Lowering, base: usize, err_tag: Ref) void {
        if (base > self.defer_stack.items.len) return;
        const tag_ty = self.builder.getRefType(err_tag);
        const stack = self.defer_stack.items;
        var i = stack.len;
        while (i > base) {
            i -= 1;
            const entry = stack[i];
            if (entry.is_onfail) {
                if (entry.binding) |name| {
                    var ofscope = Scope.init(self.alloc, self.scope);
                    const saved = self.scope;
                    self.scope = &ofscope;
                    ofscope.put(name, .{ .ref = err_tag, .ty = tag_ty, .is_alloca = false });
                    self.lowerCleanupBody(entry.body);
                    self.scope = saved;
                    ofscope.deinit();
                } else {
                    self.lowerCleanupBody(entry.body);
                }
            } else {
                self.lowerCleanupBody(entry.body);
            }
        }
    }

    fn lowerPush(self: *Lowering, ps: *const ast.PushStmt) void {
        // push Context.{...} { body } — allocates a fresh Context on the
        // stack frame, rebinds the lowering's `current_ctx_ref` to it for
        // the body's lexical scope, then restores. No global, no walk.
        if (!self.implicit_ctx_enabled) {
            _ = self.diagnoseMissingContext("`push Context.{...}`");
            self.lowerBlock(ps.body);
            return;
        }
        const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
            _ = self.diagnoseMissingContext("`push Context.{...}`");
            self.lowerBlock(ps.body);
            return;
        };
        const saved_ctx_ref = self.current_ctx_ref;
        defer self.current_ctx_ref = saved_ctx_ref;

        const saved_target = self.target_type;
        self.target_type = ctx_ty;
        const ctx_val = self.lowerExpr(ps.context_expr);
        self.target_type = saved_target;

        const slot = self.builder.alloca(ctx_ty);
        self.builder.store(slot, ctx_val);
        self.current_ctx_ref = slot;

        self.lowerBlock(ps.body);
    }

    fn lowerMultiAssign(self: *Lowering, ma: *const ast.MultiAssign) void {
        // Evaluate all RHS values first, then assign to LHS targets
        var vals = std.ArrayList(Ref).empty;
        defer vals.deinit(self.alloc);
        for (ma.values) |v| {
            vals.append(self.alloc, self.lowerExpr(v)) catch unreachable;
        }

        for (ma.targets, 0..) |target, i| {
            if (i >= vals.items.len) break;
            const val = vals.items[i];
            switch (target.data) {
                .identifier => |id| {
                    if (self.scope) |scope| {
                        if (scope.lookup(id.name)) |binding| {
                            if (binding.is_alloca) {
                                const val_ty = self.builder.getRefType(val);
                                const store_val = if (val_ty != binding.ty and val_ty != .void and binding.ty != .void)
                                    self.coerceToType(val, val_ty, binding.ty)
                                else
                                    val;
                                self.builder.store(binding.ref, store_val);
                            }
                        }
                    }
                },
                .index_expr => |ie| {
                    const idx = self.lowerExpr(ie.index);
                    const obj_ty = self.inferExprType(ie.object);
                    const elem_ty = self.getElementType(obj_ty);
                    const ptr_ty = self.module.types.ptrTo(elem_ty);
                    const val_ty = self.builder.getRefType(val);
                    const store_val = if (val_ty != elem_ty and val_ty != .void and elem_ty != .void)
                        self.coerceToType(val, val_ty, elem_ty)
                    else
                        val;
                    // For fixed-size arrays, use the alloca pointer directly
                    const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
                    const obj_alloca = if (is_array) self.getExprAlloca(ie.object) else null;
                    if (obj_alloca) |alloca_ref| {
                        const gep = self.builder.emit(.{ .index_gep = .{ .lhs = alloca_ref, .rhs = idx } }, ptr_ty);
                        self.builder.store(gep, store_val);
                    } else {
                        const obj = self.lowerExpr(ie.object);
                        const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj, .rhs = idx } }, ptr_ty);
                        self.builder.store(gep, store_val);
                    }
                },
                .field_access => |fa| {
                    const obj_ptr = self.lowerExprAsPtr(fa.object);
                    const obj_ty = self.inferExprType(fa.object);
                    const field_name_id = self.module.types.internString(fa.field);
                    const struct_fields = self.getStructFields(obj_ty);
                    var field_idx: u32 = 0;
                    var field_ty: TypeId = .unresolved;
                    for (struct_fields, 0..) |f, fi| {
                        if (f.name == field_name_id) {
                            field_idx = @intCast(fi);
                            field_ty = f.ty;
                            break;
                        }
                    }
                    const gep = self.builder.structGepTyped(obj_ptr, field_idx, field_ty, obj_ty);
                    const val_ty = self.builder.getRefType(val);
                    const store_val = if (val_ty != field_ty and val_ty != .void and field_ty != .void)
                        self.coerceToType(val, val_ty, field_ty)
                    else
                        val;
                    self.builder.store(gep, store_val);
                },
                .deref_expr => |de| {
                    const ptr = self.lowerExpr(de.operand);
                    const pointee_ty = blk: {
                        const ptr_ty = self.inferExprType(de.operand);
                        if (!ptr_ty.isBuiltin()) {
                            const info = self.module.types.get(ptr_ty);
                            if (info == .pointer) break :blk info.pointer.pointee;
                        }
                        break :blk ptr_ty;
                    };
                    const val_ty = self.builder.getRefType(val);
                    const store_val = if (val_ty != pointee_ty and val_ty != .void and pointee_ty != .void)
                        self.coerceToType(val, val_ty, pointee_ty)
                    else
                        val;
                    self.builder.store(ptr, store_val);
                },
                else => {
                    _ = self.emitError("multi_assign_target", target.span);
                },
            }
        }
    }

    fn lowerDestructureDecl(self: *Lowering, dd: *const ast.DestructureDecl) void {
        // Lower the RHS expression (must produce a tuple)
        const saved_fbv = self.force_block_value;
        self.force_block_value = true;
        const ref = self.lowerExpr(dd.value);
        self.force_block_value = saved_fbv;
        const ty = self.builder.getRefType(ref);

        // Get tuple field info
        if (ty.isBuiltin()) return;
        const ti = self.module.types.get(ty);
        if (ti != .tuple) return;
        const tuple = ti.tuple;
        if (dd.names.len > tuple.fields.len) return;

        // E1.8 (discard rejection): when the RHS is a value-carrying failable,
        // the error slot (always the LAST tuple field) cannot be dropped. It is
        // dropped when the destructure omits it (fewer names than fields, so the
        // trailing error slot is never reached) or binds it to `_`. The `try` /
        // `catch` / `or value` consumer forms all strip the error channel (their
        // result type is non-failable), so this fires only on a BARE failable
        // destructure — exactly the case that would let an error vanish silently.
        if (self.errorChannelOf(ty) != null) {
            const err_dropped = dd.names.len < tuple.fields.len or
                std.mem.eql(u8, dd.names[dd.names.len - 1], "_");
            if (err_dropped) {
                if (self.diagnostics) |diags| {
                    diags.addFmt(.err, dd.value.span, "the error slot of a failable cannot be dropped — bind it (`v, err := …`) and handle it, or use `try` / `catch`", .{});
                }
            }
        }

        // Extract each field and bind to a new variable
        for (dd.names, 0..) |name, i| {
            if (std.mem.eql(u8, name, "_")) continue; // discard
            const field_ty = tuple.fields[i];
            const field_val = self.builder.emit(.{ .tuple_get = .{
                .base = ref,
                .field_index = @intCast(i),
                .base_type = ty,
            } }, field_ty);
            const slot = self.builder.alloca(field_ty);
            self.builder.store(slot, field_val);
            if (self.scope) |scope| {
                scope.put(name, .{ .ref = slot, .ty = field_ty, .is_alloca = true });
            }
        }

        // Destructuring a failable's result binds the error slot to a variable:
        // the user now owns the error explicitly, so the trace is absorbed
        // (ERR E3.2). A plain (non-failable) tuple destructure clears nothing.
        if (self.errorChannelOf(ty) != null) self.emitTraceClear();
    }

    // ── Comptime lowering ────────────────────────────────────────────

    /// Lower a `#run expr` that appears as a top-level constant binding:
    ///   NAME :: #run expr;
    /// Creates a comptime function wrapping the expression (for later
    /// interpretation), plus a global constant to hold the result.
    fn lowerComptimeGlobal(self: *Lowering, name: []const u8, expr: *const Node, type_ann: ?*const Node) void {
        // When the user writes `NAME :: #run expr;` with no type annotation,
        // infer the global's type from the comptime expression's return
        // shape. `resolveType(null)` returns `.s64` for legacy reasons —
        // good for primitive helpers, silently wrong for anything else.
        const expr_ty = self.inferExprType(expr);
        // A failable `#run` (bare, no `catch`/`or`): the comptime function
        // returns the full failable tuple so the #run site can inspect the
        // error slot, but the GLOBAL is typed as the success value. On a
        // comptime error the global never materializes — emit halts with a
        // diagnostic + trace (E5.2). A handled `#run … catch/or …` already
        // strips the error channel, so it lands here as non-failable.
        const is_failable = self.errorChannelOf(expr_ty) != null;
        const func_ret: TypeId = if (is_failable)
            expr_ty
        else if (type_ann) |n|
            self.resolveTypeWithBindings(n)
        else
            expr_ty;
        const global_ty: TypeId = if (is_failable) self.failableSuccessType(expr_ty) else func_ret;
        const func_id = self.createComptimeFunction(name, expr, func_ret);

        // Add a global constant whose initializer will be filled by the interpreter.
        const name_id = self.module.types.internString(name);
        const gid = self.module.addGlobal(.{
            .name = name_id,
            .ty = global_ty,
            .init_val = null, // will be filled by interpreter at emit time
            .is_const = true,
            .comptime_func = func_id,
        });

        // Register for runtime lookup: identifier resolution emits global_get
        self.program_index.global_names.put(name, .{ .id = gid, .ty = global_ty }) catch {};
    }

    /// Lower a standalone `#run expr;` at the top level (side-effect only).
    /// Creates a comptime function that the interpreter should execute.
    fn lowerComptimeSideEffect(self: *Lowering, expr: *const Node) void {
        // A failable side-effect `#run f();` returns the failable tuple so the
        // emit-time runner can detect an escaping error and halt (E5.2);
        // non-failable side effects stay `void`.
        const expr_ty = self.inferExprType(expr);
        const ret: TypeId = if (self.errorChannelOf(expr_ty) != null) expr_ty else .void;
        _ = self.createComptimeFunction("__run", expr, ret);
    }

    /// Lower a `#run expr` that appears inline within an expression.
    /// Creates a comptime function and emits a `call` to it, so the
    /// interpreter can evaluate it and replace with the constant result.
    fn lowerInlineComptime(self: *Lowering, expr: *const Node) Ref {
        const ret_ty: TypeId = self.target_type orelse self.inferExprType(expr);
        const func_id = self.createComptimeFunction("__ct", expr, ret_ty);
        // Emit a call to the comptime function. At interpretation time,
        // this will be evaluated and the result inlined as a constant.
        const func = &self.module.functions.items[@intFromEnum(func_id)];
        const final_args: []const Ref = if (func.has_implicit_ctx)
            self.alloc.dupe(Ref, &.{self.current_ctx_ref}) catch &.{}
        else
            &.{};
        return self.builder.call(func_id, final_args, ret_ty);
    }

    /// Lower a `#insert expr` statement. Evaluates `expr` at compile time to get
    /// a string, parses it as sx code, and lowers each statement inline.
    fn lowerInsertExpr(self: *Lowering, expr: *const Node) void {
        _ = self.lowerInsertExprValue(expr);
    }

    /// Like lowerInsertExpr but returns the value of the last parsed expression.
    fn lowerInsertExprValue(self: *Lowering, expr: *const Node) Ref {
        // Step 1: Substitute comptime param nodes (e.g., replace $fmt with its literal)
        const substituted = if (self.comptime_param_nodes) |cpn|
            self.substituteComptimeNodes(expr, cpn) catch expr
        else
            expr;

        // Step 2: Evaluate the expression to get a string
        const code_str = self.evalComptimeString(substituted) orelse return self.builder.constInt(0, .void);

        // Step 3: Parse the string as sx code and lower each statement
        // The last expression's value is captured as the return value
        var p = parser_mod.Parser.init(self.alloc, code_str);
        var last_val: Ref = self.builder.constInt(0, .void);
        while (p.current.tag != .eof) {
            const stmt = p.parseStmt() catch break;
            if (p.current.tag == .eof) {
                // Last statement — try to capture as expression value
                // Note: tryLowerAsExpr internally calls lowerStmt for statement nodes,
                // so we must NOT call lowerStmt again in the else branch.
                if (self.tryLowerAsExpr(stmt)) |val| {
                    last_val = val;
                }
            } else {
                self.lowerStmt(stmt);
            }
        }
        return last_val;
    }

    /// Evaluate an expression at compile time, returning its string value.
    /// Returns null if evaluation fails.
    fn evalComptimeString(self: *Lowering, expr: *const Node) ?[:0]const u8 {
        // Case 1: String literal — return it directly (no need for interpreter)
        if (expr.data == .string_literal) {
            const lit = expr.data.string_literal;
            const str = if (lit.is_raw)
                lit.raw
            else
                unescape.unescapeString(self.alloc, lit.raw) catch lit.raw;
            return self.alloc.dupeZ(u8, str) catch null;
        }

        // Case 2: Evaluate via IR interpreter, reusing the parent module.
        // The parent's `scanDecls` pass has already registered every
        // type / protocol / impl / thunk the comptime call may need
        // (Allocator, CAllocator, Context, the per-impl thunks). A
        // fresh empty module would only lazy-lower function ASTs and
        // would miss the type/protocol registrations, which would break
        // `context.allocator.X` — the protocol dispatch chain needs
        // those types to resolve struct field layout and the alloc/
        // dealloc thunks at the bottom of the dispatch.
        const ct_func_id = self.createComptimeFunction("__insert", expr, .string);

        var interp = interp_mod.Interpreter.init(self.module, self.alloc);
        defer interp.deinit();
        if (self.diagnostics) |d| if (d.import_sources) |sm| interp.setSourceMap(sm);

        const result = interp.call(ct_func_id, &.{}) catch return null;

        const str = result.asString(&interp) orelse switch (result) {
            .string => |s| s,
            else => return null,
        };

        return self.alloc.dupeZ(u8, str) catch null;
    }

    /// Lower the direct callee of a comptime expression into the ct module.
    /// Transitive dependencies are resolved lazily via the shared fn_ast_map.
    fn lowerComptimeDeps(self: *Lowering, ct: *Lowering, expr: *const Node) void {
        if (expr.data != .call) return;
        if (expr.data.call.callee.data != .identifier) return;
        const name = expr.data.call.callee.data.identifier.name;
        if (resolveBuiltin(name) != null) return;
        if (self.program_index.fn_ast_map.get(name)) |fd| {
            if (ct.resolveFuncByName(name) == null) {
                ct.lowerFunction(fd, name, false);
            }
        }
    }

    /// Substitute comptime parameter identifiers with their actual AST nodes.
    fn substituteComptimeNodes(self: *Lowering, node: *const Node, cpn: std.StringHashMap(*const Node)) !*const Node {
        // Direct identifier match
        if (node.data == .identifier) {
            if (cpn.get(node.data.identifier.name)) |replacement| {
                return replacement;
            }
        }

        // Recurse into call arguments
        if (node.data == .call) {
            var changed = false;
            const new_args = try self.alloc.alloc(*Node, node.data.call.args.len);
            for (node.data.call.args, 0..) |arg, i| {
                const substituted = try self.substituteComptimeNodes(arg, cpn);
                new_args[i] = @constCast(substituted);
                if (substituted != arg) changed = true;
            }
            if (changed) {
                const new_node = try self.alloc.create(Node);
                new_node.* = .{
                    .span = node.span,
                    .data = .{ .call = .{
                        .callee = node.data.call.callee,
                        .args = new_args,
                    } },
                };
                return new_node;
            }
        }

        return node;
    }

    /// Lower a call to a function with comptime params by inlining its body.
    /// Comptime params are substituted, `#insert` expressions are evaluated.
    fn lowerComptimeCall(self: *Lowering, fd: *const ast.FnDecl, call_node: *const ast.Call) Ref {
        // Build comptime param substitution map: param_name → call_site AST node
        var cpn = std.StringHashMap(*const Node).init(self.alloc);
        var call_arg_idx: usize = 0;
        // Pack-arg-node registration (step 2 of the variadic heterogeneous
        // type packs feature): when the fn declares a pack param, record
        // the slice of call-site arg nodes under the pack name so the
        // body's `args[$i]` lowering can substitute the i-th arg with
        // its concrete-typed value instead of the `[]Any` slice load.
        var pack_arg_name: ?[]const u8 = null;
        var pack_arg_slice: []const *const Node = &.{};

        for (fd.params) |param| {
            if (param.is_variadic) {
                // Variadic param: pack remaining call args into []Any slice
                self.lowerVariadicArgs(param.name, call_node.args, call_arg_idx);
                // Only heterogeneous pack form `..$args` (is_comptime AND
                // is_variadic) registers for typed indexing. Plain
                // `args: ..Any` keeps the existing []Any path so stdlib's
                // `format`/`print` continue boxing through Any.
                if (param.is_comptime and call_arg_idx <= call_node.args.len) {
                    pack_arg_name = param.name;
                    pack_arg_slice = call_node.args[call_arg_idx..];
                }
                break; // variadic is always the last param
            }
            if (call_arg_idx >= call_node.args.len) break;
            if (param.is_comptime) {
                cpn.put(param.name, call_node.args[call_arg_idx]) catch {};
                call_arg_idx += 1;
            } else {
                const arg_val = self.lowerExpr(call_node.args[call_arg_idx]);
                const pty = self.resolveParamType(&param);
                const slot = self.builder.alloca(pty);
                self.builder.store(slot, arg_val);
                if (self.scope) |scope| {
                    scope.put(param.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
                }
                call_arg_idx += 1;
            }
        }

        // Also bind comptime params as local string variables (for `fmt` used in runtime code)
        var cpn_iter = cpn.iterator();
        while (cpn_iter.next()) |entry| {
            const param_name = entry.key_ptr.*;
            const param_node = entry.value_ptr.*;
            if (param_node.data == .string_literal) {
                // Create a local string variable with the literal value
                const str_ref = self.lowerExpr(param_node);
                const slot = self.builder.alloca(.string);
                self.builder.store(slot, str_ref);
                if (self.scope) |scope| {
                    scope.put(param_name, .{ .ref = slot, .ty = .string, .is_alloca = true });
                }
            }
        }

        // Install comptime param nodes and lower the function body inline
        const saved_cpn = self.comptime_param_nodes;
        self.comptime_param_nodes = cpn;
        defer self.comptime_param_nodes = saved_cpn;

        // Install pack-arg-node binding. Mirrors `comptime_param_nodes`:
        // each call owns its own map, nested calls shadow. `lowerIndexExpr`
        // reads the map for `args[<int_literal>]` substitution.
        const saved_pan = self.pack_arg_nodes;
        var pan_map: std.StringHashMap([]const *const Node) = undefined;
        var pan_installed = false;
        if (pack_arg_name) |pn| {
            pan_map = std.StringHashMap([]const *const Node).init(self.alloc);
            pan_map.put(pn, pack_arg_slice) catch {};
            self.pack_arg_nodes = pan_map;
            pan_installed = true;
        }
        defer {
            if (pan_installed) pan_map.deinit();
            self.pack_arg_nodes = saved_pan;
        }

        // Lower the body — capture return value for functions with return type
        const ret_ty = self.resolveReturnType(fd);
        if (ret_ty != .void) {
            // Detect whether the body might use `return X;` statements.
            // If so, set up the inline-return slot AND a dedicated
            // "return-done" basic block so each `return X;` stores to
            // the slot and branches to ret_done. After the body lowers,
            // we switch to ret_done and load. Pure tail-expression
            // bodies (arrow form, or a block whose last stmt is an
            // expression) skip the slot+block — keeps the common
            // `format`/`#insert`-style path unchanged.
            const has_return = fnBodyHasReturn(fd.body);
            if (has_return) {
                const ret_slot = self.builder.alloca(ret_ty);
                const ret_done_bb = self.freshBlock("ct.ret_done");
                const saved_iri = self.inline_return_target;
                self.inline_return_target = .{ .slot = ret_slot, .ret_ty = ret_ty, .done_bb = ret_done_bb };
                defer self.inline_return_target = saved_iri;

                // Lower body. Tail-expression bodies (rare here since
                // has_return == true) produce a tail value we still
                // route through the slot so the load in ret_done picks
                // it up. Block-statement bodies whose last stmt is
                // `return X;` already br to ret_done from inside
                // lowerReturn.
                if (self.lowerBlockValue(fd.body)) |val| {
                    if (!self.currentBlockHasTerminator()) {
                        const v_ty = self.builder.getRefType(val);
                        const coerced = if (v_ty != ret_ty)
                            self.coerceToType(val, v_ty, ret_ty)
                        else
                            val;
                        self.builder.store(ret_slot, coerced);
                        self.builder.br(ret_done_bb, &.{});
                    }
                } else if (!self.currentBlockHasTerminator()) {
                    // Body fell through without producing a tail value
                    // AND without branching to ret_done — this only
                    // happens for bodies whose last stmt is a void
                    // statement (e.g. side-effecting). Slot is
                    // uninitialised on this path; safer to br anyway
                    // so the CFG is well-formed. The load in ret_done
                    // will read uninit, which is the same garbage
                    // behaviour the regular fn-body lowering would
                    // produce for a missing return.
                    self.builder.br(ret_done_bb, &.{});
                }

                self.builder.switchToBlock(ret_done_bb);
                return self.builder.load(ret_slot, ret_ty);
            } else {
                if (self.lowerBlockValue(fd.body)) |val| {
                    return val;
                }
            }
        } else {
            self.lowerBlock(fd.body);
        }

        return self.builder.constInt(0, .void);
    }

    /// True if `node` (a fn body) contains any top-level `return` statement.
    /// Used by inline-comptime lowering to decide whether to allocate a
    /// result slot — pure tail-expression bodies skip the slot. Walks past
    /// `if`/`while`/`for`/`match` arms (early-return inside a conditional
    /// counts) but stops at nested fn/lambda bodies (those have their own
    /// return contexts).
    fn fnBodyHasReturn(node: *const Node) bool {
        return switch (node.data) {
            .return_stmt => true,
            .block => |b| blk: {
                for (b.stmts) |s| if (fnBodyHasReturn(s)) break :blk true;
                break :blk false;
            },
            .if_expr => |ie| blk: {
                if (fnBodyHasReturn(ie.then_branch)) break :blk true;
                if (ie.else_branch) |eb| if (fnBodyHasReturn(eb)) break :blk true;
                break :blk false;
            },
            .while_expr => |we| fnBodyHasReturn(we.body),
            .for_expr => |fe| fnBodyHasReturn(fe.body),
            .match_expr => |me| blk: {
                for (me.arms) |arm| if (fnBodyHasReturn(arm.body)) break :blk true;
                break :blk false;
            },
            .defer_stmt => |ds| fnBodyHasReturn(ds.expr),
            else => false,
        };
    }

    /// Pack variadic arguments into a []Any slice. Each arg is boxed as Any {tag, value},
    /// stored into a stack-allocated array, and the slice {ptr, len} is bound to param_name.
    fn lowerVariadicArgs(self: *Lowering, param_name: []const u8, call_args: []const *const Node, start_idx: usize) void {
        const any_slice_ty = self.module.types.sliceOf(.any);
        const n = if (call_args.len > start_idx) call_args.len - start_idx else 0;

        if (n == 0) {
            // Empty slice: {null, 0}
            const null_ptr = self.builder.constNull(self.module.types.ptrTo(.any));
            const zero_len = self.builder.constInt(0, .s64);
            const slice_slot = self.builder.alloca(any_slice_ty);
            // Store ptr (field 0) and len (field 1) into the slice alloca
            const ptr_gep = self.builder.structGepTyped(slice_slot, 0, self.module.types.ptrTo(.any), any_slice_ty);
            self.builder.store(ptr_gep, null_ptr);
            const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, any_slice_ty);
            self.builder.store(len_gep, zero_len);
            if (self.scope) |scope| {
                scope.put(param_name, .{ .ref = slice_slot, .ty = any_slice_ty, .is_alloca = true });
            }
            return;
        }

        // Allocate stack array [N x Any]
        const array_ty = self.module.types.arrayOf(.any, @intCast(n));
        const array_slot = self.builder.alloca(array_ty);

        // Box each arg and store into array
        for (call_args[start_idx..], 0..) |arg, i| {
            var val = self.lowerExpr(arg);
            var source_ty = self.inferExprType(arg);
            // If AST-based inference falls back to .s64 but the lowered ref is a string/struct, use that
            if (source_ty == .unresolved) {
                const ref_ty = self.builder.getRefType(val);
                if (ref_ty == .string or ref_ty == .f32 or ref_ty == .f64 or ref_ty == .bool) {
                    source_ty = ref_ty;
                } else if (!ref_ty.isBuiltin()) {
                    const ri = self.module.types.get(ref_ty);
                    if (ri == .@"struct" or ri == .slice or ri == .optional or ri == .closure or ri == .tuple) {
                        source_ty = ref_ty;
                    }
                }
            }
            // Auto-unwrap optionals: box inner value if present, else box string "null"
            if (!source_ty.isBuiltin()) {
                const opt_info = self.module.types.get(source_ty);
                if (opt_info == .optional) {
                    const child_ty = opt_info.optional.child;
                    const has_val = self.builder.emit(.{ .optional_has_value = .{ .operand = val } }, .bool);
                    const some_bb = self.freshBlock("opt.some");
                    const none_bb = self.freshBlock("opt.none");
                    const merge_bb = self.freshBlockWithParams("opt.merge", &.{TypeId.any});
                    self.builder.condBr(has_val, some_bb, &.{}, none_bb, &.{});
                    self.builder.switchToBlock(some_bb);
                    const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
                    const boxed_inner = self.builder.boxAny(unwrapped, child_ty);
                    self.builder.br(merge_bb, &.{boxed_inner});
                    self.builder.switchToBlock(none_bb);
                    const null_str_id = self.module.types.internString("null");
                    const null_str = self.builder.constString(null_str_id);
                    const boxed_null = self.builder.boxAny(null_str, .string);
                    self.builder.br(merge_bb, &.{boxed_null});
                    self.builder.switchToBlock(merge_bb);
                    val = self.builder.blockParam(merge_bb, 0, TypeId.any);
                    source_ty = .any;
                }
            }
            const boxed = if (source_ty == .any) val else self.builder.boxAny(val, source_ty);
            // GEP to array[i] and store
            const idx_ref = self.builder.constInt(@intCast(i), .s64);
            const elem_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = idx_ref } }, self.module.types.ptrTo(.any));
            self.builder.store(elem_ptr, boxed);
        }

        // Build slice {ptr_to_first_element, len}
        const slice_slot = self.builder.alloca(any_slice_ty);
        // Get pointer to first element (array_slot is *[N x Any], GEP to element 0 gives *Any)
        const zero = self.builder.constInt(0, .s64);
        const data_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = zero } }, self.module.types.ptrTo(.any));
        const len_ref = self.builder.constInt(@intCast(n), .s64);
        // Store into slice fields
        const ptr_gep = self.builder.structGepTyped(slice_slot, 0, self.module.types.ptrTo(.any), any_slice_ty);
        self.builder.store(ptr_gep, data_ptr);
        const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, any_slice_ty);
        self.builder.store(len_gep, len_ref);

        if (self.scope) |scope| {
            scope.put(param_name, .{ .ref = slice_slot, .ty = any_slice_ty, .is_alloca = true });
        }
    }

    /// Pack variadic args into a slice for regular function calls.
    /// Detects variadic params in the function decl, packs remaining args into a typed slice,
    /// and replaces the args list with [fixed_args..., slice_ref].
    fn packVariadicCallArgs(self: *Lowering, fd: *const ast.FnDecl, c: *const ast.Call, args: *std.ArrayList(Ref)) void {
        // `#foreign` variadic uses the C calling convention's `...` tail —
        // extras are passed through directly with default argument promotion
        // (handled at the call site), not packed into an sx slice.
        if (fd.body.data == .foreign_expr and fd.params.len > 0 and fd.params[fd.params.len - 1].is_variadic) {
            return;
        }
        // Find variadic param index. The two surface forms differ in
        // what `p.type_expr` resolves to: legacy `name: ..T` declares T
        // (element type), new `..name: []T` declares []T (already a
        // slice). Unwrap the latter so the per-element packing below
        // sees T in both cases.
        var variadic_idx: ?usize = null;
        var elem_ty: TypeId = .any;
        for (fd.params, 0..) |p, i| {
            if (p.is_variadic) {
                variadic_idx = i;
                const declared = self.resolveTypeWithBindings(p.type_expr);
                elem_ty = declared;
                if (!declared.isBuiltin()) {
                    const info = self.module.types.get(declared);
                    if (info == .slice) elem_ty = info.slice.element;
                }
                break;
            }
        }
        const vi = variadic_idx orelse return; // no variadic param

        // Number of non-variadic args
        const fixed_count = vi;
        const variadic_count = if (args.items.len > fixed_count) args.items.len - fixed_count else 0;
        const slice_ty = self.module.types.sliceOf(elem_ty);

        // Check for spread operator: sum(..arr) — single spread arg becomes the slice directly
        if (variadic_count == 1 and fixed_count < c.args.len) {
            const arg_node = c.args[fixed_count];
            if (arg_node.data == .spread_expr) {
                const spread = arg_node.data.spread_expr;
                const arr_val = self.lowerExpr(spread.operand);
                const arr_ty = self.inferExprType(spread.operand);
                const arr_info = self.module.types.get(arr_ty);
                // Convert array to slice
                const slice_val = switch (arr_info) {
                    .array => self.builder.emit(.{ .array_to_slice = .{ .operand = arr_val } }, slice_ty),
                    .slice => arr_val,
                    else => arr_val,
                };
                args.shrinkRetainingCapacity(fixed_count);
                args.append(self.alloc, slice_val) catch unreachable;
                return;
            }
        }

        if (variadic_count == 0) {
            // Empty slice
            const null_ptr = self.builder.constNull(self.module.types.ptrTo(elem_ty));
            const zero_len = self.builder.constInt(0, .s64);
            const slice_slot = self.builder.alloca(slice_ty);
            const ptr_gep = self.builder.structGepTyped(slice_slot, 0, self.module.types.ptrTo(elem_ty), slice_ty);
            self.builder.store(ptr_gep, null_ptr);
            const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, slice_ty);
            self.builder.store(len_gep, zero_len);
            const slice_val = self.builder.load(slice_slot, slice_ty);
            // Replace args: keep fixed args, append slice
            args.shrinkRetainingCapacity(fixed_count);
            args.append(self.alloc, slice_val) catch unreachable;
            return;
        }

        // Determine if we need to box as Any (for ..Any params) or use raw type
        const is_any = (elem_ty == .any);
        // `..xs: []P` (slice of a protocol): each concrete arg must be erased to
        // a protocol value {ctx, vtable}, not stored raw (which would be a
        // size/type mismatch — a heap of garbage vtables → crash on dispatch).
        const elem_is_protocol = blk: {
            if (elem_ty.isBuiltin()) break :blk false;
            const ei = self.module.types.get(elem_ty);
            break :blk ei == .@"struct" and ei.@"struct".is_protocol;
        };

        // Allocate stack array [N x ElemType]
        const array_elem = if (is_any) TypeId.any else elem_ty;
        const array_ty = self.module.types.arrayOf(array_elem, @intCast(variadic_count));
        const array_slot = self.builder.alloca(array_ty);

        // Store each variadic arg into array
        for (0..variadic_count) |i| {
            var val = args.items[fixed_count + i];
            if (is_any) {
                var source_ty = self.inferExprType(c.args[fixed_count + i]);
                // If AST-based inference falls back to .s64 but the lowered ref has a richer type, use that
                if (source_ty == .unresolved) {
                    const ref_ty = self.builder.getRefType(val);
                    if (ref_ty != .unresolved and ref_ty != .void) source_ty = ref_ty;
                }
                // Auto-unwrap optionals: box inner value if present, else box string "null"
                if (!source_ty.isBuiltin()) {
                    const opt_info = self.module.types.get(source_ty);
                    if (opt_info == .optional) {
                        const child_ty = opt_info.optional.child;
                        // Branch: has_value? → box inner : box "null"
                        const has_val = self.builder.emit(.{ .optional_has_value = .{ .operand = val } }, .bool);
                        const some_bb = self.freshBlock("opt.some");
                        const none_bb = self.freshBlock("opt.none");
                        const merge_bb = self.freshBlockWithParams("opt.merge", &.{TypeId.any});
                        self.builder.condBr(has_val, some_bb, &.{}, none_bb, &.{});
                        // Some: unwrap and box inner value
                        self.builder.switchToBlock(some_bb);
                        const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
                        const boxed_inner = self.builder.boxAny(unwrapped, child_ty);
                        self.builder.br(merge_bb, &.{boxed_inner});
                        // None: box the string "null"
                        self.builder.switchToBlock(none_bb);
                        const null_str_id = self.module.types.internString("null");
                        const null_str = self.builder.constString(null_str_id);
                        const boxed_null = self.builder.boxAny(null_str, .string);
                        self.builder.br(merge_bb, &.{boxed_null});
                        // Merge
                        self.builder.switchToBlock(merge_bb);
                        val = self.builder.blockParam(merge_bb, 0, TypeId.any);
                        source_ty = .any; // already boxed
                    }
                }
                if (source_ty != .any) {
                    val = self.builder.boxAny(val, source_ty);
                }
            } else if (elem_is_protocol) {
                // Erase each concrete arg to the protocol value via the same
                // impl-driven `xx` machinery, so the runtime `[]P` holds real
                // {ctx, vtable} values and `xs[i].method()` dispatches.
                const arg_node = c.args[fixed_count + i];
                var source_ty = self.inferExprType(arg_node);
                if (source_ty == .unresolved) source_ty = self.builder.getRefType(val);
                if (source_ty != elem_ty) {
                    val = self.buildProtocolErasure(val, arg_node, source_ty, elem_ty);
                }
            }
            const idx_ref = self.builder.constInt(@intCast(i), .s64);
            const elem_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = idx_ref } }, self.module.types.ptrTo(array_elem));
            self.builder.store(elem_ptr, val);
        }

        // Build slice {ptr, len}
        const slice_slot = self.builder.alloca(slice_ty);
        const zero = self.builder.constInt(0, .s64);
        const data_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = zero } }, self.module.types.ptrTo(array_elem));
        const len_ref = self.builder.constInt(@intCast(variadic_count), .s64);
        const ptr_gep = self.builder.structGepTyped(slice_slot, 0, self.module.types.ptrTo(array_elem), slice_ty);
        self.builder.store(ptr_gep, data_ptr);
        const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, slice_ty);
        self.builder.store(len_gep, len_ref);
        const slice_val = self.builder.load(slice_slot, slice_ty);

        // Replace args: keep fixed args, append slice
        args.shrinkRetainingCapacity(fixed_count);
        args.append(self.alloc, slice_val) catch unreachable;
    }

    // ── Generic monomorphization ──────────────────────────────────

    /// Build `tp.name -> TypeId` bindings for a generic call.
    /// `args_ast` must be parallel to `fd.params`; for dot-calls the caller
    /// prepends the receiver's AST node so positions align with `fd.params[0] = self`.
    /// Caller owns the returned map and must call `.deinit()`.
    /// Lower a call to a generic function by monomorphizing it with inferred type arguments.
    fn lowerGenericCall(self: *Lowering, fd: *const ast.FnDecl, base_name: []const u8, call_node: *const ast.Call, lowered_args: []Ref) Ref {
        var bindings = self.genericResolver().buildTypeBindings(fd, call_node.args);
        defer bindings.deinit();

        const types_passed_explicitly = call_node.args.len == fd.params.len;
        const mangled_name = self.genericResolver().mangleGenericName(base_name, fd, &bindings);

        if (!self.lowered_functions.contains(mangled_name)) {
            self.monomorphizeFunction(fd, mangled_name, &bindings);
        }

        if (self.resolveFuncByName(mangled_name)) |fid| {
            const func = &self.module.functions.items[@intFromEnum(fid)];
            const ret_ty = func.ret;
            const params = func.params;
            // Build value-only args (skip type param declaration args)
            var value_args = std.ArrayList(Ref).empty;
            defer value_args.deinit(self.alloc);
            var arg_idx: usize = 0;
            for (fd.params) |p| {
                if (isTypeParamDecl(&p, fd.type_params)) {
                    if (types_passed_explicitly) arg_idx += 1;
                    continue;
                }
                if (arg_idx < lowered_args.len) {
                    value_args.append(self.alloc, lowered_args[arg_idx]) catch unreachable;
                }
                arg_idx += 1;
            }
            const final_args = self.prependCtxIfNeeded(func, value_args.items);
            self.coerceCallArgs(final_args, params);
            return self.builder.call(fid, final_args, ret_ty);
        }

        return self.emitError(base_name, call_node.callee.span);
    }

    /// Create a monomorphized instance of a generic function.
    /// Check if a call has a `cast(runtime_var, val)` argument (runtime type dispatch pattern).
    fn hasCastWithRuntimeType(self: *Lowering, c: *const ast.Call) bool {
        for (c.args) |arg| {
            if (arg.data == .call) {
                if (arg.data.call.callee.data == .identifier) {
                    const name = arg.data.call.callee.data.identifier.name;
                    if (std.mem.eql(u8, name, "cast") and arg.data.call.args.len == 2) {
                        const type_arg = arg.data.call.args[0];
                        if (type_arg.data == .identifier) {
                            // It's a runtime type if it's in scope as a variable
                            if (self.scope) |scope| {
                                if (scope.lookup(type_arg.data.identifier.name) != null) return true;
                            }
                        }
                    }
                }
            }
        }
        return false;
    }

    /// Generate runtime dispatch for a generic call inside a type-match arm.
    /// For each type tag in match_tags, monomorphizes the generic function and calls it.
    fn lowerRuntimeDispatchCall(
        self: *Lowering,
        fd: *const ast.FnDecl,
        base_name: []const u8,
        call_node: *const ast.Call,
        match_tags: []const u64,
    ) Ref {
        // Find the cast arg: cast(type_var, any_val)
        var cast_arg_idx: usize = 0;
        var type_tag_node: ?*const Node = null;
        var any_val_node: ?*const Node = null;
        for (call_node.args, 0..) |arg, i| {
            if (arg.data == .call and arg.data.call.callee.data == .identifier) {
                const name = arg.data.call.callee.data.identifier.name;
                if (std.mem.eql(u8, name, "cast") and arg.data.call.args.len == 2) {
                    cast_arg_idx = i;
                    type_tag_node = arg.data.call.args[0];
                    any_val_node = arg.data.call.args[1];
                    break;
                }
            }
        }

        // Lower the type tag (runtime value) and Any value BEFORE the switch
        const type_tag_raw = self.lowerExpr(type_tag_node orelse return self.emitError("dispatch", call_node.callee.span));
        const type_tag_node_ty = self.inferExprType(type_tag_node.?);
        const type_tag = if (type_tag_node_ty == .any)
            self.builder.emit(.{ .unbox_any = .{ .operand = type_tag_raw } }, .s64)
        else
            type_tag_raw;
        const any_val = self.lowerExpr(any_val_node orelse return self.emitError("dispatch", call_node.callee.span));

        // Lower non-cast arguments once (before the switch)
        var other_args = std.ArrayList(?Ref).empty;
        defer other_args.deinit(self.alloc);
        for (call_node.args, 0..) |arg, i| {
            if (i == cast_arg_idx) {
                other_args.append(self.alloc, null) catch unreachable; // placeholder
            } else {
                other_args.append(self.alloc, self.lowerExpr(arg)) catch unreachable;
            }
        }

        // Resolve return type (using first available binding)
        const ret_ty: TypeId = blk: {
            if (fd.return_type) |rt| {
                if (rt.data == .type_expr) {
                    if (type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map) != .unresolved) {
                        break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map);
                    }
                }
            }
            break :blk .string; // default for to_string functions
        };

        const merge_bb = self.freshBlock("dispatch.merge");
        const default_bb = self.freshBlock("dispatch.default");

        // Build switch cases
        var cases = std.ArrayList(inst_mod.SwitchBranch.Case).empty;
        defer cases.deinit(self.alloc);

        // For each type tag, create a case block
        var case_blocks = std.ArrayList(BlockId).empty;
        defer case_blocks.deinit(self.alloc);

        for (match_tags) |tag| {
            const case_bb = self.freshBlock("dispatch.case");
            case_blocks.append(self.alloc, case_bb) catch unreachable;
            cases.append(self.alloc, .{
                .value = @intCast(tag),
                .target = case_bb,
                .args = &.{},
            }) catch unreachable;
        }

        // Create a result alloca BEFORE the switch (must be before terminator)
        var result_slot: ?Ref = null;
        if (ret_ty != .void) {
            result_slot = self.builder.alloca(ret_ty);
        }

        self.builder.switchBr(type_tag, cases.items, default_bb, &.{});

        for (match_tags, 0..) |tag, ti| {
            self.builder.switchToBlock(case_blocks.items[ti]);

            const ty_id = TypeId.fromIndex(@intCast(tag));

            // Unbox the Any value to the concrete type
            const unboxed = self.builder.emit(.{ .unbox_any = .{
                .operand = any_val,
            } }, ty_id);

            if (fd.type_params.len > 0) {
                // Generic function: build type bindings + monomorphize
                var bindings = std.StringHashMap(TypeId).init(self.alloc);
                defer bindings.deinit();

                // Find which type param the cast arg corresponds to
                if (cast_arg_idx < fd.params.len) {
                    const param_te = fd.params[cast_arg_idx].type_expr;
                    if (param_te.data == .type_expr) {
                        // Direct: `param: $T` → T = ty_id
                        const tp_name = param_te.data.type_expr.name;
                        for (fd.type_params) |tp| {
                            if (std.mem.eql(u8, tp.name, tp_name)) {
                                bindings.put(tp.name, ty_id) catch {};
                                break;
                            }
                        }
                    } else if (param_te.data == .slice_type_expr) {
                        // Compound: `param: []$T` → T = element type of ty_id
                        const elem_te = param_te.data.slice_type_expr.element_type;
                        if (elem_te.data == .type_expr) {
                            const tp_name = elem_te.data.type_expr.name;
                            for (fd.type_params) |tp| {
                                if (std.mem.eql(u8, tp.name, tp_name)) {
                                    const elem_ty = self.getElementType(ty_id);
                                    bindings.put(tp.name, if (elem_ty != .void) elem_ty else ty_id) catch {};
                                    break;
                                }
                            }
                        }
                    } else if (param_te.data == .pointer_type_expr) {
                        // Compound: `param: *$T` → T = pointee type of ty_id
                        const pointee_te = param_te.data.pointer_type_expr.pointee_type;
                        if (pointee_te.data == .type_expr) {
                            const tp_name = pointee_te.data.type_expr.name;
                            for (fd.type_params) |tp| {
                                if (std.mem.eql(u8, tp.name, tp_name)) {
                                    if (!ty_id.isBuiltin()) {
                                        const pinfo = self.module.types.get(ty_id);
                                        if (pinfo == .pointer) {
                                            bindings.put(tp.name, pinfo.pointer.pointee) catch {};
                                            break;
                                        }
                                    }
                                    bindings.put(tp.name, ty_id) catch {};
                                    break;
                                }
                            }
                        }
                    }
                }

                // Build mangled name
                var mangled_buf: [256]u8 = undefined;
                var mangled_len: usize = 0;
                for (base_name) |ch| {
                    if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
                }
                for (fd.type_params) |tp| {
                    for ("__") |ch| {
                        if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
                    }
                    const bound_ty = bindings.get(tp.name) orelse ty_id;
                    const type_name_str = self.mangleTypeName(bound_ty);
                    for (type_name_str) |ch| {
                        if (mangled_len < mangled_buf.len) { mangled_buf[mangled_len] = ch; mangled_len += 1; }
                    }
                }
                const mangled_name = mangled_buf[0..mangled_len];

                // Monomorphize if not already done
                if (!self.lowered_functions.contains(mangled_name)) {
                    self.monomorphizeFunction(fd, mangled_name, &bindings);
                }

                // Build call args (replace cast arg with unboxed value, skip type param decl args)
                if (self.resolveFuncByName(mangled_name)) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    const callee_ret = func.ret;
                    const callee_params = func.params;
                    var call_args = std.ArrayList(Ref).empty;
                    defer call_args.deinit(self.alloc);
                    for (fd.params, 0..) |p, pi| {
                        if (isTypeParamDecl(&p, fd.type_params)) continue;
                        if (pi == cast_arg_idx) {
                            call_args.append(self.alloc, unboxed) catch unreachable;
                        } else if (pi < other_args.items.len) {
                            if (other_args.items[pi]) |ref| {
                                call_args.append(self.alloc, ref) catch unreachable;
                            }
                        }
                    }
                    const final_args = self.prependCtxIfNeeded(func, call_args.items);
                    self.coerceCallArgs(final_args, callee_params);
                    const result = self.builder.call(fid, final_args, callee_ret);
                    if (result_slot) |slot| {
                        self.builder.store(slot, result);
                    }
                }
            } else {
                // Non-generic function: call directly with per-tag unboxing + coercion
                const resolve_name = base_name;
                if (!self.lowered_functions.contains(resolve_name)) {
                    self.lazyLowerFunction(resolve_name);
                }
                if (self.resolveFuncByName(resolve_name)) |fid| {
                    const callee_func = &self.module.functions.items[@intFromEnum(fid)];
                    const callee_ret = callee_func.ret;
                    const callee_params = callee_func.params;
                    const callee_has_ctx = callee_func.has_implicit_ctx;
                    var call_args = std.ArrayList(Ref).empty;
                    defer call_args.deinit(self.alloc);
                    for (fd.params, 0..) |_, pi| {
                        if (pi == cast_arg_idx) {
                            // Coerce unboxed value (typed as ty_id) to param type
                            var arg = unboxed;
                            // callee param index shifts by +1 if it carries __sx_ctx
                            const callee_pi = pi + @as(usize, if (callee_has_ctx) 1 else 0);
                            if (callee_pi < callee_params.len) {
                                arg = self.coerceToType(arg, ty_id, callee_params[callee_pi].ty);
                            }
                            call_args.append(self.alloc, arg) catch unreachable;
                        } else if (pi < other_args.items.len) {
                            if (other_args.items[pi]) |ref| {
                                call_args.append(self.alloc, ref) catch unreachable;
                            }
                        }
                    }
                    // Prepend __sx_ctx if needed BEFORE coercion so indices line up.
                    var final_call_args: []Ref = call_args.items;
                    if (callee_has_ctx) {
                        final_call_args = self.alloc.alloc(Ref, call_args.items.len + 1) catch call_args.items;
                        if (final_call_args.len == call_args.items.len + 1) {
                            final_call_args[0] = self.current_ctx_ref;
                            @memcpy(final_call_args[1..], call_args.items);
                        }
                    }
                    // Coerce non-cast args (source type unknown, use s64 default).
                    // cast_arg_idx is in user-space (skips __sx_ctx); offset by ctx_slots.
                    const ctx_slots: usize = if (callee_has_ctx) 1 else 0;
                    for (0..@min(final_call_args.len, callee_params.len)) |ci| {
                        if (ci < ctx_slots) continue; // skip __sx_ctx slot
                        if ((ci - ctx_slots) != cast_arg_idx) {
                            final_call_args[ci] = self.coerceToType(final_call_args[ci], .s64, callee_params[ci].ty);
                        }
                    }
                    const result = self.builder.call(fid, final_call_args, callee_ret);
                    if (result_slot) |slot| {
                        self.builder.store(slot, result);
                    }
                }
            }

            self.builder.br(merge_bb, &.{});
        }

        // Default block: store a default value and branch to merge
        self.builder.switchToBlock(default_bb);
        if (result_slot) |slot| {
            const empty_id = self.module.types.internString("");
            const default_val = if (ret_ty == .string) self.builder.constString(empty_id) else self.zeroValue(ret_ty);
            self.builder.store(slot, default_val);
        }
        self.builder.br(merge_bb, &.{});

        // Merge block: load result
        self.builder.switchToBlock(merge_bb);
        if (result_slot) |slot| {
            return self.builder.load(slot, ret_ty);
        }
        return self.builder.constInt(0, .void);
    }

    /// Build an `[]Any` slice value from the mono's pack params and
    /// bind it to the pack name in scope. Each pack-param slot is
    /// loaded, boxed via `boxAny`, and stored into a stack [N x Any]
    /// array; the slice {data_ptr, len} is then bound. Used by
    /// `monomorphizePackFn` so bodies that reference `args` bare or
    /// index it with a runtime int resolve through the slice (with
    /// element type `Any`). Literal-indexed accesses keep the
    /// concrete per-position types via `packArgNodeAt`.
    /// Build a `[]Type` slice VALUE for a bare `$<pack>` reference.
    /// Differs from `materialisePackSlice` (which boxes each pack
    /// element as Any so the body's `args[i]` reads an Any) — this
    /// helper stores raw `.type_tag` Values via `const_type`, so the
    /// slice is a list-of-Types that builder fns walk at interp time.
    /// Slice IR type is `[]Any` (since `Type → .any`); the interp
    /// stores whichever Value the elements actually carry.
    fn buildPackSliceValue(self: *Lowering, arg_types: []const TypeId) Ref {
        const any_slice_ty = self.module.types.sliceOf(.any);
        const any_ptr_ty = self.module.types.ptrTo(.any);

        if (arg_types.len == 0) {
            const null_ptr = self.builder.constNull(any_ptr_ty);
            const zero_len = self.builder.constInt(0, .s64);
            const slice_slot = self.builder.alloca(any_slice_ty);
            const ptr_gep = self.builder.structGepTyped(slice_slot, 0, any_ptr_ty, any_slice_ty);
            self.builder.store(ptr_gep, null_ptr);
            const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, any_slice_ty);
            self.builder.store(len_gep, zero_len);
            return self.builder.load(slice_slot, any_slice_ty);
        }

        const array_ty = self.module.types.arrayOf(.any, @intCast(arg_types.len));
        const array_slot = self.builder.alloca(array_ty);

        for (arg_types, 0..) |ty, i| {
            // `const_type` produces an `.any`-typed Type value
            // (`{tag=.any, value=tid}`) — already the canonical Any
            // shape, so no re-box needed.
            const type_val = self.builder.constType(ty);
            const idx_ref = self.builder.constInt(@intCast(i), .s64);
            const elem_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = idx_ref } }, any_ptr_ty);
            self.builder.store(elem_ptr, type_val);
        }

        const slice_slot = self.builder.alloca(any_slice_ty);
        const zero = self.builder.constInt(0, .s64);
        const data_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = zero } }, any_ptr_ty);
        const len_ref = self.builder.constInt(@intCast(arg_types.len), .s64);
        const ptr_gep = self.builder.structGepTyped(slice_slot, 0, any_ptr_ty, any_slice_ty);
        self.builder.store(ptr_gep, data_ptr);
        const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, any_slice_ty);
        self.builder.store(len_gep, len_ref);
        return self.builder.load(slice_slot, any_slice_ty);
    }

    fn materialisePackSlice(
        self: *Lowering,
        scope: *Scope,
        pack_name: []const u8,
        slot_refs: []const Ref,
        arg_types: []const TypeId,
    ) void {
        const any_slice_ty = self.module.types.sliceOf(.any);
        const any_ptr_ty = self.module.types.ptrTo(.any);

        if (arg_types.len == 0) {
            const null_ptr = self.builder.constNull(any_ptr_ty);
            const zero_len = self.builder.constInt(0, .s64);
            const slice_slot = self.builder.alloca(any_slice_ty);
            const ptr_gep = self.builder.structGepTyped(slice_slot, 0, any_ptr_ty, any_slice_ty);
            self.builder.store(ptr_gep, null_ptr);
            const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, any_slice_ty);
            self.builder.store(len_gep, zero_len);
            scope.put(pack_name, .{ .ref = slice_slot, .ty = any_slice_ty, .is_alloca = true });
            return;
        }

        const array_ty = self.module.types.arrayOf(.any, @intCast(arg_types.len));
        const array_slot = self.builder.alloca(array_ty);

        for (slot_refs, arg_types, 0..) |slot, ty, i| {
            const val = self.builder.load(slot, ty);
            const boxed = if (ty == .any) val else self.builder.boxAny(val, ty);
            const idx_ref = self.builder.constInt(@intCast(i), .s64);
            const elem_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = idx_ref } }, any_ptr_ty);
            self.builder.store(elem_ptr, boxed);
        }

        const slice_slot = self.builder.alloca(any_slice_ty);
        const zero = self.builder.constInt(0, .s64);
        const data_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = zero } }, any_ptr_ty);
        const len_ref = self.builder.constInt(@intCast(arg_types.len), .s64);
        const ptr_gep = self.builder.structGepTyped(slice_slot, 0, any_ptr_ty, any_slice_ty);
        self.builder.store(ptr_gep, data_ptr);
        const len_gep = self.builder.structGepTyped(slice_slot, 1, .s64, any_slice_ty);
        self.builder.store(len_gep, len_ref);
        scope.put(pack_name, .{ .ref = slice_slot, .ty = any_slice_ty, .is_alloca = true });
    }

    /// Infer the return type of a pack-fn body for the generic-`$R`
    /// case. Walks the body looking for the first concrete return
    /// type: a `return X;` statement's value type, or — failing that —
    /// the tail expression of an arrow-form body. Caller must have
    /// `pack_arg_nodes` installed so `args[<lit>]` substitutes during
    /// inference. Falls back to `.s64` if nothing concrete is found
    /// (matches the broader "default to .s64" convention elsewhere).
    fn inferPackBodyReturnType(self: *Lowering, body: *const Node) TypeId {
        // First try explicit `return X;` — walks past structured
        // control flow but stops at nested fn / lambda bodies.
        if (self.findReturnValueType(body)) |ty| return ty;
        // Arrow-form / tail-expression body: the body IS the value.
        // For block bodies whose last stmt is an expression, walk down.
        if (body.data == .block) {
            const stmts = body.data.block.stmts;
            if (stmts.len == 0) return .void;
            return self.inferExprType(stmts[stmts.len - 1]);
        }
        return self.inferExprType(body);
    }

    /// Per-call-shape monomorphisation entry for pack-fns
    /// (`isPackFn(fd) == true`). Computes a mangled name from the
    /// call-site arg types, builds the mono if it's not cached, and
    /// emits a direct call. Pack params expand into N positional IR
    /// params with concrete types; the body's `args[<lit>]` and
    /// `args.len` resolve to those params via the pack bindings.
    fn lowerPackFnCall(self: *Lowering, fd: *const ast.FnDecl, call_node: *const ast.Call) Ref {
        // Split call args along the fd.params boundary:
        // - non-comptime non-pack params → consume one call arg as a
        //   runtime IR param.
        // - comptime non-pack params → consume one call arg, fold its
        //   value into the mangle (NOT a runtime IR param).
        // - pack param (always last) → consume the remaining call args
        //   as the pack expansion.
        var pack_arg_types = std.ArrayList(TypeId).empty;
        defer pack_arg_types.deinit(self.alloc);
        var pack_start: usize = call_node.args.len;
        // Constraint protocol of the pack param (`..xs: P`), if any. The
        // comptime type-pack `..$args` has no constraint to check.
        var pack_protocol: ?[]const u8 = null;
        var pack_is_comptime = false;
        var pack_name: []const u8 = "";
        {
            var fi: usize = 0;
            for (fd.params) |p| {
                if (isPackParam(p)) {
                    pack_start = fi;
                    pack_is_comptime = p.is_comptime;
                    pack_name = p.name;
                    if (p.is_pack and p.type_expr.data == .type_expr) {
                        pack_protocol = p.type_expr.data.type_expr.name;
                    }
                    break;
                }
                if (fi >= call_node.args.len) break;
                fi += 1;
            }
        }

        // Lower the PACK args first, taking each type from the lowered value
        // (`getRefType`) — never a pre-lowering `inferExprType` guess. Knowing
        // the pack element types up front lets the prefix args (e.g.
        // `mapper: Closure(..sources.T) -> $R`) resolve against them, so a
        // lambda arg types its params from the projected closure signature.
        // (A comptime `..$args` pack keeps `inferExprType` — its args may be
        // type-position.)
        // A pack arg is independently typed — it takes its natural type and
        // (for a comptime `..$args` pack) auto-boxes to `Any` at the call
        // boundary. It is NEVER coerced to a leftover outer `target_type`, so
        // clear it: otherwise an `xx <expr>` pack arg (whose result type IS
        // `target_type`) would cast to the stale target — e.g. `format("…", xx i)`
        // inside a `-> string` fn mis-typed the arg as `string`, monomorphizing
        // `__pack_string` and ABI-coercing the 4-byte int as a 16-byte fat
        // pointer → memory corruption (issue 0057).
        const saved_pack_tt = self.target_type;
        self.target_type = null;
        var pack_refs = std.ArrayList(Ref).empty;
        defer pack_refs.deinit(self.alloc);
        for (call_node.args[pack_start..]) |a| {
            const r = self.lowerExpr(a);
            pack_refs.append(self.alloc, r) catch return self.builder.constInt(0, .void);
            if (pack_is_comptime) {
                const it = self.inferExprType(a);
                pack_arg_types.append(self.alloc, if (it == .unresolved) self.builder.getRefType(r) else it) catch return self.builder.constInt(0, .void);
            } else {
                pack_arg_types.append(self.alloc, self.builder.getRefType(r)) catch return self.builder.constInt(0, .void);
            }
        }
        self.target_type = saved_pack_tt;

        // Install the pack's element types + constraint so prefix-arg param
        // types like `Closure(..sources.T)` resolve while lowering the prefix.
        var pat_map = std.StringHashMap([]const TypeId).init(self.alloc);
        defer pat_map.deinit();
        pat_map.put(pack_name, pack_arg_types.items) catch {};
        var pcon_map = std.StringHashMap([]const u8).init(self.alloc);
        defer pcon_map.deinit();
        if (pack_protocol) |proto| pcon_map.put(pack_name, proto) catch {};
        const saved_pat = self.pack_arg_types;
        const saved_pcon = self.pack_constraint;
        self.pack_arg_types = pat_map;
        if (pack_protocol != null) self.pack_constraint = pcon_map;

        var args = std.ArrayList(Ref).empty;
        defer args.deinit(self.alloc);
        {
            var ri: usize = 0;
            for (fd.params) |p| {
                if (isPackParam(p)) break;
                if (ri >= call_node.args.len) break;
                if (!p.is_comptime) {
                    // Contextually type the arg from the param (so a lambda arg
                    // `(x) => …` takes its param types from a `Closure(...)` param).
                    const saved_tt = self.target_type;
                    const pty = self.resolveParamType(&p);
                    if (pty != .unresolved) self.target_type = pty;
                    args.append(self.alloc, self.lowerExpr(call_node.args[ri])) catch return self.builder.constInt(0, .void);
                    self.target_type = saved_tt;
                }
                ri += 1;
            }
        }
        self.pack_arg_types = saved_pat;
        self.pack_constraint = saved_pcon;

        // Infer type-param bindings (e.g. `$R` in `mapper: Closure(..) -> $R`)
        // from the lowered prefix args. `args.items` holds the non-comptime
        // prefix refs in declaration order; match each prefix param's declared
        // type against its arg's concrete type to bind the function's
        // type-params. These flow into the mangle and the mono's
        // `self.type_bindings` so `-> VL($R)` / `Combined($R, ..)` resolve.
        var tparam_bindings = std.StringHashMap(TypeId).init(self.alloc);
        defer tparam_bindings.deinit();
        if (fd.type_params.len > 0) {
            var pref_ref_idx: usize = 0;
            for (fd.params) |p| {
                if (isPackParam(p)) break;
                if (p.is_comptime) continue;
                if (pref_ref_idx >= args.items.len) break;
                const arg_ty = self.builder.getRefType(args.items[pref_ref_idx]);
                for (fd.type_params) |tp| {
                    if (tparam_bindings.contains(tp.name)) continue;
                    if (self.extractTypeParam(p.type_expr, arg_ty, tp.name)) |ety| {
                        if (ety != .unresolved) tparam_bindings.put(tp.name, ety) catch {};
                    }
                }
                pref_ref_idx += 1;
            }
        }

        // Append the (already-lowered) pack args after the prefix args.
        for (pack_refs.items) |r| args.append(self.alloc, r) catch return self.builder.constInt(0, .void);

        // Per-position conformance: each pack arg must impl the constraint
        // protocol. Only enforced for a known protocol constraint — an unknown
        // name (e.g. a plain type used as a pack constraint) is left alone.
        if (pack_protocol) |proto| {
            if (self.program_index.protocol_ast_map.contains(proto)) {
                for (call_node.args[pack_start..], pack_arg_types.items) |arg_node, arg_ty| {
                    if (!self.protocolResolver().packArgConformsTo(proto, arg_ty)) {
                        if (self.diagnostics) |diags| {
                            diags.addFmt(.err, arg_node.span, "pack argument of type '{s}' does not conform to protocol '{s}'", .{ self.formatTypeName(arg_ty), proto });
                        }
                    }
                }
            }
        }

        // Mangle: `<fn_name>__pack__<arg_types>` with comptime values
        // (if any) folded into a `__ct_<value>` segment per non-pack
        // comptime param. Distinct call shapes — including different
        // comptime VALUES — get distinct symbols.
        var name_buf = std.ArrayList(u8).empty;
        defer name_buf.deinit(self.alloc);
        name_buf.appendSlice(self.alloc, fd.name) catch return self.builder.constInt(0, .void);
        // Comptime values first (deterministic by fd.params order).
        var ct_fi: usize = 0;
        for (fd.params) |p| {
            if (isPackParam(p)) break;
            if (ct_fi >= call_node.args.len) break;
            if (p.is_comptime) {
                name_buf.appendSlice(self.alloc, "__ct_") catch return self.builder.constInt(0, .void);
                self.genericResolver().appendComptimeValueMangle(&name_buf, call_node.args[ct_fi]);
            }
            ct_fi += 1;
        }
        // Inferred type-param bindings (deterministic by fd.type_params order).
        for (fd.type_params) |tp| {
            if (tparam_bindings.get(tp.name)) |ty| {
                name_buf.appendSlice(self.alloc, "__tp_") catch return self.builder.constInt(0, .void);
                name_buf.appendSlice(self.alloc, self.mangleTypeName(ty)) catch return self.builder.constInt(0, .void);
            }
        }
        name_buf.appendSlice(self.alloc, "__pack") catch return self.builder.constInt(0, .void);
        for (pack_arg_types.items) |t| {
            name_buf.append(self.alloc, '_') catch return self.builder.constInt(0, .void);
            name_buf.appendSlice(self.alloc, self.mangleTypeName(t)) catch return self.builder.constInt(0, .void);
        }
        const mangled = name_buf.items;

        if (!self.lowered_functions.contains(mangled)) {
            self.monomorphizePackFn(fd, mangled, pack_arg_types.items, call_node, &tparam_bindings);
        }

        const fid = self.resolveFuncByName(mangled) orelse return self.builder.constInt(0, .void);
        const func = &self.module.functions.items[@intFromEnum(fid)];
        const ret_ty = func.ret;
        const params = func.params;
        const final_args = self.prependCtxIfNeeded(func, args.items);
        self.coerceCallArgs(final_args, params);
        return self.builder.call(fid, final_args, ret_ty);
    }


    /// Build a single mono fn for the given pack-fn + concrete arg types.
    /// The mono carries N positional pack-params (synthesised names
    /// `__pack_<name>_<i>`) plus any fixed-prefix non-pack params from
    /// the original declaration. The body lowers normally — real
    /// `return X;` emits real `ret X`; `args[<lit>]` substitutes via
    /// `pack_arg_nodes`; `args.len` resolves via `pack_param_count`.
    fn monomorphizePackFn(
        self: *Lowering,
        fd: *const ast.FnDecl,
        mangled_name: []const u8,
        arg_types: []const TypeId,
        call_node: *const ast.Call,
        type_bindings: *const std.StringHashMap(TypeId),
    ) void {
        const owned_name = self.alloc.dupe(u8, mangled_name) catch return;
        self.lowered_functions.put(owned_name, {}) catch {};

        // Find the pack param's name and position in fd.params, plus its
        // constraint protocol (`..xs: Box` ⇒ "Box"; comptime `..$args` has none).
        var pack_name: []const u8 = "";
        var pack_param_idx: usize = std.math.maxInt(usize);
        var pack_proto: ?[]const u8 = null;
        for (fd.params, 0..) |p, i| {
            if (isPackParam(p)) {
                pack_name = p.name;
                pack_param_idx = i;
                if (p.is_pack and p.type_expr.data == .type_expr) {
                    pack_proto = p.type_expr.data.type_expr.name;
                }
                break;
            }
        }
        if (pack_param_idx == std.math.maxInt(usize)) return;

        // Save state — mirrors monomorphizeFunction but also captures
        // pack/inline-return state since the mono body must NOT route
        // returns through any caller's inline slot.
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        const saved_scope = self.scope;
        const saved_defer_base = self.func_defer_base;
        const saved_block_terminated = self.block_terminated;
        const saved_target = self.target_type;
        const saved_pan = self.pack_arg_nodes;
        const saved_ppc = self.pack_param_count;
        const saved_pat = self.pack_arg_types;
        const saved_pcon = self.pack_constraint;
        const saved_iri = self.inline_return_target;
        const saved_ctx_ref = self.current_ctx_ref;
        const saved_type_bindings = self.type_bindings;
        self.func_defer_base = self.defer_stack.items.len;
        self.block_terminated = false;
        self.inline_return_target = null;
        // Generic type-params inferred at the call site (e.g. `$R` from the
        // mapper's closure return). Installed for the whole mono so
        // return-type resolution and body lowering substitute them.
        self.type_bindings = type_bindings.*;
        defer {
            self.type_bindings = saved_type_bindings;
            self.scope = saved_scope;
            self.func_defer_base = saved_defer_base;
            self.block_terminated = saved_block_terminated;
            self.target_type = saved_target;
            self.pack_arg_nodes = saved_pan;
            self.pack_param_count = saved_ppc;
            self.pack_arg_types = saved_pat;
            self.pack_constraint = saved_pcon;
            self.inline_return_target = saved_iri;
            self.current_ctx_ref = saved_ctx_ref;
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
        }

        const wants_ctx = self.funcWantsImplicitCtx(fd);

        // Synthesise pack-param names + AST ident nodes used to bind
        // `args[<lit>]` substitutions during body lowering. Built
        // BEFORE return-type resolution so the generic-`$R` path can
        // pre-install the binding for type inference.
        var pack_synth_names = std.ArrayList([]const u8).empty;
        defer pack_synth_names.deinit(self.alloc);
        var pack_arg_idents = std.ArrayList(*const Node).empty;
        defer pack_arg_idents.deinit(self.alloc);
        for (arg_types, 0..) |_, i| {
            const synth_name = std.fmt.allocPrint(self.alloc, "__pack_{s}_{d}", .{ pack_name, i }) catch return;
            pack_synth_names.append(self.alloc, synth_name) catch return;
            const ident_node = self.alloc.create(Node) catch return;
            ident_node.* = .{
                .span = fd.body.span,
                .data = .{ .identifier = .{ .name = synth_name } },
            };
            pack_arg_idents.append(self.alloc, ident_node) catch return;
        }

        // Resolve return type. When the declared type is a generic
        // name (e.g. `(..$args) -> $R`), `resolveReturnType` would
        // return an opaque struct TypeId and the mono's signature
        // would be wrong. Pre-install the pack bindings + infer the
        // ret type from the body's tail expression / first explicit
        // `return X;` instead.
        var pre_pan = std.StringHashMap([]const *const Node).init(self.alloc);
        defer pre_pan.deinit();
        pre_pan.put(pack_name, pack_arg_idents.items) catch return;
        var pre_ppc = std.StringHashMap(u32).init(self.alloc);
        defer pre_ppc.deinit();
        pre_ppc.put(pack_name, @intCast(arg_types.len)) catch return;
        var pre_pat = std.StringHashMap([]const TypeId).init(self.alloc);
        defer pre_pat.deinit();
        pre_pat.put(pack_name, arg_types) catch return;
        var pre_pcon = std.StringHashMap([]const u8).init(self.alloc);
        defer pre_pcon.deinit();
        if (pack_proto) |proto| pre_pcon.put(pack_name, proto) catch return;
        self.pack_arg_nodes = pre_pan;
        self.pack_param_count = pre_ppc;
        self.pack_arg_types = pre_pat;
        self.pack_constraint = if (pack_proto != null) pre_pcon else null;

        const declared_is_generic_ret = blk: {
            const rt = fd.return_type orelse break :blk false;
            if (rt.data != .type_expr) break :blk false;
            break :blk rt.data.type_expr.is_generic;
        };
        const ret_ty: TypeId = if (declared_is_generic_ret)
            self.inferPackBodyReturnType(fd.body)
        else
            self.resolveReturnType(fd);
        self.target_type = ret_ty;

        // Param list: ctx (if needed) + fixed prefix + N pack params.
        // Comptime non-pack params are NOT in the runtime signature —
        // their values are folded into the mangle and substituted via
        // `comptime_param_nodes` / bound as runtime locals in scope.
        // NOT deinit'd — `params.items` is stored by reference in
        // `Function.init` and read back later via `func.params`.
        var params = std.ArrayList(Function.Param).empty;
        if (wants_ctx) {
            params.append(self.alloc, .{
                .name = self.module.types.internString("__sx_ctx"),
                .ty = self.module.types.ptrTo(.void),
            }) catch return;
        }
        for (fd.params, 0..) |p, i| {
            if (i == pack_param_idx) continue;
            if (p.is_comptime) continue; // folded into mangle, not in IR
            const pty = self.resolveParamType(&p);
            params.append(self.alloc, .{
                .name = self.module.types.internString(p.name),
                .ty = pty,
            }) catch return;
        }
        for (arg_types, 0..) |ty, i| {
            params.append(self.alloc, .{
                .name = self.module.types.internString(pack_synth_names.items[i]),
                .ty = ty,
            }) catch return;
        }

        const name_id = self.module.types.internString(owned_name);
        _ = self.builder.beginFunction(name_id, params.items, ret_ty);
        self.builder.currentFunc().has_implicit_ctx = wants_ctx;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);
        if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0);

        var scope = Scope.init(self.alloc, null);
        defer scope.deinit();
        self.scope = &scope;

        // Bind non-pack params. Walk fd.params + call_node.args
        // together; comptime non-pack params bind both as runtime
        // locals (so bare-name body access works) AND as
        // comptime_param_nodes entries (so `#insert` substitution
        // works). Non-comptime non-pack params consume IR param
        // slots in order.
        var cpn = std.StringHashMap(*const Node).init(self.alloc);
        defer cpn.deinit();
        var param_idx: u32 = if (wants_ctx) 1 else 0;
        var ct_arg_idx: usize = 0;
        for (fd.params, 0..) |p, i| {
            if (i == pack_param_idx) break;
            if (p.is_comptime) {
                if (ct_arg_idx < call_node.args.len) {
                    const call_arg = call_node.args[ct_arg_idx];
                    cpn.put(p.name, call_arg) catch return;
                    // Bind as a runtime local for bare-name access.
                    // Lower the call arg as a value, then alloca + store.
                    const val = self.lowerExpr(call_arg);
                    const val_ty = self.builder.getRefType(val);
                    const slot = self.builder.alloca(val_ty);
                    self.builder.store(slot, val);
                    scope.put(p.name, .{ .ref = slot, .ty = val_ty, .is_alloca = true });
                }
                ct_arg_idx += 1;
                continue;
            }
            const pty = self.resolveParamType(&p);
            const slot = self.builder.alloca(pty);
            self.builder.store(slot, Ref.fromIndex(param_idx));
            scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
            param_idx += 1;
            ct_arg_idx += 1;
        }
        // Install comptime_param_nodes for the body lowering.
        const saved_cpn = self.comptime_param_nodes;
        self.comptime_param_nodes = cpn;
        defer self.comptime_param_nodes = saved_cpn;
        var pack_param_slots = std.ArrayList(Ref).empty;
        defer pack_param_slots.deinit(self.alloc);
        for (arg_types, 0..) |ty, i| {
            const synth_name = pack_synth_names.items[i];
            const slot = self.builder.alloca(ty);
            self.builder.store(slot, Ref.fromIndex(param_idx));
            scope.put(synth_name, .{ .ref = slot, .ty = ty, .is_alloca = true });
            pack_param_slots.append(self.alloc, slot) catch return;
            param_idx += 1;
        }

        // Pack bindings remain installed from the pre-resolution
        // (generic-`$R`) inference step above. No need to reinstall.

        // Materialise an `[]Any` slice value for the pack name so
        // bare `args` (forwarding) and `args[<runtime_int>]` (loops)
        // resolve at runtime. Per-position type info is lost via
        // Any boxing — that's the inherent cost of treating a
        // heterogeneous pack as a uniform value. Literal-indexed
        // access still goes through `packArgNodeAt` and keeps the
        // concrete per-position types.
        self.materialisePackSlice(&scope, pack_name, pack_param_slots.items, arg_types);

        if (ret_ty != .void) {
            const body_val = self.lowerBlockValue(fd.body);
            if (!self.currentBlockHasTerminator()) {
                if (body_val) |val| {
                    const val_ty = self.builder.getRefType(val);
                    const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val;
                    self.builder.ret(coerced, ret_ty);
                } else {
                    self.ensureTerminator(ret_ty);
                }
            }
        } else {
            self.lowerBlock(fd.body);
            self.ensureTerminator(ret_ty);
        }
        self.builder.finalize();
    }

    fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name: []const u8, bindings: *std.StringHashMap(TypeId)) void {
        // Mark as lowered before lowering (prevents infinite recursion)
        // Need to dupe the name since mangled_name may be stack-allocated
        const owned_name = self.alloc.dupe(u8, mangled_name) catch return;
        self.lowered_functions.put(owned_name, {}) catch {};

        // Save builder state
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        const saved_scope = self.scope;
        const saved_bindings = self.type_bindings;
        const saved_defer_base = self.func_defer_base;
        const saved_block_terminated = self.block_terminated;
        const saved_target = self.target_type;
        // Pack-fn mono state is lexical to the pack-fn body. A generic
        // function called from inside a pack-fn mono (e.g.
        // `build(args: []Type, $ret: Type)` invoked from
        // `probe(..$args) { build($args, void) }`) must not inherit the
        // caller's pack maps — `lowerFieldAccess`'s `<pack_name>.len`
        // intercept would otherwise constant-fold the callee's
        // same-named param to whichever shape triggered the first mono
        // and bake the wrong arity into the cached IR. Same shape of
        // fix as `lazyLowerFunction` (issue-0048, commit 0ede097).
        const saved_pan = self.pack_arg_nodes;
        const saved_ppc = self.pack_param_count;
        const saved_pat = self.pack_arg_types;
        const saved_iri = self.inline_return_target;
        self.pack_arg_nodes = null;
        self.pack_param_count = null;
        self.pack_arg_types = null;
        self.inline_return_target = null;
        defer {
            self.pack_arg_nodes = saved_pan;
            self.pack_param_count = saved_ppc;
            self.pack_arg_types = saved_pat;
            self.inline_return_target = saved_iri;
        }
        self.func_defer_base = self.defer_stack.items.len;
        self.block_terminated = false;

        // Install type bindings
        self.type_bindings = bindings.*;

        // Resolve return type with type bindings active. The body's tail
        // expression inherits this as its target_type so bare `.{...}`
        // literals resolve to the monomorphised return type instead of
        // whatever leaked in from the caller (e.g. caller's xx target).
        const ret_ty = self.resolveReturnType(fd);
        self.target_type = ret_ty;

        const wants_ctx = self.funcWantsImplicitCtx(fd);
        const saved_ctx_ref_mono = self.current_ctx_ref;
        defer self.current_ctx_ref = saved_ctx_ref_mono;

        // Build param list (substituting type params, skipping type param declarations).
        // Prepend `__sx_ctx: *void` at slot 0 if the function gets the implicit param.
        var params = std.ArrayList(Function.Param).empty;
        if (wants_ctx) {
            params.append(self.alloc, .{
                .name = self.module.types.internString("__sx_ctx"),
                .ty = self.module.types.ptrTo(.void),
            }) catch unreachable;
        }
        for (fd.params) |p| {
            if (isTypeParamDecl(&p, fd.type_params)) continue;
            const pty = self.resolveParamType(&p);
            params.append(self.alloc, .{
                .name = self.module.types.internString(p.name),
                .ty = pty,
            }) catch unreachable;
        }

        // Create the monomorphized function
        const name_id = self.module.types.internString(owned_name);
        const func_id = self.builder.beginFunction(name_id, params.items, ret_ty);
        _ = func_id;
        self.builder.currentFunc().has_implicit_ctx = wants_ctx;

        // Create entry block
        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);
        if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0);

        // Create scope and bind params
        var scope = Scope.init(self.alloc, null);
        defer scope.deinit();
        self.scope = &scope;

        {
            var param_idx: u32 = if (wants_ctx) 1 else 0;
            for (fd.params) |p| {
                if (isTypeParamDecl(&p, fd.type_params)) continue;
                const pty = self.resolveParamType(&p);
                const slot = self.builder.alloca(pty);
                const param_ref = Ref.fromIndex(param_idx);
                self.builder.store(slot, param_ref);
                scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true });
                param_idx += 1;
            }
        }

        // Handle builtin function bodies (e.g. #builtin sqrt monomorphized to sqrt__f32)
        if (fd.body.data == .builtin_expr) {
            // Emit builtin call with param 0, then return
            if (resolveBuiltin(fd.name)) |bid| {
                const param0 = Ref.fromIndex(0);
                const result = self.builder.callBuiltin(bid, &.{param0}, ret_ty);
                self.builder.ret(result, ret_ty);
            } else {
                self.ensureTerminator(ret_ty);
            }
            self.builder.finalize();
        } else {
            // Lower the function body
            if (ret_ty != .void) {
                const body_val = self.lowerBlockValue(fd.body);
                if (!self.currentBlockHasTerminator()) {
                    if (body_val) |val| {
                        const val_ty = self.builder.getRefType(val);
                        const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val;
                        self.builder.ret(coerced, ret_ty);
                    } else {
                        self.ensureTerminator(ret_ty);
                    }
                }
            } else {
                self.lowerBlock(fd.body);
                self.ensureTerminator(ret_ty);
            }
            self.builder.finalize();
        }

        // Restore builder state
        self.type_bindings = saved_bindings;
        self.scope = saved_scope;
        self.func_defer_base = saved_defer_base;
        self.block_terminated = saved_block_terminated;
        self.target_type = saved_target;
        self.builder.func = saved_func;
        self.builder.current_block = saved_block;
        self.builder.inst_counter = saved_counter;
    }

    // ── Reflection builtins ────────────────────────────────────────

    /// Try to lower a call as a reflection builtin (expanded inline during lowering).
    /// Returns null if the call is not a recognized reflection builtin.
    fn tryLowerReflectionCall(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
        if (std.mem.eql(u8, name, "size_of")) {
            // size_of(T) → const_int(sizeof(T))
            const ty = self.resolveTypeArg(c.args[0]);
            const size: i64 = @intCast(self.typeSizeBytes(ty));
            return self.builder.constInt(size, .s64);
        }
        if (std.mem.eql(u8, name, "align_of")) {
            const ty = self.resolveTypeArg(c.args[0]);
            const a: i64 = @intCast(self.module.types.typeAlignBytes(ty));
            return self.builder.constInt(a, .s64);
        }
        if (std.mem.eql(u8, name, "field_count")) {
            // field_count(T) → const_int(N)
            const ty = self.resolveTypeArg(c.args[0]);
            const info = self.module.types.get(ty);
            const count: i64 = switch (info) {
                .@"struct" => |s| @intCast(s.fields.len),
                .@"union" => |u| @intCast(u.fields.len),
                .tagged_union => |u| @intCast(u.fields.len),
                .@"enum" => |e| @intCast(e.variants.len),
                .array => |a| @intCast(a.length),
                .vector => |v| @intCast(v.length),
                else => 0,
            };
            return self.builder.constInt(count, .s64);
        }
        if (std.mem.eql(u8, name, "type_name")) {
            // type_name(T):
            //   - Statically resolvable arg (type expression, pack
            //     index, generic binding, etc.) → fold to const_string
            //     at lower time.
            //   - Dynamic arg (e.g. `list[i]` indexing into a
            //     `$args`-derived []Type slice) → emit a
            //     `callBuiltin(.type_name, [arg_ref])`. The interp's
            //     arm (commit 9600ba5) reads the runtime `.type_tag`
            //     and returns the per-position name. Without this
            //     split, the catch-all `else => .s64` in
            //     `resolveTypeArg` silently returns "s64" for every
            //     dynamic call — exactly the silent-arm pattern the
            //     project's REJECTED PATTERNS forbid.
            if (self.isStaticTypeArg(c.args[0])) {
                const ty = self.resolveTypeArg(c.args[0]);
                const tn_str = self.formatTypeName(ty);
                const sid = self.module.types.internString(tn_str);
                return self.builder.constString(sid);
            }
            const arg_ref = self.lowerExpr(c.args[0]);
            const args_owned = self.alloc.dupe(Ref, &.{arg_ref}) catch return self.builder.constString(self.module.types.internString(""));
            return self.builder.callBuiltin(.type_name, args_owned, .string);
        }
        if (std.mem.eql(u8, name, "type_eq")) {
            // type_eq(T1, T2) → const_bool — comptime TypeId equality.
            // TypeIds are interned per structural shape so equality on
            // them matches the user's intuition: `type_eq(s64, s64)` is
            // true, `type_eq(*s64, *s64)` is true, distinct shapes are
            // false. Pack-indexed types (`$args[0]`) resolve through
            // `resolveTypeArg` → `resolveTypeWithBindings`.
            if (c.args.len < 2) return self.builder.constBool(false);
            const a = self.resolveTypeArg(c.args[0]);
            const b = self.resolveTypeArg(c.args[1]);
            return self.builder.constBool(a == b);
        }
        if (std.mem.eql(u8, name, "has_impl")) {
            // has_impl(P, T) → const_bool. Returns true when type T has
            // a reachable impl for protocol P. P is either:
            // - plain protocol name (`Hash`, `Eq`) for unary protocols;
            // - parameterised call like `Into(Block)` — for protocols
            //   with type args, the args must be fully spelled.
            // Delegates to `computeHasImpl` (shared with the
            // `tryConstBoolCondition` arm so `inline if has_impl(...)`
            // folds at compile time).
            if (c.args.len < 2) return self.builder.constBool(false);
            const ty = self.resolveTypeArg(c.args[1]);
            return self.builder.constBool(self.computeHasImpl(c.args[0], ty));
        }
        if (std.mem.eql(u8, name, "is_flags")) {
            const ty = self.resolveTypeArg(c.args[0]);
            if (!ty.isBuiltin()) {
                const info = self.module.types.get(ty);
                if (info == .@"enum") return self.builder.constBool(info.@"enum".is_flags);
            }
            return self.builder.constBool(false);
        }
        if (std.mem.eql(u8, name, "compile_error")) {
            // compile_error(msg) — raise a build-time diagnostic at
            // the call site. The argument must be a string literal so
            // the message text is available at lower time. Returns a
            // void-typed const (the call site is consumed for its
            // side effect, not its value).
            if (self.diagnostics) |diags| {
                if (c.args.len < 1) {
                    diags.addFmt(.err, c.callee.span, "compile_error requires a string argument", .{});
                } else if (c.args[0].data == .string_literal) {
                    const lit = c.args[0].data.string_literal;
                    const msg = if (lit.is_raw)
                        lit.raw
                    else
                        unescape.unescapeString(self.alloc, lit.raw) catch lit.raw;
                    diags.addFmt(.err, c.callee.span, "{s}", .{msg});
                } else {
                    diags.addFmt(.err, c.callee.span, "compile_error argument must be a string literal", .{});
                }
            }
            return self.builder.constInt(0, .void);
        }
        if (std.mem.eql(u8, name, "field_name")) {
            // field_name(T, i) → field_name_get instruction
            if (c.args.len < 2) return self.builder.constString(self.module.types.internString(""));
            const ty = self.resolveTypeArg(c.args[0]);
            const idx = self.lowerExpr(c.args[1]);
            return self.builder.emit(.{ .field_name_get = .{
                .base = .none,
                .index = idx,
                .struct_type = ty,
            } }, .string);
        }
        if (std.mem.eql(u8, name, "is_comptime")) {
            // True under the comptime interpreter, false in compiled code — the
            // op decides per backend (it can't fold here, since the same IR
            // serves both). Lets stdlib gate a comptime-only diagnostic branch.
            return self.builder.emit(.{ .is_comptime = {} }, .bool);
        }
        if (std.mem.eql(u8, name, "__interp_print_frames")) {
            // Backs `trace.print_interpreter_frames()`: dumps the interp call
            // chain at comptime, no-op in compiled code (ERR E4.1).
            return self.builder.emit(.{ .interp_print_frames = {} }, .void);
        }
        if (std.mem.eql(u8, name, "__trace_resolve_frame")) {
            // Backs `trace.sx`'s formatter: a raw trace-buffer u64 → a `Frame`.
            // Compiled code reinterprets the operand as `*Frame` and loads it;
            // the interp unpacks (func_id, span.start) and resolves (ERR E3.0
            // slice 3b). Result type is the `Frame` struct from trace.sx.
            const frame_ty = self.module.types.findByName(self.module.types.internString("Frame")) orelse {
                if (self.diagnostics) |d| d.addFmt(.err, null, "`__trace_resolve_frame` needs `Frame` (from trace.sx) in scope", .{});
                return self.builder.constInt(0, .void);
            };
            const arg = self.lowerExpr(c.args[0]);
            return self.builder.emit(.{ .trace_resolve = .{ .operand = arg } }, frame_ty);
        }
        if (std.mem.eql(u8, name, "error_tag_name")) {
            // error_tag_name(e) → look the error-set value's runtime tag id up
            // in the always-linked tag-name table. The value IS its u32 tag id.
            if (c.args.len < 1) return self.builder.constString(self.module.types.internString(""));
            const e = self.lowerExpr(c.args[0]);
            return self.builder.emit(.{ .error_tag_name_get = .{ .operand = e } }, .string);
        }
        if (std.mem.eql(u8, name, "field_value")) {
            // field_value(s, i) → field_value_get instruction (structs/unions)
            //                   → index_get + box_any (slices/arrays)
            if (c.args.len < 2) return self.builder.constInt(0, .any);
            const base = self.lowerExpr(c.args[0]);
            const idx = self.lowerExpr(c.args[1]);
            const struct_ty = self.inferExprType(c.args[0]);

            // For slices, arrays, and vectors, use index_get to access elements
            if (!struct_ty.isBuiltin()) {
                const ti = self.module.types.get(struct_ty);
                if (ti == .slice or ti == .array or ti == .vector) {
                    const elem_ty = self.getElementType(struct_ty);
                    const elem = self.builder.emit(.{ .index_get = .{ .lhs = base, .rhs = idx } }, elem_ty);
                    return self.builder.boxAny(elem, elem_ty);
                }
            }

            return self.builder.emit(.{ .field_value_get = .{
                .base = base,
                .index = idx,
                .struct_type = struct_ty,
            } }, .any);
        }
        if (std.mem.eql(u8, name, "type_of")) {
            // type_of(val) — produce a Type value (.any-typed aggregate).
            if (c.args.len < 1) return self.builder.constType(.void);
            const arg_ty = self.inferExprType(c.args[0]);
            if (arg_ty == .any) {
                // Runtime: extract tag, rebuild Any with `{.any, tag}` so
                // the returned value carries Type semantics (tag field
                // says ".any" → the value field holds the type id).
                const val = self.lowerExpr(c.args[0]);
                const tag_val = self.builder.structGet(val, 0, .s64);
                return self.builder.boxAny(tag_val, .any);
            } else {
                return self.builder.constType(arg_ty);
            }
        }
        if (std.mem.eql(u8, name, "field_index")) {
            // field_index(T, val) → extract tag from tagged union
            if (c.args.len < 2) return self.builder.constInt(0, .s64);
            const val = self.lowerExpr(c.args[1]);
            // For tagged unions: extract field 0 (the tag)
            return self.builder.emit(.{ .enum_tag = .{ .operand = val } }, .s64);
        }
        if (std.mem.eql(u8, name, "field_value_int")) {
            // field_value_int(T, i) → lookup enum variant value by index
            if (c.args.len < 2) return self.builder.constInt(0, .s64);
            const ty = self.resolveTypeArg(c.args[0]);
            const idx = self.lowerExpr(c.args[1]);
            // For enums with explicit values, build a global value array and index into it
            if (!ty.isBuiltin()) {
                const ti = self.module.types.get(ty);
                if (ti == .@"enum") {
                    if (ti.@"enum".explicit_values) |vals| {
                        // Build inline switch: for each index, return the explicit value
                        // Simple approach: build an array of constants and use index_get
                        var elems = std.ArrayList(Ref).empty;
                        defer elems.deinit(self.alloc);
                        for (vals) |v| {
                            elems.append(self.alloc, self.builder.constInt(v, .s64)) catch unreachable;
                        }
                        const arr_ty = self.module.types.arrayOf(.s64, @intCast(vals.len));
                        const arr = self.builder.structInit(elems.items, arr_ty);
                        return self.builder.emit(.{ .index_get = .{ .lhs = arr, .rhs = idx } }, .s64);
                    }
                }
            }
            // Default: return the index itself (regular enums)
            return idx;
        }
        return null;
    }

    /// Resolve a type argument from a call expression. Handles:
    /// - Type param bindings ($T → concrete type via type_bindings)
    /// - Direct type names (Vec4 → lookup in TypeTable)
    /// - type_expr AST nodes
    /// True iff `node` matches an AST shape that `resolveTypeArg`
    /// can resolve to a concrete TypeId without falling through to
    /// the silent `.s64` default. Used by `tryLowerReflectionCall`
    /// to split static-fold from dynamic-builtin-call paths.
    ///
    /// Static-arg shapes mirror the explicit arms of `resolveTypeArg`:
    ///   - type_expr / identifier (type name or bound generic)
    ///   - pack_index_type_expr (`$pack[<lit>]`)
    ///   - compound type literals (pointer, array, slice, optional,
    ///     many_pointer, function_type_expr)
    ///   - parameterised type-constructor `call` (Vector, List, etc.)
    ///   - tuple_literal as a tuple TYPE
    ///
    /// Dynamic shapes (index_expr, field_access, runtime locals,
    /// etc.) fall to the alternative path that emits a builtin_call.
    fn isStaticTypeArg(self: *Lowering, node: *const Node) bool {
        switch (node.data) {
            .type_expr => |te| {
                // A type-keyword name (e.g. `s64`) is always static.
                // A user-defined name that happens to be in scope as
                // a runtime variable (`x: Type = s64; type_name(x)`)
                // is NOT static — route through the dynamic builtin
                // call so the runtime lookup table fires.
                if (self.scope) |scope| {
                    if (scope.lookup(te.name) != null) return false;
                }
                return true;
            },
            .identifier => |id| {
                if (self.scope) |scope| {
                    if (scope.lookup(id.name) != null) return false;
                }
                return true;
            },
            .pack_index_type_expr,
            .pointer_type_expr,
            .many_pointer_type_expr,
            .array_type_expr,
            .slice_type_expr,
            .optional_type_expr,
            .function_type_expr,
            .tuple_literal,
            .call,
            => return true,
            else => return false,
        }
    }

    /// True iff `node` is a Type-shaped expression that resolves to a
    /// concrete TypeId at lower time WITHOUT being a runtime variable
    /// reference. Differs from `isStaticTypeArg` in that we exclude
    /// identifiers that are in scope as runtime locals/globals — those
    /// are runtime Type values (e.g. `t: Type = f64`) and the
    /// comparison fold can't statically resolve them.
    fn isStaticTypeRef(self: *Lowering, node: *const Node) bool {
        switch (node.data) {
            .type_expr => |te| {
                // Compound type names (`s64`, `Point`, `Vec4`) resolve
                // statically. If the name is also a runtime var in
                // scope, it's a value reference, not a type ref.
                if (self.scope) |scope| {
                    if (scope.lookup(te.name) != null) return false;
                }
                return self.isKnownTypeName(te.name) or
                    self.module.types.findByName(self.module.types.internString(te.name)) != null or
                    self.program_index.type_alias_map.get(te.name) != null;
            },
            .identifier => |id| {
                if (self.scope) |scope| {
                    if (scope.lookup(id.name) != null) return false;
                }
                return self.isKnownTypeName(id.name) or
                    self.module.types.findByName(self.module.types.internString(id.name)) != null or
                    self.program_index.type_alias_map.get(id.name) != null;
            },
            .pointer_type_expr,
            .many_pointer_type_expr,
            .array_type_expr,
            .slice_type_expr,
            .optional_type_expr,
            .function_type_expr,
            .pack_index_type_expr,
            => return true,
            .call => |cl| {
                // `type_of(x)` resolves statically when `x`'s type is
                // known — which it always is for a typed expression.
                if (cl.callee.data == .identifier and
                    std.mem.eql(u8, cl.callee.data.identifier.name, "type_of") and
                    cl.args.len == 1)
                {
                    return true;
                }
                return false;
            },
            else => return false,
        }
    }

    /// Resolve a tuple LITERAL used in a type position (`(s32, s32)` reinterpreted
    /// as a tuple type at a type-demanding site such as `size_of`). Every element
    /// must itself denote a type; a non-type element — e.g. the `1` in
    /// `(s32, 1)` — is a user error. Emit a diagnostic pointing at the offending
    /// element and return `.unresolved`; never fabricate a tuple with a bogus
    /// field (issue 0067). type_bridge.resolveAstType builds the tuple only after
    /// this validation passes.
    fn resolveTupleLiteralTypeArg(self: *Lowering, node: *const Node) TypeId {
        for (node.data.tuple_literal.elements) |el| {
            if (!type_bridge.isTypeShapedAstNode(el.value, &self.module.types)) {
                if (self.diagnostics) |diags| {
                    diags.addFmt(.err, el.value.span, "tuple type element is not a type (found `{s}`); a tuple used as a type must list only types, e.g. `(s32, s32)`", .{@tagName(el.value.data)});
                }
                return .unresolved;
            }
        }
        return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map);
    }

    pub fn resolveTypeArg(self: *Lowering, node: *const Node) TypeId {
        // Pack-index access in a type-arg slot (e.g. `type_name($args[0])`
        // or `type_eq($args[i], s64)`). Same shape as the
        // `resolveTypeWithBindings` arm — looks up the bound pack types
        // and returns the i-th. OOB and no-active-binding emit focused
        // diagnostics rather than silently defaulting to .s64 (the
        // catch-all `else` below) — that fall-through is exactly the
        // "silent unimplemented arm" the project's REJECTED PATTERNS
        // forbid.
        if (node.data == .pack_index_type_expr) {
            const pi = node.data.pack_index_type_expr;
            if (self.pack_arg_types) |pat| {
                if (pat.get(pi.pack_name)) |arg_tys| {
                    if (pi.index < arg_tys.len) return arg_tys[pi.index];
                    if (self.diagnostics) |diags| {
                        diags.addFmt(.err, node.span, "pack-index ${s}[{}] out of bounds: '{s}' has {} element{s}", .{
                            pi.pack_name, pi.index, pi.pack_name, arg_tys.len,
                            if (arg_tys.len == 1) @as([]const u8, "") else @as([]const u8, "s"),
                        });
                    }
                    return .unresolved;
                }
            }
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, node.span, "pack-index ${s}[{}] used outside an active pack binding", .{
                    pi.pack_name, pi.index,
                });
            }
            return .unresolved;
        }
        // Bare `$<name>` in a type-arg position. Single-type generic
        // bindings (`$R: Type` in `Closure(..$args) -> $R`) live in
        // `type_bindings`; if the name is bound there, return the
        // bound TypeId directly. Pack bindings would otherwise resolve
        // to a slice value, not a single Type — the caller (e.g.
        // `type_name(...)`) expects a single arg.
        if (node.data == .comptime_pack_ref) {
            const cpr = node.data.comptime_pack_ref;
            if (self.type_bindings) |tb| {
                if (tb.get(cpr.pack_name)) |ty| return ty;
            }
        }
        switch (node.data) {
            .identifier => |id| {
                // Check type bindings first (from generic monomorphization)
                if (self.type_bindings) |tb| {
                    if (tb.get(id.name)) |ty| return ty;
                }
                if (self.program_index.type_alias_map.get(id.name)) |alias_ty| return alias_ty;
                const name_id = self.module.types.internString(id.name);
                if (self.module.types.findByName(name_id)) |t| return t;
                if (self.diagnostics) |diags| {
                    diags.addFmt(.err, node.span, "unresolved type: '{s}'", .{id.name});
                }
                return .unresolved;
            },
            .type_expr => |te| {
                if (self.program_index.type_alias_map.get(te.name)) |alias_ty| return alias_ty;
                return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map);
            },
            .call => |cl| {
                // `type_of(x)` resolves to `inferExprType(x)` at lower
                // time when `x`'s type is statically known (which it
                // is for any expression — type inference always
                // produces a concrete TypeId). Lets
                // `type_of(a) == s64` fold the same as
                // `inferExprType(a) == s64`.
                if (cl.callee.data == .identifier and
                    std.mem.eql(u8, cl.callee.data.identifier.name, "type_of") and
                    cl.args.len == 1)
                {
                    return self.inferExprType(cl.args[0]);
                }
                // Handle type constructor calls: size_of(Sx(f32)), size_of(Complex(u32))
                return self.resolveTypeCallWithBindings(&cl);
            },
            .tuple_literal => return self.resolveTupleLiteralTypeArg(node),
            .pointer_type_expr,
            .many_pointer_type_expr,
            .array_type_expr,
            .slice_type_expr,
            .optional_type_expr,
            .function_type_expr,
            => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map),
            else => return .unresolved,
        }
    }

    /// Format a type name for display (e.g. "*Point", "[]s32", "[3]f64").
    pub fn formatTypeName(self: *Lowering, ty: TypeId) []const u8 {
        // Builtin types: use their canonical name
        if (ty == .s8) return "s8";
        if (ty == .s16) return "s16";
        if (ty == .s32) return "s32";
        if (ty == .s64) return "s64";
        if (ty == .u8) return "u8";
        if (ty == .u16) return "u16";
        if (ty == .u32) return "u32";
        if (ty == .u64) return "u64";
        if (ty == .f32) return "f32";
        if (ty == .f64) return "f64";
        if (ty == .bool) return "bool";
        if (ty == .void) return "void";
        if (ty == .string) return "string";
        if (ty == .any) return "Any";
        if (ty == .usize) return "usize";
        if (ty == .isize) return "isize";

        const info = self.module.types.get(ty);
        return switch (info) {
            .@"struct" => |s| self.module.types.getString(s.name),
            .@"union" => |u| self.module.types.getString(u.name),
            .tagged_union => |u| self.module.types.getString(u.name),
            .@"enum" => |e| self.module.types.getString(e.name),
            .pointer => |p| blk: {
                const inner = self.formatTypeName(p.pointee);
                break :blk std.fmt.allocPrint(self.alloc, "*{s}", .{inner}) catch "pointer";
            },
            .many_pointer => |p| blk: {
                const inner = self.formatTypeName(p.element);
                break :blk std.fmt.allocPrint(self.alloc, "[*]{s}", .{inner}) catch "many_pointer";
            },
            .slice => |s| blk: {
                const inner = self.formatTypeName(s.element);
                break :blk std.fmt.allocPrint(self.alloc, "[]{s}", .{inner}) catch "slice";
            },
            .array => |a| blk: {
                const inner = self.formatTypeName(a.element);
                break :blk std.fmt.allocPrint(self.alloc, "[{d}]{s}", .{ a.length, inner }) catch "array";
            },
            .signed => |w| std.fmt.allocPrint(self.alloc, "s{d}", .{w}) catch "signed",
            .unsigned => |w| std.fmt.allocPrint(self.alloc, "u{d}", .{w}) catch "unsigned",
            .optional => |o| blk: {
                const inner = self.formatTypeName(o.child);
                break :blk std.fmt.allocPrint(self.alloc, "?{s}", .{inner}) catch "optional";
            },
            .vector => |v| blk: {
                const inner = self.formatTypeName(v.element);
                break :blk std.fmt.allocPrint(self.alloc, "Vector({d},{s})", .{ v.length, inner }) catch "vector";
            },
            else => @tagName(info),
        };
    }

    /// Format a function type string like "() -> s32" or "(s32, s32) -> s32".
    fn formatFnTypeString(self: *Lowering, fd: *const ast.FnDecl) []const u8 {
        var buf: [512]u8 = undefined;
        var pos: usize = 0;
        buf[pos] = '(';
        pos += 1;
        for (fd.params, 0..) |p, i| {
            if (i > 0) {
                @memcpy(buf[pos..][0..2], ", ");
                pos += 2;
            }
            const pty = self.resolveParamType(&p);
            const name = self.formatTypeName(pty);
            @memcpy(buf[pos..][0..name.len], name);
            pos += name.len;
        }
        buf[pos] = ')';
        pos += 1;
        const ret_ty = self.resolveReturnType(fd);
        if (ret_ty != .void) {
            @memcpy(buf[pos..][0..4], " -> ");
            pos += 4;
            const rname = self.formatTypeName(ret_ty);
            @memcpy(buf[pos..][0..rname.len], rname);
            pos += rname.len;
        }
        const result = self.alloc.alloc(u8, pos) catch unreachable;
        @memcpy(result, buf[0..pos]);
        return result;
    }

    /// Format a type name for function name mangling (identifier-safe).
    /// E.g. *Point → "ptr_Point", []s32 → "slice_s32", [3]f64 → "array_3_f64".
    /// Check if a param type expression references a type param name (possibly nested).
    pub fn matchTypeParam(_: *Lowering, type_node: *const Node, tp_name: []const u8) bool {
        return switch (type_node.data) {
            .type_expr => |te| std.mem.eql(u8, te.name, tp_name),
            .identifier => |id| std.mem.eql(u8, id.name, tp_name),
            .slice_type_expr => |st| matchTypeParamStatic(st.element_type, tp_name),
            .pointer_type_expr => |pt| matchTypeParamStatic(pt.pointee_type, tp_name),
            .many_pointer_type_expr => |mp| matchTypeParamStatic(mp.element_type, tp_name),
            .optional_type_expr => |ot| matchTypeParamStatic(ot.inner_type, tp_name),
            .array_type_expr => |at| matchTypeParamStatic(at.element_type, tp_name),
            .closure_type_expr => |ct| blk: {
                for (ct.param_types) |pt| if (matchTypeParamStatic(pt, tp_name)) break :blk true;
                if (ct.return_type) |rt| if (matchTypeParamStatic(rt, tp_name)) break :blk true;
                break :blk false;
            },
            else => false,
        };
    }

    fn matchTypeParamStatic(type_node: *const Node, tp_name: []const u8) bool {
        return switch (type_node.data) {
            .type_expr => |te| std.mem.eql(u8, te.name, tp_name),
            .identifier => |id| std.mem.eql(u8, id.name, tp_name),
            .slice_type_expr => |st| matchTypeParamStatic(st.element_type, tp_name),
            .pointer_type_expr => |pt| matchTypeParamStatic(pt.pointee_type, tp_name),
            .many_pointer_type_expr => |mp| matchTypeParamStatic(mp.element_type, tp_name),
            .optional_type_expr => |ot| matchTypeParamStatic(ot.inner_type, tp_name),
            .array_type_expr => |at| matchTypeParamStatic(at.element_type, tp_name),
            .closure_type_expr => |ct| blk: {
                for (ct.param_types) |pt| if (matchTypeParamStatic(pt, tp_name)) break :blk true;
                if (ct.return_type) |rt| if (matchTypeParamStatic(rt, tp_name)) break :blk true;
                break :blk false;
            },
            else => false,
        };
    }

    /// Extract the concrete type that corresponds to a type param from an arg type.
    /// E.g., param type []$T with arg type []s64 → T = s64.
    pub fn extractTypeParam(self: *Lowering, type_node: *const Node, arg_ty: TypeId, tp_name: []const u8) ?TypeId {
        return switch (type_node.data) {
            .type_expr => |te| if (std.mem.eql(u8, te.name, tp_name)) arg_ty else null,
            .identifier => |id| if (std.mem.eql(u8, id.name, tp_name)) arg_ty else null,
            .slice_type_expr => |st| blk: {
                // arg_ty should be a slice → extract element type
                if (arg_ty.isBuiltin()) break :blk null;
                const info = self.module.types.get(arg_ty);
                break :blk switch (info) {
                    .slice => |s| self.extractTypeParam(st.element_type, s.element, tp_name),
                    else => null,
                };
            },
            .pointer_type_expr => |pt| blk: {
                if (arg_ty.isBuiltin()) break :blk null;
                const info = self.module.types.get(arg_ty);
                break :blk switch (info) {
                    .pointer => |p| self.extractTypeParam(pt.pointee_type, p.pointee, tp_name),
                    else => null,
                };
            },
            .many_pointer_type_expr => |mp| blk: {
                if (arg_ty.isBuiltin()) break :blk null;
                const info = self.module.types.get(arg_ty);
                break :blk switch (info) {
                    .many_pointer => |p| self.extractTypeParam(mp.element_type, p.element, tp_name),
                    else => null,
                };
            },
            .optional_type_expr => |ot| blk: {
                if (arg_ty.isBuiltin()) break :blk null;
                const info = self.module.types.get(arg_ty);
                break :blk switch (info) {
                    .optional => |o| self.extractTypeParam(ot.inner_type, o.child, tp_name),
                    else => null,
                };
            },
            .array_type_expr => |at| blk: {
                if (arg_ty.isBuiltin()) break :blk null;
                const info = self.module.types.get(arg_ty);
                break :blk switch (info) {
                    .array => |a| self.extractTypeParam(at.element_type, a.element, tp_name),
                    else => null,
                };
            },
            .closure_type_expr => |ct| blk: {
                if (arg_ty.isBuiltin()) break :blk null;
                const info = self.module.types.get(arg_ty);
                const c_params: []const TypeId, const c_ret: TypeId = switch (info) {
                    .closure => |c| .{ c.params, c.ret },
                    .function => |f| .{ f.params, f.ret },
                    else => break :blk null,
                };
                // Prefer the return position (`Closure(...) -> $R`), then params.
                if (ct.return_type) |rt| {
                    if (self.extractTypeParam(rt, c_ret, tp_name)) |ety| break :blk ety;
                }
                for (ct.param_types, 0..) |pt, i| {
                    if (i >= c_params.len) break;
                    if (self.extractTypeParam(pt, c_params[i], tp_name)) |ety| break :blk ety;
                }
                break :blk null;
            },
            else => null,
        };
    }

    /// Mangle a TypeId into its mono-key fragment. Thin delegation to the
    /// canonical owner (`GenericResolver`, `generics.zig`); kept on `Lowering`
    /// because ~30 cross-cutting callers (impl-map keys, conversion keys, shape
    /// keys) reach it here, well beyond generic monomorphization.
    pub fn mangleTypeName(self: *Lowering, ty: TypeId) []const u8 {
        return self.genericResolver().mangleTypeName(ty);
    }

    /// Resolve type category names (like "int", "struct", "float") to matching TypeId tag values.
    /// Returns a list of TypeId index values that match the category.
    fn resolveTypeCategoryTags(self: *Lowering, name: []const u8) []const u64 {
        var tags = std.ArrayList(u64).empty;

        // Fixed builtin categories
        if (std.mem.eql(u8, name, "int")) {
            tags.append(self.alloc, TypeId.s8.index()) catch {};
            tags.append(self.alloc, TypeId.s16.index()) catch {};
            tags.append(self.alloc, TypeId.s32.index()) catch {};
            tags.append(self.alloc, TypeId.s64.index()) catch {};
            tags.append(self.alloc, TypeId.u8.index()) catch {};
            tags.append(self.alloc, TypeId.u16.index()) catch {};
            tags.append(self.alloc, TypeId.u32.index()) catch {};
            tags.append(self.alloc, TypeId.u64.index()) catch {};
            tags.append(self.alloc, TypeId.usize.index()) catch {};
            tags.append(self.alloc, TypeId.isize.index()) catch {};
            return tags.items;
        }
        if (std.mem.eql(u8, name, "float")) {
            tags.append(self.alloc, TypeId.f32.index()) catch {};
            tags.append(self.alloc, TypeId.f64.index()) catch {};
            return tags.items;
        }
        if (std.mem.eql(u8, name, "bool")) {
            tags.append(self.alloc, TypeId.bool.index()) catch {};
            return tags.items;
        }
        if (std.mem.eql(u8, name, "string")) {
            tags.append(self.alloc, TypeId.string.index()) catch {};
            return tags.items;
        }
        if (std.mem.eql(u8, name, "void")) {
            tags.append(self.alloc, TypeId.void.index()) catch {};
            return tags.items;
        }
        if (std.mem.eql(u8, name, "type") or std.mem.eql(u8, name, "Type")) {
            tags.append(self.alloc, TypeId.any.index()) catch {};
            return tags.items;
        }

        // Dynamic categories: scan TypeTable for matching types
        const Category = enum { @"struct", @"enum", @"union", slice, array, pointer, vector, optional, error_set };
        const cat: ?Category = if (std.mem.eql(u8, name, "struct"))
            .@"struct"
        else if (std.mem.eql(u8, name, "enum") or std.mem.eql(u8, name, "union"))
            .@"enum"
        else if (std.mem.eql(u8, name, "slice"))
            .slice
        else if (std.mem.eql(u8, name, "array"))
            .array
        else if (std.mem.eql(u8, name, "pointer"))
            .pointer
        else if (std.mem.eql(u8, name, "vector"))
            .vector
        else if (std.mem.eql(u8, name, "optional"))
            .optional
        else if (std.mem.eql(u8, name, "error_set"))
            .error_set
        else
            null;

        if (cat) |c| {
            for (self.module.types.infos.items, 0..) |info, idx| {
                const matches = switch (c) {
                    .@"struct" => info == .@"struct",
                    .@"enum" => info == .@"enum" or info == .tagged_union,
                    .@"union" => info == .@"union" or info == .tagged_union,
                    .slice => info == .slice,
                    .array => info == .array,
                    .pointer => info == .pointer or info == .many_pointer,
                    .vector => info == .vector,
                    .optional => info == .optional,
                    .error_set => info == .error_set,
                };
                if (matches) {
                    tags.append(self.alloc, @intCast(idx)) catch {};
                }
            }
        }

        // Specific type name (e.g., Point, Color) — look up in type registry
        if (tags.items.len == 0) {
            const name_id = self.module.types.internString(name);
            if (self.module.types.findByName(name_id)) |tid| {
                tags.append(self.alloc, tid.index()) catch {};
            }
        }

        return tags.items;
    }

    /// Check if a match expression is a type-category match (patterns are type/category names).
    fn inferMatchResultType(self: *Lowering, me: *const ast.MatchExpr) TypeId {
        // Infer result type from the first non-null arm body.
        // If we skip null_literal arms and find a concrete type T, and there
        // were null arms, the result is ?T (optional).
        var has_null = false;
        var saw_unresolved = false;
        var saw_noreturn = false;
        for (me.arms) |arm| {
            const last_node = if (arm.body.data == .block) blk: {
                if (arm.body.data.block.stmts.len > 0) {
                    break :blk arm.body.data.block.stmts[arm.body.data.block.stmts.len - 1];
                }
                break :blk arm.body;
            } else arm.body;

            if (last_node.data == .null_literal) {
                has_null = true;
                continue;
            }

            // First arm with a statically-inferable type determines the result.
            // An arm whose type isn't inferable from the AST alone (e.g. a bare
            // enum literal) doesn't decide — keep looking; the caller falls back
            // to the contextual target type if none of the arms resolve.
            const arm_ty = self.inferExprType(last_node);
            // A diverging arm (`noreturn` — `return` / `raise` / `break` /
            // `continue`) doesn't produce a value, so it doesn't decide the
            // result type; keep looking. The match is `noreturn` only if EVERY
            // arm diverges (handled after the loop).
            if (arm_ty == .noreturn) {
                saw_noreturn = true;
                continue;
            }
            if (arm_ty == .unresolved) {
                saw_unresolved = true;
                continue;
            }
            if (has_null and arm_ty != .void) {
                return self.module.types.optionalOf(arm_ty);
            }
            return arm_ty;
        }
        if (saw_unresolved) return .unresolved;
        if (saw_noreturn) return .noreturn; // all arms diverge
        return .void;
    }

    fn isTypeCategoryMatch(me: *const ast.MatchExpr) bool {
        for (me.arms) |arm| {
            if (arm.pattern) |pat| {
                const name = switch (pat.data) {
                    .identifier => |id| id.name,
                    .type_expr => |te| te.name,
                    else => continue,
                };
                const categories = [_][]const u8{
                    "int", "float", "bool", "string", "void", "type", "Type",
                    "struct", "enum", "union", "slice", "array", "pointer", "vector",
                };
                for (categories) |cat| {
                    if (std.mem.eql(u8, name, cat)) return true;
                }
                // Also match specific struct/enum type names (e.g., case Point:)
                if (name.len > 0 and name[0] >= 'A' and name[0] <= 'Z') return true;
            }
        }
        return false;
    }

    /// After args have been lowered, append the lowered values of any
    /// `param: T = default_expr` defaults for positions past `args.items.len`.
    /// Stops at the first param without a default. Used at method-dispatch
    /// sites whose callee is a field_access (so `expandCallDefaults` can't
    /// handle them up front). The default expression is lowered in the
    /// caller's current scope, so identifiers like `context.allocator`
    /// resolve to the caller's runtime context.
    fn appendDefaultArgs(self: *Lowering, fd: *const ast.FnDecl, args: *std.ArrayList(Ref)) void {
        if (args.items.len >= fd.params.len) return;
        var i: usize = args.items.len;
        while (i < fd.params.len) : (i += 1) {
            const dflt = fd.params[i].default_expr orelse break;
            const v = self.lowerExpr(dflt);
            args.append(self.alloc, v) catch unreachable;
        }
    }

    /// When a bare-identifier call omits trailing positional args and the
    /// callee's signature provides defaults for them, return a fresh Call
    /// node with the defaults filled in. Returns null when no expansion is
    /// needed (callee unknown, all args provided, or no defaults available).
    fn expandCallDefaults(self: *Lowering, c: *const ast.Call) ?*ast.Call {
        const fd = blk: {
            switch (c.callee.data) {
                .identifier => |id| {
                    const eff_name = blk2: {
                        const scoped = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
                        if (self.program_index.ufcs_alias_map.get(id.name)) |target| {
                            break :blk2 if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
                        }
                        break :blk2 scoped;
                    };
                    break :blk self.program_index.fn_ast_map.get(eff_name) orelse return null;
                },
                // Namespace call `mod.fn(args)` — args map directly to params
                // (no `self` prepend), so default expansion is the same shape as
                // a bare call. A METHOD call `value.method(args)` prepends `self`
                // (arg/param counts are offset), so it's excluded: only treat the
                // receiver as a namespace when it isn't a value in scope.
                .field_access => |fa| {
                    const obj_name: ?[]const u8 = switch (fa.object.data) {
                        .identifier => |id| id.name,
                        .type_expr => |te| te.name,
                        else => null,
                    };
                    const name = obj_name orelse return null;
                    if (self.scope) |scope| {
                        if (scope.lookup(name) != null) return null; // method call on a value
                    }
                    if (self.program_index.global_names.contains(name)) return null;
                    const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ name, fa.field }) catch fa.field;
                    break :blk self.program_index.fn_ast_map.get(qualified) orelse self.program_index.fn_ast_map.get(fa.field) orelse return null;
                },
                else => return null,
            }
        };
        if (c.args.len >= fd.params.len) return null;
        var end: usize = c.args.len;
        while (end < fd.params.len) : (end += 1) {
            if (fd.params[end].default_expr == null) break;
        }
        if (end == c.args.len) return null;

        var new_args = self.alloc.alloc(*ast.Node, end) catch return null;
        for (c.args, 0..) |arg, i| new_args[i] = arg;
        var i: usize = c.args.len;
        while (i < end) : (i += 1) {
            const def = fd.params[i].default_expr.?;
            // `#caller_location` resolves at the CALL site, not the callee's
            // signature: emit a fresh marker carrying the call's span + file so
            // lowering synthesizes the caller's `Source_Location` (ERR E4.1b).
            if (def.data == .caller_location) {
                const n = self.alloc.create(ast.Node) catch return null;
                n.* = .{ .span = c.callee.span, .data = .{ .caller_location = {} }, .source_file = c.callee.source_file };
                new_args[i] = n;
            } else {
                new_args[i] = def;
            }
        }
        const new_call = self.alloc.create(ast.Call) catch return null;
        new_call.* = .{ .callee = c.callee, .args = new_args };
        return new_call;
    }

    /// Resolve parameter types for a call expression (for target_type context).
    /// Returns empty slice if the function can't be resolved.
    /// Return the param types of a Function from the caller's POV — i.e.
    /// skipping the synthetic `__sx_ctx` slot when present. lowerCall's
    /// arg-lowering uses these to set `target_type` per arg, and user
    /// args don't include `__sx_ctx`, so the slot must be elided.
    fn userParamTypes(self: *Lowering, func: *const Function) []TypeId {
        const start: usize = if (func.has_implicit_ctx) 1 else 0;
        var types_list = std.ArrayList(TypeId).empty;
        if (func.params.len > start) {
            for (func.params[start..]) |p| {
                types_list.append(self.alloc, p.ty) catch unreachable;
            }
        }
        return types_list.items;
    }

    fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call) []const TypeId {
        // Method calls: obj.method(args) — resolve param types from the method signature,
        // skipping the first param (self) since it's prepended later.
        if (c.callee.data == .field_access) {
            const fa = c.callee.data.field_access;

            // Namespace/static call: `Type.method(args)` where `Type` is a type
            // identifier (not a value in scope). Args correspond to ALL params
            // — no self prepend — so target_type for arg lowering must include
            // the leading param. Skipping it would lose the protocol context
            // for `xx ptr` inline-cast args.
            if (fa.object.data == .identifier) {
                const obj_name = fa.object.data.identifier.name;
                const is_value = blk: {
                    if (self.scope) |scope| {
                        if (scope.lookup(obj_name) != null) break :blk true;
                    }
                    if (self.program_index.global_names.contains(obj_name)) break :blk true;
                    break :blk false;
                };
                if (!is_value) {
                    const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ obj_name, fa.field }) catch return &.{};
                    if (self.resolveFuncByName(qualified)) |fid| {
                        const func = &self.module.functions.items[@intFromEnum(fid)];
                        return self.userParamTypes(func);
                    }
                    if (self.program_index.fn_ast_map.get(qualified)) |fd| {
                        var types_list = std.ArrayList(TypeId).empty;
                        for (fd.params) |p| {
                            types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
                        }
                        return types_list.items;
                    }
                }
            }

            const obj_ty = self.inferExprType(fa.object);
            // Protocol-typed receiver: look up the method on the protocol decl. The
            // protocol's ProtocolMethodInfo.param_types already excludes self.
            if (self.getProtocolInfo(obj_ty)) |proto_info| {
                for (proto_info.methods) |m| {
                    if (std.mem.eql(u8, m.name, fa.field)) return m.param_types;
                }
            }
            // Optional-protocol receiver (`?GPU`): same as above but the
            // protocol type sits inside the optional's payload.
            if (!obj_ty.isBuiltin()) {
                const opt_info = self.module.types.get(obj_ty);
                if (opt_info == .optional) {
                    if (self.getProtocolInfo(opt_info.optional.child)) |proto_info| {
                        for (proto_info.methods) |m| {
                            if (std.mem.eql(u8, m.name, fa.field)) return m.param_types;
                        }
                    }
                }
            }
            // Closure-typed struct field: `c.on(args)` lowers to call_closure on
            // the field value. Pick up the callee's param types from the closure
            // type so each arg gets the right target_type during lowering.
            if (!obj_ty.isBuiltin()) {
                const field_name_id = self.module.types.internString(fa.field);
                const struct_fields = self.getStructFields(obj_ty);
                for (struct_fields) |f| {
                    if (f.name == field_name_id and !f.ty.isBuiltin()) {
                        const fti = self.module.types.get(f.ty);
                        if (fti == .closure) return fti.closure.params;
                        if (fti == .function) return fti.function.params;
                    }
                }
            }
            if (self.getStructTypeName(obj_ty)) |sname| {
                // Foreign-class receiver (`#objc_class` / `#jni_class` / etc.):
                // resolve the method from `foreign_class_map` walking `#extends`.
                // Without this path, `target_type` for each arg falls back to
                // whatever `self.target_type` was on entry — typically the
                // enclosing fn's return type — which silently truncates `xx ptr`
                // casts inside e.g. a `BOOL`-returning method body.
                if (self.program_index.foreign_class_map.get(sname)) |fcd| {
                    if (self.findForeignMethodInChain(fcd, fa.field)) |found| {
                        const md = found.method;
                        const saved_fc = self.current_foreign_class;
                        defer self.current_foreign_class = saved_fc;
                        self.current_foreign_class = found.fcd;
                        const user_param_start: usize = if (md.is_static) 0 else 1;
                        if (md.params.len > user_param_start) {
                            var types_list = std.ArrayList(TypeId).empty;
                            for (md.params[user_param_start..]) |p_node| {
                                types_list.append(self.alloc, self.resolveType(p_node)) catch unreachable;
                            }
                            return types_list.items;
                        }
                        return &.{};
                    }
                }

                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, fa.field }) catch return &.{};
                // Try already-lowered functions first
                if (self.resolveFuncByName(qualified)) |fid| {
                    const func = &self.module.functions.items[@intFromEnum(fid)];
                    // Skip both `__sx_ctx` (if present) AND `self` param;
                    // caller args include neither.
                    const skip: usize = (if (func.has_implicit_ctx) @as(usize, 1) else 0) + 1;
                    if (func.params.len > skip) {
                        var types_list = std.ArrayList(TypeId).empty;
                        for (func.params[skip..]) |p| {
                            types_list.append(self.alloc, p.ty) catch unreachable;
                        }
                        return types_list.items;
                    }
                }
                // Try AST map (not yet lowered)
                if (self.program_index.fn_ast_map.get(qualified)) |fd| {
                    if (fd.params.len > 0) {
                        var types_list = std.ArrayList(TypeId).empty;
                        for (fd.params[1..]) |p| {
                            types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
                        }
                        return types_list.items;
                    }
                }
                // Try generic struct template method: List__Container.append → List.append
                // with type bindings from the struct instantiation
                if (self.struct_instance_template.get(sname)) |tmpl_name| {
                    const tmpl_qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ tmpl_name, fa.field }) catch return &.{};
                    if (self.program_index.fn_ast_map.get(tmpl_qualified)) |fd| {
                        if (fd.params.len > 0) {
                            // Temporarily set type_bindings so resolveParamType can substitute T → concrete type
                            const saved_bindings = self.type_bindings;
                            if (self.struct_instance_bindings.getPtr(sname)) |bindings| {
                                self.type_bindings = bindings.*;
                            }
                            var types_list = std.ArrayList(TypeId).empty;
                            for (fd.params[1..]) |p| {
                                types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
                            }
                            self.type_bindings = saved_bindings;
                            return types_list.items;
                        }
                    }
                }
            }
            return &.{};
        }
        if (c.callee.data != .identifier) return &.{};
        const bare_name = c.callee.data.identifier.name;
        const name = blk: {
            const scoped = if (self.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name;
            if (self.program_index.ufcs_alias_map.get(bare_name)) |target| {
                break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target;
            }
            break :blk scoped;
        };

        // Check declared functions
        if (self.resolveFuncByName(name)) |fid| {
            const func = &self.module.functions.items[@intFromEnum(fid)];
            return self.userParamTypes(func);
        }

        // Check AST map for function signatures
        if (self.program_index.fn_ast_map.get(name)) |fd| {
            var types_list = std.ArrayList(TypeId).empty;
            for (fd.params) |p| {
                types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
            }
            return types_list.items;
        }

        // Check global function pointer variables
        if (self.program_index.global_names.get(bare_name)) |gi| {
            if (!gi.ty.isBuiltin()) {
                const ti = self.module.types.get(gi.ty);
                if (ti == .function) {
                    return ti.function.params;
                }
            }
        }

        // Check local scope for function pointer variables
        if (self.scope) |scope| {
            if (scope.lookup(bare_name)) |binding| {
                if (!binding.ty.isBuiltin()) {
                    const ti = self.module.types.get(binding.ty);
                    if (ti == .function) {
                        return ti.function.params;
                    }
                }
            }
        }

        return &.{};
    }

    /// Check if a param is a type param declaration ($T: Type).
    /// A type param declaration has param.name == one of the type_params names.
    pub fn isTypeParamDecl(param: *const ast.Param, type_params: []const ast.StructTypeParam) bool {
        for (type_params) |tp| {
            if (std.mem.eql(u8, param.name, tp.name)) return true;
        }
        return false;
    }

    /// Check if a function has comptime (non-Type) value parameters.
    fn hasComptimeParams(fd: *const ast.FnDecl) bool {
        for (fd.params) |p| {
            if (p.is_comptime) return true;
        }
        return false;
    }

    /// Pack-fn: has a trailing heterogeneous pack param (`is_variadic
    /// AND is_comptime`). Mixed shapes — non-pack comptime params
    /// before the pack — are also accepted; the mono folds those
    /// comptime VALUES into the mangled name and binds them as both
    /// comptime substitutions (for #insert) and runtime locals (for
    /// bare-name body references).
    fn isPackFn(fd: *const ast.FnDecl) bool {
        for (fd.params) |p| {
            if (isPackParam(p)) return true;
        }
        return false;
    }

    /// A trailing pack parameter: the comptime type-pack `..$args`
    /// (`is_comptime`) or the protocol-constrained pack `..xs: P` (`is_pack`).
    /// Both monomorphize per call shape via `lowerPackFnCall`; the slice
    /// variadic (`..xs: []T`) is neither and stays a runtime slice.
    fn isPackParam(p: ast.Param) bool {
        return p.is_variadic and (p.is_comptime or p.is_pack);
    }

    /// Creates a temporary function marked `is_comptime = true` that wraps
    /// the given expression as its return value. Returns the FuncId.
    pub fn createComptimeFunction(self: *Lowering, prefix: []const u8, expr: *const Node, ret_ty: TypeId) FuncId {
        var buf: [64]u8 = undefined;
        const name = std.fmt.bufPrint(&buf, "{s}_{d}", .{ prefix, self.comptime_counter }) catch prefix;
        self.comptime_counter += 1;

        // Save current builder + lowering state. The wrapper fn we're
        // about to build runs the comptime expression in isolation —
        // it must NOT inherit the enclosing call's `inline_return_target`
        // (which would re-route a `return` inside the wrapper into a
        // slot belonging to a different basic block), pack bindings
        // (which would substitute caller's `args` inside the wrapper),
        // or comptime-param bindings (which would substitute caller's
        // `$fmt` inside the wrapper's #insert children). Without these
        // saves, nested comptime calls leak outer state into the
        // interp-executed wrapper, producing garbage stores (issue-0046
        // face 1 — storeAtRawPtr null).
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        const saved_scope = self.scope;
        const saved_ctx_ref = self.current_ctx_ref;
        const saved_iri = self.inline_return_target;
        const saved_pan = self.pack_arg_nodes;
        const saved_ppc = self.pack_param_count;
        const saved_pat = self.pack_arg_types;
        const saved_cpn = self.comptime_param_nodes;
        const saved_block_terminated = self.block_terminated;
        const saved_target_type = self.target_type;
        const saved_func_defer_base = self.func_defer_base;
        self.inline_return_target = null;
        self.pack_arg_nodes = null;
        self.pack_param_count = null;
        self.pack_arg_types = null;
        self.comptime_param_nodes = null;
        self.block_terminated = false;
        self.target_type = null;
        self.func_defer_base = self.defer_stack.items.len;
        defer {
            self.current_ctx_ref = saved_ctx_ref;
            self.inline_return_target = saved_iri;
            self.pack_arg_nodes = saved_pan;
            self.pack_param_count = saved_ppc;
            self.pack_arg_types = saved_pat;
            self.comptime_param_nodes = saved_cpn;
            self.block_terminated = saved_block_terminated;
            self.target_type = saved_target_type;
            self.func_defer_base = saved_func_defer_base;
        }

        // Build params: implicit `__sx_ctx` at slot 0 when the program
        // uses Context (so the body's `context.X` reads + transitive calls
        // resolve cleanly). The comptime function's top-level invocation
        // supplies `&__sx_default_context` (interp via callWithDefaultContext;
        // codegen via the comptime-eval glue in emit_llvm).
        const wants_ctx = self.implicit_ctx_enabled;
        const params_slice = blk: {
            if (!wants_ctx) break :blk &[_]Function.Param{};
            const owned = self.alloc.alloc(Function.Param, 1) catch break :blk &[_]Function.Param{};
            owned[0] = .{
                .name = self.module.types.internString("__sx_ctx"),
                .ty = self.module.types.ptrTo(.void),
            };
            break :blk owned;
        };

        // Create the comptime function
        const name_id = self.module.types.internString(name);
        const func_id = self.builder.beginFunction(name_id, params_slice, ret_ty);

        // Mark as comptime + has_implicit_ctx
        const fn_mut = self.module.getFunctionMut(func_id);
        fn_mut.is_comptime = true;
        fn_mut.has_implicit_ctx = wants_ctx;

        // Create entry block
        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);
        if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0);

        // Create a scope that chains to the enclosing scope (so the
        // expression can reference names visible at the #run site).
        var ct_scope = Scope.init(self.alloc, saved_scope);
        self.scope = &ct_scope;

        // Lower the expression and return it
        const result = self.lowerExpr(expr);
        if (ret_ty == .void) {
            self.builder.retVoid();
        } else {
            self.builder.ret(result, ret_ty);
        }

        self.builder.finalize();

        // Restore builder state
        self.scope = saved_scope;
        ct_scope.deinit();
        self.builder.func = saved_func;
        self.builder.current_block = saved_block;
        self.builder.inst_counter = saved_counter;

        return func_id;
    }

    // ── Block helpers ───────────────────────────────────────────────

    fn freshBlock(self: *Lowering, prefix: []const u8) BlockId {
        return self.freshBlockWithParams(prefix, &.{});
    }

    fn freshBlockWithParams(self: *Lowering, prefix: []const u8, params: []const TypeId) BlockId {
        var buf: [64]u8 = undefined;
        const name = std.fmt.bufPrint(&buf, "{s}.{d}", .{ prefix, self.block_counter }) catch prefix;
        self.block_counter += 1;
        const name_id = self.module.types.internString(name);
        return self.builder.appendBlock(name_id, params);
    }

    fn currentBlockHasTerminator(self: *Lowering) bool {
        const func = self.builder.module.getFunctionMut(self.builder.func.?);
        const block_idx = self.builder.current_block orelse return true;
        const block = &func.blocks.items[block_idx.index()];
        if (block.insts.items.len > 0) {
            const last_op = block.insts.items[block.insts.items.len - 1].op;
            return switch (last_op) {
                .ret, .ret_void, .br, .cond_br, .switch_br, .@"unreachable" => true,
                else => false,
            };
        }
        return false;
    }

    // ── Type resolution ─────────────────────────────────────────────
    // Delegates to type_bridge for full AST type node resolution.

    fn resolveReturnType(self: *Lowering, fd: *const ast.FnDecl) TypeId {
        if (fd.return_type) |rt| {
            return self.resolveTypeWithBindings(rt);
        }
        // No explicit annotation — the type is inferred from the body, which
        // references the function's own parameters (`(x: s32) => x * 2`). Those
        // params aren't pushed into `self.scope` until body lowering, so bind
        // them into a temporary scope here; otherwise `inferExprType` can't
        // resolve `x`, the inference yields `.unresolved`, and that reaches LLVM
        // emission as `func.ret` (issue 0059). Whether it slipped through used to
        // depend on a same-named binding lingering from earlier lowering.
        var tmp_scope = Scope.init(self.alloc, self.scope);
        defer tmp_scope.deinit();
        const saved_scope = self.scope;
        self.scope = &tmp_scope;
        defer self.scope = saved_scope;
        for (fd.params, 0..) |p, i| {
            // Bind only plain annotated value params — that's all the body's
            // return type can depend on by name. Skip variadic / pack / comptime
            // params (their concrete types come from per-call substitution) and
            // unannotated ones (no context here). Resolve the type directly via
            // resolveTypeWithBindings rather than resolveParamType: the latter
            // does variadic/pack bookkeeping that must run exactly once, at body
            // lowering — calling it here too corrupts that state.
            if (p.is_variadic or p.is_pack or p.is_comptime) continue;
            if (p.type_expr.data == .inferred_type) continue;
            const pty = self.resolveTypeWithBindings(p.type_expr);
            tmp_scope.put(p.name, .{ .ref = Ref.fromIndex(@intCast(i)), .ty = pty, .is_alloca = false });
        }
        // Arrow functions without explicit return type: infer from body expression.
        if (fd.is_arrow) {
            return self.inferExprType(fd.body);
        }
        // Not arrow: an explicit `return <value>` statement wins. Otherwise
        // default to void — the body's tail expression is a side-effect
        // statement, not an implicit return.
        if (self.findReturnValueType(fd.body)) |ty| return ty;
        return .void;
    }

    /// Walk a function body and return the type of the first `return <value>;`
    /// statement encountered. Does not descend into nested function or lambda
    /// declarations (those have their own return types).
    fn findReturnValueType(self: *Lowering, node: *const Node) ?TypeId {
        return switch (node.data) {
            .return_stmt => |rs| if (rs.value) |v| self.inferExprType(v) else null,
            .block => |blk| blk: {
                for (blk.stmts) |s| {
                    if (self.findReturnValueType(s)) |t| break :blk t;
                }
                break :blk null;
            },
            .if_expr => |ie| blk: {
                if (self.findReturnValueType(ie.then_branch)) |t| break :blk t;
                if (ie.else_branch) |eb| {
                    if (self.findReturnValueType(eb)) |t| break :blk t;
                }
                break :blk null;
            },
            .while_expr => |we| self.findReturnValueType(we.body),
            .for_expr => |fe| self.findReturnValueType(fe.body),
            .match_expr => |me| blk: {
                for (me.arms) |arm| {
                    if (self.findReturnValueType(arm.body)) |t| break :blk t;
                }
                break :blk null;
            },
            else => null,
        };
    }

    fn resolveParamType(self: *Lowering, p: *const ast.Param) TypeId {
        // A plain value param with no annotation can only be typed from
        // context (a lambda's target closure signature). When `resolveParamType`
        // is reached for one, there is no such context — so it's a genuine
        // "missing annotation" error, not an 8-byte-int guess. (Comptime/
        // variadic pack params also carry `inferred_type` but get their types
        // from per-call substitution, so they're exempt here.)
        if (p.type_expr.data == .inferred_type and !p.is_comptime and !p.is_variadic and !p.is_pack) {
            if (self.diagnostics) |d| {
                d.addFmt(.err, p.type_expr.span, "parameter '{s}' has no type annotation", .{p.name});
            }
            return .unresolved;
        }
        const declared_ty = self.resolveTypeWithBindings(p.type_expr);
        if (p.is_variadic) {
            // Two surface forms:
            //   - legacy `name: ..T` — declared_ty is the element type;
            //     wrap to receive a `[]T` slice.
            //   - new     `..name: []T` — declared_ty is already the slice
            //     type; use it as-is. Wrapping here would double up to
            //     `[][]T` and downstream LLVM emission crashes when the
            //     caller's argument-marshal pack produces a `[]T` that
            //     doesn't match the callee's stored param shape.
            if (!declared_ty.isBuiltin()) {
                const info = self.module.types.get(declared_ty);
                if (info == .slice) return declared_ty;
            }
            return self.module.types.sliceOf(declared_ty);
        }
        return declared_ty;
    }

    pub fn resolveType(self: *Lowering, type_ann: *const Node) TypeId {
        return self.resolveTypeWithBindings(type_ann);
    }

    /// Construct a `TypeResolver` view over the current lowering state (borrows
    /// only; cheap by-value, reflects current `diagnostics` / `program_index`).
    fn typeResolver(self: *Lowering) TypeResolver {
        return .{
            .alloc = self.alloc,
            .types = &self.module.types,
            .diagnostics = self.diagnostics,
            .index = &self.program_index,
        };
    }

    /// Snapshot the active resolution context (Principle 2) for `TypeResolver`.
    /// A2.2 wires the type bindings + literal target; the pack/comptime fields
    /// are populated as A2.3 moves the cases that consume them.
    fn resolveEnv(self: *Lowering) ResolveEnv {
        return .{
            .type_bindings = if (self.type_bindings) |*tb| tb else null,
            .target_type = self.target_type,
        };
    }

    /// Inner-type recursion hook for `TypeResolver.resolveCompound`: resolves a
    /// child type node through the full stateful resolver, so generic structs /
    /// bindings / aliases in element position keep their resolution.
    pub fn resolveInner(self: *Lowering, node: *const Node) TypeId {
        return self.resolveTypeWithBindings(node);
    }

    /// Resolve a type node, checking type_bindings first for generic type params.
    pub fn resolveTypeWithBindings(self: *Lowering, node: *const Node) TypeId {
        // Pack-index in a type position: `$<pack>[<lit>]` resolves to the
        // i-th element type of the active pack binding (step 3 of the
        // variadic heterogeneous type packs feature). Unblocks parametric
        // trampoline bodies (`(*void, $args[0]) -> $args[1]`) in stdlib's
        // generic Into(Block) impl. OOB indices / a missing binding emit a
        // diagnostic and return the `.unresolved` sentinel — never a plausible
        // `.s64`, which would silently fabricate an 8-byte int.
        if (node.data == .pack_index_type_expr) {
            const pi = node.data.pack_index_type_expr;
            if (self.pack_arg_types) |pat| {
                if (pat.get(pi.pack_name)) |arg_tys| {
                    if (pi.index < arg_tys.len) return arg_tys[pi.index];
                    if (self.diagnostics) |diags| {
                        diags.addFmt(.err, node.span, "pack-index type ${s}[{}] out of bounds: '{s}' has {} element{s}", .{
                            pi.pack_name, pi.index, pi.pack_name, arg_tys.len,
                            if (arg_tys.len == 1) @as([]const u8, "") else @as([]const u8, "s"),
                        });
                    }
                    return .unresolved;
                }
            }
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, node.span, "pack-index type ${s}[{}] used outside an active pack binding", .{
                    pi.pack_name, pi.index,
                });
            }
            return .unresolved;
        }
        // `*Self` substitution inside foreign-class member declarations
        // — both foreign and sx-defined — resolves to the class's own
        // 0-field stub struct (i.e. the opaque Obj-C pointer type).
        // This matches the Obj-C idiom where `self` IS the object.
        // `self.field` access on sx-defined classes is rewritten by
        // lowerFieldAccess to go through the `__sx_state` ivar
        // (object_getIvar + struct_gep) when needed — see M1.2 A.3.
        if (node.data == .type_expr and std.mem.eql(u8, node.data.type_expr.name, "Self")) {
            if (self.current_foreign_class) |fcd| {
                if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) {
                    return self.foreignClassStructType(fcd);
                }
            }
        }
        // Structural type shapes — `*T`, `[*]T`, `[]T`, `?T`, `[N]T`, functions,
        // PLAIN closures, and PLAIN tuples — are owned by
        // `TypeResolver.resolveCompound` (A2.3b). Element types recurse through
        // the full stateful resolver (`resolveInner` → here) so generic structs
        // / bindings keep their resolution. resolveCompound returns null only
        // for the pack-shaped forms (`Closure(..p)`, spread tuples) below.
        if (TypeResolver.resolveCompound(&self.module.types, node, self)) |t| return t;
        // Generic type-param binding (`$T`, or a bare return-type `T` without
        // the `$` prefix) — owned by TypeResolver via the explicit ResolveEnv.
        // The parameterized / call / closure / function arms that used to live
        // here were redundant with the unconditional handling just below (both
        // read the active bindings through the same resolvers), so they're gone.
        if (TypeResolver.resolveBinding(node, self.resolveEnv())) |t| return t;
        // Even without active type_bindings, handle parameterized types with struct templates
        if (node.data == .parameterized_type_expr) {
            return self.resolveParameterizedWithBindings(&node.data.parameterized_type_expr);
        }
        if (node.data == .call) {
            return self.resolveTypeCallWithBindings(&node.data.call);
        }
        // Plain structural shapes were handled by resolveCompound above. What
        // reaches here is the PACK-shaped subset, owned by `PackResolver`
        // (packs.zig): pack-shaped `Closure(..p)` and spread tuples. (Functions
        // are never pack-shaped at the type level — resolveCompound owns them
        // all, so there is no function arm here.)
        switch (node.data) {
            .closure_type_expr => |ct| {
                return self.packResolver().resolveClosureTypeWithBindings(&ct);
            },
            .tuple_type_expr => |tt| {
                return self.packResolver().resolveTupleTypeWithBindings(&tt);
            },
            // `(..$Ts)` in a type position (e.g. a struct field) parses as a
            // tuple LITERAL whose elements include a pack spread; PackResolver
            // expands it (returns null when no spread, so we fall through).
            .tuple_literal => |tl| {
                if (self.packResolver().resolveTupleLiteralType(&tl)) |t| return t;
            },
            else => {},
        }
        // An unbound generic type param (`$R` with no active binding) must not
        // fabricate an empty-struct stub — that surfaces as `R{}` downstream.
        // Return `.unresolved` so callers (e.g. lambda return-type inference,
        // call-site `$R` inference) treat it as not-yet-known.
        if (node.data == .type_expr and node.data.type_expr.is_generic) {
            return .unresolved;
        }
        // Bare type names resolve through TypeResolver, which reads the
        // canonical alias table directly (`ProgramIndex.type_alias_map`). Other
        // node kinds (inline type decls, error types) still route through
        // type_bridge, which now takes the alias map as an explicit argument
        // (the `TypeTable.aliases` borrow is gone, A2.3).
        switch (node.data) {
            .type_expr => |te| return self.typeResolver().resolveName(te.name),
            .identifier => |id| return self.typeResolver().resolveName(id.name),
            // A non-spread tuple literal in a type position is a tuple-type
            // literal (`(s32, s32)`); validate its elements are types and reject
            // non-type elements loudly (issue 0067).
            .tuple_literal => return self.resolveTupleLiteralTypeArg(node),
            else => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map),
        }
    }

    /// Bind a `PackResolver` to this Lowering for pack-aware TYPE-position
    /// resolution (`Closure(..p)` / `(Params...) -> R` / `(..xs)` tuples and
    /// their `..xs.T` projections). A2.3 moved that logic into `packs.zig`.
    fn packResolver(self: *Lowering) PackResolver {
        return .{ .l = self };
    }

    /// Resolve a .call node that represents a type constructor (e.g., List(T), Vector(N, T)).
    fn resolveTypeCallWithBindings(self: *Lowering, cl: *const ast.Call) TypeId {
        const callee_name: []const u8 = switch (cl.callee.data) {
            .identifier => |id| id.name,
            .field_access => |fa| fa.field,
            else => return .unresolved,
        };
        // Built-in: Vector(N, T)
        if (std.mem.eql(u8, callee_name, "Vector") and cl.args.len == 2) {
            const length: u32 = switch (cl.args[0].data) {
                .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))),
                .identifier => |id| blk: {
                    if (self.comptime_value_bindings) |cvb| {
                        if (cvb.get(id.name)) |v| break :blk @intCast(@as(u64, @bitCast(v)));
                    }
                    break :blk 0;
                },
                else => 0,
            };
            const elem = self.resolveTypeWithBindings(cl.args[1]);
            return self.module.types.vectorOf(elem, length);
        }
        // User-defined generic struct
        if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| {
            return self.instantiateGenericStruct(tmpl, cl.args);
        }
        // User-defined type-returning function: Complex(u32), Sx(f32)
        // Also resolve via scope fn_names (local functions get mangled names)
        const resolved_name = if (self.scope) |scope| (scope.lookupFn(callee_name) orelse callee_name) else callee_name;
        if (self.program_index.fn_ast_map.get(resolved_name)) |fd| {
            if (fd.type_params.len > 0) {
                if (self.instantiateTypeFunction(callee_name, callee_name, fd, cl.args)) |ty| {
                    return ty;
                }
            }
        }
        // Try as a named type
        const name_id = self.module.types.internString(callee_name);
        return self.module.types.findByName(name_id) orelse .unresolved;
    }

    /// Resolve a parameterized type expr, substituting bindings for type/value params.
    /// Handles both built-in types (Vector) and user-defined generic structs.
    fn resolveParameterizedWithBindings(self: *Lowering, pt: *const ast.ParameterizedTypeExpr) TypeId {
        const base_name = if (std.mem.lastIndexOfScalar(u8, pt.name, '.')) |dot| pt.name[dot + 1 ..] else pt.name;
        const table = &self.module.types;

        // Vector(N, T) — built-in parameterized type
        if (std.mem.eql(u8, base_name, "Vector")) {
            if (pt.args.len == 2) {
                // Resolve length: literal, or bound comptime value
                const length: u32 = switch (pt.args[0].data) {
                    .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))),
                    .identifier => |id| blk: {
                        if (self.comptime_value_bindings) |cvb| {
                            if (cvb.get(id.name)) |v| break :blk @intCast(@as(u64, @bitCast(v)));
                        }
                        break :blk 0;
                    },
                    .type_expr => |te| blk: {
                        if (self.comptime_value_bindings) |cvb| {
                            if (cvb.get(te.name)) |v| break :blk @intCast(@as(u64, @bitCast(v)));
                        }
                        break :blk 0;
                    },
                    else => 0,
                };
                // Resolve element type through bindings
                const elem = self.resolveTypeWithBindings(pt.args[1]);
                return table.vectorOf(elem, length);
            }
        }

        // User-defined generic struct: look up template and instantiate
        if (self.program_index.struct_template_map.getPtr(base_name)) |tmpl| {
            return self.instantiateGenericStruct(tmpl, pt.args);
        }

        // Parameterized protocol used as a value type (`VL(s64)`): materialize a
        // 16-byte protocol value with the type-arg bound (not a 0-field stub).
        if (self.program_index.protocol_ast_map.get(base_name)) |pd| {
            if (pd.type_params.len > 0) {
                return self.instantiateParamProtocol(pd, pt.args);
            }
        }

        // Fallback: register as named type placeholder
        const name_id = table.internString(pt.name);
        return table.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } });
    }

    /// Instantiate a generic struct template with concrete args.
    /// E.g., Vec(3, f32) → struct Vec__3_f32 { data: Vector(3, f32) }
    fn instantiateGenericStruct(self: *Lowering, tmpl: *const StructTemplate, args: []const *const Node) TypeId {
        const table = &self.module.types;

        // Build mangled name dynamically: StructName__arg1_arg2
        var name_parts = std.ArrayList(u8).empty;
        name_parts.appendSlice(self.alloc, tmpl.name) catch {};

        // Bind type params to args and build name suffix
        const saved_type_bindings = self.type_bindings;
        const saved_value_bindings = self.comptime_value_bindings;
        const saved_pack_bindings = self.pack_bindings;
        const saved_pack_arg_types = self.pack_arg_types;
        var tb = std.StringHashMap(TypeId).init(self.alloc);
        var cvb = std.StringHashMap(i64).init(self.alloc);
        var pb = std.StringHashMap([]const TypeId).init(self.alloc);

        for (tmpl.type_params, 0..) |tp, i| {
            if (i >= args.len) break;

            // `..$Ts: []Type` — bind the REMAINING args as a type pack.
            if (tp.is_variadic) {
                var pack_tys = std.ArrayList(TypeId).empty;
                for (args[i..]) |a| {
                    // A spread arg `..sources.T` expands to the source pack's
                    // per-element (projected) types; a plain arg is one type.
                    if (a.data == .spread_expr) {
                        if (self.packResolver().packTypeElems(a.data.spread_expr.operand)) |elems| {
                            defer self.alloc.free(elems);
                            for (elems) |ty| {
                                pack_tys.append(self.alloc, ty) catch {};
                                name_parts.appendSlice(self.alloc, "__") catch {};
                                name_parts.appendSlice(self.alloc, self.formatTypeName(ty)) catch {};
                            }
                            continue;
                        }
                    }
                    const ty = self.resolveTypeWithBindings(a);
                    pack_tys.append(self.alloc, ty) catch {};
                    name_parts.appendSlice(self.alloc, "__") catch {};
                    name_parts.appendSlice(self.alloc, self.formatTypeName(ty)) catch {};
                }
                pb.put(tp.name, pack_tys.toOwnedSlice(self.alloc) catch &.{}) catch {};
                break; // a pack param is always last
            }

            name_parts.appendSlice(self.alloc, "__") catch {};

            if (tp.is_type_param) {
                const ty = self.resolveTypeWithBindings(args[i]);
                tb.put(tp.name, ty) catch {};
                const tname = self.formatTypeName(ty);
                name_parts.appendSlice(self.alloc, tname) catch {};
            } else {
                // Value param (e.g., $N: u32) — extract integer
                const val: i64 = switch (args[i].data) {
                    .int_literal => |lit| lit.value,
                    else => 0,
                };
                cvb.put(tp.name, val) catch {};
                var val_buf: [32]u8 = undefined;
                const val_str = std.fmt.bufPrint(&val_buf, "{d}", .{val}) catch "0";
                name_parts.appendSlice(self.alloc, val_str) catch {};
            }
        }

        const mangled_name = name_parts.items;

        // Check if already instantiated
        const name_id = table.internString(mangled_name);
        if (table.findByName(name_id)) |existing| {
            // Already registered — check if it has fields
            const info = table.get(existing);
            if (info == .@"struct" and info.@"struct".fields.len > 0) {
                return existing;
            }
        }

        // Set up bindings and resolve fields. `pack_bindings` makes a
        // pack-shaped field type like `(..$Ts)` resolve to the bound type list.
        self.type_bindings = tb;
        self.comptime_value_bindings = cvb;
        self.pack_bindings = pb;
        self.pack_arg_types = pb;

        var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
        for (tmpl.field_names, tmpl.field_type_nodes) |fname, ftype_node| {
            const field_ty = self.resolveTypeWithBindings(ftype_node);
            fields.append(self.alloc, .{
                .name = table.internString(fname),
                .ty = field_ty,
            }) catch unreachable;
        }

        // Restore bindings
        self.type_bindings = saved_type_bindings;
        self.comptime_value_bindings = saved_value_bindings;
        self.pack_bindings = saved_pack_bindings;
        self.pack_arg_types = saved_pack_arg_types;

        // Register the monomorphized struct
        const info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items } };
        const id = if (table.findByName(name_id)) |existing| existing else table.intern(info);
        table.update(id, info);

        // Bind the template name to this concrete instance so a method's
        // `self: *Combined` (the template name) resolves to `*Combined__s64_s64`
        // — otherwise `self.field` hits the 0-field generic stub.
        tb.put(tmpl.name, id) catch {};

        // Store the type bindings and template name for method resolution
        const owned_mangled = self.alloc.dupe(u8, mangled_name) catch return id;
        self.struct_instance_bindings.put(owned_mangled, tb) catch {};
        self.struct_instance_template.put(owned_mangled, tmpl.name) catch {};

        return id;
    }

    /// Instantiate a type-returning function: `Foo :: Complex(u32)` where
    /// `Complex :: ($T:Type) -> Type { return struct { value: T; count: u32; }; }`
    /// Walks the function body to find the returned struct/enum, resolves field types
    /// with the provided type bindings, and registers the result.
    fn instantiateTypeFunction(self: *Lowering, alias_name: []const u8, template_name: []const u8, fd: *const ast.FnDecl, args: []const *const Node) ?TypeId {
        const table = &self.module.types;

        // Build type bindings from params + args
        const saved_type_bindings = self.type_bindings;
        const saved_value_bindings = self.comptime_value_bindings;
        var tb = std.StringHashMap(TypeId).init(self.alloc);
        var cvb = std.StringHashMap(i64).init(self.alloc);

        // Build mangled name
        var name_parts = std.ArrayList(u8).empty;
        name_parts.appendSlice(self.alloc, template_name) catch {};

        for (fd.type_params, 0..) |tp, i| {
            if (i >= args.len) break;
            name_parts.appendSlice(self.alloc, "__") catch {};

            // Check if this is a Type param ($T: Type) or a value param ($N: u32)
            const is_type_param = if (tp.constraint.data == .type_expr)
                std.mem.eql(u8, tp.constraint.data.type_expr.name, "Type")
            else
                true; // default to type param

            if (is_type_param) {
                const ty = self.resolveTypeWithBindings(args[i]);
                tb.put(tp.name, ty) catch {};
                const tname = self.formatTypeName(ty);
                name_parts.appendSlice(self.alloc, tname) catch {};
            } else {
                const val: i64 = switch (args[i].data) {
                    .int_literal => |lit| lit.value,
                    else => 0,
                };
                cvb.put(tp.name, val) catch {};
                var val_buf: [32]u8 = undefined;
                const val_str = std.fmt.bufPrint(&val_buf, "{d}", .{val}) catch "0";
                name_parts.appendSlice(self.alloc, val_str) catch {};
            }
        }

        const mangled_name = name_parts.items;

        // Check if already instantiated
        const mangled_name_id = table.internString(mangled_name);
        if (table.findByName(mangled_name_id)) |existing| {
            const info = table.get(existing);
            if ((info == .@"struct" and info.@"struct".fields.len > 0) or info == .@"union" or info == .tagged_union) {
                return existing;
            }
        }

        // Activate bindings
        self.type_bindings = tb;
        self.comptime_value_bindings = cvb;
        defer {
            self.type_bindings = saved_type_bindings;
            self.comptime_value_bindings = saved_value_bindings;
        }

        // Determine if alias_name is a real alias (e.g., "Foo" for "Complex(u32)")
        // or just the template name itself (inline use like "Sx(f32)")
        const has_alias = !std.mem.eql(u8, alias_name, template_name);

        // Try struct first
        if (findStructInBody(fd.body)) |struct_decl| {
            // Resolve struct fields with type bindings active
            var struct_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
            for (struct_decl.field_names, struct_decl.field_types) |fname, ftype_node| {
                const field_ty = self.resolveTypeWithBindings(ftype_node);
                struct_fields.append(self.alloc, .{
                    .name = table.internString(fname),
                    .ty = field_ty,
                }) catch {};
            }

            // Always register under mangled name
            const mangled_info: types.TypeInfo = .{ .@"struct" = .{
                .name = mangled_name_id,
                .fields = struct_fields.items,
            } };
            const mangled_id = if (table.findByName(mangled_name_id)) |existing| existing else table.intern(mangled_info);
            table.update(mangled_id, mangled_info);

            // If there's a real alias, also register under alias name and in alias map
            if (has_alias) {
                const alias_name_id = table.internString(alias_name);
                const alias_info: types.TypeInfo = .{ .@"struct" = .{
                    .name = alias_name_id,
                    .fields = struct_fields.items,
                } };
                const alias_id = if (table.findByName(alias_name_id)) |existing| existing else table.intern(alias_info);
                table.update(alias_id, alias_info);

                // Store defaults if any
                if (struct_decl.field_defaults.len > 0) {
                    self.struct_defaults_map.put(alias_name, struct_decl.field_defaults) catch {};
                }

                return alias_id;
            }

            return mangled_id;
        }

        // Try tagged enum/union
        if (findUnionInBody(fd.body)) |enum_decl| {
            return self.instantiateTypeUnion(if (has_alias) alias_name else mangled_name, mangled_name, &enum_decl);
        }

        return null;
    }

    /// Instantiate a tagged enum from a type function body.
    fn instantiateTypeUnion(self: *Lowering, alias_name: []const u8, mangled_name: []const u8, ed: *const ast.EnumDecl) ?TypeId {
        const table = &self.module.types;

        // Build variant fields (tagged enum variants stored as StructInfo.Field)
        var variant_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
        for (ed.variant_names, 0..) |vname, i| {
            const payload_ty: TypeId = if (i < ed.variant_types.len and ed.variant_types[i] != null)
                self.resolveTypeWithBindings(ed.variant_types[i].?)
            else
                .void;
            variant_fields.append(self.alloc, .{
                .name = table.internString(vname),
                .ty = payload_ty,
            }) catch {};
        }

        const alias_name_id = table.internString(alias_name);
        const info: types.TypeInfo = .{ .tagged_union = .{
            .name = alias_name_id,
            .fields = variant_fields.items,
            .tag_type = .s64,
        } };
        const id = if (table.findByName(alias_name_id)) |existing| existing else table.intern(info);
        table.update(id, info);

        // Also register under mangled name
        if (!std.mem.eql(u8, alias_name, mangled_name)) {
            const mangled_name_id = table.internString(mangled_name);
            const mangled_info: types.TypeInfo = .{ .tagged_union = .{
                .name = mangled_name_id,
                .fields = variant_fields.items,
                .tag_type = .s64,
            } };
            const mid = if (table.findByName(mangled_name_id)) |existing| existing else table.intern(mangled_info);
            table.update(mid, mangled_info);
        }

        return id;
    }

    /// Walk an AST body to find a struct declaration (from `return struct { ... }` or bare struct expr).
    fn findStructInBody(body: *const Node) ?ast.StructDecl {
        if (body.data == .struct_decl) return body.data.struct_decl;
        if (body.data == .block) {
            for (body.data.block.stmts) |stmt| {
                if (stmt.data == .return_stmt) {
                    if (stmt.data.return_stmt.value) |val| {
                        if (val.data == .struct_decl) return val.data.struct_decl;
                    }
                }
                if (stmt.data == .struct_decl) return stmt.data.struct_decl;
            }
        }
        return null;
    }

    /// Walk an AST body to find a tagged enum declaration.
    fn findUnionInBody(body: *const Node) ?ast.EnumDecl {
        const isTaggedEnum = struct {
            fn check(node: *const Node) ?ast.EnumDecl {
                if (node.data == .enum_decl and node.data.enum_decl.variant_types.len > 0) {
                    return node.data.enum_decl;
                }
                return null;
            }
        };
        if (isTaggedEnum.check(body)) |ed| return ed;
        const stmts = if (body.data == .block) body.data.block.stmts else return null;
        for (stmts) |stmt| {
            if (stmt.data == .return_stmt) {
                if (stmt.data.return_stmt.value) |val| {
                    if (isTaggedEnum.check(val)) |ed| return ed;
                }
            }
            if (isTaggedEnum.check(stmt)) |ed| return ed;
        }
        return null;
    }

    // ── Type registration ───────────────────────────────────────────

    /// Register a struct declaration's fields and methods in the IR type table.
    /// Register a `Foo :: error { A, B }` declaration as an error-set type.
    /// Rejects an empty set here (sema gate) since type_bridge has no
    /// diagnostics; non-empty sets are interned via type_bridge.
    fn registerErrorSetDecl(self: *Lowering, node: *const Node) void {
        const esd = node.data.error_set_decl;
        if (esd.tag_names.len == 0) {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, node.span, "error set '{s}' must declare at least one tag", .{esd.name});
            }
            return;
        }
        _ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map);
    }

    fn registerStructDecl(self: *Lowering, sd: *const ast.StructDecl) void {
        const table = &self.module.types;
        const name_id = table.internString(sd.name);

        // Generic structs: store as owned template, don't resolve fields yet
        if (sd.type_params.len > 0) {
            const owned_name = self.alloc.dupe(u8, sd.name) catch return;

            // Build owned type_params
            const tps = self.alloc.alloc(TemplateParam, sd.type_params.len) catch return;
            for (sd.type_params, 0..) |tp, i| {
                tps[i] = .{
                    .name = self.alloc.dupe(u8, tp.name) catch return,
                    // $T: Type, $T: Lerpable, $T: Type/Eq — all are type params.
                    // `..$Ts: []Type` (variadic) is a type-pack param. Only value
                    // params like $N: u32 are non-type.
                    .is_type_param = tp.is_variadic or (if (tp.constraint.data == .type_expr) blk: {
                        const cname = tp.constraint.data.type_expr.name;
                        // "Type" or a protocol name → type param
                        break :blk std.mem.eql(u8, cname, "Type") or
                            self.program_index.protocol_decl_map.contains(cname) or
                            self.program_index.protocol_ast_map.contains(cname);
                    } else false),
                    .is_variadic = tp.is_variadic,
                };
            }

            // Copy field names
            const fnames = self.alloc.alloc([]const u8, sd.field_names.len) catch return;
            for (sd.field_names, 0..) |fn_str, i| {
                fnames[i] = self.alloc.dupe(u8, fn_str) catch return;
            }

            // Field type nodes: these are *Node pointers into the AST.
            // Copy the slice of pointers (the nodes themselves are heap-allocated).
            const ftype_nodes = self.alloc.dupe(*const Node, sd.field_types) catch return;

            self.program_index.struct_template_map.put(owned_name, .{
                .name = owned_name,
                .type_params = tps,
                .field_names = fnames,
                .field_type_nodes = ftype_nodes,
            }) catch {};

            // Register methods under "TemplateName.method" in fn_ast_map
            for (sd.methods) |method_node| {
                if (method_node.data == .fn_decl) {
                    const method_fd = &method_node.data.fn_decl;
                    const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, method_fd.name }) catch continue;
                    self.program_index.fn_ast_map.put(qualified, method_fd) catch {};
                }
            }
            return;
        }

        // Build field list, expanding #using entries
        var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
        var field_idx: usize = 0;
        var using_idx: usize = 0;
        const total_explicit = sd.field_names.len;
        while (field_idx < total_explicit or using_idx < sd.using_entries.len) {
            // Insert #using fields at their declared positions
            while (using_idx < sd.using_entries.len and sd.using_entries[using_idx].insert_index == fields.items.len) {
                const ue = sd.using_entries[using_idx];
                const used_name_id = table.internString(ue.type_name);
                if (table.findByName(used_name_id)) |used_ty| {
                    const used_info = table.get(used_ty);
                    if (used_info == .@"struct") {
                        for (used_info.@"struct".fields) |f| {
                            fields.append(self.alloc, f) catch unreachable;
                        }
                    }
                }
                using_idx += 1;
            }
            if (field_idx < total_explicit) {
                const field_ty = self.resolveType(sd.field_types[field_idx]);
                fields.append(self.alloc, .{
                    .name = table.internString(sd.field_names[field_idx]),
                    .ty = field_ty,
                }) catch unreachable;
                field_idx += 1;
            } else break;
        }
        // Append remaining #using entries after all explicit fields
        while (using_idx < sd.using_entries.len) {
            const ue = sd.using_entries[using_idx];
            const used_name_id = table.internString(ue.type_name);
            if (table.findByName(used_name_id)) |used_ty| {
                const used_info = table.get(used_ty);
                if (used_info == .@"struct") {
                    for (used_info.@"struct".fields) |f| {
                        fields.append(self.alloc, f) catch unreachable;
                    }
                }
            }
            using_idx += 1;
        }

        // Qualify inline __anon type names: __anon → StructName.field_name
        for (sd.field_names, 0..) |fname, fi| {
            if (fi < fields.items.len) {
                const field_ty = fields.items[fi].ty;
                if (!field_ty.isBuiltin()) {
                    self.qualifyAnonType(table, field_ty, sd.name, fname);
                }
            }
        }

        // Check if a forward-reference placeholder already exists (with empty fields)
        // If so, update it in-place rather than creating a duplicate
        const info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items } };
        const id = if (table.findByName(name_id)) |existing| existing else table.intern(info);
        table.update(id, info);

        // Store field defaults for struct literal lowering
        if (sd.field_defaults.len > 0) {
            var has_any_default = false;
            for (sd.field_defaults) |d| {
                if (d != null) { has_any_default = true; break; }
            }
            if (has_any_default) {
                self.struct_defaults_map.put(sd.name, sd.field_defaults) catch {};
            }
        }

        // Register struct methods as StructName.method in fn_ast_map
        for (sd.methods) |method_node| {
            if (method_node.data == .fn_decl) {
                const method_fd = &method_node.data.fn_decl;
                // Build qualified name: StructName.method
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, method_fd.name }) catch continue;
                self.program_index.fn_ast_map.put(qualified, method_fd) catch {};
                // Declare extern stub (body is lowered lazily on demand)
                self.declareFunction(method_fd, qualified);
            }
        }

        // Register struct-level constants (e.g., GRAVITY :f32: 9.81)
        for (sd.constants) |const_node| {
            if (const_node.data == .const_decl) {
                const cd = const_node.data.const_decl;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, cd.name }) catch continue;
                const ty: ?TypeId = if (cd.type_annotation) |ta| type_bridge.resolveAstType(ta, table, &self.program_index.type_alias_map) else null;
                self.struct_const_map.put(qualified, .{ .value = cd.value, .ty = ty }) catch {};
            }
        }
    }

    /// Rename an __anon type to a qualified name: ParentStruct.field_name
    /// Also renames variant payload struct types from __anon.X to ParentStruct.field_name.X
    fn qualifyAnonType(self: *Lowering, table: *types.TypeTable, ty: TypeId, parent_name: []const u8, field_name: []const u8) void {
        const ti = table.get(ty);
        switch (ti) {
            .@"union" => |u| {
                const old_name = table.getString(u.name);
                if (!std.mem.eql(u8, old_name, "__anon")) return;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ parent_name, field_name }) catch return;
                const qname_id = table.internString(qualified);
                table.update(ty, .{ .@"union" = .{ .name = qname_id, .fields = u.fields } });
            },
            .tagged_union => |u| {
                const old_name = table.getString(u.name);
                if (!std.mem.eql(u8, old_name, "__anon")) return;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ parent_name, field_name }) catch return;
                const qname_id = table.internString(qualified);
                // Rename variant payload structs: __anon.X → ParentStruct.field.X
                for (u.fields) |f| {
                    if (!f.ty.isBuiltin()) {
                        const finfo = table.get(f.ty);
                        if (finfo == .@"struct") {
                            const sname = table.getString(finfo.@"struct".name);
                            if (std.mem.startsWith(u8, sname, "__anon.")) {
                                const suffix = sname["__anon".len..]; // .VariantName
                                const sq = std.fmt.allocPrint(self.alloc, "{s}{s}", .{ qualified, suffix }) catch continue;
                                const sq_id = table.internString(sq);
                                table.update(f.ty, .{ .@"struct" = .{ .name = sq_id, .fields = finfo.@"struct".fields } });
                            }
                        }
                    }
                }
                table.update(ty, .{ .tagged_union = .{ .name = qname_id, .fields = u.fields, .tag_type = u.tag_type, .backing_type = u.backing_type, .explicit_tag_values = u.explicit_tag_values } });
            },
            .@"enum" => |e| {
                const old_name = table.getString(e.name);
                if (!std.mem.eql(u8, old_name, "__anon")) return;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ parent_name, field_name }) catch return;
                const qname_id = table.internString(qualified);
                table.update(ty, .{ .@"enum" = .{ .name = qname_id, .variants = e.variants, .explicit_values = e.explicit_values } });
            },
            .@"struct" => |s| {
                const old_name = table.getString(s.name);
                if (!std.mem.eql(u8, old_name, "__anon")) return;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ parent_name, field_name }) catch return;
                const qname_id = table.internString(qualified);
                table.update(ty, .{ .@"struct" = .{ .name = qname_id, .fields = s.fields } });
            },
            else => {},
        }
    }

    /// Register a protocol declaration as a struct type in the IR type table.
    /// Inline protocols: { ctx: *void, method1: *void, method2: *void, ... }
    /// Non-inline protocols: { ctx: *void, __vtable: *void }
    /// Also stores protocol info for dispatch and vtable struct type for vtable protocols.
    /// Register a protocol declaration. Thin delegation to the canonical owner
    /// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` as a `pub`
    /// entry point because the scan pass + several unit tests reach it here.
    pub fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void {
        return self.protocolResolver().registerProtocolDecl(pd);
    }

    /// Instantiate a parameterized protocol as a runtime VALUE type:
    /// `VL(s64)` → a 16-byte `{ctx, __vtable}` protocol value (`is_protocol`),
    /// with method infos resolved under the type-arg binding (so `get -> T`
    /// becomes `get -> s64`) and the binding recorded for projection. Cached by
    /// the mangled name `VL__s64`. Mirrors the non-parameterized path in
    /// `registerProtocolDecl`.
    fn instantiateParamProtocol(self: *Lowering, pd: *const ast.ProtocolDecl, args: []const *const Node) TypeId {
        const table = &self.module.types;
        const void_ptr_ty = table.ptrTo(.void);

        var np = std.ArrayList(u8).empty;
        np.appendSlice(self.alloc, pd.name) catch {};
        var tb = std.StringHashMap(TypeId).init(self.alloc);
        for (pd.type_params, 0..) |tp, i| {
            if (i >= args.len) break;
            const ty = self.resolveTypeWithBindings(args[i]);
            tb.put(tp.name, ty) catch {};
            np.appendSlice(self.alloc, "__") catch {};
            np.appendSlice(self.alloc, self.formatTypeName(ty)) catch {};
        }
        const mangled = np.items;
        const name_id = table.internString(mangled);
        if (table.findByName(name_id)) |existing| {
            const info = table.get(existing);
            if (info == .@"struct" and info.@"struct".is_protocol) return existing;
        }

        // Value struct: {ctx, __vtable} (or ctx + fn-ptrs for an inline protocol).
        var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
        fields.append(self.alloc, .{ .name = table.internString("ctx"), .ty = void_ptr_ty }) catch unreachable;
        if (pd.is_inline) {
            for (pd.methods) |m| fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
        } else {
            fields.append(self.alloc, .{ .name = table.internString("__vtable"), .ty = void_ptr_ty }) catch unreachable;
        }
        const struct_info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items, .is_protocol = true } };
        const id = if (table.findByName(name_id)) |existing| existing else table.intern(struct_info);
        table.update(id, struct_info);

        // Method infos resolved with the type-arg binding (T → s64).
        const saved_tb = self.type_bindings;
        self.type_bindings = tb;
        var method_infos = std.ArrayList(ProtocolMethodInfo).empty;
        for (pd.methods) |method| {
            var ptypes = std.ArrayList(TypeId).empty;
            for (method.params) |p| {
                const pty = blk: {
                    if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) break :blk void_ptr_ty;
                    break :blk self.resolveTypeWithBindings(p);
                };
                ptypes.append(self.alloc, pty) catch unreachable;
            }
            var ret_is_self = false;
            const ret = if (method.return_type) |rt| blk: {
                if (rt.data == .type_expr and std.mem.eql(u8, rt.data.type_expr.name, "Self")) {
                    ret_is_self = true;
                    break :blk void_ptr_ty;
                }
                break :blk self.resolveTypeWithBindings(rt);
            } else .void;
            method_infos.append(self.alloc, .{
                .name = method.name,
                .param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable,
                .ret_type = ret,
                .ret_is_self = ret_is_self,
            }) catch unreachable;
        }
        self.type_bindings = saved_tb;

        const owned = self.alloc.dupe(u8, mangled) catch return id;
        self.program_index.protocol_decl_map.put(owned, .{
            .name = owned,
            .is_inline = pd.is_inline,
            .methods = self.alloc.dupe(ProtocolMethodInfo, method_infos.items) catch unreachable,
        }) catch {};
        // Record the type-arg binding so projection (`xs.T`, `.value`) and
        // method-arg resolution on this instance can recover it.
        self.struct_instance_bindings.put(owned, tb) catch {};

        if (!pd.is_inline) {
            var vtable_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
            for (pd.methods) |m| vtable_fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable;
            var vtable_name_buf: [192]u8 = undefined;
            const vtable_name = std.fmt.bufPrint(&vtable_name_buf, "__{s}__Vtable", .{mangled}) catch "__Vtable";
            const vtable_ty = table.intern(.{ .@"struct" = .{ .name = table.internString(vtable_name), .fields = vtable_fields.items } });
            self.protocol_vtable_type_map.put(owned, vtable_ty) catch {};
        }
        return id;
    }

    // ── Pack projection name resolution (Feature 1, Decision 4) ──────────
    //
    // A `..pack.<name>` projection can target two protocol namespaces:
    //   - type-arg namespace: the `protocol($T, ...)` params.
    //   - runtime-accessor namespace: the protocol's methods (protocols have
    //     no fields; a zero-arg method like `value` is the accessor).
    // Resolution is POSITION-driven, not precedence-driven: type position
    // consults type-args, value position consults methods, with NO
    // cross-namespace fallback.

    pub const ProjectionPosition = enum { type_position, value_position };

    pub const PackProjection = union(enum) {
        type_arg: u32, // index into the protocol's `type_params`
        method: u32, // index into the protocol's `methods`
        not_found, // `name` absent from the position-selected namespace
    };

    /// Find `name` in `protocol_name`'s type-arg namespace (`protocol($T,...)`).
    /// Returns the `type_params` index, or null (also for unknown protocols).
    pub fn lookupProtocolArg(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
        const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
        for (pd.type_params, 0..) |tp, i| {
            if (std.mem.eql(u8, tp.name, name)) return @intCast(i);
        }
        return null;
    }

    /// Find `name` in `protocol_name`'s runtime-accessor namespace (its methods
    /// — protocols have no fields). Returns the `methods` index, or null.
    pub fn lookupProtocolField(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
        const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null;
        for (pd.methods, 0..) |m, i| {
            if (std.mem.eql(u8, m.name, name)) return @intCast(i);
        }
        return null;
    }

    /// Resolve `..pack.<name>` against `protocol_name` by position (Decision 4).
    /// No cross-namespace fallback: a value-position name that exists only as a
    /// type-arg (or vice versa) is `.not_found`, letting the caller emit a
    /// position-specific diagnostic (G3, Step 2.7).
    pub fn resolvePackProjection(
        self: *Lowering,
        protocol_name: []const u8,
        name: []const u8,
        pos: ProjectionPosition,
    ) PackProjection {
        return switch (pos) {
            .type_position => if (self.lookupProtocolArg(protocol_name, name)) |i|
                .{ .type_arg = i }
            else
                .not_found,
            .value_position => if (self.lookupProtocolField(protocol_name, name)) |i|
                .{ .method = i }
            else
                .not_found,
        };
    }

    /// Register a foreign-class declaration. The alias goes into
    /// `foreign_class_map` for method-dispatch lookup. The underlying
    /// type (e.g. `*Activity`) is resolved via the existing struct
    /// fallback in `type_bridge.resolveTypeName` (which interns unknown
    /// named types as 0-field structs).
    ///
    /// sx-defined Obj-C classes (no `#foreign`, runtime == .objc_class)
    /// also land in `module.objc_defined_class_cache` in declaration
    /// order AND have their bodied methods registered into `fn_ast_map`
    /// under qualified names `<ClassName>.<methodName>`. Lazy lowering
    /// then handles the body via the standard path; `*Self` is
    /// substituted to `*<ClassName>State` during body lowering (M1.2 A.2b).
    fn registerForeignClassDecl(self: *Lowering, fcd: *const ast.ForeignClassDecl) void {
        self.program_index.foreign_class_map.put(fcd.name, fcd) catch {};
        if (!fcd.is_foreign and fcd.runtime == .objc_class) {
            if (self.module.lookupObjcDefinedClass(fcd.name) == null) {
                self.module.appendObjcDefinedClass(fcd.name, fcd);
                // M2.3 — resolve the `#extends` alias to the actual
                // Obj-C runtime class name. `#extends NSObjectBase`
                // where NSObjectBase is aliased to "NSObject" must
                // pass "NSObject" to objc_allocateClassPair, otherwise
                // the runtime's class-hierarchy link is broken and
                // inherited-method dispatch fails.
                self.module.setObjcDefinedClassParent(fcd.name, self.resolveObjcParentName(fcd));
                // M1.2 A.4b.i: per-class ivar handle global. The class-pair
                // init constructor (emit_llvm) populates it via
                // class_getInstanceVariable after the class is registered;
                // IMP trampolines read it to find the __sx_state ivar.
                self.declareObjcDefinedStateIvarGlobal(fcd.name);
                // M1.2 A.6: per-class class-object global. -dealloc reads
                // it to build an `objc_super` struct for `[super dealloc]`
                // dispatch via `objc_msgSendSuper2`.
                self.declareObjcDefinedClassGlobal(fcd.name);
            }
            self.registerObjcDefinedClassMethods(fcd);
        }
    }

    /// Resolve the `#extends ParentAlias` declaration on a sx-defined
    /// `#objc_class` to the actual Obj-C runtime class name. Falls
    /// back to "NSObject" when no `#extends` is declared.
    /// Aliases that resolve to foreign Obj-C classes use the
    /// foreign_path; aliases for OTHER sx-defined classes use the
    /// alias name directly (which equals the Obj-C class name for
    /// sx-defined classes).
    fn resolveObjcParentName(self: *Lowering, fcd: *const ast.ForeignClassDecl) []const u8 {
        for (fcd.members) |m| switch (m) {
            .extends => |alias| {
                if (self.program_index.foreign_class_map.get(alias)) |parent_fcd| {
                    if (parent_fcd.is_foreign) return parent_fcd.foreign_path;
                    // Sx-defined parent — its alias IS its Obj-C name.
                    return parent_fcd.name;
                }
                // Unknown alias — pass through as-is and let the
                // runtime diagnose if it's genuinely wrong.
                return alias;
            },
            else => {},
        };
        return "NSObject";
    }

    /// Declare a per-class global `__<ClassName>_state_ivar : *void = null`.
    /// emit_llvm's `emitObjcDefinedClassInit` constructor fills it in via
    /// `class_getInstanceVariable(cls, "__sx_state")` once per module load.
    fn declareObjcDefinedStateIvarGlobal(self: *Lowering, class_name: []const u8) void {
        const gname = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{class_name}) catch return;
        const name_id = self.module.types.internString(gname);
        _ = self.module.addGlobal(.{
            .name = name_id,
            .ty = self.module.types.ptrTo(.void),
            .init_val = .null_val,
            .is_extern = false,
            .is_const = false,
        });
    }

    /// Declare a per-class global `__<ClassName>_class : *void = null`.
    /// emit_llvm's `emitObjcDefinedClassInit` constructor stores the
    /// freshly-allocated Class pointer into it after objc_registerClassPair.
    /// The synthesized `-dealloc` IMP reads it to construct an `objc_super`
    /// for `[super dealloc]` dispatch.
    fn declareObjcDefinedClassGlobal(self: *Lowering, class_name: []const u8) void {
        const gname = std.fmt.allocPrint(self.alloc, "__{s}_class", .{class_name}) catch return;
        const name_id = self.module.types.internString(gname);
        _ = self.module.addGlobal(.{
            .name = name_id,
            .ty = self.module.types.ptrTo(.void),
            .init_val = .null_val,
            .is_extern = false,
            .is_const = false,
        });
    }

    /// For each bodied instance method on an sx-defined `#objc_class`,
    /// synthesize an `FnDecl` from the `ForeignMethodDecl`, register it
    /// in `fn_ast_map` under `<ClassName>.<methodName>`, declare the IR
    /// function, AND collect per-method registration data (selector
    /// mangling + type encoding + IMP symbol name) into the class's
    /// cache entry so emit_llvm can wire up `class_addMethod` calls
    /// (M1.2 A.4b.iii). Bodyless declarations are skipped — they
    /// reference inherited / external methods, not sx-side bodies.
    fn registerObjcDefinedClassMethods(self: *Lowering, fcd: *const ast.ForeignClassDecl) void {
        // Set current_foreign_class so `*Self` substitutions in
        // declareFunction's type resolution find the state struct.
        const saved = self.current_foreign_class;
        self.current_foreign_class = fcd;
        defer self.current_foreign_class = saved;

        var method_infos = std.ArrayList(Module.ObjcDefinedMethodEntry).empty;

        for (fcd.members) |m| {
            const method = switch (m) {
                .method => |md| md,
                else => continue,
            };
            const body = method.body orelse continue;
            const fd = self.synthesizeFnDeclFromObjcMethod(method, body) orelse continue;
            const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, method.name }) catch continue;
            self.program_index.fn_ast_map.put(qualified, fd) catch {};
            self.declareFunction(fd, qualified);

            // Selector mangling — A.1's deriveObjcSelector handles
            // `#selector("...")` override + the default rule. Static
            // methods use the same mangling rule (their first param
            // ISN'T *Self, so no offset).
            //
            // ABI for the IMP signature (both instance + class methods):
            //   `(recv: id|Class, _cmd: SEL, ...user_args) -> ret`
            // For instance methods the user-declared self is at param[0]
            // (skipped); class methods have no self in the AST.
            const user_param_start: usize = if (method.is_static) 0 else 1;
            const user_arg_count = if (method.params.len > user_param_start) method.params.len - user_param_start else 0;
            const sel_info = self.deriveObjcSelector(method, user_arg_count);

            const ret_ty: TypeId = if (method.return_type) |rt| self.resolveType(rt) else .void;
            var arg_tys = std.ArrayList(TypeId).empty;
            defer arg_tys.deinit(self.alloc);
            if (method.params.len > user_param_start) {
                for (method.params[user_param_start..]) |p_node| {
                    arg_tys.append(self.alloc, self.resolveType(p_node)) catch unreachable;
                }
            }
            const encoding = self.objcTypeEncodingFromSignature(ret_ty, arg_tys.items, null) catch continue;

            const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, method.name }) catch continue;

            method_infos.append(self.alloc, .{
                .sel = sel_info.sel,
                .encoding = encoding,
                .imp_name = imp_name,
                .is_class = method.is_static,
            }) catch unreachable;
        }

        if (method_infos.items.len > 0) {
            const methods_slice = method_infos.toOwnedSlice(self.alloc) catch return;
            self.module.setObjcDefinedClassMethods(fcd.name, methods_slice);
        }
    }

    /// Build an `FnDecl` whose params are zipped from the
    /// `ForeignMethodDecl.params` (type nodes) and `param_names`. Used
    /// to feed sx-defined class methods through the standard
    /// fn-lowering pipeline. Allocator-owned; lives for the duration
    /// of the Lowering pass.
    fn synthesizeFnDeclFromObjcMethod(self: *Lowering, method: ast.ForeignMethodDecl, body: *ast.Node) ?*ast.FnDecl {
        if (method.params.len != method.param_names.len) return null;
        var params = std.ArrayList(ast.Param).empty;
        for (method.params, method.param_names) |type_node, p_name| {
            params.append(self.alloc, .{
                .name = p_name,
                .name_span = .{ .start = 0, .end = 0 },
                .type_expr = type_node,
            }) catch unreachable;
        }
        const fd = self.alloc.create(ast.FnDecl) catch return null;
        fd.* = .{
            .name = method.name,
            .params = params.toOwnedSlice(self.alloc) catch unreachable,
            .return_type = method.return_type,
            .body = body,
        };
        return fd;
    }

    /// If `name` matches an sx-defined `#objc_class`'s qualified-method
    /// pattern (`<ClassName>.<methodName>`), return the class's
    /// ForeignClassDecl. Used by `lowerFunction` to set
    /// `current_foreign_class` so `*Self` resolves to the state struct
    /// during body lowering.
    fn lookupObjcDefinedClassForMethod(self: *Lowering, name: []const u8) ?*const ast.ForeignClassDecl {
        const dot = std.mem.indexOf(u8, name, ".") orelse return null;
        return self.module.lookupObjcDefinedClass(name[0..dot]);
    }

    /// Lazily declare the `sx_jni_env_tl_get` / `sx_jni_env_tl_set`
    /// runtime externs (step 2.16c). The storage lives in
    /// `library/vendors/sx_jni_runtime/sx_jni_env_tl.c` as a
    /// `_Thread_local` slot — keeping it OUT of the user's IR module
    /// is what lets the LLVM ORC JIT load the module cleanly without
    /// orc_rt platform support. AOT targets get the same .c file
    /// linked in via `needs_jni_env_tl_runtime`, which Compilation
    /// reads to append a synthetic c_import alongside the user's.
    fn getJniEnvTlFids(self: *Lowering) struct { get: FuncId, set: FuncId } {
        self.needs_jni_env_tl_runtime = true;
        const ptr_ty = self.module.types.ptrTo(.void);
        if (self.jni_env_tl_get_fid == null) {
            const name = self.module.types.internString("sx_jni_env_tl_get");
            const fid = self.builder.declareExtern(name, &.{}, ptr_ty);
            const func = self.module.getFunctionMut(fid);
            func.call_conv = .c;
            self.jni_env_tl_get_fid = fid;
        }
        if (self.jni_env_tl_set_fid == null) {
            const name = self.module.types.internString("sx_jni_env_tl_set");
            const env_param = self.module.types.internString("env");
            var params = std.ArrayList(inst_mod.Function.Param).empty;
            params.append(self.alloc, .{ .name = env_param, .ty = ptr_ty }) catch unreachable;
            const fid = self.builder.declareExtern(name, params.toOwnedSlice(self.alloc) catch unreachable, .void);
            const func = self.module.getFunctionMut(fid);
            func.call_conv = .c;
            self.jni_env_tl_set_fid = fid;
        }
        return .{ .get = self.jni_env_tl_get_fid.?, .set = self.jni_env_tl_set_fid.? };
    }

    /// Lazily declare the `sx_trace_push(u64)` / `sx_trace_clear()` runtime
    /// externs (ERR E3.1). Storage is a `_Thread_local` ring buffer in
    /// `library/vendors/sx_trace_runtime/sx_trace.c` — kept OUT of the user's IR
    /// module (same JIT-TLS reason as the JNI env slot). Setting
    /// `needs_trace_runtime` signals Compilation to auto-link the .c for AOT.
    /// Wired into the `raise` / `try` push sites and the absorbing clear sites
    /// at ERR E3.2.
    fn getTraceFids(self: *Lowering) struct { push: FuncId, clear: FuncId } {
        self.needs_trace_runtime = true;
        if (self.trace_push_fid == null) {
            const name = self.module.types.internString("sx_trace_push");
            const frame_param = self.module.types.internString("frame");
            var params = std.ArrayList(inst_mod.Function.Param).empty;
            params.append(self.alloc, .{ .name = frame_param, .ty = .u64 }) catch unreachable;
            const fid = self.builder.declareExtern(name, params.toOwnedSlice(self.alloc) catch unreachable, .void);
            self.module.getFunctionMut(fid).call_conv = .c;
            self.trace_push_fid = fid;
        }
        if (self.trace_clear_fid == null) {
            const name = self.module.types.internString("sx_trace_clear");
            const fid = self.builder.declareExtern(name, &.{}, .void);
            self.module.getFunctionMut(fid).call_conv = .c;
            self.trace_clear_fid = fid;
        }
        return .{ .push = self.trace_push_fid.?, .clear = self.trace_clear_fid.? };
    }

    /// Error return-traces are emitted in debug-ish builds and skipped in
    /// release (ERR E3.2 build-mode gating). `sx run` defaults to `-O0`
    /// (`.none`), the common dev path; `.default`/`.aggressive` are release.
    /// The spec's `--release-traces` opt-in + a `BuildOptions.error_traces`
    /// accessor are a later refinement; for now the opt level is the gate.
    fn tracesEnabled(self: *Lowering) bool {
        const tc = self.target_config orelse return true; // no target → treat as debug
        return tc.opt_level == .none or tc.opt_level == .less;
    }

    /// Emit a trace-buffer push of `frame` (an opaque u64) at a failure site.
    /// No-op when traces are disabled (release). `frame` is a placeholder until
    /// DWARF (E3.0) supplies real return-address PCs and E3.3 resolves them.
    fn emitTracePush(self: *Lowering, frame: Ref) void {
        if (!self.tracesEnabled()) return;
        const fids = self.getTraceFids();
        const coerced = self.coerceToType(frame, self.builder.getRefType(frame), .u64);
        const args = self.alloc.dupe(Ref, &.{coerced}) catch return;
        _ = self.builder.emit(.{ .call = .{ .callee = fids.push, .args = args } }, .void);
    }

    /// Emit a trace-buffer clear at an absorbing site (`catch` / `or value` /
    /// destructure). No-op when traces are disabled.
    fn emitTraceClear(self: *Lowering) void {
        if (!self.tracesEnabled()) return;
        const fids = self.getTraceFids();
        _ = self.builder.emit(.{ .call = .{ .callee = fids.clear, .args = &.{} } }, .void);
    }

    /// The trace frame value for a failure site (ERR E3.0 slice 3a). Emits the
    /// niladic `.trace_frame` op (span-stamped via `Builder.current_span`); each
    /// backend resolves it to a real frame — `emit_llvm` to a `Frame*`, `interp`
    /// to a packed `(func_id, offset)`. The result feeds `sx_trace_push`.
    fn placeholderTraceFrame(self: *Lowering) Ref {
        return self.builder.emit(.{ .trace_frame = {} }, .u64);
    }

    /// When a namespaced import (`Ns :: #import "..."`) contains foreign-class
    /// declarations, ALSO register them under their qualified name `Ns.Class`
    /// so receiver types like `*Ns.Class` can find the fcd. The recursive
    /// scan/lower already handles bare-name registration; this only adds the
    /// qualified-name entry, so cross-class refs in method signatures
    /// (`*View` → bare lookup) still work.
    fn registerNamespacedForeignClasses(self: *Lowering, ns: ast.NamespaceDecl) void {
        for (ns.decls) |inner| {
            if (inner.data == .foreign_class_decl) {
                const fcd = &inner.data.foreign_class_decl;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ ns.name, fcd.name }) catch fcd.name;
                self.program_index.foreign_class_map.put(qualified, fcd) catch {};
            } else if (inner.data == .namespace_decl) {
                // Nested namespaces — qualify with both prefixes.
                self.registerNamespacedForeignClasses(inner.data.namespace_decl);
            }
        }
    }


    // ── Protocol dispatch ──────────────────────────────────────────

    /// Check if a type name is a registered protocol.
    fn isProtocolType(self: *Lowering, type_name: []const u8) bool {
        return self.program_index.protocol_decl_map.contains(type_name);
    }

    /// Get protocol info for a TypeId (if it's a protocol type).
    /// Protocol lookup. Thin delegation to the canonical owner
    /// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` because ~9
    /// callers (dispatch sites here + `calls.zig`) reach it.
    pub fn getProtocolInfo(self: *Lowering, ty: TypeId) ?ProtocolDeclInfo {
        return self.protocolResolver().getProtocolInfo(ty);
    }

    /// Get or create thunks for a (protocol, concrete_type) pair.
    /// Returns a slice of FuncIds, one per protocol method.
    fn getOrCreateThunks(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8) []const FuncId {
        // Key: "Proto\x00Type"
        const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch return &.{};
        if (self.protocol_thunk_map.get(key)) |thunks| return thunks;

        // PLANNING: which methods need a thunk (owned by the registry).
        const methods = self.protocolResolver().protocolMethodInfos(proto_name) orelse return &.{};
        var thunk_ids = std.ArrayList(FuncId).empty;
        defer thunk_ids.deinit(self.alloc);

        // EMISSION: materialize one thunk per method (stays in Lowering).
        for (methods) |method| {
            const thunk_id = self.createProtocolThunk(proto_name, concrete_type_name, method);
            thunk_ids.append(self.alloc, thunk_id) catch unreachable;
        }

        const owned = self.alloc.dupe(FuncId, thunk_ids.items) catch unreachable;
        self.protocol_thunk_map.put(key, owned) catch {};
        return owned;
    }

    /// Emit the process-wide default Context as an LLVM static constant.
    ///
    ///   @__sx_default_context = internal constant %Context {
    ///     %Allocator { ptr null,
    ///                  ptr @__thunk_CAllocator_Allocator_alloc,
    ///                  ptr @__thunk_CAllocator_Allocator_dealloc },
    ///     ptr null
    ///   }
    ///
    /// Used by FFI inbound wrappers (Step 4) and the interp's default-
    /// context call entry (Step 7). Only emitted when the program imports
    /// `std.sx` — without that, Context / Allocator / CAllocator aren't
    /// registered and the global has no purpose.
    fn emitDefaultContextGlobal(self: *Lowering) void {
        const tbl = &self.module.types;
        const ctx_name_id = tbl.internString("Context");
        const ctx_ty = tbl.findByName(ctx_name_id) orelse return;
        if (tbl.findByName(tbl.internString("Allocator")) == null) return;
        if (tbl.findByName(tbl.internString("CAllocator")) == null) return;

        // Force the CAllocator → Allocator thunks to exist so we can
        // reference them by FuncId in the static initializer.
        const thunks = self.getOrCreateThunks("Allocator", "CAllocator");
        if (thunks.len < 2) return;

        // Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void }
        // CAllocator is stateless, so ctx is null.
        const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return;
        alloc_fields[0] = .null_val;
        alloc_fields[1] = .{ .func_ref = thunks[0] };
        alloc_fields[2] = .{ .func_ref = thunks[1] };

        // Context value: { allocator: Allocator, data: *void }
        const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return;
        ctx_fields[0] = .{ .aggregate = alloc_fields };
        ctx_fields[1] = .null_val;

        const global_name = "__sx_default_context";
        const global_name_id = tbl.internString(global_name);
        const gid = self.module.addGlobal(.{
            .name = global_name_id,
            .ty = ctx_ty,
            .init_val = .{ .aggregate = ctx_fields },
            .is_const = true,
        });
        self.program_index.global_names.put(global_name, .{ .id = gid, .ty = ctx_ty }) catch {};
    }

    /// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret
    /// The thunk calls ConcreteType.method(ctx, args...).
    fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId {
        // Build params: [__sx_ctx]? + ctx: *void + method params.
        // Thunks are sx-side functions, so they get the implicit __sx_ctx
        // at slot 0 when it's enabled program-wide. The concrete protocol
        // receiver (ctx) follows at slot 1; user method args at slot 2+.
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        defer params.deinit(self.alloc);
        const void_ptr = self.module.types.ptrTo(.void);
        const thunk_has_ctx = self.implicit_ctx_enabled;
        if (thunk_has_ctx) {
            params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr }) catch unreachable;
        }
        params.append(self.alloc, .{ .name = self.module.types.internString("ctx"), .ty = void_ptr }) catch unreachable;
        for (method.param_types, 0..) |pty, i| {
            var buf: [32]u8 = undefined;
            const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg";
            params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable;
        }

        // Generate unique name
        var name_buf: [192]u8 = undefined;
        const thunk_name = std.fmt.bufPrint(&name_buf, "__thunk_{s}_{s}_{s}", .{ concrete_type_name, proto_name, method.name }) catch "__thunk";
        const thunk_name_id = self.module.types.internString(thunk_name);

        // Save builder state
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        const saved_ctx_ref_thunk = self.current_ctx_ref;
        defer self.current_ctx_ref = saved_ctx_ref_thunk;

        const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable;
        var func = inst_mod.Function.init(thunk_name_id, owned_params, method.ret_type);
        func.has_implicit_ctx = thunk_has_ctx;
        const func_id = self.module.addFunction(func);
        self.builder.func = func_id;
        self.builder.inst_counter = @intCast(owned_params.len);
        if (thunk_has_ctx) self.current_ctx_ref = Ref.fromIndex(0);
        const entry_block = self.builder.appendBlock(self.module.types.internString("entry"), &.{});
        self.builder.switchToBlock(entry_block);

        // Ensure the concrete method is lowered
        const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ concrete_type_name, method.name }) catch method.name;
        if (!self.lowered_functions.contains(qualified)) {
            if (self.program_index.fn_ast_map.contains(qualified)) {
                self.lazyLowerFunction(qualified);
            } else if (self.struct_instance_template.get(concrete_type_name)) |tmpl_name| {
                // Generic-struct instance (`Combined__s64_s64`): the impl method
                // is registered under the template name (`Combined.get`).
                // Monomorphize it for this instance's bindings so the thunk has a
                // concrete `Combined__s64_s64.get` to call.
                const tmpl_qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ tmpl_name, method.name }) catch method.name;
                if (self.program_index.fn_ast_map.get(tmpl_qualified)) |fd| {
                    if (self.struct_instance_bindings.getPtr(concrete_type_name)) |bindings| {
                        self.monomorphizeFunction(fd, qualified, bindings);
                    }
                }
            }
        }

        // Call the concrete method: ConcreteType.method(__sx_ctx?, ctx, args...).
        // The concrete method is itself an sx function that takes the
        // implicit __sx_ctx at slot 0 (when implicit_ctx is enabled); we
        // forward the thunk's own __sx_ctx.
        if (self.resolveFuncByName(qualified)) |concrete_fid| {
            const concrete_func = &self.module.functions.items[@intFromEnum(concrete_fid)];
            var call_args = std.ArrayList(Ref).empty;
            defer call_args.deinit(self.alloc);

            // Slot offsets inside the thunk: __sx_ctx at 0 (if present),
            // protocol receiver (ctx) at slot user_base, user args at +1, +2...
            const user_base: u32 = if (thunk_has_ctx) 1 else 0;

            // Forward our __sx_ctx to the concrete method's __sx_ctx slot.
            if (concrete_func.has_implicit_ctx) {
                call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
            }

            // Pass ctx as the next arg (it's the concrete *Type disguised as *void).
            // If the concrete method expects a value (e.g., f32) not a pointer, load from ctx.
            const ctx_ref = Ref.fromIndex(user_base);
            const concrete_receiver_idx: usize = if (concrete_func.has_implicit_ctx) 1 else 0;
            if (concrete_receiver_idx < concrete_func.params.len) {
                const first_concrete_ty = concrete_func.params[concrete_receiver_idx].ty;
                const first_info = self.module.types.get(first_concrete_ty);
                if (first_info != .pointer) {
                    // Concrete expects value — load from ctx pointer
                    call_args.append(self.alloc, self.builder.load(ctx_ref, first_concrete_ty)) catch unreachable;
                } else {
                    call_args.append(self.alloc, ctx_ref) catch unreachable;
                }
            } else {
                call_args.append(self.alloc, ctx_ref) catch unreachable;
            }
            for (method.param_types, 0..) |proto_pty, i| {
                var arg_ref = Ref.fromIndex(@intCast(user_base + 1 + i));
                // If protocol param is a pointer (Self→*void) but concrete method
                // expects a value type, load the value from the pointer.
                const concrete_idx = concrete_receiver_idx + 1 + i;
                if (concrete_idx < concrete_func.params.len) {
                    const concrete_pty = concrete_func.params[concrete_idx].ty;
                    const proto_info = self.module.types.get(proto_pty);
                    const concrete_info = self.module.types.get(concrete_pty);
                    if (proto_info == .pointer and concrete_info != .pointer) {
                        arg_ref = self.builder.load(arg_ref, concrete_pty);
                    }
                }
                call_args.append(self.alloc, arg_ref) catch unreachable;
            }
            const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable;
            const concrete_ret = concrete_func.ret;
            const result = self.builder.call(concrete_fid, owned_args, concrete_ret);
            if (method.ret_type != .void) {
                // If protocol returns *void (Self) but concrete returns a value type,
                // box the value: alloca+store and return the pointer
                const ret_info = self.module.types.get(method.ret_type);
                const concrete_ret_info = self.module.types.get(concrete_ret);
                if (ret_info == .pointer and concrete_ret_info != .pointer) {
                    const slot = self.builder.alloca(concrete_ret);
                    self.builder.store(slot, result);
                    self.builder.ret(slot, method.ret_type);
                } else {
                    self.builder.ret(result, method.ret_type);
                }
            } else {
                self.builder.retVoid();
            }
        } else {
            // Can't resolve concrete method — emit unreachable
            _ = self.builder.emit(.{ .@"unreachable" = {} }, .void);
        }
        self.builder.finalize();

        // Restore builder state
        self.builder.func = saved_func;
        self.builder.current_block = saved_block;
        self.builder.inst_counter = saved_counter;

        return func_id;
    }

    /// Build a protocol value from a concrete pointer.
    /// For inline protocols: struct_init { ctx, thunk1, thunk2, ... }
    /// For vtable protocols: struct_init { ctx, vtable_ptr } where vtable is stack-allocated
    /// When `heap_copy` is true, the concrete data is heap-copied so the protocol value
    /// outlives the current stack frame (used when source is a value, not an explicit pointer).
    /// When false, the pointer is used directly (user manages the pointee's lifetime).
    fn buildProtocolValue(self: *Lowering, concrete_ptr: Ref, proto_name: []const u8, concrete_type_name: []const u8, proto_ty: TypeId, concrete_ty: TypeId, heap_copy: bool) Ref {
        const pd = self.program_index.protocol_decl_map.get(proto_name) orelse return concrete_ptr;
        const thunks = self.getOrCreateThunks(proto_name, concrete_type_name);
        if (thunks.len != pd.methods.len) return concrete_ptr;

        const void_ptr_ty = self.module.types.ptrTo(.void);

        // When source is a value (not an explicit pointer), heap-allocate
        // so the protocol value outlives the current stack frame.
        // When source is an explicit pointer (xx @obj), use it directly —
        // the user is responsible for the pointee's lifetime.
        var ctx_ptr = concrete_ptr;
        if (heap_copy) {
            const concrete_size = self.module.types.typeSizeBytes(concrete_ty);
            const size_ref = self.builder.constInt(@intCast(concrete_size), .s64);
            const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty);
            _ = self.callForeign("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty);
            ctx_ptr = heap_ptr;
        }

        if (pd.is_inline) {
            // Inline: { ctx, fn1, fn2, ... }
            var field_vals = std.ArrayList(Ref).empty;
            defer field_vals.deinit(self.alloc);
            field_vals.append(self.alloc, ctx_ptr) catch unreachable;
            for (thunks) |thunk_id| {
                const fn_ref = self.builder.emit(.{ .func_ref = thunk_id }, void_ptr_ty);
                field_vals.append(self.alloc, fn_ref) catch unreachable;
            }
            const owned = self.alloc.dupe(Ref, field_vals.items) catch unreachable;
            return self.builder.emit(.{ .struct_init = .{ .fields = owned } }, proto_ty);
        } else {
            // Vtable: { ctx, vtable_ptr }
            // Vtable is a global constant (same function pointers for every instance
            // of the same Protocol+ConcreteType pair). Cached per pair.
            const vtable_ty = self.protocol_vtable_type_map.get(proto_name) orelse return concrete_ptr;

            // Build cache key: "Proto\x00Type"
            const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch unreachable;

            const vtable_global_id = self.protocol_vtable_global_map.get(key) orelse blk: {
                // Create vtable global with function pointer initializer
                const global_name = std.fmt.allocPrint(self.alloc, "__{s}__{s}__vtable", .{ proto_name, concrete_type_name }) catch unreachable;
                const global_name_id = self.module.types.strings.intern(self.alloc, global_name);
                const thunk_ids = self.alloc.dupe(FuncId, thunks) catch unreachable;
                const gid = self.module.addGlobal(.{
                    .name = global_name_id,
                    .ty = vtable_ty,
                    .init_val = .{ .vtable = thunk_ids },
                    .is_const = true,
                });
                self.protocol_vtable_global_map.put(key, gid) catch {};
                break :blk gid;
            };

            // Reference the vtable global's address
            const vtable_ptr_ty = self.module.types.ptrTo(vtable_ty);
            const vtable_addr = self.builder.emit(.{ .global_addr = vtable_global_id }, vtable_ptr_ty);

            // Build protocol struct: { ctx, &vtable }
            var proto_fields = std.ArrayList(Ref).empty;
            defer proto_fields.deinit(self.alloc);
            proto_fields.append(self.alloc, ctx_ptr) catch unreachable;
            proto_fields.append(self.alloc, vtable_addr) catch unreachable;
            const proto_owned = self.alloc.dupe(Ref, proto_fields.items) catch unreachable;
            return self.builder.emit(.{ .struct_init = .{ .fields = proto_owned } }, proto_ty);
        }
    }

    /// Emit protocol method dispatch for a protocol-typed receiver.
    /// Returns the call result ref.
    fn emitProtocolDispatch(self: *Lowering, receiver: Ref, proto_info: ProtocolDeclInfo, method_name: []const u8, args: []const Ref, proto_ty: TypeId) Ref {
        // Find method index
        var method_idx: ?usize = null;
        var method_info: ?ProtocolMethodInfo = null;
        for (proto_info.methods, 0..) |m, i| {
            if (std.mem.eql(u8, m.name, method_name)) {
                method_idx = i;
                method_info = m;
                break;
            }
        }
        const mi = method_info orelse return self.emitError(method_name, null);
        const midx = method_idx orelse 0;

        // Extract ctx from protocol struct (field 0)
        const void_ptr = self.module.types.ptrTo(.void);
        const ctx = self.builder.structGet(receiver, 0, void_ptr);

        // Extract fn_ptr
        const fn_ptr = if (proto_info.is_inline) blk: {
            // Inline: fn_ptr at field 1+method_idx
            break :blk self.builder.structGet(receiver, @intCast(1 + midx), void_ptr);
        } else blk: {
            // Vtable: load vtable struct, extract fn_ptr at method_idx
            const vtable_ptr = self.builder.structGet(receiver, 1, void_ptr);
            const vtable_ty = self.protocol_vtable_type_map.get(proto_info.name) orelse return self.emitError("vtable", null);
            const vtable = self.builder.emit(.{ .deref = .{ .operand = vtable_ptr } }, vtable_ty);
            break :blk self.builder.structGet(vtable, @intCast(midx), void_ptr);
        };
        _ = proto_ty;

        // Build call args: [__sx_ctx]? + receiver_ctx + user args.
        // Protocol thunks are sx-side, so they carry the implicit __sx_ctx
        // at slot 0 when the program uses Context — forward our caller's
        // ctx so the thunk's body (and the concrete method it forwards to)
        // sees the same Context as the dispatching code.
        var call_args = std.ArrayList(Ref).empty;
        defer call_args.deinit(self.alloc);
        if (self.implicit_ctx_enabled) {
            call_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
        }
        call_args.append(self.alloc, ctx) catch unreachable;
        for (args, 0..) |a, i| {
            const expected_ty = if (i < mi.param_types.len) mi.param_types[i] else void_ptr;
            const arg_ty = self.builder.getRefType(a);

            // Untargeted `null` lowers as const_null with type .void. Re-emit it
            // as a null of the expected pointer type instead of alloca'ing void.
            if (arg_ty == .void and expected_ty == void_ptr) {
                call_args.append(self.alloc, self.builder.constNull(void_ptr)) catch unreachable;
                continue;
            }
            // A protocol method that expects `*void` accepts any single-pointer
            // value directly (`*T`, `[*]T`). Only wrap non-pointer values in an
            // alloca-slot — wrapping a pointer would pass the stack slot's
            // address instead of the actual pointer, and the callee would read
            // 8 bytes of pointer plus garbage from beyond the stack.
            const is_pointer_ty = if (!arg_ty.isBuiltin()) blk: {
                const info = self.module.types.get(arg_ty);
                break :blk info == .pointer or info == .many_pointer;
            } else false;
            if (expected_ty == void_ptr and arg_ty != void_ptr and !is_pointer_ty) {
                const slot = self.builder.alloca(arg_ty);
                self.builder.store(slot, a);
                call_args.append(self.alloc, slot) catch unreachable;
            } else {
                // Coerce to match declared parameter type (critical for WASM strict signatures)
                const coerced = self.coerceToType(a, arg_ty, expected_ty);
                call_args.append(self.alloc, coerced) catch unreachable;
            }
        }
        const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable;
        const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type);

        // If the protocol method was declared `-> Self` (encoded here as *void)
        // and the caller expects a value type, unbox: load the concrete value
        // from the returned pointer. A literal `-> *void` return is NOT
        // auto-loaded — it's a real pointer whose pointee size we don't know.
        if (mi.ret_is_self) {
            if (self.target_type) |target| {
                const target_info = self.module.types.get(target);
                if (target_info != .pointer) {
                    return self.builder.load(raw_result, target);
                }
            }
        }
        return raw_result;
    }

    /// Resolve the concrete type name for protocol erasure.
    /// Handles both direct types and pointer-to-types.
    pub fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 {
        if (ty.isBuiltin()) {
            // Primitive types like s64 — check if they have toName()
            return self.module.types.typeName(ty);
        }
        const info = self.module.types.get(ty);
        if (info == .pointer) {
            // *ConcreteType → resolve pointee
            const pointee = info.pointer.pointee;
            if (pointee.isBuiltin()) return self.module.types.typeName(pointee);
            const pi = self.module.types.get(pointee);
            if (pi == .@"struct") return self.module.types.getString(pi.@"struct".name);
            return null;
        }
        if (info == .@"struct") return self.module.types.getString(info.@"struct".name);
        return null;
    }

    // ── Helpers ─────────────────────────────────────────────────────

    /// Infer the type of an expression from its AST node (used for untyped var decls).
    pub fn inferExprType(self: *Lowering, node: *const Node) TypeId {
        return switch (node.data) {
            .call => |*c| self.callResolver().resultType(c),
            else => self.exprTyper().inferType(node),
        };
    }

    fn exprTyper(self: *Lowering) ExprTyper {
        return .{ .l = self };
    }

    fn callResolver(self: *Lowering) CallResolver {
        return .{ .l = self };
    }

    pub fn genericResolver(self: *Lowering) GenericResolver {
        return .{ .l = self };
    }

    pub fn protocolResolver(self: *Lowering) ProtocolResolver {
        return .{ .l = self };
    }

    pub fn coercionResolver(self: *Lowering) CoercionResolver {
        return .{ .l = self };
    }

    /// Lower the `xx` operator (type coercion).
    /// Uses self.target_type for context when available. Handles:
    /// - Any → concrete type: unbox_any
    /// - int → int: widen/narrow
    /// - int ↔ float: int_to_float/float_to_int
    fn lowerXX(self: *Lowering, operand: Ref, operand_node: *const Node) Ref {
        // Use the operand's *actual* lowered Ref type rather than reaching
        // back through inferExprType — the latter doesn't cover every
        // expression shape (notably lambdas), and a wrong src_ty here can
        // route the cast through coerceToType (e.g. a bogus s64→ptr bitcast)
        // and silently skip the user-space Into fallback.
        const src_ty = self.builder.getRefType(operand);
        const target_explicit = self.target_type != null;
        const dst_ty = self.target_type orelse .unresolved;

        // PLANNING: the `xx`-head decision (conversions.zig). `.coerce` falls
        // through to the built-in ladder + the user-`Into` fallback below.
        switch (self.coercionResolver().classifyXX(src_ty, dst_ty)) {
            // Any → concrete type: unbox.
            .unbox_any => {
                // When inside a float match arm covering both f32 and f64,
                // and target is f64, we need a mini-dispatch to unbox correctly.
                // f32 values are stored as zext(bitcast(f32→i32), i64) in Any,
                // so bitcasting i64→f64 directly gives wrong results for f32.
                if (dst_ty == .f64) {
                    if (self.current_match_tags) |tags| {
                        var has_f32 = false;
                        var has_f64 = false;
                        for (tags) |t| {
                            const tid = TypeId.fromIndex(@intCast(t));
                            if (tid == .f32) has_f32 = true;
                            if (tid == .f64) has_f64 = true;
                        }
                        if (has_f32 and has_f64) {
                            return self.lowerAnyToF64Dispatch(operand);
                        }
                        if (has_f32 and !has_f64) {
                            // Only f32 values: unbox as f32, then widen
                            const f32_val = self.builder.emit(.{ .unbox_any = .{
                                .operand = operand,
                            } }, .f32);
                            return self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
                        }
                    }
                }
                return self.builder.emit(.{ .unbox_any = .{
                    .operand = operand,
                } }, dst_ty);
            },
            // Same type: no-op.
            .no_op => return operand,
            // Concrete → Protocol: build protocol value.
            .erase_protocol => return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty),
            // Protocol → pointer: recover the typed ctx pointer (field 0).
            // The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or
            // `{ ctx, vtable_ptr }` — either way, ctx lives at field 0.
            .protocol_to_pointer => {
                const void_ptr_ty = self.module.types.ptrTo(.void);
                const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty);
                if (dst_ty == void_ptr_ty) return ctx_ref;
                return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty);
            },
            .coerce => {},
        }

        const result = self.coerceToType(operand, src_ty, dst_ty);

        // User-space fallback via `impl Into(Target) for Source`. Only fires
        // when the target was explicitly named (not the .s64 default), src and
        // dst differ, and the built-in ladder made no progress. Built-ins
        // always win.
        if (target_explicit and src_ty != dst_ty and result == operand) {
            if (self.tryUserConversion(operand, operand_node, src_ty, dst_ty)) |converted| {
                return converted;
            }
            // Pointer-target fallback: `xx <expr>` whose surrounding context
            // expects `*T` (a fn arg slot, a var typed as a pointer-to-aggregate)
            // can be satisfied by `impl Into(T) for src` plus an implicit
            // alloca+store on the result. Lets users write
            // `fn(xx () => { ... })` instead of materialising a named Block local
            // just to take its address.
            if (!dst_ty.isBuiltin()) {
                const dst_info = self.module.types.get(dst_ty);
                if (dst_info == .pointer) {
                    const pointee = dst_info.pointer.pointee;
                    if (pointee != src_ty) {
                        if (self.tryUserConversion(operand, operand_node, src_ty, pointee)) |converted| {
                            const slot = self.builder.alloca(pointee);
                            self.builder.store(slot, converted);
                            return slot;
                        }
                    }
                }
            }
        }
        return result;
    }

    /// Detect the `xx closure : Block` cast pattern so `tryUserConversion`
    /// can emit a focused diagnostic when no `Into(Block) for Closure(...)`
    /// impl is reachable. Replaces what was briefly a compiler-synthesised
    /// trampoline path with a "declare an impl" requirement — the stdlib
    /// covers common signatures (see modules/std/objc_block.sx), users
    /// add their own for unusual ones.
    fn isClosureToBlockCast(self: *Lowering, src_ty: TypeId, dst_ty: TypeId) bool {
        if (src_ty.isBuiltin()) return false;
        const src_info = self.module.types.get(src_ty);
        if (src_info != .closure) return false;
        if (dst_ty.isBuiltin()) return false;
        const dst_info = self.module.types.get(dst_ty);
        if (dst_info != .@"struct") return false;
        const block_name = self.module.types.internString("Block");
        return dst_info.@"struct".name == block_name;
    }

    /// Pack-variadic impl matching. Walks `param_impl_pack_map[pack_key]`
    /// and returns a call ref when a single pack impl matches `src_ty`'s
    /// shape (concrete src closure / fn with the same fixed prefix as
    /// the impl's source pack closure). Binds the pack-var to the source's
    /// tail param types and the return-var (when generic) to the source's
    /// return type, then monomorphises the convert method.
    /// Returns null if no pack impls registered for this (proto, dst) or
    /// none of them match `src_ty`'s shape.
    fn tryPackImplMatch(
        self: *Lowering,
        operand: Ref,
        operand_node: *const Node,
        src_ty: TypeId,
        dst_ty: TypeId,
        proto_name: []const u8,
        pack_key: []const u8,
        guard_key: u64,
    ) ?Ref {
        _ = operand_node;
        // PLANNING: select the matching pack impl + its `convert` (registry).
        const match = self.protocolResolver().matchPackImpl(src_ty, pack_key) orelse return null;
        const entry = match.entry;
        const fd = match.convert_fd;
        const src_params = match.src_params;
        const src_ret = match.src_ret;
        const table = &self.module.types;
        // EMISSION: bind the pack tail + ret-var, monomorphise, call (Lowering).

        // Build bindings. Target → dst_ty (already in the protocol's type
        // params), pack-var → src tail TypeIds, ret-var (when generic) →
        // src ret.
        const ent_pack_start = table.get(entry.source_pack_ty).closure.pack_start.?;
        const tail = src_params[ent_pack_start..];
        const tail_owned = self.alloc.dupe(TypeId, tail) catch return null;

        var bindings = std.StringHashMap(TypeId).init(self.alloc);
        defer bindings.deinit();
        const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null;
        bindings.put(pd.type_params[0].name, dst_ty) catch return null;
        if (entry.ret_var_name) |rv| bindings.put(rv, src_ret) catch return null;

        var pack_bindings = std.StringHashMap([]const TypeId).init(self.alloc);
        defer pack_bindings.deinit();
        pack_bindings.put(entry.pack_var_name, tail_owned) catch return null;

        // Mangled name keyed on the CONCRETE source so distinct shapes
        // monomorphise separately. Same scheme as the concrete path:
        // "<src>.convert__<dst>".
        const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{
            self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
        }) catch return null;

        self.xx_reentrancy.put(guard_key, {}) catch {};
        defer _ = self.xx_reentrancy.remove(guard_key);

        if (!self.lowered_functions.contains(mangled)) {
            const saved_pack = self.pack_bindings;
            self.pack_bindings = pack_bindings;
            defer self.pack_bindings = saved_pack;
            self.monomorphizeFunction(fd, mangled, &bindings);
        }

        const fid = self.resolveFuncByName(mangled) orelse return null;
        const func = &self.module.functions.items[@intFromEnum(fid)];
        const ret_ty = func.ret;
        const params = func.params;
        var single = [_]Ref{operand};
        const final_args = self.prependCtxIfNeeded(func, single[0..]);
        self.coerceCallArgs(final_args, params);
        return self.builder.call(fid, final_args, ret_ty);
    }

    /// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise
    /// the impl's `convert` method and emit a direct call. Returns null when
    /// no impl matches (caller falls back to the built-in result, which is
    /// the unchanged operand — Phase 3 emits no diagnostic for v0).
    fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) ?Ref {
        // Reentrancy guard — pack (src, dst) into a u64.
        const guard_key: u64 = (@as(u64, src_ty.index()) << 32) | @as(u64, dst_ty.index());
        if (self.xx_reentrancy.contains(guard_key)) {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, operand_node.span, "recursive xx conversion from '{s}' to '{s}'", .{
                    self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
                });
            }
            return operand;
        }

        // Build lookup key: "Into\x00<dst_mangled>\x00<src_mangled>".
        // Hardcoded to the "Into" protocol for v1. Generalising to other
        // parameterised protocols would walk protocol_decl_map looking for
        // protocols that take a single type-param and have a `convert` method.
        const proto_name = "Into";
        const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null;
        if (pd.type_params.len != 1) return null;

        var key_buf = std.ArrayList(u8).empty;
        key_buf.appendSlice(self.alloc, proto_name) catch return null;
        key_buf.append(self.alloc, 0) catch return null;
        key_buf.appendSlice(self.alloc, self.mangleTypeName(dst_ty)) catch return null;
        key_buf.append(self.alloc, 0) catch return null;
        key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null;
        const key = key_buf.items;

        // Pack-only key (proto + dst) — used if the concrete lookup misses.
        // Same prefix as the concrete key, minus the `\x00<src_mangled>` tail.
        const dst_mangled_len = self.mangleTypeName(dst_ty).len;
        const pack_key = key_buf.items[0 .. proto_name.len + 1 + dst_mangled_len];

        const entries_opt = self.param_impl_map.get(key);
        const has_concrete = entries_opt != null and entries_opt.?.items.len > 0;
        if (!has_concrete) {
            // Concrete miss — try the pack map before emitting a diagnostic.
            if (self.tryPackImplMatch(operand, operand_node, src_ty, dst_ty, proto_name, pack_key, guard_key)) |result| {
                return result;
            }
            if (self.isClosureToBlockCast(src_ty, dst_ty)) {
                if (self.diagnostics) |diags| {
                    const saved = diags.current_source_file;
                    diags.current_source_file = operand_node.source_file orelse self.current_source_file;
                    defer diags.current_source_file = saved;
                    diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)});
                }
                return operand;
            }
            return null;
        }
        const entries = entries_opt.?;

        // Filter by import visibility: only impls in modules that the current
        // file transitively imports (or the current file itself) are reachable.
        // Falls open when import_graph isn't wired (e.g. comptime callers).
        var visible_impls = std.ArrayList(ParamImplEntry).empty;
        defer visible_impls.deinit(self.alloc);
        self.protocolResolver().findVisibleImpls(entries.items, &visible_impls);

        if (visible_impls.items.len == 0) {
            if (self.diagnostics) |diags| {
                const saved = diags.current_source_file;
                diags.current_source_file = operand_node.source_file orelse self.current_source_file;
                defer diags.current_source_file = saved;
                diags.addFmt(.err, operand_node.span, "no visible xx conversion from '{s}' to '{s}' — impl exists in another module but is not imported", .{
                    self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
                });
            }
            return operand;
        }
        if (visible_impls.items.len > 1) {
            if (self.diagnostics) |diags| {
                const saved = diags.current_source_file;
                diags.current_source_file = operand_node.source_file orelse self.current_source_file;
                defer diags.current_source_file = saved;
                diags.addFmt(.err, operand_node.span, "duplicate xx conversion from '{s}' to '{s}': impls in {s} and {s}", .{
                    self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
                    visible_impls.items[0].defining_module, visible_impls.items[1].defining_module,
                });
            }
            return operand;
        }
        const entry = visible_impls.items[0];

        // Find the `convert` method on this impl.
        var convert_fd: ?*const ast.FnDecl = null;
        for (entry.methods) |m| {
            if (std.mem.eql(u8, m.name, "convert")) {
                convert_fd = m;
                break;
            }
        }
        const fd = convert_fd orelse return null;

        // Bind Target → dst_ty.
        var bindings = std.StringHashMap(TypeId).init(self.alloc);
        defer bindings.deinit();
        bindings.put(pd.type_params[0].name, dst_ty) catch return null;

        // Mangled name: "<src>.convert__<dst>".
        const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{
            self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
        }) catch return null;

        self.xx_reentrancy.put(guard_key, {}) catch {};
        defer _ = self.xx_reentrancy.remove(guard_key);

        if (!self.lowered_functions.contains(mangled)) {
            self.monomorphizeFunction(fd, mangled, &bindings);
        }

        const fid = self.resolveFuncByName(mangled) orelse return null;
        const func = &self.module.functions.items[@intFromEnum(fid)];
        const ret_ty = func.ret;
        const params = func.params;
        var single = [_]Ref{operand};
        const final_args = self.prependCtxIfNeeded(func, single[0..]);
        self.coerceCallArgs(final_args, params);
        return self.builder.call(fid, final_args, ret_ty);
    }

    /// True for expression shapes that name an addressable storage location
    /// (variables, fields, array elements, dereferenced pointers). Used by
    /// `xx <struct-typed expr>` to decide between borrow (lvalue → take the
    /// address) and heap-copy (rvalue → allocate a fresh copy).
    fn isLvalueExpr(self: *Lowering, node: *const Node) bool {
        _ = self;
        return switch (node.data) {
            .identifier, .field_access, .index_expr, .deref_expr => true,
            else => false,
        };
    }

    /// Build a protocol value from a concrete value via xx conversion.
    /// Coerce `val` (type `src`) to `dst`: if `dst` is a protocol, `xx`-erase
    /// the concrete value into it; otherwise fall back to numeric/struct
    /// coercion. Used to materialize a pack into a protocol-typed tuple field.
    fn coerceOrErase(self: *Lowering, val: Ref, src: TypeId, dst: TypeId, node: *const Node) Ref {
        if (src == dst) return val;
        if (!dst.isBuiltin()) {
            const di = self.module.types.get(dst);
            if (di == .@"struct" and di.@"struct".is_protocol) {
                return self.buildProtocolErasure(val, node, src, dst);
            }
        }
        return self.coerceToType(val, src, dst);
    }

    fn buildProtocolErasure(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) Ref {
        const dst_info = self.module.types.get(dst_ty);
        if (dst_info != .@"struct") return operand;
        const proto_name = self.module.types.getString(dst_info.@"struct".name);

        // Determine concrete type name and type — resolve through pointer if needed
        var concrete_ptr = operand;
        var concrete_type_name: ?[]const u8 = null;
        var concrete_ty: TypeId = src_ty;
        var heap_copy = false;

        if (!src_ty.isBuiltin()) {
            const src_info = self.module.types.get(src_ty);
            if (src_info == .pointer) {
                // xx @acc — operand is already a pointer (user manages lifetime)
                const pointee = src_info.pointer.pointee;
                concrete_type_name = self.resolveConcreteTypeName(pointee);
                concrete_ty = pointee;
                heap_copy = false;
            } else if (src_info == .@"struct") {
                // Struct-typed operand. Split on lvalue-ness:
                //   - lvalue (identifier, field, index, deref): borrow the
                //     storage the operand already names. No heap copy; the
                //     protocol value's ctx points at the caller's slot, and
                //     mutations through the protocol are visible to the
                //     original. Lifetime is the caller's responsibility.
                //   - rvalue (struct literal, call result, etc.): heap-copy
                //     into a fresh allocation so the protocol value is
                //     self-contained and outlives this expression.
                concrete_type_name = self.module.types.getString(src_info.@"struct".name);
                concrete_ty = src_ty;
                if (self.isLvalueExpr(operand_node)) {
                    concrete_ptr = self.lowerExprAsPtr(operand_node);
                    heap_copy = false;
                } else {
                    heap_copy = true;
                    const slot = self.builder.alloca(src_ty);
                    self.builder.store(slot, operand);
                    concrete_ptr = slot;
                }
            }
        }

        // Also try from the operand node for struct literals: xx Accumulator.{ total = 0 }
        if (concrete_type_name == null) {
            concrete_type_name = self.inferConcreteTypeName(operand_node);
            if (concrete_type_name != null) heap_copy = true;
        }

        if (concrete_type_name) |ctn| {
            return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
        }
        return operand;
    }

    /// Try to infer the concrete type name from an AST node (for struct literals etc.)
    fn inferConcreteTypeName(self: *Lowering, node: *const Node) ?[]const u8 {
        return switch (node.data) {
            .struct_literal => |sl| if (sl.struct_name) |n| n else null,
            .unary_op => |uop| if (uop.op == .address_of) self.inferConcreteTypeName(uop.operand) else null,
            .identifier => |id| blk: {
                // Check if identifier's type resolves to a struct
                if (self.scope) |scope| {
                    if (scope.lookup(id.name)) |binding| {
                        if (!binding.ty.isBuiltin()) {
                            const bi = self.module.types.get(binding.ty);
                            if (bi == .@"struct") break :blk self.module.types.getString(bi.@"struct".name);
                            if (bi == .pointer) {
                                const pointee = bi.pointer.pointee;
                                if (!pointee.isBuiltin()) {
                                    const pi = self.module.types.get(pointee);
                                    if (pi == .@"struct") break :blk self.module.types.getString(pi.@"struct".name);
                                }
                            }
                        }
                    }
                }
                break :blk null;
            },
            else => null,
        };
    }

    /// Generate a mini-dispatch for unboxing Any to f64 when the value might be f32 or f64.
    /// Uses alloca-based merge: create result slot, branch, store in each arm, load after merge.
    fn lowerAnyToF64Dispatch(self: *Lowering, any_val: Ref) Ref {
        // Create result alloca BEFORE the branch
        const result_slot = self.builder.alloca(.f64);

        // Extract type tag from Any
        const tag = self.builder.structGet(any_val, 0, .s64);

        const f32_bb = self.freshBlock("f32.unbox");
        const f64_bb = self.freshBlock("f64.unbox");
        const merge_bb = self.freshBlock("float.merge");

        // Branch: tag == f32_tag ? f32_bb : f64_bb
        const f32_tag = self.builder.constInt(TypeId.f32.index(), .s64);
        const cond = self.builder.emit(.{ .cmp_eq = .{ .lhs = tag, .rhs = f32_tag } }, .bool);
        self.builder.condBr(cond, f32_bb, &.{}, f64_bb, &.{});

        // f32 block: unbox as f32, fpext to f64, store
        self.builder.switchToBlock(f32_bb);
        const f32_val = self.builder.emit(.{ .unbox_any = .{
            .operand = any_val,
        } }, .f32);
        const f64_from_f32 = self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
        self.builder.store(result_slot, f64_from_f32);
        self.builder.br(merge_bb, &.{});

        // f64 block: unbox as f64 directly, store
        self.builder.switchToBlock(f64_bb);
        const f64_val = self.builder.emit(.{ .unbox_any = .{
            .operand = any_val,
        } }, .f64);
        self.builder.store(result_slot, f64_val);
        self.builder.br(merge_bb, &.{});

        // Merge block: load result
        self.builder.switchToBlock(merge_bb);
        return self.builder.load(result_slot, .f64);
    }

    /// Produce a default value for a type, applying struct field defaults.
    /// For structs with defaults (e.g., `b: s32 = 99`), creates a struct_literal with defaults applied.
    /// For other types, returns a zero value.
    fn buildDefaultValue(self: *Lowering, ty: TypeId) Ref {
        if (ty.isBuiltin()) return self.builder.constInt(0, ty);
        const info = self.module.types.get(ty);
        if (info != .@"struct" and info != .tuple) return self.zeroValue(ty);
        // For tuples, build a zero-initialized tuple
        if (info == .tuple) {
            var field_vals = std.ArrayList(Ref).empty;
            defer field_vals.deinit(self.alloc);
            for (info.tuple.fields) |f| {
                field_vals.append(self.alloc, self.zeroValue(f)) catch unreachable;
            }
            return self.builder.emit(.{
                .tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
            }, ty);
        }
        // Check for struct defaults
        const struct_name_str = self.module.types.getString(info.@"struct".name);
        const field_defaults = self.struct_defaults_map.get(struct_name_str) orelse
            return self.builder.constUndef(ty);
        const fields = info.@"struct".fields;
        var field_vals = std.ArrayList(Ref).empty;
        defer field_vals.deinit(self.alloc);
        for (fields, 0..) |f, i| {
            if (i < field_defaults.len) {
                if (field_defaults[i]) |default_expr| {
                    const saved_tt = self.target_type;
                    self.target_type = f.ty;
                    const val = self.lowerExpr(default_expr);
                    self.target_type = saved_tt;
                    field_vals.append(self.alloc, val) catch unreachable;
                } else {
                    field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable;
                }
            } else {
                field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable;
            }
        }
        return self.builder.emit(.{
            .struct_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
        }, ty);
    }

    /// Wrap ty in ?ty, but flatten: if ty is already ?U, return ?U (not ??U)
    pub fn optionalOfFlattened(self: *Lowering, ty: TypeId) TypeId {
        if (!ty.isBuiltin()) {
            const info = self.module.types.get(ty);
            if (info == .optional) return ty;
        }
        return self.module.types.optionalOf(ty);
    }

    /// Produce a zero/default value for any type — constInt(0) for integers,
    /// constNull for pointers, constUndef for structs/complex types.
    fn zeroValue(self: *Lowering, ty: TypeId) Ref {
        if (ty.isBuiltin()) return self.builder.constInt(0, ty);
        const info = self.module.types.get(ty);
        return switch (info) {
            // Arbitrary-width integer types (u1, u2, s4, ...) interned as
            // `.signed`/`.unsigned` variants — fall through `isBuiltin()`.
            .signed, .unsigned => self.builder.constInt(0, ty),
            .pointer, .tuple, .optional => self.builder.constNull(ty),
            .@"struct", .array, .slice, .many_pointer => self.builder.constNull(ty),
            else => self.builder.constUndef(ty),
        };
    }

    fn emitModuleConst(self: *Lowering, ci: ModuleConstInfo) Ref {
        switch (ci.value.data) {
            .int_literal => |lit| {
                // If declared type is float, convert integer value to float constant
                if (ci.ty == .f32 or ci.ty == .f64) {
                    return self.builder.constFloat(@floatFromInt(lit.value), ci.ty);
                }
                return self.builder.constInt(lit.value, ci.ty);
            },
            .float_literal => |lit| return self.builder.constFloat(lit.value, ci.ty),
            .bool_literal => |lit| return self.builder.emit(.{ .const_bool = lit.value }, .bool),
            .string_literal => |lit| {
                const str = if (lit.is_raw) lit.raw else unescape.unescapeString(self.alloc, lit.raw) catch lit.raw;
                const sid = self.module.types.internString(str);
                return self.builder.constString(sid);
            },
            .undef_literal => return self.builder.constUndef(ci.ty),
            .null_literal => return self.builder.constNull(ci.ty),
            else => {
                // Complex expressions (struct_literal, call, etc.) — lower on demand
                const saved_target = self.target_type;
                self.target_type = ci.ty;
                const result = self.lowerExpr(ci.value);
                self.target_type = saved_target;
                return result;
            },
        }
    }

    fn emitPlaceholder(self: *Lowering, name: []const u8) Ref {
        const sid = self.module.types.internString(name);
        return self.builder.emit(.{ .placeholder = sid }, .s64);
    }

    /// Check if a name refers to a known type (primitive or registered struct/enum/union).
    /// Used to distinguish type-as-value (silent placeholder) from genuinely unresolved names.
    pub fn isKnownTypeName(self: *Lowering, name: []const u8) bool {
        if (type_bridge.resolveTypePrimitive(name) != null) return true;
        if (self.type_bindings) |bindings| {
            if (bindings.get(name) != null) return true;
        }
        if (self.program_index.type_alias_map.get(name) != null) return true;
        const name_id = self.module.types.internString(name);
        return self.module.types.findByName(name_id) != null;
    }

    /// Update `self.current_source_file` and mirror it onto `diags.current_source_file`,
    /// so any diagnostic emitted from inside a function lowered from another module is
    /// attributed to that module — not whichever file the diagnostics list was init'd with.
    fn setCurrentSourceFile(self: *Lowering, source_file: ?[]const u8) void {
        self.current_source_file = source_file;
        if (self.diagnostics) |d| d.current_source_file = source_file;
    }

    fn emitError(self: *Lowering, name: []const u8, span: ?ast.Span) Ref {
        if (self.diagnostics) |diags| {
            // The literal message carries the lowering's `current_source_file`
            // and enclosing function name. The diagnostic renderer's
            // `source_file` -> `file:line:col` prefix can drift when a span is
            // offset into one source but the diagnostic falls back to another
            // (e.g. synthetic AST nodes inserted from `#insert` take their
            // span from the call site, not from the string being inserted).
            // Embedding the file + function in the message means a
            // misattributed span can never hide WHERE the lookup actually
            // failed. Setting SX_TRACE_UNRESOLVED=1 also dumps a Zig stack
            // trace at the emit site to surface the calling lowering path.
            const sf = self.current_source_file orelse "<unknown>";
            const fn_name: []const u8 = if (self.builder.func) |fid|
                self.module.types.getString(self.module.functions.items[@intFromEnum(fid)].name)
            else
                "<top-level>";
            if (std.c.getenv("SX_TRACE_UNRESOLVED") != null) {
                std.debug.print("\n== unresolved '{s}' (in {s} fn {s}) ==\n", .{ name, sf, fn_name });
                std.debug.dumpCurrentStackTrace(.{ .first_address = @returnAddress() });
            }
            diags.addFmt(.err, span, "unresolved '{s}' (in {s} fn {s})", .{ name, sf, fn_name });
        }
        return self.emitPlaceholder(name);
    }

    fn emitFieldError(self: *Lowering, obj_ty: TypeId, field: []const u8, span: ast.Span) Ref {
        if (self.diagnostics) |diags| {
            const ty_name = self.formatTypeName(obj_ty);
            diags.addFmt(.err, span, "field '{s}' not found on type '{s}'", .{ field, ty_name });
        }
        return self.emitPlaceholder(field);
    }

    /// Insert a conversion if src_ty and dst_ty differ.
    /// Handles int widening/narrowing, float widening/narrowing, and int↔float.
    fn coerceToType(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref {
        // PLANNING: classify the built-in coercion (conversions.zig).
        // EMISSION: each arm below reproduces the original lowering.
        switch (self.coercionResolver().classify(src_ty, dst_ty)) {
            .no_op, .none => return val,
            // Unbox Any → concrete type
            .unbox_any => return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty),
            // Box concrete → Any
            .box_any => return self.builder.boxAny(val, src_ty),
            // Closure VALUE → bare function-pointer slot: not soundly representable.
            // A bare `(T) -> U` slot is called as `fn_ptr(ctx, args)` with NO env
            // arg, but a closure's underlying fn takes an env slot — so passing a
            // closure value's fn_ptr drops the env and shifts the args (UB for a
            // matching ABI, a wrong-tuple read for ∅-widening, a segfault when the
            // closure captures). Only a closure LITERAL can cross this boundary,
            // via the static adapter `lowerLambda` emits (so a literal arrives here
            // already typed `.function`). Reject the variable case loudly.
            .closure_to_fn_reject => {
                if (self.diagnostics) |d| {
                    const cs = self.builder.current_span;
                    d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "a closure value cannot be passed as a bare function-pointer `(...) -> ...` — its environment can't be carried across the bare ABI; pass the closure literal directly at the call site, or declare the parameter type as `Closure(...)`", .{});
                }
                return val;
            },
            // Tuple → Tuple element-wise coercion (e.g. a `(s64, s64)` literal
            // flowing into a `(s32, s32)` slot — the multi-value failable success
            // tuple). Same arity: extract each slot, coerce it, rebuild.
            .tuple_elementwise => {
                const si = self.module.types.get(src_ty);
                const di = self.module.types.get(dst_ty);
                var elems = std.ArrayList(Ref).empty;
                defer elems.deinit(self.alloc);
                for (si.tuple.fields, di.tuple.fields, 0..) |sf, df, i| {
                    const fv = self.builder.emit(.{ .tuple_get = .{ .base = val, .field_index = @intCast(i), .base_type = src_ty } }, sf);
                    elems.append(self.alloc, self.coerceToType(fv, sf, df)) catch unreachable;
                }
                return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty);
            },
            // Optional → Concrete unwrapping (flow-sensitive narrowing coercion)
            .optional_unwrap => {
                const child_ty = self.module.types.get(src_ty).optional.child;
                const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
                return self.coerceToType(unwrapped, child_ty, dst_ty);
            },
            // void → Optional: produce null (void is the type of null_literal)
            .void_to_optional => return self.builder.constNull(dst_ty),
            // Concrete → Optional wrapping (coerce to the inner type first)
            .optional_wrap => {
                const child_ty = self.module.types.get(dst_ty).optional.child;
                const coerced = self.coerceToType(val, src_ty, child_ty);
                return self.builder.emit(.{ .optional_wrap = .{ .operand = coerced } }, dst_ty);
            },
            // Concrete → Protocol (auto type erasure)
            .erase_protocol => {
                const proto_name = self.module.types.getString(self.module.types.get(dst_ty).@"struct".name);
                const ctn = self.resolveConcreteTypeName(src_ty).?;
                // If src is a pointer, use directly; otherwise alloca+store + heap-copy
                var concrete_ptr = val;
                var concrete_ty = src_ty;
                var heap_copy = false;
                if (!src_ty.isBuiltin()) {
                    const si = self.module.types.get(src_ty);
                    if (si == .pointer) {
                        concrete_ty = si.pointer.pointee;
                        heap_copy = false;
                    } else {
                        const slot = self.builder.alloca(src_ty);
                        self.builder.store(slot, val);
                        concrete_ptr = slot;
                        heap_copy = true;
                    }
                }
                return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
            },
            .int_to_float => return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
            .float_to_int => return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
            // Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot.
            // Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches
            // to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level
            // since LLVMBuildBitCast itself doesn't accept ptr↔int.
            .ptr_int_bitcast => return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
            .narrow => return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
            .widen => return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
        }
    }

    /// Get the alloca Ref for an expression, if it's a simple variable reference.
    /// Returns null for complex expressions (field access, function calls, etc.)
    fn getExprAlloca(self: *Lowering, node: *const Node) ?Ref {
        const name = switch (node.data) {
            .identifier => |id| id.name,
            .type_expr => |te| te.name,
            else => return null,
        };
        if (self.scope) |scope| {
            if (scope.lookup(name)) |binding| {
                if (binding.is_alloca) return binding.ref;
            }
        }
        return null;
    }

    /// Get the element type for a slice/array/string type. A non-collection
    /// type has no element type — return `.unresolved` (asking for it is a bug)
    /// rather than a plausible `.s64`.
    pub fn getElementType(self: *Lowering, ty: TypeId) TypeId {
        if (ty == .string) return .u8;
        if (ty.isBuiltin()) return .unresolved;
        const info = self.module.types.get(ty);
        return switch (info) {
            .slice => |s| s.element,
            .array => |a| a.element,
            .vector => |v| v.element,
            .many_pointer => |p| p.element,
            else => .unresolved,
        };
    }

    pub fn isFloat(ty: TypeId) bool {
        return ty == .f32 or ty == .f64;
    }

    fn isInt(ty: TypeId) bool {
        return switch (ty) {
            .s8, .s16, .s32, .s64, .u8, .u16, .u32, .u64, .usize, .isize => true,
            else => false,
        };
    }

    pub fn isIntEx(self: *Lowering, ty: TypeId) bool {
        if (isInt(ty)) return true;
        if (!ty.isBuiltin()) {
            const info = self.module.types.get(ty);
            return switch (info) {
                .signed, .unsigned => true,
                else => false,
            };
        }
        return false;
    }

    /// Operands valid for a scalar numeric op (`+ - * / %`): ints (incl.
    /// custom widths), floats, SIMD vectors, and pointers (pointer
    /// arithmetic). `.unresolved` returns true so a type we couldn't infer
    /// is never diagnosed — the check only fires on a concretely
    /// incompatible operand (e.g. `string`, a struct, an enum).
    fn isArithOperand(self: *Lowering, ty: TypeId) bool {
        if (ty == .unresolved) return true;
        if (isInt(ty) or isFloat(ty)) return true;
        if (ty.isBuiltin()) return false;
        return switch (self.module.types.get(ty)) {
            .signed, .unsigned, .vector, .pointer, .many_pointer => true,
            else => false,
        };
    }

    /// Operands valid for ordering comparisons (`< <= > >=`): numbers
    /// (incl. custom int widths), enums (ordinal), pointers (address
    /// order), bool, and SIMD vectors. NOT strings (no lexicographic `<`
    /// lowering exists) or any other aggregate. `.unresolved` passes so an
    /// un-inferable operand is never falsely diagnosed.
    fn isOrderingOperand(self: *Lowering, ty: TypeId) bool {
        if (ty == .unresolved) return true;
        if (isInt(ty) or isFloat(ty) or ty == .bool) return true;
        if (ty.isBuiltin()) return false;
        return switch (self.module.types.get(ty)) {
            .signed, .unsigned, .@"enum", .pointer, .many_pointer, .vector => true,
            else => false,
        };
    }

    /// Operands valid for bitwise/shift ops (`& | ^ << >>`): integers
    /// (incl. custom widths), enums (flags are int-backed), bool, and SIMD
    /// vectors. NOT floats, strings, pointers, or aggregates. `.unresolved`
    /// passes (see `isOrderingOperand`).
    fn isBitwiseOperand(self: *Lowering, ty: TypeId) bool {
        if (ty == .unresolved) return true;
        if (isInt(ty) or ty == .bool) return true;
        if (ty.isBuiltin()) return false;
        return switch (self.module.types.get(ty)) {
            .signed, .unsigned, .@"enum", .vector => true,
            else => false,
        };
    }

    /// The named error-set TypeId of `node`'s type, or null if not an
    /// error-set-typed expression.
    fn errorSetTypeOf(self: *Lowering, node: *const Node) ?TypeId {
        const t = self.inferExprType(node);
        if (t.isBuiltin()) return null;
        return if (self.module.types.get(t) == .error_set) t else null;
    }

    /// True when `node` is an `error.X` tag literal (`field_access` whose
    /// object is the `error` keyword, parsed as identifier "error").
    fn isErrorTagLiteralNode(node: *const Node) bool {
        if (node.data != .field_access) return false;
        const obj = node.data.field_access.object;
        return obj.data == .identifier and std.mem.eql(u8, obj.data.identifier.name, "error");
    }

    /// Lower `==` / `!=` when an error-set value or `error.X` tag is involved.
    /// Returns null when neither operand is error-related (general path runs).
    /// Both operands must be a tag (an `error.X` literal or an error-set value);
    /// otherwise it's a type error (e.g. comparing a tag to a raw integer).
    fn tryLowerErrorSetEquality(self: *Lowering, bop: *const ast.BinaryOp) ?Ref {
        const l_set = self.errorSetTypeOf(bop.lhs);
        const r_set = self.errorSetTypeOf(bop.rhs);
        const l_tag = isErrorTagLiteralNode(bop.lhs);
        const r_tag = isErrorTagLiteralNode(bop.rhs);
        if (l_set == null and r_set == null and !l_tag and !r_tag) return null;

        const l_ok = l_set != null or l_tag;
        const r_ok = r_set != null or r_tag;
        if (!l_ok or !r_ok) {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, bop.lhs.span, "an error-set value compares only with an `error.X` tag or another error-set value; coerce with `xx` to compare the raw id", .{});
            }
            return self.builder.constBool(false);
        }

        // Lower both sides with the set type as context so an `error.X` literal
        // resolves to it (and validates membership). Two bare tag literals with
        // no set context lower to global u32 ids (cross-set comparison is OK).
        const set_ty = l_set orelse r_set;
        const saved = self.target_type;
        if (set_ty) |st| self.target_type = st;
        const lv = self.lowerExpr(bop.lhs);
        const rv = self.lowerExpr(bop.rhs);
        self.target_type = saved;
        return if (bop.op == .eq)
            self.builder.cmpEq(lv, rv)
        else
            self.builder.emit(.{ .cmp_ne = .{ .lhs = lv, .rhs = rv } }, .bool);
    }

    /// The declared return type of the function currently being lowered (the
    /// inlined body's type wins while inlining a comptime call), or null when
    /// there is no enclosing function.
    fn effectiveReturnType(self: *Lowering) ?TypeId {
        if (self.inline_return_target) |iri| return iri.ret_ty;
        if (self.builder.func) |fid| return self.module.functions.items[@intFromEnum(fid)].ret;
        return null;
    }

    /// If `ret_ty` belongs to a failable function, the TypeId of its error
    /// channel; else null. `-> !Named` / `-> !` resolve the error set directly;
    /// `-> (T..., !)` carries it as the last tuple field (the locked ABI).
    pub fn errorChannelOf(self: *Lowering, ret_ty: TypeId) ?TypeId {
        if (ret_ty.isBuiltin()) return null;
        switch (self.module.types.get(ret_ty)) {
            .error_set => return ret_ty,
            .tuple => |t| {
                if (t.fields.len == 0) return null;
                const last = t.fields[t.fields.len - 1];
                if (last.isBuiltin()) return null;
                return if (self.module.types.get(last) == .error_set) last else null;
            },
            else => return null,
        }
    }

    /// True for the bare-`!` inferred placeholder error set (reserved name "!").
    fn isInferredErrorSet(self: *Lowering, set: TypeId) bool {
        if (set.isBuiltin()) return false;
        const info = self.module.types.get(set);
        if (info != .error_set) return false;
        return std.mem.eql(u8, self.module.types.getString(info.error_set.name), "!");
    }

    /// Diagnose every tag of `src` that is not also a member of `dst` (the
    /// enclosing function's named error set). Both must be `.error_set` types.
    fn checkErrorSetSubset(self: *Lowering, src: TypeId, dst: TypeId, span: ast.Span) void {
        if (src.isBuiltin()) return;
        const src_info = self.module.types.get(src);
        if (src_info != .error_set) return;
        self.diagTagsNotInSet(src_info.error_set.tags, dst, span);
    }

    /// Diagnose every tag id in `src_tags` that is not a member of the named
    /// error set `dst`. Shared by the named-set subset check and E1.4b's
    /// inferred-callee widening (where the callee's tags come from the SCC,
    /// not a `.error_set` TypeId).
    fn diagTagsNotInSet(self: *Lowering, src_tags: []const u32, dst: TypeId, span: ast.Span) void {
        if (dst.isBuiltin()) return;
        const dst_info = self.module.types.get(dst);
        if (dst_info != .error_set) return;
        for (src_tags) |tag| {
            var found = false;
            for (dst_info.error_set.tags) |d| {
                if (d == tag) {
                    found = true;
                    break;
                }
            }
            if (!found) {
                if (self.diagnostics) |diags| {
                    diags.addFmt(.err, span, "error tag 'error.{s}' is not in caller's error set '{s}'", .{ self.module.types.getTagName(tag), self.module.types.getString(dst_info.error_set.name) });
                }
            }
        }
    }

    /// `raise EXPR;` — terminate the enclosing failable function via the error
    /// channel. E1.3 lowers the **pure-failable** shape (`-> !` / `-> !Named`,
    /// whose return type IS the error set): emit `ret(EXPR)`. The value-carrying
    /// shape (`-> (T..., !)`) needs the value slots set to `undef` alongside the
    /// error slot — that tuple ABI lands in E2.1/E2.2, so we bail loudly here
    /// rather than ship a half-built return that silently corrupts value slots.
    fn lowerRaise(self: *Lowering, rs: *const ast.RaiseStmt, span: ast.Span) void {
        // (1) `raise` is legal only inside a failable function.
        const ret_ty = self.effectiveReturnType() orelse {
            self.diagRaiseNotFailable(span);
            return;
        };
        const err_set = self.errorChannelOf(ret_ty) orelse {
            self.diagRaiseNotFailable(span);
            return;
        };
        const inferred = self.isInferredErrorSet(err_set);

        // (2) Set check. Lowering EXPR with the function's error set as the
        //     target type makes a literal `raise error.X` validate `X ∈ set`
        //     inside lowerErrorTagLiteral (the inferred placeholder accepts any
        //     tag). The variable form `raise e` is subset-checked below.
        const saved_target = self.target_type;
        self.target_type = err_set;
        const tag_ref = self.lowerExpr(rs.tag);
        self.target_type = saved_target;

        if (!inferred and !isErrorTagLiteralNode(rs.tag)) {
            if (self.errorSetTypeOf(rs.tag)) |src_set| {
                self.checkErrorSetSubset(src_set, err_set, span);
            }
        }

        // (3) Push a trace frame: `raise` always escapes the function (ERR E3.2).
        //     Before cleanup, so the frame records the raise site itself.
        self.emitTracePush(self.placeholderTraceFrame());

        // (4) Emit the failure return. Pure-failable: the return type IS the
        //     error set, so return the tag value directly.
        if (ret_ty == err_set) {
            const tag_ty = self.builder.getRefType(tag_ref);
            const coerced = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref;
            self.emitErrorCleanup(self.func_defer_base, coerced);
            if (self.inline_return_target) |iri| {
                self.builder.store(iri.slot, coerced);
                self.builder.br(iri.done_bb, &.{});
            } else {
                self.builder.ret(coerced, err_set);
            }
        } else {
            // Value-carrying `-> (T..., !)`: the error path leaves the value
            // slots undefined and carries the tag in the error slot (ERR E2.1).
            const tag_ty = self.builder.getRefType(tag_ref);
            const coerced_tag = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref;
            self.emitErrorCleanup(self.func_defer_base, coerced_tag);
            const fields = self.module.types.get(ret_ty).tuple.fields;
            var slots = std.ArrayList(Ref).empty;
            defer slots.deinit(self.alloc);
            for (fields[0 .. fields.len - 1]) |vty| {
                slots.append(self.alloc, self.builder.constUndef(vty)) catch unreachable;
            }
            const tup = self.buildFailableTuple(ret_ty, slots.items, coerced_tag);
            self.emitTupleRet(ret_ty, tup);
        }
    }

    /// Return a value-carrying failable function's success tuple
    /// `{value(s)..., 0}` from `ref` (the user-returned value part). Forwarding
    /// a full failable tuple (`return other_failable()` / explicit `return
    /// (v, e)`) returns it as-is. Single-value `-> (T, !)` takes `ref` as the
    /// lone value; multi-value `-> (T1, ..., !)` takes `ref` as a value-tuple
    /// `(T1, ...)` and re-assembles its slots alongside the success error slot.
    fn lowerFailableSuccessReturn(self: *Lowering, ref: Ref, ret_ty: TypeId, span: ast.Span) void {
        const fields = self.module.types.get(ret_ty).tuple.fields;
        const err_ty = fields[fields.len - 1];
        const val_ty = self.builder.getRefType(ref);
        if (val_ty == ret_ty) {
            // The expression already IS the full failable tuple (forwarding).
            self.emitTupleRet(ret_ty, ref);
            return;
        }
        const n_vals = fields.len - 1;
        if (n_vals == 1) {
            const cv = self.coerceToType(ref, val_ty, fields[0]);
            const tup = self.buildFailableTuple(ret_ty, &.{cv}, self.builder.constInt(0, err_ty));
            self.emitTupleRet(ret_ty, tup);
            return;
        }
        // Multi-value: `ref` must be a value-tuple `(T1, ..., Tn)`. Extract
        // each value slot, coerce to the declared field type, and re-assemble
        // with the success error slot (0).
        if (val_ty.isBuiltin() or self.module.types.get(val_ty) != .tuple or self.module.types.get(val_ty).tuple.fields.len != n_vals) {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, span, "a multi-value failable function (`-> (T1, ..., !)`) must `return` a {d}-tuple of its value types", .{n_vals});
            }
            return;
        }
        const vfields = self.module.types.get(val_ty).tuple.fields;
        var vals = std.ArrayList(Ref).empty;
        defer vals.deinit(self.alloc);
        for (0..n_vals) |i| {
            const fv = self.builder.emit(.{ .tuple_get = .{ .base = ref, .field_index = @intCast(i), .base_type = val_ty } }, vfields[i]);
            vals.append(self.alloc, self.coerceToType(fv, vfields[i], fields[i])) catch unreachable;
        }
        const tup = self.buildFailableTuple(ret_ty, vals.items, self.builder.constInt(0, err_ty));
        self.emitTupleRet(ret_ty, tup);
    }

    /// Build a failable return tuple `{value_refs..., tag}` typed `ret_ty`.
    fn buildFailableTuple(self: *Lowering, ret_ty: TypeId, value_refs: []const Ref, tag: Ref) Ref {
        var fields = std.ArrayList(Ref).empty;
        defer fields.deinit(self.alloc);
        fields.appendSlice(self.alloc, value_refs) catch unreachable;
        fields.append(self.alloc, tag) catch unreachable;
        return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, fields.items) catch unreachable } }, ret_ty);
    }

    /// The success (value-part) type of a value-carrying failable tuple
    /// `op_ty` (`-> (T..., !)`): the lone value type for a single-value
    /// failable, or a synthesized value-tuple `(T1, ..., Tn)` (error slot
    /// dropped) for a multi-value one. Callers must pass a value-carrying
    /// tuple — a pure `-> !`'s success type is `void`, handled separately.
    pub fn failableSuccessType(self: *Lowering, op_ty: TypeId) TypeId {
        const fields = self.module.types.get(op_ty).tuple.fields;
        const n_vals = fields.len - 1;
        if (n_vals == 1) return fields[0];
        return self.module.types.intern(.{ .tuple = .{
            .fields = self.alloc.dupe(TypeId, fields[0..n_vals]) catch unreachable,
            .names = null,
        } });
    }

    /// Extract the success value from an evaluated value-carrying failable
    /// tuple `result` (type `op_ty`): the lone value slot for single-value,
    /// or an assembled value-tuple (typed `succ_ty`) for multi-value.
    fn extractSuccessValue(self: *Lowering, result: Ref, op_ty: TypeId, succ_ty: TypeId) Ref {
        const fields = self.module.types.get(op_ty).tuple.fields;
        const n_vals = fields.len - 1;
        if (n_vals == 1) {
            return self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = 0, .base_type = op_ty } }, fields[0]);
        }
        var vals = std.ArrayList(Ref).empty;
        defer vals.deinit(self.alloc);
        for (0..n_vals) |i| {
            vals.append(self.alloc, self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = @intCast(i), .base_type = op_ty } }, fields[i])) catch unreachable;
        }
        return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, vals.items) catch unreachable } }, succ_ty);
    }

    /// Extract the error slot (always the last field) of an evaluated
    /// value-carrying failable tuple `result`, typed as `err_set`.
    fn extractErrorSlot(self: *Lowering, result: Ref, op_ty: TypeId, err_set: TypeId) Ref {
        const fields = self.module.types.get(op_ty).tuple.fields;
        return self.builder.emit(.{ .tuple_get = .{ .base = result, .field_index = @intCast(fields.len - 1), .base_type = op_ty } }, err_set);
    }

    /// Emit a return of an already-assembled tuple, honoring inline-comptime
    /// return targets (store + branch) vs a real function return.
    fn emitTupleRet(self: *Lowering, ret_ty: TypeId, tup: Ref) void {
        if (self.inline_return_target) |iri| {
            self.builder.store(iri.slot, tup);
            self.builder.br(iri.done_bb, &.{});
        } else {
            self.builder.ret(tup, ret_ty);
        }
    }

    fn diagRaiseNotFailable(self: *Lowering, span: ast.Span) void {
        if (self.diagnostics) |diags| {
            if (self.in_lambda_body) {
                diags.addFmt(.err, span, "lambda body raises; declare its return type explicitly with `-> (T, !)` or `-> (T, !Named)`", .{});
            } else {
                diags.addFmt(.err, span, "`raise` is only valid inside a failable function (a return type with `!` or `!Named`)", .{});
            }
        }
    }

    /// True if `node`'s value is failable — a `try` (the result is its
    /// operand's success value, but the expression itself routes an error) or
    /// any expression whose type carries an error channel (a bare failable
    /// call). Used to detect failable `or` chains (deferred to E1.4b).
    fn exprIsFailable(self: *Lowering, node: *const Node) bool {
        if (node.data == .try_expr) return true;
        return self.errorChannelOf(self.inferExprType(node)) != null;
    }

    /// `try X` — a fallible attempt (ERR step E1.4a: the STANDALONE form, whose
    /// failure target is function-propagation). Evaluates X; on failure, runs
    /// the function's defers and returns the error to the caller; on success,
    /// continues with X's value. E1.4a lowers the pure-failable shape (callee
    /// `-> !` / `-> !Named`, caller likewise pure-failable). Value-carrying
    /// callees, propagation from a value-carrying caller, and `try` inside an
    /// `or` chain need the error-channel tuple ABI / fallback routing — those
    /// land in E1.4b/E2, so we bail loudly here.
    /// Synthesize a `Source_Location` value for a `#caller_location` marker
    /// (ERR E4.1b). The node's `span`/`source_file` are the CALL site (rewritten
    /// by `expandCallDefaults`); resolve them to file / line:col against the
    /// source text and stamp the enclosing (caller) function name.
    fn lowerCallerLocation(self: *Lowering, node: *const Node) Ref {
        const sl_tid = self.module.types.findByName(self.module.types.internString("Source_Location")) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, node.span, "`#caller_location` needs `Source_Location` (from std.sx) in scope", .{});
            return self.builder.constInt(0, .void);
        };
        const file = node.source_file orelse self.current_source_file orelse (self.main_file orelse "");
        const src = self.sourceForFile(file);
        const loc = errors.SourceLoc.compute(src, node.span.start);
        const func_name = self.currentFunctionName();
        var fields = [_]Ref{
            self.builder.constString(self.module.types.internString(file)),
            self.builder.constInt(@intCast(loc.line), .s32),
            self.builder.constInt(@intCast(loc.col), .s32),
            self.builder.constString(self.module.types.internString(func_name)),
        };
        return self.builder.emit(.{ .struct_init = .{ .fields = self.alloc.dupe(Ref, &fields) catch unreachable } }, sl_tid);
    }

    /// The source text for `file`, via the diagnostics' file→source map (which
    /// includes the main file). Empty if unavailable — line:col then degrade to
    /// 1:1 rather than crash.
    fn sourceForFile(self: *Lowering, file: []const u8) []const u8 {
        const diags = self.diagnostics orelse return "";
        if (diags.import_sources) |is| {
            if (is.get(file)) |s| return s;
        }
        return diags.source;
    }

    /// Name of the function currently being lowered (the caller, at a
    /// `#caller_location` site), or "" outside any function.
    fn currentFunctionName(self: *Lowering) []const u8 {
        const fid = self.builder.func orelse return "";
        return self.module.types.getString(self.module.functions.items[@intFromEnum(fid)].name);
    }

    fn lowerTry(self: *Lowering, operand: *const Node, span: ast.Span) Ref {
        // (1) `try` is legal only inside a failable function.
        const caller_ret = self.effectiveReturnType() orelse {
            self.diagTryNotFailable(span);
            return self.builder.constInt(0, .void);
        };
        const caller_set = self.errorChannelOf(caller_ret) orelse {
            self.diagTryNotFailable(span);
            return self.builder.constInt(0, .void);
        };

        // (2) The operand must be failable. This is the sole failable-operand
        //     check (the parser imposes none — see E0.2).
        const op_ty = self.inferExprType(operand);
        const callee_set = self.errorChannelOf(op_ty) orelse {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, span, "`try` requires a failable expression; operand has type '{s}'", .{self.formatTypeName(op_ty)});
            }
            return self.builder.constInt(0, .void);
        };

        // A value-carrying callee (`-> (T..., !)`) returns a tuple
        // `{v..., err}`; a pure-failable callee (`-> !`) returns the bare
        // error tag.
        const callee_value_carrying = op_ty != callee_set;

        // (3) Widening: the callee's escape set must be ⊆ the caller's named
        //     set. For an inferred caller (`!`) the absorption happens in the
        //     whole-program SCC (E1.4b) — no check here.
        self.checkEscapeWidening(operand, callee_set, caller_set, span);

        // (4) Lower: evaluate the operand, then branch on its error tag (which
        //     is the bare result for a pure callee, or the last tuple slot for
        //     a value-carrying one).
        const result = self.lowerExpr(operand);
        const err_val = if (callee_value_carrying)
            self.extractErrorSlot(result, op_ty, callee_set)
        else
            result;
        const err_ty = self.builder.getRefType(err_val);
        const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);

        const prop_bb = self.freshBlock("try.prop");
        const ok_bb = self.freshBlock("try.ok");
        self.builder.condBr(is_err, prop_bb, &.{}, ok_bb, &.{});

        // Propagation: push a trace frame (this `try` failure escapes to the
        // caller — ERR E3.2), run the function's cleanups (defers + onfails,
        // since this is an error exit), then return the caller's failure
        // carrying this tag (pure caller → `ret(tag)`; value-carrying →
        // `ret {undef…, tag}`).
        self.builder.switchToBlock(prop_bb);
        self.emitTracePush(self.placeholderTraceFrame());
        self.emitErrorCleanup(self.func_defer_base, err_val);
        self.emitErrorReturn(caller_ret, caller_set, err_val);

        // Success: a value-carrying callee yields its value part (the lone
        // value, or a value-tuple); a pure-failable callee has no value (void).
        self.builder.switchToBlock(ok_bb);
        if (callee_value_carrying) {
            const succ_ty = self.failableSuccessType(op_ty);
            return self.extractSuccessValue(result, op_ty, succ_ty);
        }
        return self.builder.constInt(0, .void);
    }

    /// Return the enclosing function's failure carrying error tag `err`. A
    /// pure-failable caller (`-> !`) returns the tag directly; a value-carrying
    /// caller (`-> (T..., !)`) returns `{undef value slots..., tag}`. Honors
    /// inline-comptime return targets. The caller emits defers first.
    fn emitErrorReturn(self: *Lowering, caller_ret: TypeId, caller_set: TypeId, err: Ref) void {
        const ety = self.builder.getRefType(err);
        const coerced = if (ety != caller_set) self.coerceToType(err, ety, caller_set) else err;
        if (caller_ret == caller_set) {
            if (self.inline_return_target) |iri| {
                self.builder.store(iri.slot, coerced);
                self.builder.br(iri.done_bb, &.{});
            } else {
                self.builder.ret(coerced, caller_set);
            }
        } else {
            const fields = self.module.types.get(caller_ret).tuple.fields;
            var undefs = std.ArrayList(Ref).empty;
            defer undefs.deinit(self.alloc);
            for (fields[0 .. fields.len - 1]) |vty| {
                undefs.append(self.alloc, self.builder.constUndef(vty)) catch unreachable;
            }
            const tup = self.buildFailableTuple(caller_ret, undefs.items, coerced);
            self.emitTupleRet(caller_ret, tup);
        }
    }

    fn diagTryNotFailable(self: *Lowering, span: ast.Span) void {
        if (self.diagnostics) |diags| {
            diags.addFmt(.err, span, "`try` is only valid inside a failable function (a return type with `!` or `!Named`)", .{});
        }
    }

    /// `expr catch [e] BODY` — inline failure handler (ERR step E1.5,
    /// pure-failable slice). Evaluates `expr`; on failure, binds the tag to
    /// `e` (if present) and runs BODY; on success, the value is `void` (a
    /// pure-failable LHS has no success value). BODY either diverges (via
    /// `noreturn` — E1.4c) or falls through. `catch` consumes the error
    /// locally, so — unlike `try` / `raise` — it needs no failable *enclosing*
    /// function. Value-carrying LHS (binding the success value / a
    /// value-producing body unifying with the success tuple) needs the
    /// error-channel tuple ABI and lands in E2 — bail loudly here.
    fn lowerCatch(self: *Lowering, ce: *const ast.CatchExpr, span: ast.Span) Ref {
        // A failable `or` chain operand (`(try a or try b) catch e …`) routes
        // its total failure to the catch handler — not the function — via the
        // chain-fail target (ERR E2.4). A chain's value type is non-failable
        // `T`, so it wouldn't pass the `errorChannelOf` check below.
        if (ce.operand.data == .binary_op and ce.operand.data.binary_op.op == .or_op and
            self.orIsFailableChain(&ce.operand.data.binary_op))
        {
            return self.lowerCatchOverChain(ce, span);
        }

        const op_ty = self.inferExprType(ce.operand);
        const err_set = self.errorChannelOf(op_ty) orelse {
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, span, "`catch` requires a failable expression; operand has type '{s}'", .{self.formatTypeName(op_ty)});
            }
            return self.builder.constInt(0, .void);
        };
        // Pure-failable LHS (`-> !`): no success value. Run the body on the
        // error path; both paths fall through to a value-less merge.
        if (op_ty == err_set) {
            const err_val = self.lowerExpr(ce.operand);
            const err_ty = self.builder.getRefType(err_val);
            const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);
            const handle_bb = self.freshBlock("catch.handle");
            const merge_bb = self.freshBlock("catch.merge");
            self.builder.condBr(is_err, handle_bb, &.{}, merge_bb, &.{});
            self.builder.switchToBlock(handle_bb);
            _ = self.runCatchBody(ce, err_val, err_set, null);
            // The handler can inspect the trace (`trace.print_current()`); the
            // absorption clear fires once it completes WITHOUT re-raising (a
            // fall-through). A diverging body (`raise` / `return`) keeps /
            // discards the buffer on its own path (ERR E3.2; reconciles
            // PLAN-ERR §clear-points "cleared before body" with §catch-over-or
            // "frames still in the buffer when the body runs").
            if (!self.currentBlockHasTerminator()) {
                self.emitTraceClear();
                self.builder.br(merge_bb, &.{});
            }
            self.builder.switchToBlock(merge_bb);
            return self.builder.constInt(0, .void);
        }

        // Value-carrying LHS (`-> (T..., !)`): on success the catch yields the
        // value part (the lone value, or a value-tuple); on error it yields
        // the handler body's value. The paths merge through a block-parameter
        // (phi).
        const succ_ty = self.failableSuccessType(op_ty);
        const result = self.lowerExpr(ce.operand);
        const err_val = self.extractErrorSlot(result, op_ty, err_set);
        const succ_val = self.extractSuccessValue(result, op_ty, succ_ty);
        const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_set) } }, .bool);

        const handle_bb = self.freshBlock("catch.handle");
        const merge_bb = self.freshBlockWithParams("catch.merge", &.{succ_ty});
        // Success → merge with the value slot; error → run the handler.
        self.builder.condBr(is_err, handle_bb, &.{}, merge_bb, &.{succ_val});

        self.builder.switchToBlock(handle_bb);
        const body_val = self.runCatchBody(ce, err_val, err_set, succ_ty);
        if (!self.currentBlockHasTerminator()) {
            self.finishCatchHandler(body_val, succ_ty, merge_bb, span);
        }

        self.builder.switchToBlock(merge_bb);
        return self.builder.blockParam(merge_bb, 0, succ_ty);
    }

    /// `(failable or-chain) catch [e] BODY` (ERR E2.4). The chain's operands
    /// route per the chain rules; its TOTAL failure (the final operand failing)
    /// is redirected to the catch handler via `chain_fail_target` rather than
    /// propagating to the function. `e` binds the final error tag; the handler's
    /// value (or divergence) joins the chain's success value at the merge.
    fn lowerCatchOverChain(self: *Lowering, ce: *const ast.CatchExpr, span: ast.Span) Ref {
        const chain = &ce.operand.data.binary_op;

        // The error tag reaching the handler is the final operand's (left-assoc
        // chain → the top-level rhs). A value-terminator last operand means the
        // chain can't fail — nothing for `catch` to absorb.
        const last = unwrapTryNode(chain.rhs);
        const last_ty = self.inferExprType(last);
        const err_set = self.errorChannelOf(last_ty) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, span, "`catch` here is redundant — the `or` chain already absorbs every failure via its value terminator", .{});
            return self.builder.constInt(0, .void);
        };

        const succ_ty = self.orChainSuccessType(chain);
        const has_value = succ_ty != .void;

        const handle_bb = self.freshBlockWithParams("catch.handle", &.{err_set});
        const merge_bb = if (has_value)
            self.freshBlockWithParams("catch.merge", &.{succ_ty})
        else
            self.freshBlock("catch.merge");

        // Lower the chain with its total failure routed to the handler.
        const saved = self.chain_fail_target;
        self.chain_fail_target = .{ .bb = handle_bb, .set = err_set };
        const chain_val = self.lowerExpr(ce.operand);
        self.chain_fail_target = saved;
        // Chain success → merge with its value (the buffer was already cleared
        // at the succeeding operand inside the chain).
        if (has_value) {
            const cv = self.coerceToType(chain_val, self.builder.getRefType(chain_val), succ_ty);
            self.builder.br(merge_bb, &.{cv});
        } else {
            self.builder.br(merge_bb, &.{});
        }

        // Handler: bind the final tag, run the body. The buffer still holds the
        // chain's frames (handler may inspect them); absorb on non-diverging exit.
        self.builder.switchToBlock(handle_bb);
        const tag = self.builder.blockParam(handle_bb, 0, err_set);
        const body_val = self.runCatchBody(ce, tag, err_set, if (has_value) succ_ty else null);
        if (!self.currentBlockHasTerminator()) {
            self.finishCatchHandler(body_val, succ_ty, merge_bb, span);
        }

        self.builder.switchToBlock(merge_bb);
        return if (has_value) self.builder.blockParam(merge_bb, 0, succ_ty) else self.builder.constInt(0, .void);
    }

    /// Close a non-terminated `catch` handler block. `succ_ty` is the catch's
    /// result type (`.void` for a pure-failable / void-chain catch — the merge
    /// block then has no parameter). A `body_val` typed `noreturn` (e.g. a
    /// `process.exit` / other noreturn call, which is NOT an IR terminator)
    /// diverges: close with `unreachable` and skip the merge edge so its
    /// "value" never reaches a phi. Otherwise clear the absorbed trace and
    /// branch to the merge (coercing the body value, or diagnosing a missing /
    /// void value for a value-carrying catch).
    fn finishCatchHandler(self: *Lowering, body_val: ?Ref, succ_ty: TypeId, merge_bb: BlockId, span: ast.Span) void {
        if (body_val) |v| {
            if (self.builder.getRefType(v) == .noreturn) {
                self.builder.emitUnreachable();
                return;
            }
        }
        self.emitTraceClear();
        if (succ_ty == .void) {
            self.builder.br(merge_bb, &.{});
            return;
        }
        const bv: Ref = blk: {
            if (body_val) |v| {
                const vty = self.builder.getRefType(v);
                if (vty != .void) break :blk self.coerceToType(v, vty, succ_ty);
            }
            if (self.diagnostics) |diags| {
                diags.addFmt(.err, span, "`catch` body must produce a value of type '{s}' (or diverge with `return` / `raise`)", .{self.formatTypeName(succ_ty)});
            }
            break :blk self.builder.constUndef(succ_ty);
        };
        self.builder.br(merge_bb, &.{bv});
    }

    /// Lower a `catch` body in a child scope that binds the error tag to the
    /// catch binding (if any). When `want_ty` is non-null (value-carrying
    /// catch), returns the body's value (or null if the body diverged); when
    /// null (pure-failable catch), runs the body for effect and returns null.
    fn runCatchBody(self: *Lowering, ce: *const ast.CatchExpr, err_val: Ref, err_set: TypeId, want_ty: ?TypeId) ?Ref {
        var handle_scope = Scope.init(self.alloc, self.scope);
        const saved_scope = self.scope;
        self.scope = &handle_scope;
        defer {
            self.scope = saved_scope;
            handle_scope.deinit();
        }
        if (ce.binding) |name| {
            handle_scope.put(name, .{ .ref = err_val, .ty = err_set, .is_alloca = false });
        }
        if (want_ty == null) {
            if (ce.body.data == .block) self.lowerBlock(ce.body) else _ = self.lowerExpr(ce.body);
            return null;
        }
        const saved_fbv = self.force_block_value;
        self.force_block_value = true;
        defer self.force_block_value = saved_fbv;
        return if (ce.body.data == .block) self.lowerBlockValue(ce.body) else self.lowerExpr(ce.body);
    }

    /// `lhs or rhs` with a failable LHS (ERR step E2.4a — the value-terminator
    /// form). On LHS success the result is its value part (the lone value, or a
    /// value-tuple); on failure the LHS error is discarded and the result is
    /// `rhs` (a plain value of the success type), so the whole expression is
    /// non-failable. The CHAIN form (`... or try ...` / a failable RHS) needs
    /// the fallback-target routing deferred from E1.4 — bail.
    /// Widening at an escape (function-propagation) site: the escaping set must
    /// be ⊆ the caller's named set. An inferred caller (`!`) absorbs everything
    /// via the whole-program SCC (E1.4b) — no check. A bare-`!` callee carries
    /// no tags on its placeholder TypeId, so check its SCC-converged set.
    /// Shared by `try` propagation and a failable `or` chain's final operand.
    fn checkEscapeWidening(self: *Lowering, callee_node: *const Node, callee_set: TypeId, caller_set: TypeId, span: ast.Span) void {
        if (self.isInferredErrorSet(caller_set)) return;
        if (!self.isInferredErrorSet(callee_set)) {
            self.checkErrorSetSubset(callee_set, caller_set, span);
            return;
        }
        // Bare-`!` callee: either a named top-level function (its converged set
        // is name-keyed) or a closure/fn-type SLOT (its set is shape-keyed,
        // shared program-wide by value-signature).
        if (callTargetName(callee_node)) |nm| {
            if (self.inferred_error_sets.get(nm)) |tags| {
                self.diagTagsNotInSet(tags, caller_set, span);
                return;
            }
        }
        if (self.shapeKeyOfCallee(callee_node)) |key| {
            if (self.shape_inferred_sets.get(key)) |tags| {
                self.diagTagsNotInSet(tags, caller_set, span);
            }
            // Empty union (no closure of this shape ever raises) → silently
            // allowed: the slot's `!` resolves to ∅ (ERR E5.1 sub-feature 6).
        }
    }

    /// Structural test: is this `or` a *failable* construct (value-terminator or
    /// chain), rather than a boolean / optional-unwrap `or`? True when either
    /// operand is failable-like — a `try`, an error-channel-typed expression, or
    /// itself a nested failable `or` chain. Kept separate from `inferExprType`:
    /// a `try`-chain's *value* type is its success type `T` (non-failable), so
    /// the chain-ness is structural, not type-derived.
    pub fn orIsFailableChain(self: *Lowering, bop: *const ast.BinaryOp) bool {
        return self.operandIsFailableLike(bop.lhs) or self.operandIsFailableLike(bop.rhs);
    }

    fn operandIsFailableLike(self: *Lowering, node: *const Node) bool {
        if (node.data == .try_expr) return true;
        if (node.data == .binary_op and node.data.binary_op.op == .or_op) {
            return self.orIsFailableChain(&node.data.binary_op);
        }
        return self.errorChannelOf(self.inferExprType(node)) != null;
    }

    /// The success (value) type of a failable `or` chain: descend to the
    /// leftmost operand, unwrap any `try`, and take its failable success type
    /// (`void` for a pure-`-> !` chain). All operands share this type.
    pub fn orChainSuccessType(self: *Lowering, bop: *const ast.BinaryOp) TypeId {
        var lhs = bop.lhs;
        while (lhs.data == .binary_op and lhs.data.binary_op.op == .or_op and
            self.orIsFailableChain(&lhs.data.binary_op))
        {
            lhs = lhs.data.binary_op.lhs;
        }
        const ft = self.inferExprType(unwrapTryNode(lhs));
        const fset = self.errorChannelOf(ft) orelse return .unresolved;
        return if (ft == fset) .void else self.failableSuccessType(ft);
    }

    /// `try X` → `X` (the underlying failable); any other node unchanged. In an
    /// `or` chain the `try` marker's routing IS the chain, so the chain lowers
    /// the underlying failable directly rather than re-entering `lowerTry`.
    fn unwrapTryNode(node: *const Node) *const Node {
        return if (node.data == .try_expr) node.data.try_expr.operand else node;
    }

    /// Flatten a left-associative failable `or` chain into its operands,
    /// left-to-right. `a or b or c` parses as `(a or b) or c`; this collects
    /// `[a, b, c]`. Walks the left spine only while it stays a failable
    /// `or` chain (a parenthesized non-chain `or` on the left stops the walk).
    fn flattenOrChain(self: *Lowering, bop: *const ast.BinaryOp, list: *std.ArrayList(*const Node)) void {
        if (bop.lhs.data == .binary_op and bop.lhs.data.binary_op.op == .or_op and
            self.orIsFailableChain(&bop.lhs.data.binary_op))
        {
            self.flattenOrChain(&bop.lhs.data.binary_op, list);
        } else {
            list.append(self.alloc, bop.lhs) catch unreachable;
        }
        list.append(self.alloc, bop.rhs) catch unreachable;
    }

    /// Lower a failable `or` (ERR E2.4): a value-terminator (`lhs or value`) or
    /// a chain (`try a or try b or …`, possibly with a trailing value
    /// terminator). Left-to-right, short-circuit: each failable operand's
    /// failure routes to the next operand; the final operand either absorbs
    /// (value terminator) or propagates to the enclosing function. Each failed
    /// attempt pushes a trace frame; an absorbing resolution (any operand
    /// succeeding, or the value terminator) clears the buffer; total failure
    /// preserves the frames for the caller.
    fn lowerFailableOr(self: *Lowering, bop: *const ast.BinaryOp) Ref {
        const span = bop.lhs.span;

        var operands = std.ArrayList(*const Node).empty;
        defer operands.deinit(self.alloc);
        self.flattenOrChain(bop, &operands);
        const last_idx = operands.items.len - 1;
        const last_is_value = !self.operandIsFailableLike(operands.items[last_idx]);

        // The chain's total-failure routing. An absorbing consumer (`catch`)
        // sets this so the final operand's failure reaches the handler; cleared
        // while lowering operands so a nested operand doesn't inherit it.
        const fail_target = self.chain_fail_target;
        self.chain_fail_target = null;
        defer self.chain_fail_target = fail_target;

        // Success type from the first operand (a failable; unwrap any `try`).
        const first_ty = self.inferExprType(unwrapTryNode(operands.items[0]));
        const first_set = self.errorChannelOf(first_ty) orelse {
            if (self.diagnostics) |d| d.addFmt(.err, span, "the left operand of a failable `or` must be failable; got '{s}'", .{self.formatTypeName(first_ty)});
            return self.builder.constInt(0, .void);
        };
        const has_value = first_ty != first_set;
        const succ_ty = if (has_value) self.failableSuccessType(first_ty) else TypeId.void;

        // Pure-failable LHS (`-> !`) with a value terminator: nothing to fall
        // back to.
        if (!has_value and last_is_value) {
            if (self.diagnostics) |d| d.addFmt(.err, span, "`or value` requires a value-carrying failable (`-> (T, !)`) — a `-> !` has no success value to fall back to; use `catch` to absorb the error", .{});
            return self.builder.constInt(0, .void);
        }

        // Caller failability — only needed when the chain can propagate to the
        // function (final operand is failable AND no absorbing consumer target).
        var caller_ret: TypeId = .void;
        var caller_set: TypeId = .void;
        if (!last_is_value and fail_target == null) {
            const cret = self.effectiveReturnType();
            const cset = if (cret) |r| self.errorChannelOf(r) else null;
            if (cset == null) {
                if (self.diagnostics) |d| d.addFmt(.err, span, "a failable `or` chain propagates on total failure, so it is only valid inside a failable function — add a value terminator (`… or value`) or wrap with `catch`", .{});
                return self.builder.constInt(0, .void);
            }
            caller_ret = cret.?;
            caller_set = cset.?;
        }

        const merge_bb = if (has_value)
            self.freshBlockWithParams("orc.merge", &.{succ_ty})
        else
            self.freshBlock("orc.merge");

        for (operands.items, 0..) |operand, i| {
            const is_last = i == last_idx;

            if (is_last and last_is_value) {
                // Value terminator: absorbs every prior failure.
                self.emitTraceClear();
                const saved = self.target_type;
                self.target_type = succ_ty;
                const v = self.lowerExpr(operand);
                self.target_type = saved;
                const vc = self.coerceToType(v, self.builder.getRefType(v), succ_ty);
                self.builder.br(merge_bb, &.{vc});
                break;
            }

            // Failable operand (`try X` marker or a bare failable). Lower the
            // underlying failable; the `try` marker's routing IS the chain.
            const underlying = unwrapTryNode(operand);
            const op_ty = self.inferExprType(underlying);
            const op_set = self.errorChannelOf(op_ty) orelse {
                if (self.diagnostics) |d| d.addFmt(.err, operand.span, "operand of a failable `or` chain must be failable; got '{s}'", .{self.formatTypeName(op_ty)});
                return self.builder.constInt(0, .void);
            };
            const op_value_carrying = op_ty != op_set;

            // Widening applies only when the final failure escapes to the
            // function (no absorbing consumer); a `catch` target absorbs it.
            if (is_last and fail_target == null) self.checkEscapeWidening(underlying, op_set, caller_set, operand.span);

            const result = self.lowerExpr(underlying);
            const err_val = if (op_value_carrying) self.extractErrorSlot(result, op_ty, op_set) else result;
            const err_ty = self.builder.getRefType(err_val);
            const is_err = self.builder.emit(.{ .cmp_ne = .{ .lhs = err_val, .rhs = self.builder.constInt(0, err_ty) } }, .bool);

            const ok_bb = self.freshBlock("orc.ok");
            const fail_bb = self.freshBlock(if (is_last) "orc.prop" else "orc.next");
            self.builder.condBr(is_err, fail_bb, &.{}, ok_bb, &.{});

            // Success: the chain resolved here — clear the buffer, merge value.
            self.builder.switchToBlock(ok_bb);
            self.emitTraceClear();
            if (has_value) {
                const sv = self.extractSuccessValue(result, op_ty, succ_ty);
                const svc = self.coerceToType(sv, self.builder.getRefType(sv), succ_ty);
                self.builder.br(merge_bb, &.{svc});
            } else {
                self.builder.br(merge_bb, &.{});
            }

            // Failure: push a trace frame, then either route to the next
            // operand (same block — no function exit, so `onfail` does not
            // fire) or, for the final operand, resolve the total failure: to an
            // absorbing consumer (`catch`) if one set a target, else propagate
            // to the caller.
            self.builder.switchToBlock(fail_bb);
            self.emitTracePush(self.placeholderTraceFrame());
            if (is_last) {
                if (fail_target) |t| {
                    const ec = self.coerceToType(err_val, self.builder.getRefType(err_val), t.set);
                    self.builder.br(t.bb, &.{ec});
                } else {
                    self.emitErrorCleanup(self.func_defer_base, err_val);
                    self.emitErrorReturn(caller_ret, caller_set, err_val);
                }
            }
            // else: fall through — the next operand is lowered in fail_bb.
        }

        self.builder.switchToBlock(merge_bb);
        return if (has_value) self.builder.blockParam(merge_bb, 0, succ_ty) else self.builder.constInt(0, .void);
    }

    // ── ERR E1.4b: whole-program inferred-error-set convergence ──────────

    /// The bare callee name of a call expression (`g(...)` → "g"), or null if
    /// the node isn't a direct call to a named function. E1.4b resolves only
    /// the bare identifier (top-level functions); UFCS / mangled-local callees
    /// aren't tracked by the SCC.
    fn callTargetName(node: *const Node) ?[]const u8 {
        if (node.data != .call) return null;
        const callee = node.data.call.callee;
        return if (callee.data == .identifier) callee.data.identifier.name else null;
    }

    /// True when `rt` is a pure bare-`!` failable return (`-> !`, the inferred
    /// set) — NOT `!Named` and NOT a value-carrying `-> (T..., !)` tuple.
    fn astIsPureBareInferred(rt: ?*const Node) bool {
        const n = rt orelse return false;
        return n.data == .error_type_expr and n.data.error_type_expr.name == null;
    }

    /// The named-set name of a pure `-> !Named` return (`"Named"`), or null for
    /// bare-`!`, value-carrying, or non-failable returns.
    fn astPureNamedSet(rt: ?*const Node) ?[]const u8 {
        const n = rt orelse return null;
        if (n.data != .error_type_expr) return null;
        return n.data.error_type_expr.name;
    }

    /// The declared tags of a named error set, by name; null if not a
    /// registered error set.
    fn namedSetTags(self: *Lowering, name: []const u8) ?[]const u32 {
        const sid = self.module.types.internString(name);
        const tid = self.module.types.findByName(sid) orelse return null;
        if (tid.isBuiltin()) return null;
        const info = self.module.types.get(tid);
        return if (info == .error_set) info.error_set.tags else null;
    }

    /// Walk a function body's AST collecting its DIRECT escape sites: literal
    /// `raise error.X` tags (→ `tags`) and standalone `try g()` callee names
    /// (→ `edges`). Recurses through statement/expression containers but stops
    /// at nested function boundaries (a nested fn/lambda's raises are its own).
    /// Variable `raise e` and value-carrying `try` are out of E1.4b scope.
    fn collectErrorSites(self: *Lowering, node: *const Node, tags: *std.ArrayList(u32), edges: *std.ArrayList([]const u8)) void {
        switch (node.data) {
            .raise_stmt => |rs| {
                if (isErrorTagLiteralNode(rs.tag)) {
                    tags.append(self.alloc, self.module.types.internTag(rs.tag.data.field_access.field)) catch {};
                }
                self.collectErrorSites(rs.tag, tags, edges);
            },
            .try_expr => |te| {
                if (callTargetName(te.operand)) |nm| edges.append(self.alloc, nm) catch {};
                self.collectErrorSites(te.operand, tags, edges);
            },
            .block => |b| for (b.stmts) |s| self.collectErrorSites(s, tags, edges),
            .if_expr => |ie| {
                self.collectErrorSites(ie.condition, tags, edges);
                self.collectErrorSites(ie.then_branch, tags, edges);
                if (ie.else_branch) |eb| self.collectErrorSites(eb, tags, edges);
            },
            .while_expr => |w| {
                self.collectErrorSites(w.condition, tags, edges);
                self.collectErrorSites(w.body, tags, edges);
            },
            .for_expr => |f| {
                self.collectErrorSites(f.iterable, tags, edges);
                if (f.range_end) |re| self.collectErrorSites(re, tags, edges);
                self.collectErrorSites(f.body, tags, edges);
            },
            .return_stmt => |r| if (r.value) |v| self.collectErrorSites(v, tags, edges),
            .var_decl => |v| if (v.value) |val| self.collectErrorSites(val, tags, edges),
            .const_decl => |c| self.collectErrorSites(c.value, tags, edges),
            .destructure_decl => |d| self.collectErrorSites(d.value, tags, edges),
            .assignment => |a| {
                self.collectErrorSites(a.target, tags, edges);
                self.collectErrorSites(a.value, tags, edges);
            },
            .multi_assign => |m| {
                for (m.targets) |t| self.collectErrorSites(t, tags, edges);
                for (m.values) |v| self.collectErrorSites(v, tags, edges);
            },
            .call => |c| {
                self.collectErrorSites(c.callee, tags, edges);
                for (c.args) |a| self.collectErrorSites(a, tags, edges);
            },
            .binary_op => |b| {
                self.collectErrorSites(b.lhs, tags, edges);
                self.collectErrorSites(b.rhs, tags, edges);
            },
            .unary_op => |u| self.collectErrorSites(u.operand, tags, edges),
            .deref_expr => |d| self.collectErrorSites(d.operand, tags, edges),
            .force_unwrap => |fu| self.collectErrorSites(fu.operand, tags, edges),
            .null_coalesce => |nc| {
                self.collectErrorSites(nc.lhs, tags, edges);
                self.collectErrorSites(nc.rhs, tags, edges);
            },
            .field_access => |fa| self.collectErrorSites(fa.object, tags, edges),
            .index_expr => |ix| {
                self.collectErrorSites(ix.object, tags, edges);
                self.collectErrorSites(ix.index, tags, edges);
            },
            .spread_expr => |s| self.collectErrorSites(s.operand, tags, edges),
            .catch_expr => |ce| {
                self.collectErrorSites(ce.operand, tags, edges);
                self.collectErrorSites(ce.body, tags, edges);
            },
            .defer_stmt => |d| self.collectErrorSites(d.expr, tags, edges),
            .push_stmt => |p| {
                self.collectErrorSites(p.context_expr, tags, edges);
                self.collectErrorSites(p.body, tags, edges);
            },
            .array_literal => |al| for (al.elements) |el| self.collectErrorSites(el, tags, edges),
            .tuple_literal => |tl| for (tl.elements) |el| self.collectErrorSites(el.value, tags, edges),
            // Stop at nested function boundaries; leaves contribute nothing.
            else => {},
        }
    }

    /// Whole-program fix-point that converges each top-level bare-`!` function's
    /// inferred error set (ERR E1.4b). Runs after `scanDecls` (ASTs + named
    /// error sets registered) and before body lowering, so `lowerTry`'s
    /// named-caller widening sees the converged callee sets. Also emits the
    /// empty-inferred warning. Scope: pure-failable functions (value-carrying
    /// raise/try aren't lowered yet — E2).
    pub fn convergeInferredErrorSets(self: *Lowering) void {
        const Node_ = struct {
            tags: std.ArrayList(u32),
            edges: std.ArrayList([]const u8),
            rt: ?*const Node,
        };
        var work = std.StringHashMap(Node_).init(self.alloc);
        defer work.deinit();

        // Seed each bare-`!` function with its direct escape sites.
        var it = self.program_index.fn_ast_map.iterator();
        while (it.next()) |e| {
            const fd = e.value_ptr.*;
            if (!astIsPureBareInferred(fd.return_type)) continue;
            var tags = std.ArrayList(u32).empty;
            var edges = std.ArrayList([]const u8).empty;
            self.collectErrorSites(fd.body, &tags, &edges);
            work.put(e.key_ptr.*, .{ .tags = tags, .edges = edges, .rt = fd.return_type }) catch {};
        }

        // Union edge contributions until no set grows (monotone → terminates).
        var changed = true;
        while (changed) {
            changed = false;
            var wit = work.iterator();
            while (wit.next()) |we| {
                for (we.value_ptr.edges.items) |callee| {
                    const callee_tags: []const u32 = blk: {
                        if (work.getPtr(callee)) |cc| break :blk cc.tags.items;
                        if (self.program_index.fn_ast_map.get(callee)) |cfd| {
                            if (astPureNamedSet(cfd.return_type)) |nm| {
                                break :blk self.namedSetTags(nm) orelse &.{};
                            }
                        }
                        break :blk &.{};
                    };
                    for (callee_tags) |t| {
                        if (!containsTag(we.value_ptr.tags.items, t)) {
                            we.value_ptr.tags.append(self.alloc, t) catch {};
                            changed = true;
                        }
                    }
                }
            }
        }

        // Store the converged sets (sorted) and warn on empty inferred sets.
        var sit = work.iterator();
        while (sit.next()) |se| {
            const sorted = self.alloc.dupe(u32, se.value_ptr.tags.items) catch continue;
            std.mem.sort(u32, sorted, {}, std.sort.asc(u32));
            self.inferred_error_sets.put(se.key_ptr.*, sorted) catch {};
            if (sorted.len == 0 and !std.mem.eql(u8, se.key_ptr.*, "main")) {
                if (self.diagnostics) |diags| {
                    if (se.value_ptr.rt) |rt| {
                        diags.addFmt(.warn, rt.span, "function '{s}' is declared `!` but never errors — drop the `!`", .{se.key_ptr.*});
                    }
                }
            }
        }
    }

    fn containsTag(tags: []const u32, t: u32) bool {
        for (tags) |x| if (x == t) return true;
        return false;
    }

    /// Whole-program union of each bare-`!` closure/fn-type SHAPE's escape set
    /// (ERR E5.1 sub-feature 2). Walks every function body for closure literals;
    /// each bare-`!` failable literal contributes its raises (+ `try named_fn()`
    /// edges, resolved against the name-keyed converged sets) to the node shared
    /// by all occurrences of its value-signature shape. A `try slot(x)` against
    /// any matching-shape slot then widens against this union.
    pub fn convergeClosureShapeSets(self: *Lowering) void {
        var it = self.program_index.fn_ast_map.iterator();
        while (it.next()) |e| {
            self.collectClosureShapes(e.value_ptr.*.body);
        }
    }

    /// Recurse the AST collecting closure-literal shape contributions. Unlike
    /// `collectErrorSites`, this descends THROUGH lambda boundaries (a nested
    /// closure is its own shape, and may itself contain closures).
    fn collectClosureShapes(self: *Lowering, node: *const Node) void {
        switch (node.data) {
            .lambda => |lam| {
                self.recordClosureShape(&lam);
                self.collectClosureShapes(lam.body);
            },
            .block => |b| for (b.stmts) |s| self.collectClosureShapes(s),
            .if_expr => |ie| {
                self.collectClosureShapes(ie.condition);
                self.collectClosureShapes(ie.then_branch);
                if (ie.else_branch) |eb| self.collectClosureShapes(eb);
            },
            .while_expr => |w| {
                self.collectClosureShapes(w.condition);
                self.collectClosureShapes(w.body);
            },
            .for_expr => |f| {
                self.collectClosureShapes(f.iterable);
                if (f.range_end) |re| self.collectClosureShapes(re);
                self.collectClosureShapes(f.body);
            },
            .return_stmt => |r| if (r.value) |v| self.collectClosureShapes(v),
            .raise_stmt => |rs| self.collectClosureShapes(rs.tag),
            .var_decl => |v| if (v.value) |val| self.collectClosureShapes(val),
            .const_decl => |c| self.collectClosureShapes(c.value),
            .destructure_decl => |d| self.collectClosureShapes(d.value),
            .assignment => |a| {
                self.collectClosureShapes(a.target);
                self.collectClosureShapes(a.value);
            },
            .multi_assign => |m| {
                for (m.targets) |t| self.collectClosureShapes(t);
                for (m.values) |v| self.collectClosureShapes(v);
            },
            .call => |c| {
                self.collectClosureShapes(c.callee);
                for (c.args) |a| self.collectClosureShapes(a);
            },
            .binary_op => |b| {
                self.collectClosureShapes(b.lhs);
                self.collectClosureShapes(b.rhs);
            },
            .unary_op => |u| self.collectClosureShapes(u.operand),
            .deref_expr => |d| self.collectClosureShapes(d.operand),
            .force_unwrap => |fu| self.collectClosureShapes(fu.operand),
            .null_coalesce => |nc| {
                self.collectClosureShapes(nc.lhs);
                self.collectClosureShapes(nc.rhs);
            },
            .field_access => |fa| self.collectClosureShapes(fa.object),
            .index_expr => |ix| {
                self.collectClosureShapes(ix.object);
                self.collectClosureShapes(ix.index);
            },
            .spread_expr => |s| self.collectClosureShapes(s.operand),
            .try_expr => |te| self.collectClosureShapes(te.operand),
            .catch_expr => |ce| {
                self.collectClosureShapes(ce.operand);
                self.collectClosureShapes(ce.body);
            },
            .defer_stmt => |d| self.collectClosureShapes(d.expr),
            .push_stmt => |p| {
                self.collectClosureShapes(p.context_expr);
                self.collectClosureShapes(p.body);
            },
            .array_literal => |al| for (al.elements) |el| self.collectClosureShapes(el),
            .tuple_literal => |tl| for (tl.elements) |el| self.collectClosureShapes(el.value),
            else => {},
        }
    }

    /// Record one closure literal's contribution to its value-signature shape's
    /// inferred-`!` union. No-op unless the literal is a CONCRETE (non-generic)
    /// bare-`!` failable closure; named-set / non-failable literals add no tags.
    fn recordClosureShape(self: *Lowering, lam: *const ast.Lambda) void {
        if (lam.type_params.len > 0) return; // generic shapes out of scope (sub-feature 8)
        const rt_node = lam.return_type orelse return; // no annotation → non-failable infer
        const ret = self.resolveType(rt_node);
        const es = self.errorChannelOf(ret) orelse return; // not failable
        if (!self.isInferredErrorSet(es)) return; // `!Named` → its own set, not the inferred union

        var ptys = std.ArrayList(TypeId).empty;
        defer ptys.deinit(self.alloc);
        for (lam.params) |p| {
            if (p.is_variadic or p.is_pack or p.is_comptime) return; // not a plain fn-type slot
            ptys.append(self.alloc, self.resolveType(p.type_expr)) catch return;
        }
        const key = self.closureShapeKey(ptys.items, self.returnValuePart(ret));

        var tags = std.ArrayList(u32).empty;
        defer tags.deinit(self.alloc);
        var edges = std.ArrayList([]const u8).empty;
        defer edges.deinit(self.alloc);
        self.collectErrorSites(lam.body, &tags, &edges);
        for (edges.items) |callee| {
            for (self.calleeEscapeTags(callee)) |t| {
                if (!containsTag(tags.items, t)) tags.append(self.alloc, t) catch {};
            }
        }
        self.unionShapeTags(key, tags.items);
    }

    /// The escape tags of a callee referenced by name from a `try g()` edge:
    /// a bare-`!` callee's converged set, or a `-> !Named` callee's declared set.
    fn calleeEscapeTags(self: *Lowering, callee: []const u8) []const u32 {
        if (self.inferred_error_sets.get(callee)) |t| return t;
        if (self.program_index.fn_ast_map.get(callee)) |cfd| {
            if (astPureNamedSet(cfd.return_type)) |nm| return self.namedSetTags(nm) orelse &.{};
        }
        return &.{};
    }

    /// Merge `new_tags` into the shape node `key` (sorted, deduped). The map is
    /// content-keyed (StringHashMap), so re-`put` with a fresh equal key string
    /// overwrites the existing node's value in place.
    fn unionShapeTags(self: *Lowering, key: []const u8, new_tags: []const u32) void {
        var list = std.ArrayList(u32).empty;
        defer list.deinit(self.alloc);
        if (self.shape_inferred_sets.get(key)) |existing| list.appendSlice(self.alloc, existing) catch {};
        for (new_tags) |t| {
            if (!containsTag(list.items, t)) list.append(self.alloc, t) catch {};
        }
        const sorted = self.alloc.dupe(u32, list.items) catch return;
        std.mem.sort(u32, sorted, {}, std.sort.asc(u32));
        self.shape_inferred_sets.put(key, sorted) catch {};
    }

    /// Canonical key for a callable VALUE-signature: param types + the value
    /// part of the return (error slot excluded). Bare-`!` and non-failable
    /// shapes of the same value-sig — and `.function` vs `.closure` of that
    /// sig — collapse to one key, so all occurrences share one inferred node.
    fn closureShapeKey(self: *Lowering, params: []const TypeId, value_ret: TypeId) []const u8 {
        var buf = std.ArrayList(u8).empty;
        buf.appendSlice(self.alloc, "shape") catch return "shape";
        for (params) |p| {
            buf.append(self.alloc, '_') catch return "shape";
            buf.appendSlice(self.alloc, self.mangleTypeName(p)) catch return "shape";
        }
        buf.appendSlice(self.alloc, "__") catch return "shape";
        buf.appendSlice(self.alloc, self.mangleTypeName(value_ret)) catch return "shape";
        return buf.items;
    }

    /// The value part of a (possibly failable) return type, error slot dropped:
    /// `(T, !)` → T (or a value-tuple); pure `-> !` → void; non-failable → self.
    fn returnValuePart(self: *Lowering, ret: TypeId) TypeId {
        const es = self.errorChannelOf(ret) orelse return ret;
        if (ret == es) return .void;
        return self.failableSuccessType(ret);
    }

    /// Shape key of a call's callee expression when it's a closure/fn-type slot
    /// (variable, field, index — anything with a `.closure`/`.function` type),
    /// for the program-wide shape-union widening lookup. Null for non-callables.
    fn shapeKeyOfCallee(self: *Lowering, node: *const Node) ?[]const u8 {
        if (node.data != .call) return null;
        const fty = self.inferExprType(node.data.call.callee);
        if (fty.isBuiltin()) return null;
        const info = self.module.types.get(fty);
        const params: []const TypeId = switch (info) {
            .closure => |c| c.params,
            .function => |f| f.params,
            else => return null,
        };
        const ret: TypeId = switch (info) {
            .closure => |c| c.ret,
            .function => |f| f.ret,
            else => return null,
        };
        return self.closureShapeKey(params, self.returnValuePart(ret));
    }

    fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 {
        return switch (op) {
            .add => "+",
            .sub => "-",
            .mul => "*",
            .div => "/",
            .mod => "%",
            .eq => "==",
            .neq => "!=",
            .lt => "<",
            .lte => "<=",
            .gt => ">",
            .gte => ">=",
            .and_op => "and",
            .or_op => "or",
            .bit_and => "&",
            .bit_or => "|",
            .bit_xor => "^",
            .shl => "<<",
            .shr => ">>",
            .in_op => "in",
        };
    }

    fn typeBits(ty: TypeId) u32 {
        return switch (ty) {
            .bool => 1,
            .s8, .u8 => 8,
            .s16, .u16 => 16,
            .s32, .u32 => 32,
            .s64, .u64 => 64,
            .usize, .isize => 0, // target-dependent — use typeBitsEx
            .f32 => 32,
            .f64 => 64,
            else => 0,
        };
    }

    pub fn typeBitsEx(self: *Lowering, ty: TypeId) u32 {
        if (ty == .usize or ty == .isize) return @as(u32, self.module.types.pointer_size) * 8;
        const b = typeBits(ty);
        if (b > 0) return b;
        if (!ty.isBuiltin()) {
            const info = self.module.types.get(ty);
            return switch (info) {
                .signed => |w| @as(u32, w),
                .unsigned => |w| @as(u32, w),
                else => 0,
            };
        }
        return 0;
    }

    /// Apply C default argument promotion to variadic-tail args. These rules
    /// (bool/s8/s16/u8/u16 → s32, f32 → f64) match the C calling convention's
    /// implicit promotions when an argument is passed through `...`.
    fn promoteCVariadicArgs(self: *Lowering, args: []Ref, fixed_count: usize) void {
        if (args.len <= fixed_count) return;
        for (args[fixed_count..]) |*arg| {
            const src_ty = self.builder.getRefType(arg.*);
            const promoted: TypeId = switch (src_ty) {
                .bool, .s8, .s16, .u8, .u16 => .s32,
                .f32 => .f64,
                else => continue,
            };
            arg.* = self.coerceToType(arg.*, src_ty, promoted);
        }
    }

    /// Coerce call arguments in-place to match function parameter types.
    fn coerceCallArgs(self: *Lowering, args: []Ref, params: []const Function.Param) void {
        for (0..@min(args.len, params.len)) |i| {
            const src_ty = self.builder.getRefType(args[i]);
            const dst_ty = params[i].ty;
            if (!src_ty.isBuiltin() and !dst_ty.isBuiltin()) {
                const src_info = self.module.types.get(src_ty);
                const dst_info = self.module.types.get(dst_ty);
                // Array → many_pointer decay: alloca the array, GEP to first element
                if (src_info == .array and dst_info == .many_pointer) {
                    const slot = self.builder.alloca(src_ty);
                    self.builder.store(slot, args[i]);
                    const zero = self.builder.constInt(0, .s64);
                    args[i] = self.builder.emit(.{ .index_gep = .{ .lhs = slot, .rhs = zero } }, dst_ty);
                    continue;
                }
                // Implicit address-of: passing T value where *T is expected → alloca + store
                // Only when the pointee type matches the source type.
                if (dst_info == .pointer and src_info != .pointer and dst_info.pointer.pointee == src_ty) {
                    const slot = self.builder.alloca(src_ty);
                    self.builder.store(slot, args[i]);
                    args[i] = slot;
                    continue;
                }
            }
            args[i] = self.coerceToType(args[i], src_ty, dst_ty);
        }
    }

    fn ensureTerminator(self: *Lowering, ret_ty: TypeId) void {
        if (self.currentBlockHasTerminator()) return;
        if (ret_ty == .noreturn) {
            // A `-> noreturn` function never returns; if control reaches the
            // end of the body it's genuinely unreachable (the body is expected
            // to diverge — call another noreturn, loop forever, etc.).
            self.builder.emitUnreachable();
        } else if (ret_ty == .void) {
            self.builder.retVoid();
        } else {
            // Use const_undef for complex types (string, struct, etc.)
            const default_val = if (ret_ty == .string or !ret_ty.isBuiltin())
                self.builder.constUndef(ret_ty)
            else
                self.builder.constInt(0, ret_ty);
            self.builder.ret(default_val, ret_ty);
        }
    }

    /// Emit a C-ABI exported function for every bodied method on a
    /// `#jni_main #jni_class("...")` declaration. The symbol name follows
    /// JNI's name-mangling convention so Android's JNI runtime can resolve
    /// `private native sx_<method>(...)` (declared in the bundled
    /// classes.dex by `jni_java_emit`) without an explicit `RegisterNatives`
    /// call — i.e. `Java_<pkg-mangled>_<Class>_sx_1<method-mangled>`.
    ///
    /// Param ABI: prepended `(env: *void, self: *void)` (JNIEnv* + jobject
    /// receiver), followed by the user-declared params with pointer types
    /// type-erased to `*void` (JNI carries jobjects, not sx-typed handles —
    /// future work can keep richer typing inside the body when needed).
    /// Eagerly lower bodied instance methods on every sx-defined
    /// `#objc_class`. The Obj-C runtime invokes these via the IMP
    /// pointers wired up in M1.2 A.4 — no sx-side call path triggers
    /// lazy lowering, so we walk the cache and force-lower here.
    /// `lowerFunction` sets `current_foreign_class` automatically based
    /// on the qualified name, so `*Self` substitutions in the body
    /// resolve correctly (M1.2 A.2b). After the bodies are lowered,
    /// `emitObjcDefinedClassImps` wraps each with a C-ABI trampoline
    /// (M1.2 A.4b.ii).
    fn lowerObjcDefinedClassMethods(self: *Lowering) void {
        for (self.module.objc_defined_class_cache.items) |entry| {
            const fcd = entry.decl;
            for (fcd.members) |m| {
                const method = switch (m) {
                    .method => |md| md,
                    else => continue,
                };
                if (method.body == null) continue;
                const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, method.name }) catch continue;
                self.lazyLowerFunction(qualified);
            }
        }
        // Now the bodies are lowered — emit the C-ABI IMP trampolines
        // that bridge `objc_msgSend` invocations to them.
        self.emitObjcDefinedClassImps();
    }

    /// True if `ty` is a pointer to a struct whose name is registered
    /// in `foreign_class_map` under an Obj-C runtime. Used by the
    /// `obj.class` accessor (M1.3) to decide whether to lower the
    /// field access as a struct GEP or as `object_getClass(obj)`.
    pub fn isObjcClassPointer(self: *Lowering, ty: TypeId) bool {
        if (ty.isBuiltin()) return false;
        const ptr_info = self.module.types.get(ty);
        if (ptr_info != .pointer) return false;
        const pointee_info = self.module.types.get(ptr_info.pointer.pointee);
        if (pointee_info != .@"struct") return false;
        const struct_name = self.module.types.getString(pointee_info.@"struct".name);
        const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return false;
        return fcd.runtime == .objc_class or fcd.runtime == .objc_protocol;
    }

    /// If `obj_expr` is typed as a pointer to a foreign Obj-C class
    /// and that class (or any of its `#extends` ancestors) declares a
    /// `#property` field with the given name, return the
    /// `ForeignFieldDecl`. M2.2 + M2.3.
    pub fn lookupObjcPropertyOnPointer(self: *Lowering, obj_expr: *const ast.Node, field_name: []const u8) ?ast.ForeignFieldDecl {
        const obj_ty = self.inferExprType(obj_expr);
        if (obj_ty.isBuiltin()) return null;
        const ptr_info = self.module.types.get(obj_ty);
        if (ptr_info != .pointer) return null;
        const pointee_info = self.module.types.get(ptr_info.pointer.pointee);
        if (pointee_info != .@"struct") return null;
        const struct_name = self.module.types.getString(pointee_info.@"struct".name);
        const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return null;
        if (fcd.runtime != .objc_class and fcd.runtime != .objc_protocol) return null;
        return self.findForeignPropertyInChain(fcd, field_name);
    }

    /// Walk the `#extends` chain looking for a method by name. M2.3.
    /// Returns the owning fcd + the method decl, or null if no ancestor
    /// declares it. Depth-capped at 16 to break accidental cycles
    /// (real Obj-C class chains rarely exceed 6 levels).
    fn findForeignMethodInChain(self: *Lowering, fcd: *const ast.ForeignClassDecl, method_name: []const u8) ?struct { fcd: *const ast.ForeignClassDecl, method: ast.ForeignMethodDecl } {
        var current: *const ast.ForeignClassDecl = fcd;
        var depth: u32 = 0;
        while (depth < 16) : (depth += 1) {
            for (current.members) |m| switch (m) {
                .method => |md| if (std.mem.eql(u8, md.name, method_name)) return .{ .fcd = current, .method = md },
                else => {},
            };
            // Not on this level — follow `#extends ParentName`.
            const parent = blk: {
                for (current.members) |m| switch (m) {
                    .extends => |p| break :blk p,
                    else => {},
                };
                break :blk null;
            } orelse return null;
            current = self.program_index.foreign_class_map.get(parent) orelse return null;
        }
        return null;
    }

    /// Walk the `#extends` chain looking for a `#property` field by
    /// name. M2.3 companion to findForeignMethodInChain.
    fn findForeignPropertyInChain(self: *Lowering, fcd: *const ast.ForeignClassDecl, field_name: []const u8) ?ast.ForeignFieldDecl {
        var current: *const ast.ForeignClassDecl = fcd;
        var depth: u32 = 0;
        while (depth < 16) : (depth += 1) {
            for (current.members) |m| switch (m) {
                .field => |f| if (f.is_property and std.mem.eql(u8, f.name, field_name)) return f,
                else => {},
            };
            const parent = blk: {
                for (current.members) |m| switch (m) {
                    .extends => |p| break :blk p,
                    else => {},
                };
                break :blk null;
            } orelse return null;
            current = self.program_index.foreign_class_map.get(parent) orelse return null;
        }
        return null;
    }

    const ObjcDefinedStateField = struct {
        field_ty: TypeId,
        state_ty: TypeId,
        field_idx: u32,
        fcd: *const ast.ForeignClassDecl,
    };

    /// State-field-access info: if obj_expr is *<sx-defined-class>
    /// and `field_name` is in the state struct (not a property),
    /// returns the field's TypeId, the state struct's TypeId, and
    /// the field's index. M1.2 A.3 supports.
    pub fn lookupObjcDefinedStateFieldOnPointer(self: *Lowering, obj_expr: *const ast.Node, field_name: []const u8) ?ObjcDefinedStateField {
        const obj_ty = self.inferExprType(obj_expr);
        if (obj_ty.isBuiltin()) return null;
        const ptr_info = self.module.types.get(obj_ty);
        if (ptr_info != .pointer) return null;
        const pointee_info = self.module.types.get(ptr_info.pointer.pointee);
        if (pointee_info != .@"struct") return null;
        const struct_name = self.module.types.getString(pointee_info.@"struct".name);
        const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return null;
        // Only sx-defined Obj-C classes have a state struct. Foreign
        // classes' fields are purely declaration metadata (no state).
        if (fcd.is_foreign or fcd.runtime != .objc_class) return null;
        // Skip property fields — those dispatch via the M2.2 getter/setter
        // path. Plain instance fields take the ivar+gep path.
        for (fcd.members) |m| switch (m) {
            .field => |f| {
                if (std.mem.eql(u8, f.name, field_name)) {
                    if (f.is_property) return null;
                    const state_ty = self.objcDefinedStateStructType(fcd);
                    const state_info = self.module.types.get(state_ty);
                    if (state_info != .@"struct") return null;
                    const fname_id = self.module.types.internString(f.name);
                    for (state_info.@"struct".fields, 0..) |sf, idx| {
                        if (sf.name == fname_id) {
                            return .{
                                .field_ty = sf.ty,
                                .state_ty = state_ty,
                                .field_idx = @intCast(idx),
                                .fcd = fcd,
                            };
                        }
                    }
                    return null;
                }
            },
            else => {},
        };
        return null;
    }

    /// Lower a read of `self.field` (or `obj.field`) on a sx-defined
    /// Obj-C class: `state = object_getIvar(self, load(ivar_global))`
    /// then `struct_gep(state, idx)` + load. M1.2 A.3 — the runtime
    /// hop through the hidden ivar.
    fn lowerObjcDefinedStateFieldRead(
        self: *Lowering,
        obj_expr: *const ast.Node,
        info: ObjcDefinedStateField,
    ) Ref {
        const obj_ref = self.lowerExpr(obj_expr);
        const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return Ref.none;
        const ptr_void = self.module.types.ptrTo(.void);
        const field_addr = self.builder.emit(.{ .struct_gep = .{
            .base = state_ptr,
            .field_index = info.field_idx,
            .base_type = info.state_ty,
        } }, ptr_void);
        return self.builder.load(field_addr, info.field_ty);
    }

    /// `state = object_getIvar(obj, load(__<Cls>_state_ivar))`. Shared
    /// helper for state-field read + write (M1.2 A.3).
    fn lowerObjcDefinedStateForObj(self: *Lowering, obj_ref: Ref, fcd: *const ast.ForeignClassDecl) ?Ref {
        const ptr_void = self.module.types.ptrTo(.void);
        const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null;
        defer self.alloc.free(ivar_global_name);
        const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return null;
        const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
        const ivar_handle = self.builder.load(ivar_addr, ptr_void);
        const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
        const args = self.alloc.alloc(Ref, 2) catch return null;
        args[0] = obj_ref;
        args[1] = ivar_handle;
        return self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = args } }, ptr_void);
    }

    /// Lower `obj.field` for an Obj-C `#property` field as
    /// `objc_msg_send(obj, sel_<fieldName>)`. M2.2 — getter side.
    /// The setter side lives in the assignment-statement lowering.
    fn lowerObjcPropertyGetter(self: *Lowering, obj_expr: *const ast.Node, field: ast.ForeignFieldDecl, _: []const u8, _: ast.Span) Ref {
        const obj_ref = self.lowerExpr(obj_expr);
        const ret_ty = self.resolveType(field.field_type);
        const vptr_ty = self.module.types.ptrTo(.void);
        // The selector for a property getter is the field name verbatim
        // (Obj-C convention; the override hook is for niche cases like
        // `isHidden` and lands with M2.2's modifier handling).
        const sel_slot_gid = self.internObjcSelector(field.name);
        const slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty));
        const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty);
        return self.builder.emit(.{ .objc_msg_send = .{
            .recv = obj_ref,
            .sel = sel,
            .args = &.{},
        } }, ret_ty);
    }

    /// Lower `obj.field = val` for an Obj-C `#property` field as
    /// `objc_msg_send(obj, sel_set<Field>:, val)`. M2.2 — setter side.
    /// Selector: prepend "set", capitalize the first letter of the
    /// field name, append ":".  `backgroundColor` → `setBackgroundColor:`.
    fn lowerObjcPropertySetter(self: *Lowering, obj_expr: *const ast.Node, field: ast.ForeignFieldDecl, val: Ref) void {
        const obj_ref = self.lowerExpr(obj_expr);
        const vptr_ty = self.module.types.ptrTo(.void);

        // Build the setter selector.
        var sel_buf = std.ArrayList(u8).empty;
        defer sel_buf.deinit(self.alloc);
        sel_buf.appendSlice(self.alloc, "set") catch unreachable;
        if (field.name.len > 0) {
            sel_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable;
            sel_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable;
        }
        sel_buf.append(self.alloc, ':') catch unreachable;
        const sel_str = self.alloc.dupe(u8, sel_buf.items) catch unreachable;

        const sel_slot_gid = self.internObjcSelector(sel_str);
        const slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty));
        const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty);
        const args = self.alloc.alloc(Ref, 1) catch unreachable;
        args[0] = val;
        _ = self.builder.emit(.{ .objc_msg_send = .{
            .recv = obj_ref,
            .sel = sel,
            .args = args,
        } }, .void);
    }

    /// Get a FuncId for an external C-callconv function. If a function
    /// with this exported name already exists in the module (e.g.
    /// declared by stdlib `#foreign` decl), return it; otherwise
    /// declare it fresh with the given signature.
    ///
    /// One helper instead of a `get<Name>Fid` per runtime function —
    /// avoids per-function cache fields and per-function boilerplate.
    fn ensureCRuntimeDecl(self: *Lowering, name: []const u8, param_tys: []const TypeId, ret_ty: TypeId) FuncId {
        const name_id = self.module.types.internString(name);
        for (self.module.functions.items, 0..) |f, i| {
            if (f.name == name_id) return FuncId.fromIndex(@intCast(i));
        }
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        for (param_tys, 0..) |pty, i| {
            // Param names don't matter at the LLVM ABI boundary —
            // synthesize generic ones (`a0`, `a1`, ...) so we don't
            // need a parallel name list per call site.
            const synth = std.fmt.allocPrint(self.alloc, "a{d}", .{i}) catch unreachable;
            params.append(self.alloc, .{
                .name = self.module.types.internString(synth),
                .ty = pty,
            }) catch unreachable;
        }
        const fid = self.builder.declareExtern(name_id, params.toOwnedSlice(self.alloc) catch unreachable, ret_ty);
        self.module.getFunctionMut(fid).call_conv = .c;
        return fid;
    }

    /// For each bodied instance method on a sx-defined `#objc_class`,
    /// emit a C-ABI IMP trampoline that the Obj-C runtime calls (after
    /// the dispatch path from `objc_msgSend`). The trampoline:
    ///   1. Loads the cached ivar handle from `@__<Cls>_state_ivar`.
    ///   2. Calls `object_getIvar(obj, ivar)` to get the `*<Cls>State`
    ///      state pointer.
    ///   3. Calls the sx body `@<Cls>.<method>(__sx_default_context,
    ///      state, ...user_args)` (default sx-callconv).
    ///   4. Returns the result (or `ret void`).
    ///
    /// IMP name: `__<ClassName>_<methodName>_imp`. emit_llvm's
    /// constructor (A.4b.ii companion) registers this via
    /// `class_addMethod` with a derived selector + type encoding.
    fn emitObjcDefinedClassImps(self: *Lowering) void {
        for (self.module.objc_defined_class_cache.items) |entry| {
            const fcd = entry.decl;
            // Synthesize +alloc (M1.2 A.5) and -dealloc (M1.2 A.6). emit_llvm
            // registers +alloc on the metaclass and -dealloc on the class
            // itself after objc_registerClassPair.
            self.emitObjcDefinedClassAllocImp(fcd);
            self.emitObjcDefinedClassDeallocImp(fcd);
            for (fcd.members) |m| {
                switch (m) {
                    .method => |method| {
                        if (method.body == null) continue;
                        self.emitObjcDefinedClassImp(fcd, method);
                    },
                    .field => |field| {
                        // M2.2 second pass — sx-defined property fields
                        // synthesize getter (+ setter unless `readonly`)
                        // IMPs that GEP into the state struct.
                        if (field.is_property) {
                            self.emitObjcDefinedClassPropertyImps(fcd, field);
                        }
                    },
                    else => {},
                }
            }
        }
    }

    /// M2.2 second pass — emit synthesized getter/setter IMPs for a
    /// property field on a sx-defined `#objc_class`. The state struct
    /// already holds the field (via objcDefinedStateStructType); the
    /// IMPs just dispatch a load/store through the `__sx_state` ivar.
    ///
    /// Getter IMP:   `__<Cls>_<field>_imp(self, _cmd) -> T`
    ///   state = object_getIvar(self, load(__<Cls>_state_ivar))
    ///   return state.<field>
    ///
    /// Setter IMP (skipped if `readonly` in modifiers):
    /// `__<Cls>_set<Field>_imp(self, _cmd, val) -> void`
    ///   state = object_getIvar(self, load(__<Cls>_state_ivar))
    ///   state.<field> = val
    ///
    /// Both IMPs land in the cache's methods slice with appropriate
    /// selectors + encodings; emit_llvm's class_addMethod loop wires
    /// them up like any other instance method.
    /// M4.B — interpretation of `#property(...)` modifiers for ARC.
    /// `assign` is the default for primitives (direct store, no ARC ops);
    /// `strong` is the default for pointer-to-object types (retain on
    /// assign, release on dealloc); `weak` and `copy` are explicit. The
    /// helper rejects ambiguous combinations loudly per the silent-error
    /// budget — `*void` requires explicit modifier, `weak` requires an
    /// object-pointer slot.
    const ObjcPropertyKind = enum {
        assign,    // primitives or explicitly opted-out object slots
        strong,    // default for *<ObjC-class> — retain on assign, release on dealloc
        weak,      // objc_storeWeak / objc_loadWeakRetained — auto-nilling
        copy,      // [val copy] on assign — for immutable-wanting String/Array slots

        pub fn isObject(k: ObjcPropertyKind) bool {
            return k == .strong or k == .weak or k == .copy;
        }
    };

    /// Resolve a `#property(...)` field's ARC kind. Loud at compile time
    /// for known footguns (per the silent-error budget in the plan):
    ///   - unknown modifier name (typo) → diagnostic
    ///   - `weak` on a non-object field type → diagnostic
    ///   - `strong` (explicit or defaulted) on `*void` (ambiguous: Obj-C
    ///     object vs raw memory) → require explicit modifier
    fn objcPropertyKind(self: *Lowering, field: ast.ForeignFieldDecl) ObjcPropertyKind {
        // Survey the modifier list.
        var has_strong = false;
        var has_weak   = false;
        var has_copy   = false;
        var has_assign = false;
        for (field.property_modifiers) |mod| {
            if (std.mem.eql(u8, mod, "strong")) has_strong = true
            else if (std.mem.eql(u8, mod, "weak"))   has_weak   = true
            else if (std.mem.eql(u8, mod, "copy"))   has_copy   = true
            else if (std.mem.eql(u8, mod, "assign")) has_assign = true
            else if (std.mem.eql(u8, mod, "readonly")) {
                // Orthogonal to ARC kind — no-op here.
            }
            else if (std.mem.eql(u8, mod, "nonatomic") or std.mem.eql(u8, mod, "atomic")) {
                // Atomicity — recorded for the property attribute string;
                // doesn't affect the ARC kind.
            }
            else if (std.mem.startsWith(u8, mod, "getter(") or std.mem.startsWith(u8, mod, "setter(")) {
                // Selector overrides — handled elsewhere.
            }
            else {
                if (self.diagnostics) |d| {
                    const span = ast.Span{ .start = 0, .end = 0 };
                    d.addFmt(.err, span, "unknown #property modifier '{s}' on field '{s}' — expected one of: strong, weak, copy, assign, readonly, nonatomic, atomic, getter(\"...\"), setter(\"...\")", .{ mod, field.name });
                }
            }
        }

        // Mutually-exclusive ARC modifiers — at most one.
        const explicit_count: u32 =
            (@as(u32, if (has_strong) 1 else 0)) +
            (@as(u32, if (has_weak)   1 else 0)) +
            (@as(u32, if (has_copy)   1 else 0)) +
            (@as(u32, if (has_assign) 1 else 0));
        if (explicit_count > 1) {
            if (self.diagnostics) |d| {
                const span = ast.Span{ .start = 0, .end = 0 };
                d.addFmt(.err, span, "conflicting #property modifiers on field '{s}' — strong/weak/copy/assign are mutually exclusive", .{field.name});
            }
        }

        // Resolve the field's type to decide defaults + validate.
        const field_ty = self.resolveType(field.field_type);
        const is_pointer = !field_ty.isBuiltin() and self.module.types.get(field_ty) == .pointer;
        const is_object_ptr = is_pointer and blk: {
            const pointee = self.module.types.get(field_ty).pointer.pointee;
            // `*void` is NOT considered an object pointer — ambiguous.
            if (pointee == .void) break :blk false;
            // `*T` where T is a foreign-class struct (Obj-C class).
            if (pointee.isBuiltin()) break :blk false;
            const pointee_info = self.module.types.get(pointee);
            if (pointee_info != .@"struct") break :blk false;
            const struct_name = self.module.types.getString(pointee_info.@"struct".name);
            const fcd = self.program_index.foreign_class_map.get(struct_name) orelse break :blk false;
            break :blk fcd.runtime == .objc_class or fcd.runtime == .objc_protocol;
        };

        // `weak` requires an object pointer — `weak s32` is meaningless and
        // would invoke objc_storeWeak on a non-object slot.
        if (has_weak and !is_object_ptr) {
            if (self.diagnostics) |d| {
                const span = ast.Span{ .start = 0, .end = 0 };
                d.addFmt(.err, span, "#property(weak) on field '{s}' requires a pointer-to-Obj-C-class type; got '{s}'", .{ field.name, self.module.types.typeName(field_ty) });
            }
        }

        // `copy` requires an object pointer — `copy s32` makes no sense.
        if (has_copy and !is_object_ptr) {
            if (self.diagnostics) |d| {
                const span = ast.Span{ .start = 0, .end = 0 };
                d.addFmt(.err, span, "#property(copy) on field '{s}' requires a pointer-to-Obj-C-class type (typically NSString or NSArray)", .{field.name});
            }
        }

        // `*void` is ambiguous (Obj-C object vs raw memory): require explicit
        // modifier so the user opts into ARC semantics consciously.
        if (is_pointer) {
            const pointee = self.module.types.get(field_ty).pointer.pointee;
            if (pointee == .void and explicit_count == 0) {
                if (self.diagnostics) |d| {
                    const span = ast.Span{ .start = 0, .end = 0 };
                    d.addFmt(.err, span, "#property on field '{s}' of type '*void' is ambiguous — specify `#property(strong|weak|copy|assign)` explicitly (Obj-C object vs raw memory)", .{field.name});
                }
                return .assign; // assume safe default to keep compilation going
            }
        }

        // Apply explicit modifier or default.
        if (has_weak)   return .weak;
        if (has_copy)   return .copy;
        if (has_strong) return .strong;
        if (has_assign) return .assign;
        // Default: object pointers → strong; everything else → assign.
        return if (is_object_ptr) .strong else .assign;
    }

    /// Lazily declare libobjc's ARC runtime helpers. Idempotent — uses
    /// `ensureCRuntimeDecl` which skips already-declared symbols. Called
    /// from the property setter/getter and -dealloc emission paths when
    /// they need to emit a retain/release/storeWeak/etc.
    fn ensureArcRuntimeDecls(self: *Lowering) void {
        const ptr_void = self.module.types.ptrTo(.void);
        _ = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void);
        _ = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void);
        _ = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void);
        _ = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void);
        _ = self.ensureCRuntimeDecl("objc_initWeak", &.{ ptr_void, ptr_void }, ptr_void);
        _ = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void);
    }

    fn emitObjcDefinedClassPropertyImps(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl) void {
        const state_ty = self.objcDefinedStateStructType(fcd);
        const state_info = self.module.types.get(state_ty);
        if (state_info != .@"struct") return;
        // Find the field's index in the state struct.
        const field_name_id = self.module.types.internString(field.name);
        var field_idx: ?u32 = null;
        for (state_info.@"struct".fields, 0..) |sf, i| {
            if (sf.name == field_name_id) {
                field_idx = @intCast(i);
                break;
            }
        }
        const fidx = field_idx orelse return;
        const field_ty = self.resolveType(field.field_type);

        // M4.B: validate modifiers + resolve ARC kind. Side-effect: emits
        // diagnostics for typos, weak-on-non-object, ambiguous *void, etc.
        // For now the setter/getter still emit bare load/store; subsequent
        // M4.B commits wire the actual ARC ops keyed on this kind.
        _ = self.objcPropertyKind(field);

        // (1) Getter: __<Cls>_<field>_imp
        self.emitObjcDefinedPropertyGetter(fcd, field, state_ty, fidx, field_ty);

        // (2) Setter — skipped for `readonly`.
        var is_readonly = false;
        for (field.property_modifiers) |mod| {
            if (std.mem.eql(u8, mod, "readonly")) {
                is_readonly = true;
                break;
            }
        }
        if (!is_readonly) {
            self.emitObjcDefinedPropertySetter(fcd, field, state_ty, fidx, field_ty);
        }

        // (3) Register in the cache's methods slice. Both IMPs use the
        // method-registration pipeline that lands in class_addMethod
        // calls from emit_llvm.
        self.registerObjcDefinedPropertyMethodEntries(fcd, field, field_ty, is_readonly);
    }

    fn emitObjcDefinedPropertyGetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void {
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        defer {
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
        }

        const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return;
        const name_id = self.module.types.internString(imp_name);
        const ptr_void = self.module.types.ptrTo(.void);

        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return;
        params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;
        const params_slice = params.toOwnedSlice(self.alloc) catch return;

        _ = self.builder.beginFunction(name_id, params_slice, field_ty);
        const func = self.builder.currentFunc();
        func.linkage = .external;
        func.call_conv = .c;
        func.has_implicit_ctx = false;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        // state = object_getIvar(self, load @__<Cls>_state_ivar)
        const self_ref = Ref.fromIndex(0);
        const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return;
        defer self.alloc.free(ivar_global_name);
        const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return;
        const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
        const ivar_handle = self.builder.load(ivar_addr, ptr_void);
        const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
        const get_args = self.alloc.alloc(Ref, 2) catch return;
        get_args[0] = self_ref;
        get_args[1] = ivar_handle;
        const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void);

        const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void);

        // M4.B getter — weak fields go through objc_loadWeakRetained +
        // objc_autorelease for race-safe reads. The bare-load path
        // (strong/copy/assign) is the common case and reads the slot
        // directly.
        const kind = self.objcPropertyKind(field);
        if (kind == .weak) {
            self.ensureArcRuntimeDecls();
            const load_weak_fid = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void);
            const autorelease_fid = self.ensureCRuntimeDecl("objc_autorelease", &.{ptr_void}, ptr_void);

            // retained = objc_loadWeakRetained(field_addr)
            //   - atomic upgrade-to-strong via libobjc's side-table; if the
            //     target deinitialised, returns null. The caller gets a
            //     +1 retained reference (or null).
            const load_args = self.alloc.alloc(Ref, 1) catch return;
            load_args[0] = field_addr;
            const retained = self.builder.emit(.{ .call = .{ .callee = load_weak_fid, .args = load_args } }, ptr_void);

            // autoreleased = objc_autorelease(retained)
            //   - drops it into the current pool so the caller doesn't need
            //     to manually release. Returns the same pointer (typed).
            const ar_args = self.alloc.alloc(Ref, 1) catch return;
            ar_args[0] = retained;
            const autoreleased = self.builder.emit(.{ .call = .{ .callee = autorelease_fid, .args = ar_args } }, ptr_void);

            self.builder.ret(autoreleased, field_ty);
            self.builder.finalize();
            return;
        }

        // strong / copy / assign — bare load.
        const val = self.builder.load(field_addr, field_ty);
        self.builder.ret(val, field_ty);
        self.builder.finalize();
    }

    fn emitObjcDefinedPropertySetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void {
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        defer {
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
        }

        // Setter selector: set<Field>:  →  imp name: __<Cls>_set<Field>_imp
        var setter_field_buf = std.ArrayList(u8).empty;
        defer setter_field_buf.deinit(self.alloc);
        setter_field_buf.appendSlice(self.alloc, "set") catch unreachable;
        if (field.name.len > 0) {
            setter_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable;
            setter_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable;
        }
        const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_field_buf.items }) catch return;
        const name_id = self.module.types.internString(imp_name);
        const ptr_void = self.module.types.ptrTo(.void);

        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return;
        params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;
        params.append(self.alloc, .{ .name = self.module.types.internString("val"), .ty = field_ty }) catch return;
        const params_slice = params.toOwnedSlice(self.alloc) catch return;

        _ = self.builder.beginFunction(name_id, params_slice, .void);
        const func = self.builder.currentFunc();
        func.linkage = .external;
        func.call_conv = .c;
        func.has_implicit_ctx = false;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        const self_ref = Ref.fromIndex(0);
        const val_ref = Ref.fromIndex(2);
        const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return;
        defer self.alloc.free(ivar_global_name);
        const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return;
        const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
        const ivar_handle = self.builder.load(ivar_addr, ptr_void);
        const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
        const get_args = self.alloc.alloc(Ref, 2) catch return;
        get_args[0] = self_ref;
        get_args[1] = ivar_handle;
        const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void);

        const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void);

        // M4.B setter — emit ARC ops based on the property's modifier kind.
        const kind = self.objcPropertyKind(field);
        switch (kind) {
            .assign => {
                // Primitives or explicit assign: bare store, no ARC.
                self.builder.store(field_addr, val_ref);
            },
            .strong => {
                // Retain new, release old. Order matters: retain first
                // (in case val == old, we don't release before retain).
                self.ensureArcRuntimeDecls();
                const retain_fid = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void);
                const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void);

                // old = load field_addr
                const old_val = self.builder.load(field_addr, field_ty);
                // new = objc_retain(val)
                const retain_args = self.alloc.alloc(Ref, 1) catch return;
                retain_args[0] = val_ref;
                _ = self.builder.emit(.{ .call = .{ .callee = retain_fid, .args = retain_args } }, ptr_void);
                // store field_addr, val
                self.builder.store(field_addr, val_ref);
                // objc_release(old)  — Apple's runtime treats release(NULL) as a no-op,
                // so we skip an explicit null-check (saves a branch on every assign).
                const release_args = self.alloc.alloc(Ref, 1) catch return;
                release_args[0] = old_val;
                _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = release_args } }, .void);
            },
            .weak => {
                // objc_storeWeak(field_addr, val) handles first-store
                // (init) and re-store (destroy old + init new) atomically.
                self.ensureArcRuntimeDecls();
                const store_weak_fid = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void);
                const store_args = self.alloc.alloc(Ref, 2) catch return;
                store_args[0] = field_addr;
                store_args[1] = val_ref;
                _ = self.builder.emit(.{ .call = .{ .callee = store_weak_fid, .args = store_args } }, ptr_void);
            },
            .copy => {
                // copy = objc_msgSend(val, sel_copy)  — returns retained
                //                                       (NSCopying contract).
                // Release old, then store the copy.
                self.ensureArcRuntimeDecls();
                const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void);

                // Load + cache the `copy` selector slot.
                const sel_copy_gid = self.internObjcSelector("copy");
                const sel_slot_ptr = self.builder.emit(.{ .global_addr = sel_copy_gid }, self.module.types.ptrTo(ptr_void));
                const sel_copy = self.builder.emit(.{ .load = .{ .operand = sel_slot_ptr } }, ptr_void);

                // copy = [val copy]
                const copy_args = self.alloc.alloc(Ref, 0) catch return;
                const copied = self.builder.emit(.{ .objc_msg_send = .{
                    .recv = val_ref,
                    .sel = sel_copy,
                    .args = copy_args,
                } }, ptr_void);

                const old_val = self.builder.load(field_addr, field_ty);
                self.builder.store(field_addr, copied);
                const release_args = self.alloc.alloc(Ref, 1) catch return;
                release_args[0] = old_val;
                _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = release_args } }, .void);
            },
        }
        self.builder.retVoid();
        self.builder.finalize();
    }

    /// Append the property's getter (and setter, unless readonly)
    /// entries to the class's method-registration slice so emit_llvm
    /// calls class_addMethod on each. Selectors + encodings derived
    /// from the field type.
    fn registerObjcDefinedPropertyMethodEntries(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, field_ty: TypeId, is_readonly: bool) void {
        const cur = self.module.lookupObjcDefinedClass(fcd.name) orelse return;
        _ = cur;
        // Find the existing entry and grow its methods slice.
        var new_methods = std.ArrayList(Module.ObjcDefinedMethodEntry).empty;
        for (self.module.objc_defined_class_cache.items) |entry| {
            if (!std.mem.eql(u8, entry.name, fcd.name)) continue;
            for (entry.methods) |m| new_methods.append(self.alloc, m) catch unreachable;

            // Getter entry — selector = field name, encoding = "<ret>@:".
            const getter_enc = self.objcTypeEncodingFromSignature(field_ty, &.{}, null) catch return;
            const getter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return;
            new_methods.append(self.alloc, .{
                .sel = field.name,
                .encoding = getter_enc,
                .imp_name = getter_imp_name,
                .is_class = false,
            }) catch unreachable;

            // Setter entry — selector = set<Field>:, encoding = "v@:<ty>".
            if (!is_readonly) {
                var sel_buf = std.ArrayList(u8).empty;
                defer sel_buf.deinit(self.alloc);
                sel_buf.appendSlice(self.alloc, "set") catch unreachable;
                if (field.name.len > 0) {
                    sel_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable;
                    sel_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable;
                }
                sel_buf.append(self.alloc, ':') catch unreachable;
                const setter_sel = self.alloc.dupe(u8, sel_buf.items) catch return;

                const setter_enc = self.objcTypeEncodingFromSignature(.void, &.{field_ty}, null) catch return;

                var setter_imp_field_buf = std.ArrayList(u8).empty;
                defer setter_imp_field_buf.deinit(self.alloc);
                setter_imp_field_buf.appendSlice(self.alloc, "set") catch unreachable;
                if (field.name.len > 0) {
                    setter_imp_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable;
                    setter_imp_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable;
                }
                const setter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_imp_field_buf.items }) catch return;

                new_methods.append(self.alloc, .{
                    .sel = setter_sel,
                    .encoding = setter_enc,
                    .imp_name = setter_imp_name,
                    .is_class = false,
                }) catch unreachable;
            }
            break;
        }
        const slice = new_methods.toOwnedSlice(self.alloc) catch return;
        self.module.setObjcDefinedClassMethods(fcd.name, slice);
    }

    fn emitObjcDefinedClassImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void {
        // Class methods (no `*Self` first param) skip the ivar read —
        // they have no instance state to thread through.
        if (md.is_static) {
            self.emitObjcDefinedClassStaticImp(fcd, md);
            return;
        }

        // Save+restore builder state — we're switching into a new fn
        // mid-pass and need to restore for the next emit_llvm steps.
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        defer {
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
        }

        const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, md.name }) catch return;
        const name_id = self.module.types.internString(imp_name);
        const ptr_void = self.module.types.ptrTo(.void);

        // C-ABI signature: (obj: *void, _cmd: *void, ...user_args) -> ret.
        // User params skip index 0 (which is *Self).
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{ .name = self.module.types.internString("obj"), .ty = ptr_void }) catch return;
        params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;

        // Set current_foreign_class so *Self in user-param resolution
        // resolves to *<Cls>State (M1.2 A.2b). Save+restore.
        const saved_fc = self.current_foreign_class;
        self.current_foreign_class = fcd;
        defer self.current_foreign_class = saved_fc;

        const param_start: usize = 1;
        for (md.params[param_start..], 0..) |p_node, i| {
            // User params are reflected at the C-ABI boundary AS-IS —
            // the runtime trampoline forwards them through to the body.
            // *Self here would be a programming error (only the implicit
            // self at index 0 is *Self), but we use resolveType to handle
            // pointer types correctly.
            const pty = self.resolveType(p_node);
            params.append(self.alloc, .{
                .name = self.module.types.internString(md.param_names[param_start + i]),
                .ty = pty,
            }) catch return;
        }

        const ret_ty: TypeId = if (md.return_type) |rt| self.resolveType(rt) else .void;
        const params_slice = params.toOwnedSlice(self.alloc) catch return;

        _ = self.builder.beginFunction(name_id, params_slice, ret_ty);
        const func = self.builder.currentFunc();
        func.linkage = .external;
        func.call_conv = .c;
        func.has_implicit_ctx = false;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        // Pass the Obj-C receiver pointer through to the sx body as
        // `self`. The body's `self: *Self` type resolves to the
        // foreign-class stub (the opaque Obj-C type), matching Apple's
        // Obj-C semantics where `self` IS the object. `self.field`
        // access on a sx-defined class is rewritten by lowerFieldAccess
        // to go through `object_getIvar(self, __sx_state_ivar)` and
        // a struct_gep on the state struct — see M1.2 A.3.
        const obj_ref = Ref.fromIndex(0);

        // Call sx body `@<Cls>.<method>(default_ctx, self, ...user_args)`.
        const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return;
        defer self.alloc.free(body_name);
        const body_fid = self.resolveFuncByName(body_name) orelse return;

        const ctx_ref: ?Ref = blk: {
            if (!self.implicit_ctx_enabled) break :blk null;
            const dctx_gi = self.program_index.global_names.get("__sx_default_context") orelse break :blk null;
            break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void);
        };

        // Build arg list: [ctx?] + self + user_args.
        const num_user_args = params_slice.len - 2; // minus obj + _cmd
        const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + 1 + num_user_args;
        const call_args = self.alloc.alloc(Ref, num_call_args) catch return;
        var idx: usize = 0;
        if (ctx_ref) |c_ref| {
            call_args[idx] = c_ref;
            idx += 1;
        }
        call_args[idx] = obj_ref;
        idx += 1;
        var ip: usize = 2;
        while (ip < params_slice.len) : (ip += 1) {
            call_args[idx] = Ref.fromIndex(@intCast(ip));
            idx += 1;
        }

        const call_ref = self.builder.emit(.{ .call = .{
            .callee = body_fid,
            .args = call_args,
        } }, ret_ty);

        // (4) Return.
        if (ret_ty == .void) {
            self.builder.retVoid();
        } else {
            self.builder.ret(call_ref, ret_ty);
        }

        self.builder.finalize();
    }

    /// Synthesize the `+alloc` IMP for an sx-defined `#objc_class`.
    /// Class method registered on the metaclass — when `[SxFoo alloc]`
    /// runs from Apple's runtime (Info.plist principal class,
    /// NSCoder unarchive, UIKit reflection), this IMP fires.
    ///
    /// C-ABI: `(cls: id, _cmd: SEL) -> id`. No implicit ctx.
    ///
    /// Body (M4.0):
    ///   %instance = class_createInstance(cls, 0)
    ///   %ctx_addr = &__sx_default_context
    ///   %state    = ctx_addr.allocator.alloc(STATE_SIZE)
    ///   memset(state, 0, STATE_SIZE)
    ///   state[0]  = allocator                    ← capture for -dealloc
    ///   object_setIvar(instance, __sx_state_ivar, state)
    ///   ret instance
    ///
    /// Sx-side `Cls.alloc()` is intercepted at the call site (see
    /// `lowerObjcStaticCall`) and emits the same sequence inline with
    /// `current_ctx_ref` as the ctx — so `push Context.{ allocator = ... }`
    /// flows through to per-instance allocator capture without going via
    /// the IMP.
    fn emitObjcDefinedClassAllocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void {
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        defer {
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
        }

        const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_alloc_imp", .{fcd.name}) catch return;
        const name_id = self.module.types.internString(imp_name);
        const ptr_void = self.module.types.ptrTo(.void);

        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{ .name = self.module.types.internString("cls"), .ty = ptr_void }) catch return;
        params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;
        const params_slice = params.toOwnedSlice(self.alloc) catch return;

        _ = self.builder.beginFunction(name_id, params_slice, ptr_void);
        const func = self.builder.currentFunc();
        func.linkage = .external;
        func.call_conv = .c;
        func.has_implicit_ctx = false;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        // ctx_addr = &__sx_default_context — IMP runs in Apple's runtime
        // context, no implicit sx ctx to inherit, so use the process-wide
        // default allocator. Sx-side callers bypass this IMP entirely
        // (compiler intercepts Cls.alloc()) and use their own
        // `context.allocator`.
        const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse {
            if (self.diagnostics) |d| {
                d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassAllocImp: __sx_default_context global missing for class '{s}' (compiler bug — scan pass did not register the default context)", .{fcd.name});
            }
            return;
        };
        const ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void);

        const cls_ref = Ref.fromIndex(0);
        const instance = self.emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr) orelse return;

        self.builder.ret(instance, ptr_void);
        self.builder.finalize();
    }

    /// Shared inline sequence: allocate Obj-C instance + sx state struct,
    /// capture the allocator, bind to the `__sx_state` ivar. Used by both
    /// the `+alloc` IMP (ctx_addr = &__sx_default_context) and the sx-side
    /// `Cls.alloc()` interception (ctx_addr = current_ctx_ref).
    ///
    /// Returns the new instance pointer, or `null` if a required global is
    /// missing (compiler bug — should be impossible after scan pass).
    fn emitObjcDefinedAllocAndInit(
        self: *Lowering,
        fcd: *const ast.ForeignClassDecl,
        cls_ref: Ref,
        ctx_addr: Ref,
    ) ?Ref {
        const ptr_void = self.module.types.ptrTo(.void);

        // (1) instance = class_createInstance(cls, 0)
        const create_fid = self.ensureCRuntimeDecl("class_createInstance", &.{ ptr_void, .u64 }, ptr_void);
        const create_args = self.alloc.alloc(Ref, 2) catch return null;
        create_args[0] = cls_ref;
        create_args[1] = self.builder.constInt(0, .u64);
        const instance = self.builder.emit(.{ .call = .{ .callee = create_fid, .args = create_args } }, ptr_void);

        // STATE_SIZE = max(typeSizeBytes(__<Cls>State), 1).
        const state_struct_ty = self.objcDefinedStateStructType(fcd);
        const raw_size = self.module.types.typeSizeBytes(state_struct_ty);
        const state_size: u64 = if (raw_size == 0) 1 else @intCast(raw_size);
        const size_const = self.builder.constInt(@intCast(state_size), .u64);

        // (2) Dispatch through Context.allocator at ctx_addr:
        //       allocator = (*ctx_addr).field[0]
        //       state     = allocator.alloc(size)  (via inline-protocol fn-ptr)
        const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
            if (self.diagnostics) |d| {
                d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context type not found in module for class '{s}' (compiler bug)", .{fcd.name});
            }
            return null;
        };
        const ctx_info = self.module.types.get(ctx_ty);
        if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) {
            if (self.diagnostics) |d| {
                d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context has unexpected shape for class '{s}' (compiler bug)", .{fcd.name});
            }
            return null;
        }
        const allocator_ty = ctx_info.@"struct".fields[0].ty;
        const ctx_val = self.builder.load(ctx_addr, ctx_ty);
        const allocator = self.builder.structGet(ctx_val, 0, allocator_ty);
        const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void);
        const alloc_fn_ptr = self.builder.structGet(allocator, 1, ptr_void);
        const call_args = self.alloc.dupe(Ref, &.{ ctx_addr, alloc_ctx, size_const }) catch return null;
        const state = self.builder.emit(.{ .call_indirect = .{
            .callee = alloc_fn_ptr,
            .args = call_args,
        } }, ptr_void);

        // (3) memset(state, 0, STATE_SIZE) — zero everything including the
        // allocator slot; the next store re-writes the allocator slot.
        const memset_fid = self.ensureCRuntimeDecl("memset", &.{ ptr_void, .s32, .u64 }, ptr_void);
        const memset_args = self.alloc.alloc(Ref, 3) catch return null;
        memset_args[0] = state;
        memset_args[1] = self.builder.constInt(0, .s32);
        memset_args[2] = size_const;
        _ = self.builder.emit(.{ .call = .{ .callee = memset_fid, .args = memset_args } }, ptr_void);

        // (4) Capture allocator at state[0] — `-dealloc` reads it back.
        const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{
            .base = state,
            .field_index = 0,
            .base_type = state_struct_ty,
        } }, ptr_void);
        self.builder.store(state_alloc_addr, allocator);

        // (5) object_setIvar(instance, load(@__<Cls>_state_ivar), state)
        const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null;
        defer self.alloc.free(ivar_global_name);
        const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse {
            if (self.diagnostics) |d| {
                d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: ivar global '{s}' missing (scan-pass bug)", .{ivar_global_name});
            }
            return null;
        };
        const ivar_addr_v = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
        const ivar_handle = self.builder.load(ivar_addr_v, ptr_void);
        const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void);
        const set_args = self.alloc.alloc(Ref, 3) catch return null;
        set_args[0] = instance;
        set_args[1] = ivar_handle;
        set_args[2] = state;
        _ = self.builder.emit(.{ .call = .{ .callee = set_ivar_fid, .args = set_args } }, .void);

        return instance;
    }

    /// Emit a C-ABI IMP trampoline for a CLASS method (no `*Self`
    /// first param) on a sx-defined `#objc_class`. M2.1(b).
    /// Registered on the metaclass by emit_llvm.
    ///
    /// C-ABI: `(cls: Class, _cmd: SEL, ...user_args) -> ret`
    ///
    /// Body:
    ///   call @<Cls>.<method>(__sx_default_context, ...user_args)
    ///   ret <result>
    ///
    /// No ivar read — class methods have no per-instance state.
    fn emitObjcDefinedClassStaticImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void {
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        defer {
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
        }

        const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, md.name }) catch return;
        const name_id = self.module.types.internString(imp_name);
        const ptr_void = self.module.types.ptrTo(.void);

        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{ .name = self.module.types.internString("cls"), .ty = ptr_void }) catch return;
        params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;

        // current_foreign_class lets `*Self` (if it appears in
        // user-arg types — rare for class methods) resolve to the
        // state-struct type. Save+restore.
        const saved_fc = self.current_foreign_class;
        self.current_foreign_class = fcd;
        defer self.current_foreign_class = saved_fc;

        for (md.params, 0..) |p_node, i| {
            const pty = self.resolveType(p_node);
            params.append(self.alloc, .{
                .name = self.module.types.internString(md.param_names[i]),
                .ty = pty,
            }) catch return;
        }

        const ret_ty: TypeId = if (md.return_type) |rt| self.resolveType(rt) else .void;
        const params_slice = params.toOwnedSlice(self.alloc) catch return;

        _ = self.builder.beginFunction(name_id, params_slice, ret_ty);
        const func = self.builder.currentFunc();
        func.linkage = .external;
        func.call_conv = .c;
        func.has_implicit_ctx = false;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        // Call @<Cls>.<method>(default_ctx, ...user_args).
        const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return;
        defer self.alloc.free(body_name);
        const body_fid = self.resolveFuncByName(body_name) orelse return;

        const ctx_ref: ?Ref = blk: {
            if (!self.implicit_ctx_enabled) break :blk null;
            const dctx_gi = self.program_index.global_names.get("__sx_default_context") orelse break :blk null;
            break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void);
        };

        const num_user_args = params_slice.len - 2; // minus cls + _cmd
        const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + num_user_args;
        const call_args = self.alloc.alloc(Ref, num_call_args) catch return;
        var idx: usize = 0;
        if (ctx_ref) |c_ref| {
            call_args[idx] = c_ref;
            idx += 1;
        }
        var ip: usize = 2;
        while (ip < params_slice.len) : (ip += 1) {
            call_args[idx] = Ref.fromIndex(@intCast(ip));
            idx += 1;
        }

        const call_ref = self.builder.emit(.{ .call = .{
            .callee = body_fid,
            .args = call_args,
        } }, ret_ty);

        if (ret_ty == .void) self.builder.retVoid() else self.builder.ret(call_ref, ret_ty);
        self.builder.finalize();
    }

    /// Synthesize the `-dealloc` IMP for an sx-defined `#objc_class`.
    /// Runs when the Obj-C runtime drops the last retain on an instance.
    ///
    /// C-ABI: `(self: id, _cmd: SEL) -> void`. No implicit sx ctx.
    ///
    /// Body (M4.0c):
    ///   %state     = object_getIvar(self, load @__<Cls>_state_ivar)
    ///   %allocator = load struct_gep(state, 0)          ← __sx_allocator (M4.0a)
    ///   allocator.dealloc(state)                         ← via inline-protocol fn-ptr
    ///   object_setIvar(self, ivar, null)
    ///   [super dealloc]   // objc_msgSendSuper2(&super, sel_dealloc)
    ///   ret void
    ///
    /// The state struct's first field is the allocator captured at
    /// +alloc time (M4.0a + M4.0b). Reading it back lets -dealloc free
    /// through the same allocator the instance was constructed with —
    /// the per-instance allocator design from M1.2 A.5, now realised.
    fn emitObjcDefinedClassDeallocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void {
        const saved_func = self.builder.func;
        const saved_block = self.builder.current_block;
        const saved_counter = self.builder.inst_counter;
        defer {
            self.builder.func = saved_func;
            self.builder.current_block = saved_block;
            self.builder.inst_counter = saved_counter;
        }

        const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_dealloc_imp", .{fcd.name}) catch return;
        const name_id = self.module.types.internString(imp_name);
        const ptr_void = self.module.types.ptrTo(.void);

        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return;
        params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;
        const params_slice = params.toOwnedSlice(self.alloc) catch return;

        _ = self.builder.beginFunction(name_id, params_slice, .void);
        const func = self.builder.currentFunc();
        func.linkage = .external;
        func.call_conv = .c;
        func.has_implicit_ctx = false;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        const self_ref = Ref.fromIndex(0);

        // (1) state = object_getIvar(self, load @__<Cls>_state_ivar)
        const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return;
        defer self.alloc.free(ivar_global_name);
        const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return;
        const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
        const ivar_handle = self.builder.load(ivar_addr, ptr_void);

        const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
        const get_args = self.alloc.alloc(Ref, 2) catch return;
        get_args[0] = self_ref;
        get_args[1] = ivar_handle;
        const state = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void);

        // (2) M4.B dealloc — release strong/copy property ivars and
        // destroyWeak weak property ivars BEFORE freeing the state struct
        // (which would invalidate the pointers we need to read). Property
        // metadata is re-derived from `fcd.members`; the state struct is
        // already interned via objcDefinedStateStructType.
        const state_struct_ty = self.objcDefinedStateStructType(fcd);
        const state_info_check = self.module.types.get(state_struct_ty);
        if (state_info_check == .@"struct") {
            const state_fields = state_info_check.@"struct".fields;
            for (fcd.members) |m| switch (m) {
                .field => |f| {
                    if (!f.is_property) continue;
                    // Find the field index in the state struct (by name —
                    // M4.0a's prepended __sx_allocator shifted user fields).
                    const field_name_id = self.module.types.internString(f.name);
                    var pfidx: ?u32 = null;
                    for (state_fields, 0..) |sf, i| {
                        if (sf.name == field_name_id) {
                            pfidx = @intCast(i);
                            break;
                        }
                    }
                    const fidx = pfidx orelse continue;
                    const field_ty = self.resolveType(f.field_type);
                    const kind = self.objcPropertyKind(f);

                    switch (kind) {
                        .assign => {}, // no ARC ops
                        .strong, .copy => {
                            // val = load field; objc_release(val) — release(NULL) is a no-op.
                            self.ensureArcRuntimeDecls();
                            const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void);
                            const field_addr = self.builder.emit(.{ .struct_gep = .{
                                .base = state,
                                .field_index = fidx,
                                .base_type = state_struct_ty,
                            } }, ptr_void);
                            const val = self.builder.load(field_addr, field_ty);
                            const args = self.alloc.alloc(Ref, 1) catch continue;
                            args[0] = val;
                            _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = args } }, .void);
                        },
                        .weak => {
                            // objc_destroyWeak(&field) — unregisters the slot
                            // from libobjc's side-table.
                            self.ensureArcRuntimeDecls();
                            const destroy_weak_fid = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void);
                            const field_addr = self.builder.emit(.{ .struct_gep = .{
                                .base = state,
                                .field_index = fidx,
                                .base_type = state_struct_ty,
                            } }, ptr_void);
                            const args = self.alloc.alloc(Ref, 1) catch continue;
                            args[0] = field_addr;
                            _ = self.builder.emit(.{ .call = .{ .callee = destroy_weak_fid, .args = args } }, .void);
                        },
                    }
                },
                else => {},
            };
        }

        // (3) Free state through the captured allocator (M4.0a + M4.0b):
        //       allocator = load struct_gep(state, 0)   ← __sx_allocator field
        //       allocator.dealloc(state)                 ← inline-protocol fn-ptr at field 2
        // Compare to the old `free(state)` — that ignored the per-instance
        // allocator and went straight to libc. Now `push Context.{ allocator = arena }`
        // round-trips correctly: arena.alloc on construction, arena.dealloc here.
        const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
            if (self.diagnostics) |d| {
                d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: Context type not found for class '{s}' (compiler bug)", .{fcd.name});
            }
            return;
        };
        const ctx_info = self.module.types.get(ctx_ty);
        if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) {
            if (self.diagnostics) |d| {
                d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: Context has unexpected shape for class '{s}'", .{fcd.name});
            }
            return;
        }
        const allocator_ty = ctx_info.@"struct".fields[0].ty;

        const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{
            .base = state,
            .field_index = 0,
            .base_type = state_struct_ty,
        } }, ptr_void);
        const allocator = self.builder.load(state_alloc_addr, allocator_ty);

        // Default-context address for the implicit __sx_ctx the dealloc
        // fn-ptr takes as its first arg (the dealloc body might allocate
        // internally; default GPA is the safe baseline).
        const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse {
            if (self.diagnostics) |d| {
                d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: __sx_default_context global missing for class '{s}'", .{fcd.name});
            }
            return;
        };
        const default_ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void);
        const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void);
        const dealloc_fn_ptr = self.builder.structGet(allocator, 2, ptr_void);
        const dealloc_args = self.alloc.dupe(Ref, &.{ default_ctx_addr, alloc_ctx, state }) catch return;
        _ = self.builder.emit(.{ .call_indirect = .{
            .callee = dealloc_fn_ptr,
            .args = dealloc_args,
        } }, .void);

        // (3) object_setIvar(self, ivar, null)
        const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void);
        const null_ptr = self.builder.constInt(0, ptr_void);
        const set_args = self.alloc.alloc(Ref, 3) catch return;
        set_args[0] = self_ref;
        set_args[1] = ivar_handle;
        set_args[2] = null_ptr;
        _ = self.builder.emit(.{ .call = .{ .callee = set_ivar_fid, .args = set_args } }, .void);

        // (4) [super dealloc]
        //
        // objc_super = struct { receiver: id, super_class: Class }
        const super_struct_ty = self.module.types.intern(.{ .@"struct" = .{
            .name = self.module.types.internString("__sx_objc_super"),
            .fields = blk: {
                var f = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
                f.append(self.alloc, .{ .name = self.module.types.internString("receiver"), .ty = ptr_void }) catch unreachable;
                f.append(self.alloc, .{ .name = self.module.types.internString("super_class"), .ty = ptr_void }) catch unreachable;
                break :blk f.toOwnedSlice(self.alloc) catch unreachable;
            },
        } });
        const super_alloca = self.builder.alloca(super_struct_ty);

        // store receiver
        const recv_gep = self.builder.emit(.{ .struct_gep = .{ .base = super_alloca, .field_index = 0, .base_type = super_struct_ty } }, ptr_void);
        self.builder.store(recv_gep, self_ref);

        // store super_class = load @__<Cls>_class
        const class_global_name = std.fmt.allocPrint(self.alloc, "__{s}_class", .{fcd.name}) catch return;
        defer self.alloc.free(class_global_name);
        const class_global_id = self.lookupGlobalIdByName(class_global_name) orelse return;
        const class_addr = self.builder.emit(.{ .global_addr = class_global_id }, ptr_void);
        const class_val = self.builder.load(class_addr, ptr_void);
        const cls_gep = self.builder.emit(.{ .struct_gep = .{ .base = super_alloca, .field_index = 1, .base_type = super_struct_ty } }, ptr_void);
        self.builder.store(cls_gep, class_val);

        // sel_dealloc = sel_registerName("dealloc")
        const sel_reg_fid = self.ensureCRuntimeDecl("sel_registerName", &.{ptr_void}, ptr_void);
        const sel_str_gid = self.internStringConstantGlobal("dealloc");
        const sel_str_addr = self.builder.emit(.{ .global_addr = sel_str_gid }, ptr_void);
        const sel_args = self.alloc.alloc(Ref, 1) catch return;
        sel_args[0] = sel_str_addr;
        const sel_dealloc = self.builder.emit(.{ .call = .{ .callee = sel_reg_fid, .args = sel_args } }, ptr_void);

        // objc_msgSendSuper2(&super, sel_dealloc)
        const send_super_fid = self.ensureCRuntimeDecl("objc_msgSendSuper2", &.{ ptr_void, ptr_void }, .void);
        const send_args = self.alloc.alloc(Ref, 2) catch return;
        send_args[0] = super_alloca;
        send_args[1] = sel_dealloc;
        _ = self.builder.emit(.{ .call = .{ .callee = send_super_fid, .args = send_args } }, .void);

        self.builder.retVoid();
        self.builder.finalize();
    }

    /// Intern a C-string constant as a `[N:0]u8` global and return
    /// its GlobalId. Used by IMP trampolines that need to pass a
    /// literal string to runtime helpers (e.g. selector names).
    fn internStringConstantGlobal(self: *Lowering, s: []const u8) inst_mod.GlobalId {
        const z = self.alloc.allocSentinel(u8, s.len, 0) catch unreachable;
        @memcpy(z[0..s.len], s);
        const arr_ty = self.module.types.arrayOf(.u8, @intCast(s.len + 1));
        const slot_name = std.fmt.allocPrint(self.alloc, "__sx_objc_cstr_{s}", .{s}) catch unreachable;
        const name_id = self.module.types.internString(slot_name);
        if (self.lookupGlobalIdByName(slot_name)) |existing| {
            self.alloc.free(z);
            return existing;
        }
        var bytes_vec = std.ArrayList(inst_mod.ConstantValue).empty;
        for (z[0 .. s.len + 1]) |b| {
            bytes_vec.append(self.alloc, .{ .int = b }) catch unreachable;
        }
        const init_val: inst_mod.ConstantValue = .{ .aggregate = bytes_vec.toOwnedSlice(self.alloc) catch unreachable };
        return self.module.addGlobal(.{
            .name = name_id,
            .ty = arr_ty,
            .init_val = init_val,
            .is_extern = false,
            .is_const = true,
        });
    }

    /// Linear scan over module globals for a given name. Used for
    /// looking up the per-class ivar handle global from inside IMP
    /// trampoline emission.
    fn lookupGlobalIdByName(self: *Lowering, name: []const u8) ?inst_mod.GlobalId {
        const name_id = self.module.types.internString(name);
        for (self.module.globals.items, 0..) |g, i| {
            if (g.name == name_id) return inst_mod.GlobalId.fromIndex(@intCast(i));
        }
        return null;
    }

    fn synthesizeJniMainStubs(self: *Lowering) void {
        var seen = std.StringHashMap(void).init(self.alloc);
        defer seen.deinit();

        var it = self.program_index.foreign_class_map.iterator();
        while (it.next()) |entry| {
            const fcd = entry.value_ptr.*;
            if (!fcd.is_main) continue;
            if (fcd.is_foreign) continue;
            if (fcd.runtime != .jni_class) continue;
            if (seen.contains(fcd.foreign_path)) continue;
            seen.put(fcd.foreign_path, {}) catch continue;

            for (fcd.members) |m| switch (m) {
                .method => |md| {
                    if (md.body == null) continue;
                    if (md.is_static) continue; // future: emit static native ABI without `self`
                    self.synthesizeJniMainStub(fcd, md);
                },
                else => {},
            };
        }
    }

    fn synthesizeJniMainStub(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void {
        const mangled = jniMangleNativeName(self.alloc, fcd.foreign_path, md.name) catch return;
        const name_id = self.module.types.internString(mangled);

        const ptr_void = self.module.types.ptrTo(.void);
        var params = std.ArrayList(inst_mod.Function.Param).empty;
        params.append(self.alloc, .{
            .name = self.module.types.internString("env"),
            .ty = ptr_void,
        }) catch return;
        params.append(self.alloc, .{
            .name = self.module.types.internString("self"),
            .ty = ptr_void,
        }) catch return;

        // User's declared params (skip the implicit `*Self` at index 0 for
        // instance methods — we synthesized `self` above as the jobject).
        const param_start: usize = 1;
        for (md.params[param_start..], 0..) |p_node, i| {
            const pty = jniMapParamType(self, p_node);
            params.append(self.alloc, .{
                .name = self.module.types.internString(md.param_names[param_start + i]),
                .ty = pty,
            }) catch return;
        }

        const ret_ty = if (md.return_type) |rt| jniMapParamType(self, rt) else .void;
        const params_slice = params.toOwnedSlice(self.alloc) catch return;

        _ = self.builder.beginFunction(name_id, params_slice, ret_ty);
        self.builder.currentFunc().linkage = .external;
        self.builder.currentFunc().call_conv = .c;

        const entry_name = self.module.types.internString("entry");
        const entry = self.builder.appendBlock(entry_name, &.{});
        self.builder.switchToBlock(entry);

        var scope = Scope.init(self.alloc, self.scope);
        defer scope.deinit();
        const saved_scope = self.scope;
        self.scope = &scope;
        defer self.scope = saved_scope;

        for (params_slice, 0..) |p, i| {
            const slot = self.builder.alloca(p.ty);
            const param_ref = Ref.fromIndex(@intCast(i));
            self.builder.store(slot, param_ref);
            scope.put(self.module.types.getString(p.name), .{ .ref = slot, .ty = p.ty, .is_alloca = true });
        }

        // Push the JNIEnv* arg onto the lexical `#jni_env` stack so the
        // method body's `#jni_call(...)` / `super.method(...)` sites pick
        // it up without an explicit `#jni_env(env) { ... }` wrapper. The
        // JNI runtime guarantees the env passed to a native method is
        // valid for the calling thread.
        const env_slot = scope.lookup("env").?.ref;
        const env_loaded = self.builder.load(env_slot, ptr_void);
        const env_stack_base = self.jni_env_stack_base;
        self.jni_env_stack_base = self.jni_env_stack.items.len;
        self.jni_env_stack.append(self.alloc, env_loaded) catch {};
        defer {
            _ = self.jni_env_stack.pop();
            self.jni_env_stack_base = env_stack_base;
        }

        // Record method context so `super.method(args)` inside the body
        // can find the parent class (via `#extends`) and the method's
        // signature.
        const saved_fcd = self.current_foreign_class;
        const saved_method = self.current_foreign_method;
        self.current_foreign_class = fcd;
        self.current_foreign_method = md;
        defer {
            self.current_foreign_class = saved_fcd;
            self.current_foreign_method = saved_method;
        }

        // JNI native methods are C-callable entry points — install the
        // static default Context so `context.X` reads in the method body
        // resolve through `current_ctx_ref`. Mirror the same binding
        // `lowerFunction` does for callconv(.c) / isExportedEntryName.
        const saved_ctx_ref_jni = self.current_ctx_ref;
        defer self.current_ctx_ref = saved_ctx_ref_jni;
        if (self.implicit_ctx_enabled) {
            if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| {
                self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void);
            }
        }

        const saved_target = self.target_type;
        self.target_type = if (ret_ty != .void) ret_ty else null;
        if (ret_ty != .void) {
            const body_val = self.lowerBlockValue(md.body.?);
            if (!self.currentBlockHasTerminator()) {
                if (body_val) |val| {
                    const val_ty = self.builder.getRefType(val);
                    if (val_ty == .void) {
                        self.ensureTerminator(ret_ty);
                    } else {
                        const coerced = self.coerceToType(val, val_ty, ret_ty);
                        self.builder.ret(coerced, ret_ty);
                    }
                } else {
                    self.ensureTerminator(ret_ty);
                }
            }
        } else {
            self.lowerBlock(md.body.?);
            self.ensureTerminator(ret_ty);
        }
        self.target_type = saved_target;

        self.builder.finalize();
    }
};

/// JNI param/return type resolution: user-declared types pass through
/// `resolveType` so the method body can dispatch on richer foreign-class
/// types (`holder.getSurface()` etc.). At LLVM level both `*SurfaceHolder`
/// and `*void` lower to the same `ptr`, so the C ABI shape Java sees is
/// unchanged — only sx-side method resolution benefits.
fn jniMapParamType(self: *Lowering, type_node: *ast.Node) TypeId {
    return self.resolveType(type_node);
}

/// Whether emit_llvm's `jni_msg_send` lowering can dispatch a Call<T>Method
/// for this return type. Anything outside this set falls into the `else`
/// arm of the switches in `emit_llvm.zig` and would silently produce
/// `LLVMGetUndef` — a footgun that previously shipped (chess Android touch
/// went undef because `MotionEvent.getX() -> f32` wasn't in the switch).
/// Pointer-typed returns route through `CallObjectMethod`.
pub fn isJniReturnTypeSupported(table: *const @import("types.zig").TypeTable, ret_ty: TypeId) bool {
    return switch (ret_ty) {
        .void, .bool, .s32, .s64, .f32, .f64 => true,
        else => blk: {
            if (ret_ty.isBuiltin()) break :blk false;
            const info = table.get(ret_ty);
            break :blk info == .pointer or info == .many_pointer;
        },
    };
}

/// Encode a (foreign_path, method_name) pair as the JNI-resolved symbol
/// `Java_<pkg-mangled>_<Class>_sx_1<method-mangled>`. JNI mangling:
/// `/` → `_`, `_` → `_1`. The `sx_` prefix matches the Java-side
/// `private native sx_<name>(...)` delegate.
fn jniMangleNativeName(allocator: std.mem.Allocator, foreign_path: []const u8, method_name: []const u8) ![]u8 {
    var buf = std.ArrayList(u8).empty;
    try buf.appendSlice(allocator, "Java_");
    for (foreign_path) |ch| {
        if (ch == '/') {
            try buf.append(allocator, '_');
        } else if (ch == '_') {
            try buf.appendSlice(allocator, "_1");
        } else {
            try buf.append(allocator, ch);
        }
    }
    try buf.append(allocator, '_');
    try buf.appendSlice(allocator, "sx_1");
    for (method_name) |ch| {
        if (ch == '_') {
            try buf.appendSlice(allocator, "_1");
        } else {
            try buf.append(allocator, ch);
        }
    }
    return buf.toOwnedSlice(allocator);
}
