//! TypeScript code generator — rule-based (Tier 2) transpilation from AIR to TS.
//!
//! Extends the JavaScript codegen with:
//! - Type annotations on parameters, return types, and bindings
//! - Generics → TS generics (preserved, not erased)
//! - Traits → TS interfaces
//! - Algebraic types → discriminated union types + tagged objects
//! - Type aliases → `type X = ...`

use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::path::PathBuf;

use bock_air::{AIRNode, AirInterpolationPart, EnumVariantPayload, NodeKind, ResultVariant};
use bock_ast::{AssignOp, BinOp, Literal, TypeExpr, UnaryOp, Visibility};
use bock_errors::Span;
use bock_types::AIRModule;

use crate::error::CodegenError;
use crate::generator::{CodeGenerator, GeneratedCode, OutputFile, SourceMap, SourceMapping};
use crate::profile::TargetProfile;

/// Runtime helpers injected when `Channel` / `spawn` appear in a module.
/// See the analogous `CONCURRENCY_RUNTIME_JS` in `js.rs`.
/// Conservative module scan — if the serialized AIR mentions `Channel`
/// or `spawn`, emit the runtime prelude. Unused helpers are trivially
/// dead-code eliminated by downstream TS tooling.
fn module_uses_concurrency(items: &[AIRNode]) -> bool {
    items.iter().any(|n| {
        let s = format!("{n:?}");
        s.contains("\"Channel\"") || s.contains("\"spawn\"")
    })
}

/// Runtime type for Bock `Optional[T]` in TypeScript. The *value*
/// representation is a tagged object — `{ _tag: "Some", _0: v }` or
/// `{ _tag: "None" }` (see [`TsEmitCtx::try_emit_prelude_ctor`] and the `None`
/// identifier in [`TsEmitCtx::emit_expr`]) — so the *type* must be the matching
/// discriminated union, not `T | null`. This mirrors the Go `__bockOption`
/// runtime added in the codegen-correctness workstream: type and value agree,
/// a `match` lowered to `switch (x._tag)` narrows correctly, and the two-variant
/// union is provably exhaustive (so a `string`-returning match needs no
/// `default`).
const OPTIONAL_RUNTIME_TS: &str = "\
// ── Bock Optional runtime ──
type BockOption<T> =
  | { readonly _tag: \"Some\"; readonly _0: T }
  | { readonly _tag: \"None\" };
";

/// True if the module references `Optional`, `Some`, or `None` anywhere — or
/// calls `pop`, whose DQ30 lowering produces a `BockOption<T>`-typed IIFE —
/// so the Optional runtime type prelude must be emitted. A cheap structural
/// scan over the debug rendering, mirroring [`module_uses_concurrency`] and
/// the Go backend's `go_module_uses_optional`. Over-matching (e.g. a user
/// method named `pop`) only emits/imports an unused type alias, which is
/// harmless under the scaffolded tsconfig.
fn module_uses_optional(items: &[AIRNode]) -> bool {
    items.iter().any(|n| {
        let s = format!("{n:?}");
        s.contains("\"Optional\"")
            || s.contains("TypeOptional")
            || s.contains("\"Some\"")
            || s.contains("\"None\"")
            || s.contains("\"pop\"")
    })
}

/// Runtime type for Bock `Result[T, E]` in TypeScript. The value representation
/// is a tagged object — `{ _tag: "Ok", _0: v }` or `{ _tag: "Err", _0: e }`
/// (see [`TsEmitCtx::try_emit_prelude_ctor`], the `ResultConstruct` arm, and the
/// `Result`-match lowering) — so the type is the matching discriminated union,
/// not the structural `Ok`/`Err` aliases that previously went undefined. Both
/// arms carry the payload under the same `_0` key the match reads, so a `match r
/// { Ok(v) => …; Err(e) => … }` lowered to `switch (r._tag)` narrows correctly
/// and the two-variant union is provably exhaustive (no `default` needed). This
/// mirrors [`OPTIONAL_RUNTIME_TS`].
const RESULT_RUNTIME_TS: &str = "\
// ── Bock Result runtime ──
type BockResult<T, E> =
  | { readonly _tag: \"Ok\"; readonly _0: T }
  | { readonly _tag: \"Err\"; readonly _0: E };
";

/// True if the module references `Result`, `Ok`, or `Err` anywhere, so the
/// `Result` runtime type prelude must be emitted. Mirrors [`module_uses_optional`].
fn module_uses_result(items: &[AIRNode]) -> bool {
    items.iter().any(|n| {
        let s = format!("{n:?}");
        s.contains("\"Result\"")
            || s.contains("ResultConstruct")
            || s.contains("\"Ok\"")
            || s.contains("\"Err\"")
    })
}

const CONCURRENCY_RUNTIME_TS: &str = "\
// ── Bock concurrency runtime ──
type __BockChannel<T> = {
  send(v: T): void;
  recv(): Promise<T>;
  close(): void;
};
const __bockChannelNew = <T>(): [__BockChannel<T>, __BockChannel<T>] => {
  const queue: T[] = [];
  const waiters: Array<(v: T) => void> = [];
  const ch: __BockChannel<T> = {
    send(v: T) {
      if (waiters.length > 0) { waiters.shift()!(v); } else { queue.push(v); }
    },
    recv(): Promise<T> {
      return new Promise<T>((resolve) => {
        if (queue.length > 0) { resolve(queue.shift()!); }
        else { waiters.push(resolve); }
      });
    },
    close() {}
  };
  return [ch, ch];
};
const __bockSpawn = <T>(x: Promise<T>): Promise<T> => x;
";

/// Runtime helpers for Bock range expressions (`0..n` / `0..=n`) in TypeScript.
/// TS has no native range value, so `for i in 0..n` lowers to
/// `for (const i of range(0, n))`. `range` is half-open, `rangeInclusive`
/// inclusive — matching Python's `range(lo, hi)` / `range(lo, hi + 1)` and
/// Rust's `lo..hi` / `lo..=hi`. Emitted once into the shared `_bock_runtime.ts`
/// (per-module path) or inlined at most once (single-module path), gated on a
/// ctx flag (mirrors [`OPTIONAL_RUNTIME_TS`]).
const RANGE_RUNTIME_TS: &str = "\
// ── Bock range runtime ──
const range = (lo: number, hi: number): number[] => { const r: number[] = []; for (let i = lo; i < hi; i++) r.push(i); return r; };
const rangeInclusive = (lo: number, hi: number): number[] => { const r: number[] = []; for (let i = lo; i <= hi; i++) r.push(i); return r; };
";

/// True if the module references a `Range` node anywhere (so the range runtime
/// prelude must be emitted). Mirrors [`module_uses_optional`]. `RangePat` does
/// not contain the `Range {` substring, so match-arm range patterns do not
/// trigger the (value-only) helpers.
fn module_uses_range(items: &[AIRNode]) -> bool {
    items.iter().any(|n| format!("{n:?}").contains("Range {"))
}

/// Runtime helper for DQ29 structural equality in TypeScript — the typed twin
/// of the JS backend's `__bockEq` (see `EQ_RUNTIME_JS` in `js.rs` for the full
/// semantics): `===` on two objects is reference identity, so every stamped
/// `==`/`!=` (lanes `"structural"`/`"deep"`/`"generic"` of
/// [`crate::generator::user_eq_kind`]) and every `a.eq(b)` bridge call on an
/// `Equatable`-bounded generic lowers through this deep, order-independent
/// (for `Map`/`Set`), explicit-impl-honoring comparison. Non-objects fall back
/// to `===`, preserving IEEE `NaN !== NaN`. Emitted once into the shared
/// `_bock_runtime.ts` (per-module path) or inlined at most once
/// (single-module path), gated on a ctx flag (mirrors [`RANGE_RUNTIME_TS`]).
const EQ_RUNTIME_TS: &str = "\
// ── Bock structural equality runtime ──
const __bockEq = (a: unknown, b: unknown): boolean => {
  if (a === b) return true;
  if (typeof a !== \"object\" || typeof b !== \"object\" || a === null || b === null) {
    return a === b;
  }
  const ea = (a as { eq?: unknown }).eq;
  const eb = (b as { eq?: unknown }).eq;
  if (typeof ea === \"function\" && typeof eb === \"function\") {
    return (ea as (s: unknown, o: unknown) => boolean).call(a, a, b);
  }
  if (Array.isArray(a)) {
    if (!Array.isArray(b) || a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) { if (!__bockEq(a[i], b[i])) return false; }
    return true;
  }
  if (a instanceof Map) {
    if (!(b instanceof Map) || a.size !== b.size) return false;
    for (const [k, v] of a) {
      if (b.has(k)) { if (!__bockEq(b.get(k), v)) return false; continue; }
      let found = false;
      for (const [bk, bv] of b) { if (__bockEq(k, bk) && __bockEq(v, bv)) { found = true; break; } }
      if (!found) return false;
    }
    return true;
  }
  if (a instanceof Set) {
    if (!(b instanceof Set) || a.size !== b.size) return false;
    for (const x of a) {
      if (b.has(x)) continue;
      let found = false;
      for (const y of b) { if (__bockEq(x, y)) { found = true; break; } }
      if (!found) return false;
    }
    return true;
  }
  const ra = a as Record<string, unknown>;
  const rb = b as Record<string, unknown>;
  const ka = Object.keys(ra);
  const kb = Object.keys(rb);
  if (ka.length !== kb.length) return false;
  for (const k of ka) { if (!__bockEq(ra[k], rb[k])) return false; }
  return true;
};
";

/// True if the module contains an equality that must lower through
/// [`EQ_RUNTIME_TS`]'s `__bockEq` — a non-`"impl"` `user_eq` stamp or an
/// `Equatable`-bounded bridge call. Mirrors the JS backend's scan (see
/// `js_module_uses_eq` in `js.rs`).
fn module_uses_eq(items: &[AIRNode]) -> bool {
    items.iter().any(|n| {
        let dbg = format!("{n:?}");
        dbg.contains("\"user_eq\": String(\"structural\")")
            || dbg.contains("\"user_eq\": String(\"deep\")")
            || dbg.contains("\"user_eq\": String(\"deep_custom\")")
            || dbg.contains("\"user_eq\": String(\"generic\")")
            || dbg.contains("TraitBound:Equatable")
    })
}

/// The structural comparison runtime: lower a bounded `T: Comparable`'s
/// `a.compare(b)` through this so it dispatches to the instantiated type's own
/// `compare` method when present (a record/enum carrying an `impl Comparable`),
/// falling back to the native ternary for a primitive instantiation. Without it
/// the bounded bridge emitted the native ternary unconditionally, so `(a) < (b)`
/// on two RECORDS is always `false` and `(a) === (b)` is reference identity —
/// every comparison wrongly returned `Greater`, mis-ordering `max`/`min`/sort
/// over a user `Comparable` type. The emitted `compare` method takes
/// `(self, other)`, so the helper calls `a.compare(a, b)`. Mirrors
/// [`EQ_RUNTIME_TS`]'s `__bockEq`. (Q-bounded-comparable-codegen.)
const COMPARE_RUNTIME_TS: &str = "// ── Bock structural comparison runtime ──
type __BockOrdering = { _tag: \"Less\" } | { _tag: \"Equal\" } | { _tag: \"Greater\" };
const __bockCompare = (a: any, b: any): __BockOrdering => {
  if (a !== null && typeof a === \"object\" && typeof a.compare === \"function\") {
    return a.compare(a, b);
  }
  return (a < b ? { _tag: \"Less\" } : (a === b ? { _tag: \"Equal\" } : { _tag: \"Greater\" }));
};
";

/// True if the module contains a bounded `T: Comparable` `compare` bridge call,
/// so [`COMPARE_RUNTIME_TS`]'s `__bockCompare` must be emitted. Mirrors
/// [`module_uses_eq`].
fn module_uses_compare(items: &[AIRNode]) -> bool {
    items.iter().any(|n| {
        let dbg = format!("{n:?}");
        dbg.contains("TraitBound:Comparable")
    })
}

/// The display-string runtime: lower a `${expr}` interpolation part through this
/// so a user value with a `Displayable` impl (its emitted `to_string` method) is
/// rendered via that method, not the structural `[object Object]`. Primitives /
/// arrays / plain objects fall back to native `String(x)`. The user `to_string`
/// is `T.prototype.to_string = function(self) { … }` (snake_case, distinct from
/// JS `toString`), so the helper detects it by `typeof x.to_string ===
/// "function"` and calls `x.to_string(x)`. Mirrors `STR_RUNTIME_JS` in `js.rs`.
/// (Q-displayable-interpolation-dispatch.)
const STR_RUNTIME_TS: &str = "// ── Bock display-string runtime ──
const __bockStr = (x: any): string => {
  if (x !== null && typeof x === \"object\" && typeof x.to_string === \"function\") {
    return x.to_string(x);
  }
  return String(x);
};
";

/// True if the module contains a string interpolation (`${expr}`), so
/// [`STR_RUNTIME_TS`]'s `__bockStr` must be emitted. Mirrors [`module_uses_eq`].
fn module_uses_str(items: &[AIRNode]) -> bool {
    items.iter().any(|n| {
        let dbg = format!("{n:?}");
        dbg.contains("Interpolation")
    })
}

/// The shared per-module runtime module name (without extension). In the
/// per-module (native-import) emission path the Optional/Result runtime *types*
/// (`BockOption`, `BockResult`) and the concurrency / range runtime *helpers*
/// (`__bockChannelNew`, `range`, …) live in one file — `_bock_runtime.ts` at
/// the build root — and every emitted module imports the names it references.
/// A single shared definition avoids redeclaring `type BockOption` / `const
/// range` across files (a TS redeclaration error).
const RUNTIME_MODULE_TS: &str = "_bock_runtime";
#[derive(Debug)]
pub struct TsGenerator {
    profile: TargetProfile,
}

impl TsGenerator {
    /// Creates a new TypeScript code generator.
    #[must_use]
    pub fn new() -> Self {
        Self {
            profile: TargetProfile::typescript(),
        }
    }
}

impl Default for TsGenerator {
    fn default() -> Self {
        Self::new()
    }
}

impl CodeGenerator for TsGenerator {
    fn target(&self) -> &TargetProfile {
        &self.profile
    }

    fn generate_module(&self, module: &AIRModule) -> Result<GeneratedCode, CodegenError> {
        // Shared pre-pass: hoist value-position diverging control flow (see
        // `hoist_value_cf`) into declare-then-assign temp blocks.
        let module =
            &crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(module.clone()));
        let mut ctx = TsEmitCtx::new();
        ctx.enum_variants =
            crate::generator::collect_enum_variants(&[(module, std::path::Path::new(""))]);
        ctx.generic_decls =
            crate::generator::collect_generic_decls(&[(module, std::path::Path::new(""))]);
        ctx.trait_decls =
            crate::generator::collect_trait_decls(&[(module, std::path::Path::new(""))]);
        ctx.exported_types =
            crate::generator::collect_exported_type_names(&[(module, std::path::Path::new(""))]);
        ctx.class_fields =
            crate::generator::collect_class_fields(&[(module, std::path::Path::new(""))]);
        ctx.const_names =
            crate::generator::collect_const_names(&[(module, std::path::Path::new(""))]);
        ctx.emit_node(module)?;
        let (content, mappings) = ctx.finish();
        let source_map = SourceMap {
            generated_file: String::new(),
            mappings,
            ..Default::default()
        };
        Ok(GeneratedCode {
            files: vec![OutputFile {
                path: PathBuf::new(),
                content,
                source_map: Some(source_map),
            }],
        })
    }

    fn entry_invocation(&self, main_is_async: bool) -> Option<String> {
        if main_is_async {
            Some("(async () => { await main(); })();\n".to_string())
        } else {
            Some("main();\n".to_string())
        }
    }

    /// Emit a per-module **native ES-module import tree** (spec §20.6.1; DQ19
    /// resolved): each reachable module → its own `.ts` file, cross-module
    /// references resolved with real ESM `import`/`import type`. Mirrors the JS
    /// backend's per-module path (see [`crate::js`]) plus TS's type level:
    /// Optional/Result runtime *types* and concurrency/range runtime *helpers*
    /// are emitted once into a shared `_bock_runtime.ts`, imported per file. The
    /// minimal `package.json` `{"type":"module"}` run affordance — which makes
    /// the `.ts` source tree run under `node --experimental-strip-types` — is
    /// emitted by the **scaffolder** in project mode (S6a / DV18), not by codegen,
    /// so `--source-only` output is bare source.
    ///
    /// Output-path mapping is keyed on each module's *declared* path
    /// (`module core.option` ⇒ `core/option.ts`, imported `"./core/option.ts"` —
    /// ESM specifiers reference the *emitted* `.ts` source directly so node
    /// strip-types resolves them verbatim; `tsc` accepts the `.ts` extension via
    /// `rewriteRelativeImportExtensions`, which also rewrites it to `.js` on
    /// emit). The entry module is always `main.ts`.
    fn generate_project(
        &self,
        modules: &[(&AIRModule, &std::path::Path)],
    ) -> Result<GeneratedCode, CodegenError> {
        // Shared pre-pass: hoist value-position diverging control flow on every
        // module before registry collection or emission (see `hoist_value_cf`).
        let hoisted: Vec<(AIRModule, &std::path::Path)> = modules
            .iter()
            .map(|(m, p)| {
                (
                    crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(
                        (*m).clone(),
                    )),
                    *p,
                )
            })
            .collect();
        let modules: Vec<(&AIRModule, &std::path::Path)> =
            hoisted.iter().map(|(m, p)| (m, *p)).collect();
        let modules = modules.as_slice();
        // Emit only modules the entry program actually `use`s (plus the entry
        // itself), dependency-ordered — never the prelude-only stdlib.
        let reachable = crate::generator::reachable_modules(modules);
        let modules = reachable.as_slice();
        if modules.is_empty() {
            return Ok(GeneratedCode { files: vec![] });
        }

        let entry_idx = modules
            .iter()
            .position(|(m, _)| crate::generator::module_declares_main_fn(m))
            .unwrap_or(modules.len() - 1);

        let enum_variants = crate::generator::collect_enum_variants(modules);
        let generic_decls = crate::generator::collect_generic_decls(modules);
        let trait_decls = crate::generator::collect_trait_decls(modules);
        let exported_types = crate::generator::collect_exported_type_names(modules);
        let record_names = crate::generator::collect_record_names(modules);
        let class_fields = crate::generator::collect_class_fields(modules);
        let const_names = crate::generator::collect_const_names(modules);
        let public_symbols = crate::generator::collect_public_symbols_for_esm(modules);
        // Program-wide field/method name-collision set (camelCased). Built across
        // *all* reachable modules so a call site in `main.ts` to a renamed method
        // declared in `core/error.ts` agrees with that declaration.
        let mut field_method_collisions = HashSet::new();
        for (module, _) in modules {
            field_method_collisions.extend(crate::generator::collect_record_field_names(
                module,
                to_camel_case,
            ));
        }

        let main_is_async = modules
            .iter()
            .any(|(m, _)| crate::generator::module_main_fn_is_async(m));
        let invocation = self.entry_invocation(main_is_async);

        let mut files: Vec<OutputFile> = Vec::with_capacity(modules.len() + 2);
        let mut runtime_optional = false;
        let mut runtime_result = false;
        let mut runtime_concurrency = false;
        let mut runtime_range = false;
        let mut runtime_eq = false;
        let mut runtime_compare = false;
        let mut runtime_str = false;

        for (i, (module, source_path)) in modules.iter().enumerate() {
            let own_path = crate::generator::module_path_string(module).unwrap_or_default();
            let mut ctx = TsEmitCtx::new();
            ctx.per_module = true;
            ctx.enum_variants = enum_variants.clone();
            ctx.generic_decls = generic_decls.clone();
            ctx.trait_decls = trait_decls.clone();
            ctx.exported_types = exported_types.clone();
            // Record names need the whole reachable set so a cross-module record
            // construction lowers to `new Name(...)` (see the JS backend note).
            ctx.record_names = record_names.clone();
            // Class field-orders likewise: a cross-module `class` construction
            // must lower to its positional `new Name(...)`.
            ctx.class_fields = class_fields.clone();
            ctx.const_names = const_names.clone();
            ctx.field_method_collisions = field_method_collisions.clone();
            ctx.seed_effect_registries(modules);
            ctx.self_module_path = if i == entry_idx {
                String::new()
            } else {
                own_path.clone()
            };
            // The shared collector now matches a glob-imported (`use models.*`)
            // enum variant by its bare source name as well as its emitted
            // `Enum_Variant` value-name, so the cross-module variant import is
            // produced here for js and ts alike (no TS-local recovery needed).
            ctx.implicit_imports =
                crate::generator::implicit_esm_imports_for(module, &public_symbols, &own_path);
            ctx.public_symbols = public_symbols.clone();
            ctx.enum_variant_exports = crate::generator::enum_variant_value_names(module);
            ctx.emit_node(module)?;
            runtime_optional |= ctx.needs_runtime_optional;
            runtime_result |= ctx.needs_runtime_result;
            runtime_concurrency |= ctx.needs_runtime_concurrency;
            runtime_range |= ctx.needs_runtime_range;
            runtime_eq |= ctx.needs_runtime_eq;
            runtime_compare |= ctx.needs_runtime_compare;
            runtime_str |= ctx.needs_runtime_str;
            let (mut content, mappings) = ctx.finish();

            if i == entry_idx && crate::generator::module_declares_main_fn(module) {
                if let Some(invoc) = invocation.as_ref() {
                    if !content.is_empty() && !content.ends_with('\n') {
                        content.push('\n');
                    }
                    content.push_str(invoc);
                }
            }

            let out_path = self.module_output_path(module, source_path, i == entry_idx);
            let generated_file = out_path
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            files.push(OutputFile {
                path: out_path,
                content,
                source_map: Some(SourceMap {
                    generated_file,
                    mappings,
                    ..Default::default()
                }),
            });
        }

        // Shared runtime module: Optional/Result types and concurrency/range
        // helpers, each `export`ed (types via `export type`, values via `export
        // const`) so consuming modules `import` / `import type` them.
        if runtime_optional
            || runtime_result
            || runtime_concurrency
            || runtime_range
            || runtime_eq
            || runtime_compare
            || runtime_str
        {
            let mut content = String::new();
            if runtime_optional {
                content.push_str(&export_runtime_decls(OPTIONAL_RUNTIME_TS));
                content.push('\n');
            }
            if runtime_result {
                content.push_str(&export_runtime_decls(RESULT_RUNTIME_TS));
                content.push('\n');
            }
            if runtime_concurrency {
                content.push_str(&export_runtime_decls(CONCURRENCY_RUNTIME_TS));
                content.push('\n');
            }
            if runtime_range {
                content.push_str(&export_runtime_decls(RANGE_RUNTIME_TS));
                content.push('\n');
            }
            if runtime_compare {
                content.push_str(&export_runtime_decls(COMPARE_RUNTIME_TS));
                content.push('\n');
            }
            if runtime_str {
                content.push_str(&export_runtime_decls(STR_RUNTIME_TS));
                content.push('\n');
            }
            if runtime_eq {
                content.push_str(&export_runtime_decls(EQ_RUNTIME_TS));
                content.push('\n');
            }
            files.push(OutputFile {
                path: PathBuf::from(format!("{RUNTIME_MODULE_TS}.ts")),
                content,
                source_map: Some(SourceMap {
                    generated_file: format!("{RUNTIME_MODULE_TS}.ts"),
                    ..Default::default()
                }),
            });
        }

        // Run-affordance emission moved to the project-mode scaffolder (S6a /
        // DV18): codegen emits only the per-module `.ts` *source* tree in all
        // modes; the `package.json` `{"type":"module"}` run affordance — which
        // makes the `.ts` source tree run as ES modules under `node
        // --experimental-strip-types main.ts` — is emitted by `JsScaffolder`
        // (shared js/ts) in project mode only (never under `--source-only`). The
        // TS-only `tsconfig.json` (with `rewriteRelativeImportExtensions`) is
        // emitted by the TS scaffolder. See `scaffold.rs`.

        Ok(GeneratedCode { files })
    }

    /// Transpile `@test` functions into a Vitest/Jest `bock.test.ts` file (S7).
    ///
    /// Identical in shape to the JS backend's `generate_tests` (shared via
    /// [`crate::generator::js_ts_generate_tests`]); only the file extension and
    /// the concrete TS emit context differ. `framework` selects Vitest (default)
    /// vs Jest.
    fn generate_tests(
        &self,
        modules: &[(&AIRModule, &std::path::Path)],
        framework: &str,
    ) -> Result<crate::generator::TestArtifacts, CodegenError> {
        crate::generator::js_ts_generate_tests(
            modules,
            framework,
            &self.target().conventions.file_extension,
            // TS/ESM specifiers reference the emitted `.ts` source directly so the
            // tree runs verbatim under `node --experimental-strip-types` (which
            // does not rewrite `.js`→`.ts`); `tsc` accepts the `.ts` specifier via
            // `rewriteRelativeImportExtensions` (emitted into the scaffolded
            // tsconfig), which also rewrites it to `.js` on emit.
            "ts",
            |module, source_path, is_entry| self.module_output_path(module, source_path, is_entry),
            |ctx_modules| {
                let mut ctx = TsEmitCtx::new();
                ctx.per_module = true;
                ctx.enum_variants = crate::generator::collect_enum_variants(ctx_modules);
                ctx.generic_decls = crate::generator::collect_generic_decls(ctx_modules);
                ctx.trait_decls = crate::generator::collect_trait_decls(ctx_modules);
                ctx.exported_types = crate::generator::collect_exported_type_names(ctx_modules);
                ctx.record_names = crate::generator::collect_record_names(ctx_modules);
                ctx.class_fields = crate::generator::collect_class_fields(ctx_modules);
                ctx.const_names = crate::generator::collect_const_names(ctx_modules);
                let mut field_method_collisions = HashSet::new();
                for (module, _) in ctx_modules {
                    field_method_collisions.extend(crate::generator::collect_record_field_names(
                        module,
                        to_camel_case,
                    ));
                }
                ctx.field_method_collisions = field_method_collisions;
                ctx.seed_effect_registries(ctx_modules);
                Box::new(TsTestEmitter { ctx })
            },
        )
    }
}

/// Adapter wrapping a TS [`TsEmitCtx`] so the shared js/ts test-file builder can
/// drive expression lowering without depending on the private context type.
struct TsTestEmitter {
    ctx: TsEmitCtx,
}

impl crate::generator::JsTsExprEmitter for TsTestEmitter {
    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
        self.ctx.expr_to_string(node)
    }
}

impl TsGenerator {
    /// Output path for one module in the per-module native-import tree. The entry
    /// module is always `main.ts`; every other module is placed at the path
    /// mirrored from its declared module-path (`core.option` ⇒ `core/option.ts`).
    fn module_output_path(
        &self,
        module: &AIRModule,
        source_path: &std::path::Path,
        is_entry: bool,
    ) -> PathBuf {
        if is_entry {
            return crate::generator::derive_output_path(source_path, self.target());
        }
        match crate::generator::module_path_string(module) {
            Some(path) if !path.is_empty() => {
                let rel: PathBuf = path.split('.').collect();
                rel.with_extension(&self.target().conventions.file_extension)
            }
            _ => crate::generator::derive_output_path(source_path, self.target()),
        }
    }
}

/// Rewrite a TS runtime-prelude string so each **top-level** declaration is
/// exported: a `type NAME` line becomes `export type NAME`, a `const NAME` line
/// becomes `export const NAME`. Used to build the shared `_bock_runtime.ts`,
/// whose helpers/types consuming modules import. Only column-0 declarations are
/// rewritten; indented inner lines (a helper body's local `const`) are left
/// untouched.
fn export_runtime_decls(runtime: &str) -> String {
    runtime
        .lines()
        .map(|line| {
            if let Some(rest) = line.strip_prefix("type ") {
                format!("export type {rest}")
            } else if let Some(rest) = line.strip_prefix("const ") {
                format!("export const {rest}")
            } else {
                line.to_string()
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

// ─── Emission context ────────────────────────────────────────────────────────

/// Where the value produced by a statement-position control-flow expression
/// (lowered by [`TsEmitCtx::emit_value_in_stmt_pos`]) should go. Either `return`
/// it (the expression was a function-body tail) or assign it to a named binding
/// (the expression initialised a `let`/`const`). A diverging tail
/// (`return`/`break`/`continue`/`todo()`) ignores the sink.
#[derive(Clone)]
enum ValueSink {
    /// `return <value>;`
    Return,
    /// `<name> = <value>;`
    Assign(String),
    /// `<value>;` — the value is discarded (the expression is a statement-
    /// position tail: a loop body, a statement-`if`/`match` arm, etc., whose
    /// value Bock drops). Distinct from [`ValueSink::Return`]: emitting `return`
    /// here would abort the enclosing function (e.g. exit a `for` loop after one
    /// iteration), so the value is emitted as a bare expression statement.
    Discard,
}

/// Internal state for TypeScript emission.
struct TsEmitCtx {
    buf: String,
    indent: usize,
    /// Maps effect operation name → effect type name (e.g., "log" → "Logger").
    effect_ops: HashMap<String, String>,
    /// Maps effect type name → current handler variable name in scope.
    current_handler_vars: HashMap<String, String>,
    /// Maps function name → effect type names from its `with` clause.
    fn_effects: HashMap<String, Vec<String>>,
    /// Maps composite effect name → component effect names.
    composite_effects: HashMap<String, Vec<String>>,
    /// Names of records declared in this module (emitted as classes).
    record_names: HashSet<String>,
    /// Names of `class` declarations mapped to their **field names in
    /// declaration order**, pre-scanned across the reachable program. A Bock
    /// `class` emits a *positional* `constructor(a, b)` (unlike a record's
    /// destructured `constructor({ a, b })`), so a `class` literal `T { a: x, b:
    /// y }` must lower to `new T(x, y)` with arguments ordered by the declared
    /// field order — not the bare object literal the record path emits (which
    /// `tsc` rejects: the inherent/trait methods are not on an object-literal
    /// type). See [`crate::generator::collect_class_fields`].
    class_fields: HashMap<String, Vec<String>>,
    /// Declared names of module-scope `const`s, pre-scanned across the reachable
    /// program; emitted verbatim at both declaration and use so the two agree
    /// (the `to_camel_case` transform would mangle a SCREAMING_SNAKE use site).
    /// See [`crate::generator::collect_const_names`].
    const_names: HashSet<String>,
    /// Names of effects declared in this module (for typing handler vars).
    effect_names: HashSet<String>,
    /// 1-indexed current line in `buf`, maintained incrementally.
    cur_line: u32,
    /// 1-indexed current column (char count) in `buf`, maintained incrementally.
    cur_col: u32,
    /// Byte offset in `buf` up to which (cur_line, cur_col) is accurate.
    scan_pos: usize,
    /// Last (gen_line, gen_col) we recorded — avoids duplicate mappings.
    last_marked: Option<(u32, u32)>,
    /// Collected source-map entries (populated via [`Self::mark_span`]).
    mappings: Vec<SourceMapping>,
    /// Loop-label stack — see [`crate::generator::loop_needs_break_label`].
    /// `break` inside a `switch` exits the switch, so a statement-arm `match`
    /// that wants to `break`/`continue` an enclosing loop uses a labelled jump.
    loop_labels: Vec<Option<String>>,
    /// Depth of statement-arm `switch` emission; > 0 routes `break`/`continue`
    /// to the innermost labelled loop.
    switch_label_depth: usize,
    /// Monotonic counter for unique loop-label names.
    loop_label_counter: usize,
    /// Monotonic counter for unique `match` scrutinee temporaries. A non-trivial
    /// scrutinee (a call, etc.) is hoisted into `const __matchN = <scrutinee>;`
    /// once, so it is evaluated a single time and — crucially for TypeScript —
    /// the discriminated-union narrowing on `__matchN._tag` flows to `__matchN._0`
    /// in the arm bodies. Re-emitting the scrutinee expression inline (the prior
    /// behavior) both double-evaluated it and defeated narrowing (TS2339 on `_0`).
    match_temp_counter: usize,
    /// Set once the Optional runtime prelude has been emitted in the
    /// single-module self-contained path ([`TsGenerator::generate_module`]), so
    /// a module referencing it more than once still inlines it at most once (a
    /// duplicate `type Option<T>` is a TS redeclaration error). The per-module
    /// project path emits the runtime once into the shared `_bock_runtime.ts`.
    optional_runtime_emitted: bool,
    /// Set once the `Result` runtime prelude has been emitted; deduped exactly as
    /// [`Self::optional_runtime_emitted`] (a duplicate `type BockResult<T, E>` is
    /// a TS redeclaration error).
    result_runtime_emitted: bool,
    /// Set once the concurrency runtime prelude has been emitted; deduped exactly
    /// as [`Self::optional_runtime_emitted`].
    concurrency_runtime_emitted: bool,
    /// Set once the range runtime prelude ([`RANGE_RUNTIME_TS`]) has been
    /// emitted; deduped exactly as [`Self::optional_runtime_emitted`] (a
    /// duplicate `const range` is a redeclaration error).
    range_runtime_emitted: bool,
    /// Set once the structural-equality runtime ([`EQ_RUNTIME_TS`]) has been
    /// emitted; deduped exactly as [`Self::range_runtime_emitted`].
    eq_runtime_emitted: bool,
    /// Set once the structural-comparison runtime ([`COMPARE_RUNTIME_TS`]) has
    /// been emitted in the single-module self-contained path. Deduped exactly as
    /// [`Self::eq_runtime_emitted`]. (Q-bounded-comparable-codegen.)
    compare_runtime_emitted: bool,
    /// Set once the display-string runtime ([`STR_RUNTIME_TS`]) has been emitted
    /// in the single-module self-contained path. (Q-displayable-interpolation-dispatch.)
    str_runtime_emitted: bool,
    /// User-enum-variant registry (DV14). Same role as the JS backend's: route
    /// a unit-variant reference to the `{enum}_{variant}` const, a struct/tuple
    /// construction to the factory, and recognise `RecordPat` arms as ADT.
    /// Built-in Optional/Result pre-seeds are filtered out where bespoke
    /// lowering applies. Pre-scanned across the reached modules.
    enum_variants: crate::generator::EnumVariantRegistry,
    /// Generic-type declaration registry: a record/enum/class name → its
    /// declared generic params. An `impl Box { ... }` block carries no generic
    /// params of its own (the `T` is declared on `record Box[T]`); this lets the
    /// declaration-merged `interface Box<T>` and the `self: Box<T>` param type
    /// recover them so the merge lands on the generic class. Pre-scanned across
    /// the reached modules (mirrors [`Self::enum_variants`]).
    generic_decls: crate::generator::GenericDeclRegistry,
    /// Trait-declaration registry: a trait name → its declared generic params
    /// and methods. Used at each `impl Trait for Type` site to recover the
    /// trait's *default* methods (those carrying a body) so they can be
    /// synthesized onto the implementing type's prototype — the trait interface
    /// alone declares only signatures, so a type relying on an inherited default
    /// would otherwise have no such method. Pre-scanned across the reached modules.
    trait_decls: crate::generator::TraitDeclRegistry,
    /// When `Some(target)`, a `Self` type (`TypeSelf`) renders as `target`
    /// rather than the default `this`. Set while emitting ANY `impl` method onto
    /// a concrete target — a synthesized trait default (`other: Self`) AND the
    /// impl's own inherent methods (`fn combine(self, ...) -> Self`) alike. Each
    /// emits as a free prototype function (`Target.prototype.m = function(...)`)
    /// where `this` is not a legal type annotation (`tsc` rejects it with
    /// TS2526), so the concrete target name must be substituted in both the
    /// prototype function and the matching merged-interface signature. Cleared
    /// (None) everywhere else, so trait-*interface* methods keep rendering `Self`
    /// as `this` (valid inside an interface member).
    trait_self_subst: Option<String>,
    /// Names of `public` (exported) top-level types. The declaration-merging
    /// `interface Target { ... }` an `impl` emits must be `export`ed exactly
    /// when the `Target` class is — TS requires all declarations in a merged
    /// declaration to agree on export-ness. Pre-scanned across the reached modules.
    exported_types: std::collections::HashSet<String>,
    /// The TS type a value-position expression is being assigned *into* (the
    /// declared type of a `let x: T = <value>`), when known. Set around the
    /// `LetBinding` value emit. An expression-position `match` lowers to an IIFE
    /// (`(() => { switch (s) { … } })()`); when the value is consumed into a
    /// typed binding this annotates the IIFE arrow's return type (`(() : T =>
    /// {…})()`) and — crucially — signals that a value-`switch` over a bare
    /// identifier scrutinee must be hoisted into a temp (`const __matchN = s;
    /// switch (__matchN) …`). Without the hoist, `switch (s)` narrows `s` to the
    /// case's literal type inside each arm, so an arm body re-referencing `s`
    /// (`s === <other-literal>`) trips TS2367 ("no overlap"). Hoisting means the
    /// switch narrows the temp while arm bodies still see the original (un-
    /// narrowed) `s`. `None` outside a typed value-binding context; restored
    /// after the value so it never leaks to a sibling/outer expression.
    current_expected_type: Option<String>,
    /// True in the **per-module native-import** emission path
    /// ([`TsGenerator::generate_project`], the sole real-build path). When set,
    /// the `Module` arm emits real ESM `import`/`import type` for cross-module
    /// references, records which shared-runtime helpers/types the module needs
    /// (instead of inlining them), and a trailing `export { … }` re-exports the
    /// module's enum-variant value names (every other declaration kind already
    /// exports inline). When clear, the module is emitted as a single
    /// self-contained file with its runtime preludes inlined — the
    /// [`TsGenerator::generate_module`] path used by unit tests.
    per_module: bool,
    /// In the per-module path, records that this module references the Optional /
    /// Result runtime *types* (`BockOption` / `BockResult`) — so they are emitted
    /// once into the shared `_bock_runtime.ts` and this module `import type`s them.
    needs_runtime_optional: bool,
    needs_runtime_result: bool,
    /// As above, for the concurrency / range / structural-equality runtime
    /// *values* (`__bockChannelNew` / `range` / `__bockEq`).
    needs_runtime_concurrency: bool,
    needs_runtime_range: bool,
    needs_runtime_eq: bool,
    /// As [`Self::needs_runtime_eq`], for the bounded-`Comparable`
    /// structural-comparison runtime (`__bockCompare`).
    /// (Q-bounded-comparable-codegen.)
    needs_runtime_compare: bool,
    /// As [`Self::needs_runtime_eq`], for the display-string runtime
    /// (`__bockStr`). (Q-displayable-interpolation-dispatch.)
    needs_runtime_str: bool,
    /// Implicit cross-module imports for the per-module path — names this module
    /// references but neither declares locally nor imports via an explicit `use`.
    /// Computed in `generate_project`; emitted as ESM imports by the `Module` arm.
    implicit_imports: Vec<crate::generator::ImplicitEsmImport>,
    /// Map of every reachable public symbol → declaring module + kind, used to
    /// spell an explicit `use`d name the way its declaration emits it.
    public_symbols: HashMap<String, crate::generator::EsmSymbol>,
    /// The declared dotted module-path of the file currently being emitted, or
    /// empty for the entry file (always `main.ts` at the build root). Drives the
    /// relative-specifier computation for every emitted `import`.
    self_module_path: String,
    /// Public enum-variant value names this module declares (`Color_Red`, …),
    /// re-exported in a trailing `export { … }`. Unlike records/classes/aliases
    /// (which export inline), enum-variant interfaces/consts/factories are emitted
    /// without `export`, so cross-module references need the trailing re-export.
    /// Computed in `generate_project`.
    enum_variant_exports: Vec<String>,
    /// Camel-cased record/class field names across the reachable program, used to
    /// disambiguate a method whose camelCased name collides with a field name
    /// (`core.error`'s `message` field + `message()` method). TS rejects a class
    /// field and a (declaration-merged) interface method sharing a name with a
    /// "Duplicate identifier" error, so the *method* is renamed (`messageMethod`)
    /// at the trait-interface signature, the merged-interface signature, the
    /// prototype attachment, the class-body method, and every call site via
    /// [`Self::ts_method_name`]; the field keeps its name. Pre-seeded program-wide
    /// by `generate_project` (and extended per-module by the `Module` arm for the
    /// single-module `generate_module` path). Shared policy with go/js/py.
    field_method_collisions: HashSet<String>,
    /// Active value-sink while a statement-position control-flow expression is
    /// being emitted (see [`ValueSink`] and [`Self::emit_value_in_stmt_pos`]).
    /// A `match` arm body emitted under this sink delivers its value through it
    /// (`return v` / `binding = v`) instead of the default `return v`, so an
    /// expression-position `match` containing a `return`/`break` arm lowers to a
    /// statement `switch` whose value-arms feed the binding. `None` everywhere
    /// else; saved/restored around each statement-position lowering so it never
    /// leaks into an unrelated (IIFE-lowered) match.
    value_sink: Option<ValueSink>,
    /// Per-loop result sink, pushed/popped alongside [`Self::loop_labels`]. When
    /// a `loop` is lowered in value position (`let r = loop { … break v … }`),
    /// the innermost entry holds the sink that `break v` writes into (`r = v;
    /// break;`). `None` for an ordinary statement-position loop, where `break v`
    /// has no value destination. Indexed by loop nesting, so an inner ordinary
    /// loop inside a value loop does not capture the outer sink.
    loop_value_sinks: Vec<Option<ValueSink>>,
    /// Failure tag of the *enclosing function's* result/optional return type,
    /// driving the `?` (`Propagate`) early-return guard. `"Err"` when the fn
    /// returns `Result[T, E]`, `"None"` when it returns `Optional[T]` / `T?`,
    /// `None` otherwise. Set in [`Self::emit_fn_decl`] / [`Self::emit_class_method`]
    /// from the declared return type and restored after the body, so the guard
    /// tests the single discriminant that actually exists on the value — which
    /// also lets TS narrow the success access (`._0`) to the payload type.
    current_fn_propagate_tag: Option<&'static str>,
    /// Per-TS-lexical-block stack of simple `let`/`const` binding state. Bock
    /// permits re-binding (`let x = …; let x = …`) which shadows the prior
    /// binding in the same scope; TS `const`/`let` forbid re-declaration in one
    /// block scope (TS2451). Each frame records the TS idents already declared in
    /// the block and, of those, which need `let` (because they are re-bound or
    /// assigned later). The first declaration of a re-bound name uses `let`;
    /// every subsequent binding of the same name emits a plain assignment
    /// (`x = …`) rather than a redeclaration. Mirrors the js backend (#217). See
    /// [`LetScope`].
    let_scopes: Vec<LetScope>,
}

/// One TS lexical block's `let`/`const` binding state — see
/// [`TsEmitCtx::let_scopes`].
#[derive(Default)]
struct LetScope {
    /// Simple TS idents already emitted as a declaration in this block.
    declared: HashSet<String>,
    /// Of the block's simple bindings, those that are re-bound or assigned and
    /// so must be declared with `let` (not `const`) at their first declaration.
    needs_let: HashSet<String>,
}

impl TsEmitCtx {
    fn new() -> Self {
        Self {
            buf: String::with_capacity(4096),
            indent: 0,
            effect_ops: HashMap::new(),
            current_handler_vars: HashMap::new(),
            fn_effects: HashMap::new(),
            composite_effects: HashMap::new(),
            record_names: HashSet::new(),
            class_fields: HashMap::new(),
            const_names: HashSet::new(),
            effect_names: HashSet::new(),
            cur_line: 1,
            cur_col: 1,
            scan_pos: 0,
            last_marked: None,
            mappings: Vec::new(),
            loop_labels: Vec::new(),
            switch_label_depth: 0,
            loop_label_counter: 0,
            match_temp_counter: 0,
            optional_runtime_emitted: false,
            result_runtime_emitted: false,
            concurrency_runtime_emitted: false,
            range_runtime_emitted: false,
            eq_runtime_emitted: false,
            compare_runtime_emitted: false,
            str_runtime_emitted: false,
            enum_variants: crate::generator::EnumVariantRegistry::new(),
            generic_decls: crate::generator::GenericDeclRegistry::new(),
            trait_decls: crate::generator::TraitDeclRegistry::new(),
            trait_self_subst: None,
            exported_types: std::collections::HashSet::new(),
            current_expected_type: None,
            per_module: false,
            needs_runtime_optional: false,
            needs_runtime_result: false,
            needs_runtime_concurrency: false,
            needs_runtime_range: false,
            needs_runtime_eq: false,
            needs_runtime_compare: false,
            needs_runtime_str: false,
            implicit_imports: Vec::new(),
            public_symbols: HashMap::new(),
            self_module_path: String::new(),
            enum_variant_exports: Vec::new(),
            field_method_collisions: HashSet::new(),
            value_sink: None,
            loop_value_sinks: Vec::new(),
            current_fn_propagate_tag: None,
            let_scopes: Vec::new(),
        }
    }

    /// Classify a function's declared return type into the failure tag its `?`
    /// operator early-returns: `Some("Err")` for `Result[…]`, `Some("None")` for
    /// `Optional[…]` / `T?`, `None` for anything else.
    fn propagate_tag_for_return(ret: Option<&AIRNode>) -> Option<&'static str> {
        match ret.map(|r| &r.kind) {
            Some(NodeKind::TypeOptional { .. }) => Some("None"),
            Some(NodeKind::TypeNamed { path, .. }) => {
                match path.segments.last().map(|s| s.name.as_str()) {
                    Some("Result") => Some("Err"),
                    Some("Optional") => Some("None"),
                    _ => None,
                }
            }
            _ => None,
        }
    }

    /// Disambiguate an *already-rendered* method member name against the
    /// program's field names: when the rendered name collides with a field name,
    /// append a `Method` suffix (`message` → `messageMethod`). Returns the name
    /// unchanged otherwise.
    ///
    /// TS renders method names two ways — declarations use the raw Bock name
    /// (`name.name`) while call sites use `to_camel_case` — so this helper takes
    /// whatever each site already produced and only adds the suffix, preserving
    /// each site's existing casing. For the field/method collisions this targets
    /// (single-word field names like `message`, where raw == camelCase) the two
    /// renderings agree, so the renamed method resolves at the trait-interface
    /// signature, the merged-interface signature, the prototype attachment, the
    /// class-body method, and every call site alike. Shared policy with go/js/py
    /// (see [`crate::generator::disambiguate_method_name`]).
    fn ts_method_name(&self, rendered: &str) -> String {
        crate::generator::disambiguate_method_name(
            rendered.to_string(),
            &self.field_method_collisions,
            "Method",
        )
    }

    fn finish(self) -> (String, Vec<SourceMapping>) {
        (self.buf, self.mappings)
    }

    /// Pre-seed the effect registries (`effect_ops`, `composite_effects`,
    /// `effect_names`) from every module's top-level `EffectDecl`s. The
    /// per-module path emits each module in its own context, so a bare op used in
    /// one module whose effect is declared in another must be recognised without
    /// having emitted the declaring module first. Mirrors how `enum_variants` /
    /// `trait_decls` are collected across the reached modules and the JS /
    /// Python backends' equivalents.
    fn seed_effect_registries(&mut self, modules: &[(&AIRModule, &std::path::Path)]) {
        for (module, _) in modules {
            let NodeKind::Module { items, .. } = &module.kind else {
                continue;
            };
            for item in items {
                let NodeKind::EffectDecl {
                    name,
                    components,
                    operations,
                    ..
                } = &item.kind
                else {
                    continue;
                };
                if !components.is_empty() {
                    let comp_names: Vec<String> = components
                        .iter()
                        .map(|tp| {
                            tp.segments
                                .last()
                                .map_or("effect".to_string(), |s| s.name.clone())
                        })
                        .collect();
                    self.composite_effects.insert(name.name.clone(), comp_names);
                    continue;
                }
                self.effect_names.insert(name.name.clone());
                for op in operations {
                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
                        self.effect_ops
                            .insert(op_name.name.clone(), name.name.clone());
                    }
                }
            }
        }
    }

    /// Emit the per-module ESM `import` statements at the top of the file: the
    /// shared-runtime import (Optional/Result types via `import type`,
    /// concurrency/range helpers via a value `import`), the explicit cross-module
    /// `use` imports, and the implicit prelude imports. Grouped one statement per
    /// source module, with the relative specifier computed from this file's
    /// declared module-path. ESM specifiers reference the *emitted* `.ts` source
    /// directly (resolved verbatim by node strip-types; accepted by `tsc` via
    /// `rewriteRelativeImportExtensions`, which rewrites it to `.js` on emit).
    fn emit_esm_imports(&mut self, imports: &[AIRNode]) -> Result<(), CodegenError> {
        // ESM specifiers reference the emitted `.ts` source directly (not a `.js`
        // that is never written). `node --experimental-strip-types` resolves
        // specifiers verbatim against files on disk — it does not rewrite `.js` →
        // `.ts` — so a `.js` specifier `ERR_MODULE_NOT_FOUND`s on any *value*
        // import (type-only imports are erased by the strip pass, so they happened
        // to work, but value imports of runtime helpers / cross-module functions
        // did not). A `.ts` specifier runs verbatim under node *and* type-checks
        // under `tsc` when `rewriteRelativeImportExtensions` is set (emitted into
        // the scaffolded `tsconfig.json`; see `scaffold.rs::tsconfig_json`) — that
        // flag also rewrites the specifier to `.js` on emit so the conformance
        // `tsc -p .` → `node main.js` plan still runs.
        let ext = "ts";

        // Shared runtime: type-only and value imports, split so a type name never
        // appears in a value `import` (which would be a runtime error if elided
        // away to nothing, and is cleaner under `--strict`).
        let mut type_names: Vec<&str> = Vec::new();
        if self.needs_runtime_optional {
            type_names.push("BockOption");
        }
        if self.needs_runtime_result {
            type_names.push("BockResult");
        }
        let mut value_names: Vec<&str> = Vec::new();
        if self.needs_runtime_concurrency {
            value_names.extend(["__bockChannelNew", "__bockSpawn"]);
        }
        if self.needs_runtime_range {
            value_names.extend(["range", "rangeInclusive"]);
        }
        if self.needs_runtime_eq {
            value_names.push("__bockEq");
        }
        if self.needs_runtime_compare {
            value_names.push("__bockCompare");
        }
        if self.needs_runtime_str {
            value_names.push("__bockStr");
        }
        if !type_names.is_empty() || !value_names.is_empty() {
            let spec = crate::generator::esm_relative_specifier(
                &self.self_module_path,
                RUNTIME_MODULE_TS,
                ext,
            );
            if !type_names.is_empty() {
                self.writeln(&format!(
                    "import type {{ {} }} from \"{spec}\";",
                    type_names.join(", ")
                ));
            }
            if !value_names.is_empty() {
                self.writeln(&format!(
                    "import {{ {} }} from \"{spec}\";",
                    value_names.join(", ")
                ));
            }
        }

        // Explicit `use` imports → real ESM imports. A `use`d *function* is
        // imported under its camelCased name; other kinds keep their raw name.
        // Accumulate cross-module imports grouped by declaring module-path, split
        // into value imports (`import { … }`) and type-only imports
        // (`import type { … }`) — a TS-only distinction so an enum *type*, trait
        // interface, or type alias never appears in a value import.
        let mut value_by_module: std::collections::BTreeMap<String, Vec<String>> =
            std::collections::BTreeMap::new();
        let mut type_by_module: std::collections::BTreeMap<String, Vec<String>> =
            std::collections::BTreeMap::new();

        for import in imports {
            if let NodeKind::ImportDecl { path, items } = &import.kind {
                let target_path = path
                    .segments
                    .iter()
                    .map(|s| s.name.as_str())
                    .collect::<Vec<_>>()
                    .join(".");
                if target_path.is_empty() {
                    continue;
                }
                if let bock_ast::ImportItems::Named(named) = items {
                    for n in named {
                        // A `use`d runtime-prelude *value* name lowers inline and
                        // is never a real export of the declaring module.
                        if crate::generator::ESM_RUNTIME_PRELUDE_NAMES
                            .contains(&n.name.name.as_str())
                        {
                            continue;
                        }
                        let kind = self.public_symbols.get(&n.name.name).map(|s| s.kind);
                        let is_fn = matches!(kind, Some(crate::generator::EsmDeclKind::Function));
                        let src = ts_esm_emit_name(&n.name.name, is_fn);
                        let rendered = match &n.alias {
                            Some(alias) => {
                                let dst = ts_esm_emit_name(&alias.name, is_fn);
                                if dst == src {
                                    src
                                } else {
                                    format!("{src} as {dst}")
                                }
                            }
                            None => src,
                        };
                        let type_only = kind.is_some_and(|k| k.is_ts_type_only());
                        if type_only {
                            type_by_module.entry(target_path.clone()).or_default()
                        } else {
                            value_by_module.entry(target_path.clone()).or_default()
                        }
                        .push(rendered);
                    }
                }
                // `use Foo` / `use Foo.*` resolve via implicit imports by name.
            }
        }

        // Implicit imports: prelude-visible names referenced but not explicitly
        // `use`d, routed value vs type-only the same way.
        for imp in &self.implicit_imports {
            let rendered = ts_esm_emit_name(&imp.name, imp.is_fn());
            if imp.kind.is_ts_type_only() {
                type_by_module.entry(imp.module_path.clone()).or_default()
            } else {
                value_by_module.entry(imp.module_path.clone()).or_default()
            }
            .push(rendered);
        }

        for (module_path, mut names) in value_by_module {
            names.sort_unstable();
            names.dedup();
            let spec =
                crate::generator::esm_relative_specifier(&self.self_module_path, &module_path, ext);
            self.writeln(&format!(
                "import {{ {} }} from \"{spec}\";",
                names.join(", ")
            ));
        }
        for (module_path, mut names) in type_by_module {
            names.sort_unstable();
            names.dedup();
            let spec =
                crate::generator::esm_relative_specifier(&self.self_module_path, &module_path, ext);
            self.writeln(&format!(
                "import type {{ {} }} from \"{spec}\";",
                names.join(", ")
            ));
        }
        Ok(())
    }

    /// Emit the trailing `export { … }` for this module's public enum-variant
    /// value names (`Color_Red`, …). Every other public declaration kind exports
    /// inline in TS; enum-variant interfaces/consts/factories do not, so they are
    /// re-exported here for cross-module references. Emits nothing when there is
    /// nothing to re-export.
    fn emit_trailing_exports(&mut self) {
        if self.enum_variant_exports.is_empty() {
            return;
        }
        let mut names = self.enum_variant_exports.clone();
        names.sort_unstable();
        names.dedup();
        if !self.buf.is_empty() && !self.buf.ends_with('\n') {
            self.buf.push('\n');
        }
        self.writeln(&format!("export {{ {} }};", names.join(", ")));
    }

    /// Variant info for `path` when its last segment is a registered *user*
    /// enum variant (built-in Optional/Result pre-seeds excluded — those go
    /// through the bespoke tagged-object lowering).
    fn user_variant_for_path(
        &self,
        path: &bock_ast::TypePath,
    ) -> Option<&crate::generator::EnumVariantInfo> {
        let info = crate::generator::registered_variant(&self.enum_variants, path)?;
        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
            return None;
        }
        Some(info)
    }

    /// As [`Self::user_variant_for_path`] but keyed by a bare identifier name.
    fn user_variant_for_name(&self, name: &str) -> Option<&crate::generator::EnumVariantInfo> {
        let info = self.enum_variants.get(name)?;
        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
            return None;
        }
        Some(info)
    }

    /// If `value` is a *construction of a single user-enum variant* — a struct
    /// variant (`Open { … }`, a [`NodeKind::RecordConstruct`]), a unit variant
    /// (`Open`, a bare [`NodeKind::Identifier`]), or a tuple variant (`Open(…)`,
    /// a [`NodeKind::Call`] on a bare variant name) — return the *declaring
    /// enum's* name (the union type), else `None`.
    ///
    /// Used by [`Self::emit_stmt`] to widen the initialiser of a type-annotated
    /// `let`/`const` binding back to its declared union: `const g: Gate =
    /// Gate_Open(7)` otherwise narrows `g` to the construction variant
    /// (`Gate_Open`), and a later `match g` on the sibling variant fails `tsc
    /// --noEmit` with TS2678 (the narrowed literal `_tag` is "not comparable" to
    /// the sibling's). The declared annotation must win; widening the
    /// initialiser (`Gate_Open(7) as Gate`) defeats the const-init narrowing so
    /// the declared union type is what `g` carries at every use site.
    fn variant_construct_enum(&self, value: &AIRNode) -> Option<String> {
        match &value.kind {
            // Struct variant: `Open { level: 7 }`.
            NodeKind::RecordConstruct { path, spread, .. } if spread.is_none() => self
                .user_variant_for_path(path)
                .map(|i| i.enum_name.clone()),
            // Unit variant: a bare `Open`.
            NodeKind::Identifier { name } => self
                .user_variant_for_name(&name.name)
                .map(|i| i.enum_name.clone()),
            // Tuple variant: `Open(7)` — a call whose callee is a bare variant
            // name (the only call shape that constructs a variant; a method or
            // assoc call has a `FieldAccess` callee, not an `Identifier`).
            NodeKind::Call { callee, .. } => match &callee.kind {
                NodeKind::Identifier { name } => self
                    .user_variant_for_name(&name.name)
                    .map(|i| i.enum_name.clone()),
                _ => None,
            },
            _ => None,
        }
    }

    /// Whether a type-annotated binding's initialiser needs a widening cast to
    /// its declared type, i.e. `value` constructs a single variant of the enum
    /// the binding is declared as. The declared TS type string (`ty_ts`) must
    /// name exactly that enum's union — a binding annotated with an unrelated
    /// type, or one whose value is not a single-variant construction, is left
    /// untouched.
    fn binding_needs_union_widening(&self, value: &AIRNode, ty_ts: Option<&str>) -> bool {
        match (ty_ts, self.variant_construct_enum(value)) {
            (Some(declared), Some(enum_name)) => declared == enum_name,
            _ => false,
        }
    }

    /// Emit a `let`/`const` binding's initialiser `value` under the binding's
    /// declared TS type `ty_ts`, restoring the prior expected type afterwards so
    /// it never leaks to a sibling/nested expression.
    ///
    /// `ty_ts` is recorded as [`Self::current_expected_type`] for the value emit
    /// (so a value-position `match`/`if` IIFE annotates its arrow return and
    /// hoists a bare scrutinee — see [`Self::current_expected_type`]). When the
    /// value is a single-variant construction of the declared enum union, the
    /// emitted initialiser is widened with an `as <Union>` cast so the binding
    /// carries the declared union type rather than the narrower construction
    /// variant (see [`Self::variant_construct_enum`]).
    fn emit_let_value(
        &mut self,
        value: &AIRNode,
        ty_ts: Option<String>,
    ) -> Result<(), CodegenError> {
        // The widening cast (`Gate_Open(7) as Gate`) only applies when the
        // declared type names the enum the value constructs a variant of; an
        // unrelated annotation, or a non-construction value, emits unchanged.
        let widen_to = self
            .binding_needs_union_widening(value, ty_ts.as_deref())
            .then(|| ty_ts.clone())
            .flatten();
        let prev_expected = self.current_expected_type.take();
        self.current_expected_type = ty_ts;
        self.emit_expr(value)?;
        if let Some(union) = widen_to {
            let _ = write!(self.buf, " as {union}");
        }
        self.current_expected_type = prev_expected;
        Ok(())
    }

    /// Bring `cur_line` / `cur_col` up to date with everything appended to
    /// `buf` since the last sync.
    fn sync_pos(&mut self) {
        if self.scan_pos >= self.buf.len() {
            return;
        }
        let slice = &self.buf[self.scan_pos..];
        for ch in slice.chars() {
            if ch == '\n' {
                self.cur_line += 1;
                self.cur_col = 1;
            } else {
                self.cur_col += 1;
            }
        }
        self.scan_pos = self.buf.len();
    }

    fn mark_span(&mut self, span: Span) {
        if span.start == 0 && span.end == 0 {
            return;
        }
        self.sync_pos();
        let key = (self.cur_line, self.cur_col);
        if self.last_marked == Some(key) {
            return;
        }
        self.last_marked = Some(key);
        self.mappings.push(SourceMapping {
            gen_line: self.cur_line,
            gen_col: self.cur_col,
            src_line: 0,
            src_col: 0,
            src_offset: span.start as u32,
            src_file_id: span.file.0,
        });
    }

    fn indent_str(&self) -> String {
        "  ".repeat(self.indent)
    }

    fn write_indent(&mut self) {
        let indent = self.indent_str();
        self.buf.push_str(&indent);
    }

    fn writeln(&mut self, s: &str) {
        self.write_indent();
        self.buf.push_str(s);
        self.buf.push('\n');
    }

    // ── Prelude function mapping ──────────────────────────────────────────

    /// Emit an expression into a temporary buffer and return the string.
    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
        let start = self.buf.len();
        let saved_line = self.cur_line;
        let saved_col = self.cur_col;
        let saved_scan = self.scan_pos;
        let saved_marked = self.last_marked;
        let mappings_len = self.mappings.len();
        self.emit_expr(node)?;
        let s = self.buf[start..].to_string();
        self.buf.truncate(start);
        self.cur_line = saved_line;
        self.cur_col = saved_col;
        self.scan_pos = saved_scan;
        self.last_marked = saved_marked;
        self.mappings.truncate(mappings_len);
        Ok(s)
    }

    /// Map Bock prelude functions to TypeScript equivalents.
    fn map_prelude_call(
        &mut self,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<Option<String>, CodegenError> {
        let name = match &callee.kind {
            NodeKind::Identifier { name } => name.name.as_str(),
            _ => return Ok(None),
        };
        let arg_strs: Vec<String> = args
            .iter()
            .map(|a| self.expr_to_string(&a.value))
            .collect::<Result<_, _>>()?;
        let code = match name {
            "println" => {
                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
                format!("console.log({a})")
            }
            "print" => {
                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
                format!("process.stdout.write(String({a}))")
            }
            "debug" => {
                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
                format!("console.debug({a})")
            }
            "assert" => {
                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
                format!("if (!{a}) throw new Error(\"assertion failed\")")
            }
            "todo" => "throw new Error(\"not implemented\")".to_string(),
            "unreachable" => "throw new Error(\"unreachable\")".to_string(),
            "sleep" => {
                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
                // Route through an installed `Clock` handler if one is in scope;
                // otherwise fall through to the host primitive (default).
                if let Some(handler) = self.clock_handler_var() {
                    format!("{handler}.sleep({a})")
                } else {
                    format!("new Promise<void>((__r) => setTimeout(__r, Math.floor(({a}) / 1e6)))")
                }
            }
            _ => return Ok(None),
        };
        Ok(Some(code))
    }

    /// Recognise `Duration.xxx(...)` / `Instant.xxx(...)` associated-function
    /// calls and emit inline arithmetic. Durations are plain numbers
    /// (nanoseconds); Instants are numbers representing ns since
    /// `performance.timeOrigin`. Returns `Ok(true)` if the call was emitted.
    fn try_emit_time_assoc_call(
        &mut self,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let NodeKind::FieldAccess { object, field } = &callee.kind else {
            return Ok(false);
        };
        let NodeKind::Identifier { name: type_name } = &object.kind else {
            return Ok(false);
        };
        let arg_strs: Vec<String> = args
            .iter()
            .map(|a| self.expr_to_string(&a.value))
            .collect::<Result<_, _>>()?;
        let arg0 = || arg_strs.first().cloned().unwrap_or_default();
        let code = match (type_name.name.as_str(), field.name.as_str()) {
            ("Duration", "zero") => "0".to_string(),
            ("Duration", "nanos") => arg0(),
            ("Duration", "micros") => format!("(({}) * 1000)", arg0()),
            ("Duration", "millis") => format!("(({}) * 1000000)", arg0()),
            ("Duration", "seconds") => format!("(({}) * 1000000000)", arg0()),
            ("Duration", "minutes") => format!("(({}) * 60000000000)", arg0()),
            ("Duration", "hours") => format!("(({}) * 3600000000000)", arg0()),
            ("Instant", "now") => {
                // Route through an installed `Clock` handler's `now_monotonic`
                // op if one is in scope; otherwise emit the host primitive.
                if let Some(handler) = self.clock_handler_var() {
                    format!("{handler}.now_monotonic()")
                } else {
                    "(performance.now() * 1000000)".to_string()
                }
            }
            _ => return Ok(false),
        };
        self.buf.push_str(&code);
        Ok(true)
    }

    /// Recognise `Channel.new()`, `spawn(...)`, and method calls on a
    /// channel value (`send`, `recv`, `close`) and emit the TS runtime
    /// helper equivalents.
    fn try_emit_concurrency_call(
        &mut self,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        if let NodeKind::Identifier { name } = &callee.kind {
            if name.name == "spawn" {
                self.buf.push_str("__bockSpawn(");
                for (i, arg) in args.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.emit_expr(&arg.value)?;
                }
                self.buf.push(')');
                return Ok(true);
            }
        }
        let NodeKind::FieldAccess { object, field } = &callee.kind else {
            return Ok(false);
        };
        if let NodeKind::Identifier { name: type_name } = &object.kind {
            if type_name.name == "Channel" && field.name == "new" {
                self.buf.push_str("__bockChannelNew()");
                return Ok(true);
            }
        }
        if matches!(field.name.as_str(), "send" | "recv" | "close") {
            // First arg is the receiver duplicate (from desugaring) — skip.
            self.emit_expr(object)?;
            let _ = write!(self.buf, ".{}", field.name);
            self.buf.push('(');
            for (i, arg) in args.iter().skip(1).enumerate() {
                if i > 0 {
                    self.buf.push_str(", ");
                }
                self.emit_expr(&arg.value)?;
            }
            self.buf.push(')');
            return Ok(true);
        }
        Ok(false)
    }

    /// Recognise desugared method calls `Call(FieldAccess(recv, m), [recv, ...args])`
    /// on Duration/Instant values and emit inline arithmetic.
    ///
    /// `node` is the full `Call` AIR node, consulted only to *exclude* primitive
    /// receivers: [`is_time_method_name`] alone is ambiguous (`abs` is both
    /// `Duration.abs` and `Int.abs`/`Float.abs`), so when the checker has stamped
    /// `recv_kind = "Primitive:<Ty>"` this is a numeric method, not a time method —
    /// bail so [`Self::try_emit_numeric_method`] handles it.
    fn try_emit_time_desugared_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        if crate::generator::primitive_recv_kind(node).is_some() {
            return Ok(false);
        }
        let NodeKind::FieldAccess { object, field } = &callee.kind else {
            return Ok(false);
        };
        if let NodeKind::Identifier { name } = &object.kind {
            if matches!(name.name.as_str(), "Duration" | "Instant") {
                return Ok(false);
            }
        }
        if !is_time_method_name(&field.name) {
            return Ok(false);
        }
        let remaining: Vec<bock_air::AirArg> = args.iter().skip(1).cloned().collect();
        self.try_emit_time_method(object, &field.name, &remaining)
    }

    /// Recognise instance methods on Duration/Instant values and emit inline
    /// arithmetic.
    fn try_emit_time_method(
        &mut self,
        receiver: &AIRNode,
        method: &str,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let recv_str = self.expr_to_string(receiver)?;
        let arg_strs: Vec<String> = args
            .iter()
            .map(|a| self.expr_to_string(&a.value))
            .collect::<Result<_, _>>()?;
        let code = match method {
            "as_nanos" => format!("({recv_str})"),
            "as_millis" => format!("Math.floor(({recv_str}) / 1000000)"),
            "as_seconds" => format!("Math.floor(({recv_str}) / 1000000000)"),
            "is_zero" => format!("(({recv_str}) === 0)"),
            "is_negative" => format!("(({recv_str}) < 0)"),
            "abs" => format!("Math.abs({recv_str})"),
            "elapsed" => {
                // `instant.elapsed()` is derived: `now - instant`. Route the
                // "now" read through an installed `Clock` handler if in scope;
                // otherwise read the host monotonic clock (default).
                if let Some(handler) = self.clock_handler_var() {
                    format!("({handler}.now_monotonic() - ({recv_str}))")
                } else {
                    format!("((performance.now() * 1000000) - ({recv_str}))")
                }
            }
            "duration_since" => {
                let other = arg_strs.first().cloned().unwrap_or_default();
                format!("(({recv_str}) - ({other}))")
            }
            _ => return Ok(false),
        };
        self.buf.push_str(&code);
        Ok(true)
    }

    /// Emit Some/Ok/Err calls as tagged-object constructions, matching the
    /// representation used for user-defined enum variants. Returns true if
    /// the call was handled.
    fn try_emit_prelude_ctor(
        &mut self,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let name = match &callee.kind {
            NodeKind::Identifier { name } => name.name.as_str(),
            _ => return Ok(false),
        };
        if !matches!(name, "Some" | "Ok" | "Err") {
            return Ok(false);
        }
        let _ = write!(self.buf, "{{ _tag: \"{name}\" as const");
        if let Some(arg) = args.first() {
            self.buf.push_str(", _0: ");
            self.emit_expr(&arg.value)?;
        }
        self.buf.push_str(" }");
        Ok(true)
    }

    /// Q-prim-assoc: lower a primitive associated-conversion call
    /// (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`) to TS's native
    /// conversion. `from` is an infallible value coercion; `try_from` parses a
    /// `String` and returns the Bock `Result` tagged-object shape
    /// (`{ _tag: "Ok"/"Err" as const, _0: … }`), the `Err` payload a
    /// `ConvertError` (in scope via the `Result[T, ConvertError]` return-type
    /// import). The parse IIFE annotates its `string` param for strict mode.
    /// Returns `true` when handled.
    fn try_emit_primitive_conversion(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((target, method, arg)) =
            crate::generator::primitive_conversion_call(node, callee, args)
        else {
            return Ok(false);
        };
        let arg_str = self.expr_to_string(arg)?;
        let code = match (target, method) {
            ("Float" | "Int" | "String", "from") => format!("({arg_str})"),
            ("Int", "try_from") => format!(
                "((__s: string) => /^[+-]?[0-9]+$/.test(__s.trim()) \
                 ? {{ _tag: \"Ok\" as const, _0: Number.parseInt(__s.trim(), 10) }} \
                 : {{ _tag: \"Err\" as const, _0: new ConvertError({{ message: \
                 `cannot parse '${{__s}}' as Int` }}) }})({arg_str})"
            ),
            ("Float", "try_from") => format!(
                "((__s: string) => {{ const __t = __s.trim(); const __n = Number(__t); \
                 return (__t.length > 0 && !Number.isNaN(__n)) \
                 ? {{ _tag: \"Ok\" as const, _0: __n }} \
                 : {{ _tag: \"Err\" as const, _0: new ConvertError({{ message: \
                 `cannot parse '${{__s}}' as Float` }}) }}; }})({arg_str})"
            ),
            _ => return Ok(false),
        };
        self.buf.push_str(&code);
        Ok(true)
    }

    /// Emit a built-in `Optional`/`Result` method call to its TS form.
    ///
    /// Recognised via the checker's `recv_kind` annotation
    /// ([`crate::generator::desugared_optional_method`] /
    /// [`crate::generator::desugared_result_method`]). Both types use the tagged
    /// representation (`{ _tag, _0 }`), so the lowering is a ternary on `._tag`,
    /// wrapped in a *generic* arrow IIFE — `(<T,>(__c: BockOption<T>) => …)(recv)`
    /// / `(<T, E>(__c: BockResult<T, E>) => …)(recv)` — so the payload type is
    /// inferred from the receiver (strict-mode clean: no implicit `any`) and the
    /// receiver is evaluated exactly once. Returns `true` if handled.
    fn try_emit_container_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        if let Some((recv, method, rest)) =
            crate::generator::desugared_optional_method(node, callee, args)
        {
            self.emit_tagged_container_method(recv, method, rest, "Some", "<T,>", "BockOption<T>")?;
            return Ok(true);
        }
        if let Some((recv, method, rest)) =
            crate::generator::desugared_result_method(node, callee, args)
        {
            self.emit_tagged_container_method(
                recv,
                method,
                rest,
                "Ok",
                "<T, E>",
                "BockResult<T, E>",
            )?;
            return Ok(true);
        }
        Ok(false)
    }

    /// Lower a tagged-container method on `recv`. `present_tag` is the
    /// payload-carrying tag (`"Some"`/`"Ok"`); `type_params` / `param_ty` type
    /// the generic IIFE param (`<T,>` + `BockOption<T>`, or `<T, E>` +
    /// `BockResult<T, E>`).
    fn emit_tagged_container_method(
        &mut self,
        recv: &AIRNode,
        method: &str,
        rest: &[bock_air::AirArg],
        present_tag: &str,
        type_params: &str,
        param_ty: &str,
    ) -> Result<(), CodegenError> {
        // Pure tag tests read the receiver once → emit inline.
        match method {
            "is_some" | "is_ok" => {
                self.buf.push('(');
                self.emit_expr(recv)?;
                let _ = write!(self.buf, "._tag === \"{present_tag}\")");
                return Ok(());
            }
            "is_none" | "is_err" => {
                self.buf.push('(');
                self.emit_expr(recv)?;
                let _ = write!(self.buf, "._tag !== \"{present_tag}\")");
                return Ok(());
            }
            _ => {}
        }
        let _ = write!(self.buf, "(({type_params}(__c: {param_ty}) => ");
        match method {
            "unwrap" => {
                let _ = write!(
                    self.buf,
                    "__c._tag === \"{present_tag}\" ? __c._0 : (undefined as never)"
                );
            }
            "unwrap_or" => {
                let _ = write!(self.buf, "__c._tag === \"{present_tag}\" ? __c._0 : (");
                if let Some(d) = rest.first() {
                    self.emit_expr(&d.value)?;
                } else {
                    self.buf.push_str("undefined");
                }
                self.buf.push(')');
            }
            "map" => {
                // The callback's parameter type is the concrete payload type the
                // checker already validated; the generic IIFE param `T` is wider
                // (unconstrained), so feed the payload through `as any` to satisfy
                // strict mode without recovering the concrete type here.
                let _ = write!(
                    self.buf,
                    "__c._tag === \"{present_tag}\" ? {{ _tag: \"{present_tag}\" as const, _0: ("
                );
                if let Some(f) = rest.first() {
                    self.emit_expr(&f.value)?;
                } else {
                    self.buf.push_str("(x) => x");
                }
                self.buf.push_str(")(__c._0 as any) } : __c");
            }
            "flat_map" => {
                let _ = write!(self.buf, "__c._tag === \"{present_tag}\" ? (");
                if let Some(f) = rest.first() {
                    self.emit_expr(&f.value)?;
                } else {
                    self.buf.push_str("(x) => x");
                }
                self.buf.push_str(")(__c._0 as any) : __c");
            }
            "map_err" => {
                self.buf
                    .push_str("__c._tag === \"Ok\" ? __c : { _tag: \"Err\" as const, _0: (");
                if let Some(f) = rest.first() {
                    self.emit_expr(&f.value)?;
                } else {
                    self.buf.push_str("(x) => x");
                }
                self.buf.push_str(")(__c._0 as any) }");
            }
            _ => self.buf.push_str("(undefined as never)"),
        }
        self.buf.push_str(")(");
        self.emit_expr(recv)?;
        self.buf.push_str("))");
        Ok(())
    }

    /// Emit a read-only `List` built-in method call to its TS form.
    ///
    /// Mirrors the JS lowering but stays strict-mode clean: the
    /// `Optional`-returning methods (`get`/`first`/`last`/`index_of`) wrap the
    /// receiver in a *generic* arrow IIFE (`<T,>(__r: ReadonlyArray<T>, …):
    /// BockOption<T> => …`) so the element type `T` is inferred from the
    /// receiver and the result is the typed `BockOption<T>` union the `match`
    /// lowering narrows on `._tag`. The receiver is therefore evaluated exactly
    /// once and no parameter is implicitly `any`.
    fn try_emit_list_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, method, rest)) =
            crate::generator::desugared_list_method(node, callee, args)
        else {
            return Ok(false);
        };
        match method {
            "len" | "length" | "count" => {
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").length");
            }
            "is_empty" => {
                self.buf.push_str("((");
                self.emit_expr(recv)?;
                self.buf.push_str(").length === 0)");
            }
            "get" => {
                let Some(idx) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<T,>(__r: ReadonlyArray<T>, __i: number): BockOption<T> => \
                     (__i >= 0 && __i < __r.length) ? \
                     { _tag: \"Some\" as const, _0: __r[__i] } : { _tag: \"None\" as const })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&idx.value)?;
                self.buf.push(')');
            }
            "first" => {
                self.buf.push_str(
                    "(<T,>(__r: ReadonlyArray<T>): BockOption<T> => __r.length > 0 ? \
                     { _tag: \"Some\" as const, _0: __r[0] } : { _tag: \"None\" as const })(",
                );
                self.emit_expr(recv)?;
                self.buf.push(')');
            }
            "last" => {
                self.buf.push_str(
                    "(<T,>(__r: ReadonlyArray<T>): BockOption<T> => __r.length > 0 ? \
                     { _tag: \"Some\" as const, _0: __r[__r.length - 1] } : \
                     { _tag: \"None\" as const })(",
                );
                self.emit_expr(recv)?;
                self.buf.push(')');
            }
            "contains" => {
                let Some(x) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").includes(");
                self.emit_expr(&x.value)?;
                self.buf.push(')');
            }
            "index_of" => {
                let Some(x) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<T,>(__r: ReadonlyArray<T>, __x: T): BockOption<number> => \
                     { const __i = __r.indexOf(__x); return __i >= 0 ? \
                     { _tag: \"Some\" as const, _0: __i } : { _tag: \"None\" as const }; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&x.value)?;
                self.buf.push(')');
            }
            "concat" => {
                let Some(o) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").concat(");
                self.emit_expr(&o.value)?;
                self.buf.push(')');
            }
            "join" => {
                let Some(sep) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").join(");
                self.emit_expr(&sep.value)?;
                self.buf.push(')');
            }
            _ => return Ok(false),
        }
        Ok(true)
    }

    /// Emit an in-place `List` mutator (`push`/`append`, DQ18) to its TS form.
    ///
    /// Recognised via [`crate::generator::desugared_list_mutating_method`].
    /// Bock's `List[T]` lowers to a mutable `Array<T>` (`T[]`), so `recv.push(x)`
    /// lowers directly. The checker types these as `Void` (statement position);
    /// the receiver is a `mut` lvalue (ownership-enforced), evaluated once.
    fn try_emit_list_mutating_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, _method, rest)) =
            crate::generator::desugared_list_mutating_method(node, callee, args)
        else {
            return Ok(false);
        };
        let Some(x) = rest.first() else {
            return Ok(false);
        };
        self.buf.push('(');
        self.emit_expr(recv)?;
        self.buf.push_str(").push(");
        self.emit_expr(&x.value)?;
        self.buf.push(')');
        Ok(true)
    }

    /// Emit a DQ30 in-place `List` mutator
    /// (`pop`/`remove_at`/`insert`/`reverse`/`set`) to its TS form.
    ///
    /// Mirrors the JS lowering (arrays are reference values, so the IIFE
    /// parameter aliases the receiver and mutations are caller-visible) but
    /// stays strict-mode clean: the IIFEs are *generic* arrows over a mutable
    /// `T[]` parameter (not the read-only methods' `ReadonlyArray<T>`), `pop`
    /// returns the typed `BockOption<T>` union (the `T | undefined` from
    /// native `.pop()` is narrowed by the length guard and asserted `as T`),
    /// and `remove_at`'s `splice(i, 1)[0]` is asserted `as T` for
    /// `noUncheckedIndexedAccess` robustness. The bounds checks throw with the
    /// normalized abort message `List.<op>: index <i> out of bounds (len <n>)`
    /// (the DQ23 zero-check convention); `set`'s check is load-bearing because
    /// native index-assign past the end silently extends the array.
    fn try_emit_list_inplace_mutator(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, method, rest)) =
            crate::generator::desugared_list_inplace_mutator(node, callee, args)
        else {
            return Ok(false);
        };
        match method {
            "pop" => {
                self.buf.push_str(
                    "(<T,>(__r: T[]): BockOption<T> => __r.length > 0 ? \
                     { _tag: \"Some\" as const, _0: __r.pop() as T } : \
                     { _tag: \"None\" as const })(",
                );
                self.emit_expr(recv)?;
                self.buf.push(')');
            }
            "remove_at" => {
                let Some(idx) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<T,>(__r: T[], __i: number): T => { \
                     if (__i < 0 || __i >= __r.length) { throw new Error(\
                     \"List.remove_at: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
                     return __r.splice(__i, 1)[0] as T; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&idx.value)?;
                self.buf.push(')');
            }
            "insert" => {
                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<T,>(__r: T[], __i: number, __x: T): void => { \
                     if (__i < 0 || __i > __r.length) { throw new Error(\
                     \"List.insert: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
                     __r.splice(__i, 0, __x); })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&idx.value)?;
                self.buf.push_str(", ");
                self.emit_expr(&x.value)?;
                self.buf.push(')');
            }
            "reverse" => {
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").reverse()");
            }
            "set" => {
                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<T,>(__r: T[], __i: number, __x: T): void => { \
                     if (__i < 0 || __i >= __r.length) { throw new Error(\
                     \"List.set: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
                     __r[__i] = __x; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&idx.value)?;
                self.buf.push_str(", ");
                self.emit_expr(&x.value)?;
                self.buf.push(')');
            }
            _ => return Ok(false),
        }
        Ok(true)
    }

    /// Emit a functional (closure-taking) `List` built-in method call to its TS
    /// form.
    ///
    /// Recognised via [`crate::generator::desugared_list_functional_method`].
    /// Mirrors the JS lowering — native `map`/`filter`/`reduce`/`forEach`/`some`/
    /// `every`/`flatMap` with the closure passed *once* on the receiver array, so
    /// `tsc --strict --noImplicitAny` contextually types each callback parameter
    /// from the array's element type (the broken `recv.map(recv, cb)` form both
    /// double-passed the receiver *and* left the params implicitly `any`).
    /// `fold(init, cb)` maps to `reduce(cb, init)`; `find` wraps the native
    /// result into the tagged `BockOption`.
    fn try_emit_list_functional_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, method, rest)) =
            crate::generator::desugared_list_functional_method(node, callee, args)
        else {
            return Ok(false);
        };
        match method {
            "map" | "filter" | "for_each" | "any" | "all" | "flat_map" => {
                let Some(cb) = rest.first() else {
                    return Ok(false);
                };
                let native = match method {
                    "map" => "map",
                    "filter" => "filter",
                    "for_each" => "forEach",
                    "any" => "some",
                    "all" => "every",
                    "flat_map" => "flatMap",
                    _ => unreachable!(),
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                let _ = write!(self.buf, ").{native}(");
                self.emit_expr(&cb.value)?;
                self.buf.push(')');
            }
            "reduce" => {
                let Some(cb) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").reduce(");
                self.emit_expr(&cb.value)?;
                self.buf.push(')');
            }
            "fold" => {
                let (Some(init), Some(cb)) = (rest.first(), rest.get(1)) else {
                    return Ok(false);
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").reduce(");
                self.emit_expr(&cb.value)?;
                self.buf.push_str(", ");
                self.emit_expr(&init.value)?;
                self.buf.push(')');
            }
            "find" => {
                self.buf.push_str(
                    "(<T,>(__r: ReadonlyArray<T>, __p: (x: T) => boolean): BockOption<T> => \
                     { const __m = __r.find(__p); return __m === undefined ? \
                     { _tag: \"None\" as const } : { _tag: \"Some\" as const, _0: __m }; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                let Some(cb) = rest.first() else {
                    return Ok(false);
                };
                self.emit_expr(&cb.value)?;
                self.buf.push(')');
            }
            _ => return Ok(false),
        }
        Ok(true)
    }

    /// Emit a built-in `Map[K, V]` method call to its TS form (native `Map`).
    ///
    /// Recognised via [`crate::generator::desugared_map_method`] (gated on
    /// `recv_kind = "Map"`) and wired *before* [`Self::try_emit_list_method`].
    /// Mirrors the JS lowering but types the generic IIFEs (`<K, V>` params,
    /// `BockOption<V>` return for `get`) so `tsc --strict` narrows correctly.
    /// `get` returns the tagged `Optional` rep (`{ _tag: "Some" as const, _0: v
    /// }` / `{ _tag: "None" as const }`); mutating methods (`set`/`delete`/
    /// `merge`) mutate in place and return the receiver. Returns `true` if
    /// handled.
    fn try_emit_map_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, method, rest)) = crate::generator::desugared_map_method(node, callee, args)
        else {
            return Ok(false);
        };
        match method {
            "len" | "length" | "count" => {
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").size");
            }
            "is_empty" => {
                self.buf.push_str("((");
                self.emit_expr(recv)?;
                self.buf.push_str(").size === 0)");
            }
            "contains_key" => {
                let Some(k) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").has(");
                self.emit_expr(&k.value)?;
                self.buf.push(')');
            }
            "get" => {
                let Some(k) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<K, V>(__m: Map<K, V>, __k: K): BockOption<V> => __m.has(__k) ? \
                     { _tag: \"Some\" as const, _0: __m.get(__k)! } : { _tag: \"None\" as const })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&k.value)?;
                self.buf.push(')');
            }
            "set" => {
                let (Some(k), Some(v)) = (rest.first(), rest.get(1)) else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<K, V>(__m: Map<K, V>, __k: K, __v: V): Map<K, V> => \
                     { __m.set(__k, __v); return __m; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&k.value)?;
                self.buf.push_str(", ");
                self.emit_expr(&v.value)?;
                self.buf.push(')');
            }
            "delete" => {
                let Some(k) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<K, V>(__m: Map<K, V>, __k: K): Map<K, V> => \
                     { __m.delete(__k); return __m; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&k.value)?;
                self.buf.push(')');
            }
            "merge" => {
                let Some(o) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<K, V>(__m: Map<K, V>, __o: Map<K, V>): Map<K, V> => \
                     { for (const [__k, __v] of __o) __m.set(__k, __v); return __m; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&o.value)?;
                self.buf.push(')');
            }
            "filter" => {
                let Some(f) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<K, V>(__m: Map<K, V>, __f: (k: K, v: V) => boolean): Map<K, V> => \
                     { const __r = new Map<K, V>(); \
                     for (const [__k, __v] of __m) if (__f(__k, __v)) __r.set(__k, __v); \
                     return __r; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&f.value)?;
                self.buf.push(')');
            }
            "keys" => {
                self.buf.push_str("[...(");
                self.emit_expr(recv)?;
                self.buf.push_str(").keys()]");
            }
            "values" => {
                self.buf.push_str("[...(");
                self.emit_expr(recv)?;
                self.buf.push_str(").values()]");
            }
            "entries" | "to_list" => {
                self.buf.push_str("[...(");
                self.emit_expr(recv)?;
                self.buf.push_str(").entries()]");
            }
            "for_each" => {
                let Some(f) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<K, V>(__m: Map<K, V>, __f: (k: K, v: V) => void): void => \
                     { for (const [__k, __v] of __m) __f(__k, __v); })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&f.value)?;
                self.buf.push(')');
            }
            _ => return Ok(false),
        }
        Ok(true)
    }

    /// Emit a built-in `Set[E]` method call to its TS form (native `Set`).
    ///
    /// Recognised via [`crate::generator::desugared_set_method`] (gated on
    /// `recv_kind = "Set"`) and wired *before* [`Self::try_emit_list_method`].
    /// Generic-typed IIFEs (`<E>` params) keep `tsc --strict` happy. Mutating
    /// methods (`add`/`remove`) mutate in place and return the receiver.
    fn try_emit_set_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, method, rest)) = crate::generator::desugared_set_method(node, callee, args)
        else {
            return Ok(false);
        };
        match method {
            "len" | "length" | "count" => {
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").size");
            }
            "is_empty" => {
                self.buf.push_str("((");
                self.emit_expr(recv)?;
                self.buf.push_str(").size === 0)");
            }
            "contains" => {
                let Some(x) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push('(');
                self.emit_expr(recv)?;
                self.buf.push_str(").has(");
                self.emit_expr(&x.value)?;
                self.buf.push(')');
            }
            "add" => {
                let Some(x) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__s: Set<E>, __x: E): Set<E> => { __s.add(__x); return __s; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&x.value)?;
                self.buf.push(')');
            }
            "remove" => {
                let Some(x) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__s: Set<E>, __x: E): Set<E> => { __s.delete(__x); return __s; })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&x.value)?;
                self.buf.push(')');
            }
            "union" => {
                let Some(o) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__a: Set<E>, __b: Set<E>): Set<E> => new Set<E>([...__a, ...__b]))(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&o.value)?;
                self.buf.push(')');
            }
            "intersection" => {
                let Some(o) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__a: Set<E>, __b: Set<E>): Set<E> => \
                     new Set<E>([...__a].filter((__x) => __b.has(__x))))(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&o.value)?;
                self.buf.push(')');
            }
            "difference" => {
                let Some(o) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__a: Set<E>, __b: Set<E>): Set<E> => \
                     new Set<E>([...__a].filter((__x) => !__b.has(__x))))(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&o.value)?;
                self.buf.push(')');
            }
            "is_subset" => {
                let Some(o) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__a: Set<E>, __b: Set<E>): boolean => \
                     [...__a].every((__x) => __b.has(__x)))(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&o.value)?;
                self.buf.push(')');
            }
            "is_superset" => {
                let Some(o) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__a: Set<E>, __b: Set<E>): boolean => \
                     [...__b].every((__x) => __a.has(__x)))(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&o.value)?;
                self.buf.push(')');
            }
            "filter" => {
                let Some(f) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__s: Set<E>, __f: (x: E) => boolean): Set<E> => \
                     new Set<E>([...__s].filter(__f)))(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&f.value)?;
                self.buf.push(')');
            }
            "map" => {
                let Some(f) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__s: Set<E>, __f: (x: E) => E): Set<E> => \
                     new Set<E>([...__s].map(__f)))(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&f.value)?;
                self.buf.push(')');
            }
            "to_list" => {
                self.buf.push_str("[...(");
                self.emit_expr(recv)?;
                self.buf.push_str(")]");
            }
            "for_each" => {
                let Some(f) = rest.first() else {
                    return Ok(false);
                };
                self.buf.push_str(
                    "(<E>(__s: Set<E>, __f: (x: E) => void): void => \
                     { for (const __x of __s) __f(__x); })(",
                );
                self.emit_expr(recv)?;
                self.buf.push_str(", ");
                self.emit_expr(&f.value)?;
                self.buf.push(')');
            }
            _ => return Ok(false),
        }
        Ok(true)
    }

    /// Lower a primitive trait-bridge method call (`compare`/`eq`/`to_string`/
    /// `display` on a primitive receiver) to its TS form.
    ///
    /// Mirrors the JS lowering, but uses `as const` tags so the ternary's type
    /// is the discriminated `Ordering` union `tsc` can narrow on `._tag` in the
    /// match. `eq` → `===`; `to_string`/`display` → `String(x)`.
    /// Lower a desugared `String` built-in method call (`recv_kind =
    /// "Primitive:String"`) to its native TypeScript string op. Wired into the
    /// `Call` arm *before* `try_emit_list_method` so a String receiver's
    /// `len`/`contains`/`is_empty` dispatch here, not through the List path.
    ///
    /// `len` is the Unicode SCALAR count (`[...s].length`, iterating by code
    /// point) per spec §18.3 — not `s.length` (UTF-16 code units). `byte_len` is
    /// the UTF-8 byte count via `TextEncoder`. `replace` replaces ALL occurrences
    /// (`replaceAll`). `split` returns a TS array, the List runtime rep.
    ///
    /// Gated on `recv_kind = "Primitive:String"` directly (not the cross-backend
    /// [`crate::generator::desugared_string_method`] subset) so TS can lower the
    /// wider resolved String surface — `slice`/`substring`/`char_at`/`index_of`/
    /// `repeat`/`reverse`/`trim_start`/`trim_end` — to native ops, matching the
    /// Rust backend. The emitted forms are kept type-clean under `--strict`: the
    /// IIFE params are annotated (`number`/`string`) so no implicit `any` arises,
    /// and `char_at`/`index_of` return the typed `BockOption<…>` union (`{ _tag:
    /// "Some" as const, _0: v }` / `{ _tag: "None" as const }`).
    fn try_emit_string_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        if crate::generator::primitive_recv_kind(node) != Some("String") {
            return Ok(false);
        }
        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
            return Ok(false);
        };
        let method = field.name.as_str();
        let recv_str = self.expr_to_string(recv)?;
        let arg0 = |this: &mut Self| -> Result<Option<String>, CodegenError> {
            rest.first()
                .map(|a| this.expr_to_string(&a.value))
                .transpose()
        };
        let code = match method {
            "len" | "length" | "count" => format!("[...({recv_str})].length"),
            "byte_len" => format!("new TextEncoder().encode({recv_str}).length"),
            "is_empty" => format!("(({recv_str}).length === 0)"),
            "to_upper" => format!("({recv_str}).toUpperCase()"),
            "to_lower" => format!("({recv_str}).toLowerCase()"),
            "trim" => format!("({recv_str}).trim()"),
            "trim_start" => format!("({recv_str}).trimStart()"),
            "trim_end" => format!("({recv_str}).trimEnd()"),
            "reverse" => format!("[...({recv_str})].reverse().join('')"),
            "to_string" | "display" => format!("String({recv_str})"),
            "repeat" => {
                let Some(n) = arg0(self)? else {
                    return Ok(false);
                };
                format!("({recv_str}).repeat({n})")
            }
            "contains" => {
                let Some(p) = arg0(self)? else {
                    return Ok(false);
                };
                format!("({recv_str}).includes({p})")
            }
            "starts_with" => {
                let Some(p) = arg0(self)? else {
                    return Ok(false);
                };
                format!("({recv_str}).startsWith({p})")
            }
            "ends_with" => {
                let Some(p) = arg0(self)? else {
                    return Ok(false);
                };
                format!("({recv_str}).endsWith({p})")
            }
            "replace" => {
                let Some(from) = arg0(self)? else {
                    return Ok(false);
                };
                let Some(to) = rest
                    .get(1)
                    .map(|a| self.expr_to_string(&a.value))
                    .transpose()?
                else {
                    return Ok(false);
                };
                format!("({recv_str}).replaceAll({from}, {to})")
            }
            "split" => {
                let Some(sep) = arg0(self)? else {
                    return Ok(false);
                };
                format!("({recv_str}).split({sep})")
            }
            // `slice`/`substring(start, end)`: scalar-index half-open substring
            // (spec §18.3 — indices count Unicode scalars). Iterate by code point.
            "slice" | "substring" => {
                let Some(start) = arg0(self)? else {
                    return Ok(false);
                };
                let Some(end) = rest
                    .get(1)
                    .map(|a| self.expr_to_string(&a.value))
                    .transpose()?
                else {
                    return Ok(false);
                };
                format!("[...({recv_str})].slice({start}, {end}).join('')")
            }
            // `char_at(i)` returns `Optional[Char]` — `None` when out of range.
            "char_at" => {
                let Some(i) = arg0(self)? else {
                    return Ok(false);
                };
                self.needs_runtime_optional = true;
                format!(
                    "((__s: string[], __i: number): BockOption<string> => __i >= 0 && __i < __s.length ? {{ _tag: \"Some\" as const, _0: __s[__i] }} : {{ _tag: \"None\" as const }})([...({recv_str})], {i})"
                )
            }
            // `index_of(needle)` returns `Optional[Int]` — the scalar index of the
            // first match, or `None`. TS `indexOf` is a UTF-16 code-unit offset, so
            // convert it to a scalar index via the code-point prefix length.
            "index_of" => {
                let Some(p) = arg0(self)? else {
                    return Ok(false);
                };
                self.needs_runtime_optional = true;
                format!(
                    "((__s: string, __p: string): BockOption<number> => {{ const __b = __s.indexOf(__p); return __b >= 0 ? {{ _tag: \"Some\" as const, _0: [...__s.slice(0, __b)].length }} : {{ _tag: \"None\" as const }}; }})({recv_str}, {p})"
                )
            }
            _ => return Ok(false),
        };
        self.buf.push_str(&code);
        Ok(true)
    }

    /// Lower a desugared numeric/`Char`/`Bool` primitive method (`recv_kind =
    /// "Primitive:Int" | "Primitive:Float" | "Primitive:Char" | "Primitive:Bool"`)
    /// to its native TypeScript form. Covers the conversion and math methods the
    /// checker resolves on the scalar primitives — `to_float`/`to_int`/`abs`/`min`/
    /// `max`/`clamp`/`floor`/`ceil`/`round`/`sqrt`/… — none of which exist as
    /// methods on a TS `number`/`boolean`/string-char. Wired into the `Call` arm
    /// alongside [`Self::try_emit_string_method`], before the generic
    /// desugared-self-call fall-through (which would emit `n.to_float(n)`).
    /// `compare`/`eq`/`to_string`/`display`/`hash_code` stay on the primitive
    /// *bridge* path. The emitted forms are type-clean (`number` in, `number` out).
    fn try_emit_numeric_method(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let prim = match crate::generator::primitive_recv_kind(node) {
            Some(p @ ("Int" | "Float" | "Char" | "Bool")) => p,
            _ => return Ok(false),
        };
        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
            return Ok(false);
        };
        let method = field.name.as_str();
        let recv_str = self.expr_to_string(recv)?;
        let arg = |this: &mut Self, i: usize| -> Result<Option<String>, CodegenError> {
            rest.get(i)
                .map(|a| this.expr_to_string(&a.value))
                .transpose()
        };
        let code = match (prim, method) {
            // Conversions. Both `Int` and `Float` are a TS `number`; `to_int`
            // truncates toward zero (Bock `Float.to_int`).
            ("Int", "to_float") => format!("({recv_str})"),
            ("Float", "to_int") => format!("Math.trunc({recv_str})"),
            ("Char", "to_int") => format!("(({recv_str}).codePointAt(0) ?? 0)"),
            ("Bool", "to_int") => format!("(({recv_str}) ? 1 : 0)"),
            // Int math.
            ("Int", "abs") => format!("Math.abs({recv_str})"),
            ("Int" | "Float", "min") => {
                let Some(o) = arg(self, 0)? else {
                    return Ok(false);
                };
                format!("Math.min({recv_str}, {o})")
            }
            ("Int" | "Float", "max") => {
                let Some(o) = arg(self, 0)? else {
                    return Ok(false);
                };
                format!("Math.max({recv_str}, {o})")
            }
            ("Int" | "Float", "clamp") => {
                let (Some(lo), Some(hi)) = (arg(self, 0)?, arg(self, 1)?) else {
                    return Ok(false);
                };
                format!("Math.min(Math.max({recv_str}, {lo}), {hi})")
            }
            ("Int", "shift_left") => {
                let Some(o) = arg(self, 0)? else {
                    return Ok(false);
                };
                format!("(({recv_str}) << ({o}))")
            }
            ("Int", "shift_right") => {
                let Some(o) = arg(self, 0)? else {
                    return Ok(false);
                };
                format!("(({recv_str}) >> ({o}))")
            }
            // Float math.
            ("Float", "abs") => format!("Math.abs({recv_str})"),
            ("Float", "floor") => format!("Math.floor({recv_str})"),
            ("Float", "ceil") => format!("Math.ceil({recv_str})"),
            ("Float", "round") => format!("Math.round({recv_str})"),
            ("Float", "sqrt") => format!("Math.sqrt({recv_str})"),
            ("Float", "is_nan") => format!("Number.isNaN({recv_str})"),
            ("Float", "is_infinite") => format!("(!Number.isFinite({recv_str}))"),
            // Bool.
            ("Bool", "negate") => format!("(!({recv_str}))"),
            // Char (a one-code-point TS string).
            ("Char", "to_upper") => format!("({recv_str}).toUpperCase()"),
            ("Char", "to_lower") => format!("({recv_str}).toLowerCase()"),
            ("Char", "is_alpha") => format!("(/\\p{{L}}/u.test({recv_str}))"),
            ("Char", "is_digit") => format!("(/[0-9]/.test({recv_str}))"),
            ("Char", "is_whitespace") => format!("(/\\s/.test({recv_str}))"),
            _ => return Ok(false),
        };
        self.buf.push_str(&code);
        Ok(true)
    }

    fn try_emit_primitive_bridge(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, method, rest, prim)) =
            crate::generator::primitive_bridge_call(node, callee, args)
        else {
            return Ok(false);
        };
        self.emit_bridge_method(recv, method, rest, Some(prim))
    }

    /// Lower a sealed-core-trait bridge method on a *bounded generic type
    /// variable* (`a.eq(b)` / `a.compare(b)` inside `eq_check[T: Equatable]`) to
    /// its TS form (GAP-C). The method body is identical to the `Primitive:<Ty>`
    /// bridge; the `<T extends Equatable>` bound is separately dropped at the
    /// signature (see `generic_params_to_ts`). Fires only when the bound trait is
    /// sealed-core and NOT a user-declared trait.
    fn try_emit_trait_bound_bridge(
        &mut self,
        node: &AIRNode,
        callee: &AIRNode,
        args: &[bock_air::AirArg],
    ) -> Result<bool, CodegenError> {
        let Some((recv, method, rest, _tr)) =
            crate::generator::trait_bound_bridge_call(node, callee, args, &self.trait_decls)
        else {
            return Ok(false);
        };
        // DQ29: unlike the `Primitive:<Ty>` bridge (whose receiver is a known
        // scalar, where `===` is correct), a bounded `T: Equatable` receiver
        // may be instantiated with a RECORD — `===` would be reference
        // identity — so `a.eq(b)` lowers through the `__bockEq` structural
        // helper, which falls back to `===` for primitives.
        if method == "eq" {
            let Some(other) = rest.first() else {
                return Ok(false);
            };
            let recv_str = self.expr_to_string(recv)?;
            let other = self.expr_to_string(&other.value)?;
            let _ = write!(self.buf, "__bockEq({recv_str}, {other})");
            return Ok(true);
        }
        // Q-bounded-comparable-codegen: a bounded `T: Comparable` `compare`
        // receiver may be instantiated with a RECORD whose ordering lives in its
        // own `compare` method — the native `<`/`===` ternary the
        // `emit_bridge_method` `compare` arm emits is correct ONLY for a
        // primitive instantiation (object `<` coerces to `NaN`; `===` is
        // reference identity). Route through `__bockCompare`, which calls the
        // value's `compare` method when present and falls back to the native
        // ternary for primitives.
        if method == "compare" {
            let Some(other) = rest.first() else {
                return Ok(false);
            };
            let recv_str = self.expr_to_string(recv)?;
            let other = self.expr_to_string(&other.value)?;
            self.needs_runtime_compare = true;
            let _ = write!(self.buf, "__bockCompare({recv_str}, {other})");
            return Ok(true);
        }
        self.emit_bridge_method(recv, method, rest, None)
    }

    /// Shared body of the primitive / trait-bound bridges: emit the native TS form
    /// of `compare` (the `Ordering` ternary), `eq` (`===`), or `to_string`/
    /// `display` (`String(..)`).
    ///
    /// `widen_prim` is the receiver's primitive type name (`"Int"`, `"Float"`,
    /// …) when this is the concrete `Primitive:<Ty>` bridge, or `None` for the
    /// trait-bound bridge (whose receiver is a generic `T`). When present, the
    /// `eq` arm widens the receiver with a `as <ts-type>` assertion before
    /// `===`: two distinct *literal* operands (`(3).eq(4)`) would otherwise be
    /// inferred as the literal types `3`/`4`, which `tsc` rejects under
    /// `strictNullChecks` as TS2367 ("this comparison appears to be
    /// unintentional because the types '3' and '4' have no overlap"). The cast
    /// widens the `3` to `number`, so the comparison is between `number` and a
    /// literal — overlapping, hence accepted — while the runtime `===` is
    /// unchanged. (Q-ts-primitive-eq-literal-overlap.)
    fn emit_bridge_method(
        &mut self,
        recv: &AIRNode,
        method: &str,
        rest: &[bock_air::AirArg],
        widen_prim: Option<&str>,
    ) -> Result<bool, CodegenError> {
        let recv_str = self.expr_to_string(recv)?;
        match method {
            "compare" => {
                let Some(other) = rest.first() else {
                    return Ok(false);
                };
                let other = self.expr_to_string(&other.value)?;
                let _ = write!(
                    self.buf,
                    "(({recv_str}) < ({other}) ? {{ _tag: \"Less\" as const }} : \
                     (({recv_str}) === ({other}) ? {{ _tag: \"Equal\" as const }} : \
                     {{ _tag: \"Greater\" as const }}))"
                );
            }
            "eq" => {
                let Some(other) = rest.first() else {
                    return Ok(false);
                };
                let other = self.expr_to_string(&other.value)?;
                // Widen the receiver to its primitive TS type so two distinct
                // literal operands (`(3).eq(4)`) compare as `number === 3`
                // rather than the literal-only `3 === 4` (TS2367). See the
                // method doc-comment.
                if let Some(prim) = widen_prim {
                    let ts_ty = self.map_type_name(prim);
                    let _ = write!(self.buf, "(({recv_str} as {ts_ty}) === ({other}))");
                } else {
                    let _ = write!(self.buf, "(({recv_str}) === ({other}))");
                }
            }
            "to_string" | "display" => {
                let _ = write!(self.buf, "String({recv_str})");
            }
            _ => return Ok(false),
        }
        Ok(true)
    }

    // ── Type emission ────────────────────────────────────────────────────────

    /// Emit a type expression from an AIR type node to a TS type string.
    fn type_to_ts(&self, node: &AIRNode) -> String {
        match &node.kind {
            NodeKind::TypeNamed { path, args } => {
                let name = path
                    .segments
                    .iter()
                    .map(|s| s.name.as_str())
                    .collect::<Vec<_>>()
                    .join(".");
                let ts_name = self.map_type_name(&name);
                if args.is_empty() {
                    ts_name
                } else {
                    let arg_strs: Vec<String> = args.iter().map(|a| self.type_to_ts(a)).collect();
                    format!("{ts_name}<{}>", arg_strs.join(", "))
                }
            }
            NodeKind::TypeTuple { elems } => {
                let elem_strs: Vec<String> = elems.iter().map(|e| self.type_to_ts(e)).collect();
                format!("[{}]", elem_strs.join(", "))
            }
            NodeKind::TypeFunction { params, ret, .. } => {
                let param_strs: Vec<String> = params
                    .iter()
                    .enumerate()
                    .map(|(i, p)| format!("arg{i}: {}", self.type_to_ts(p)))
                    .collect();
                format!("({}) => {}", param_strs.join(", "), self.type_to_ts(ret))
            }
            NodeKind::TypeOptional { inner } => {
                // `T?` lowers to the tagged Optional runtime union, not `T |
                // null`: the value is `{ _tag: "Some", _0: v }` / `{ _tag:
                // "None" }`, so the type must describe that. See
                // `OPTIONAL_RUNTIME_TS`.
                format!("BockOption<{}>", self.type_to_ts(inner))
            }
            NodeKind::TypeSelf => self
                .trait_self_subst
                .clone()
                .unwrap_or_else(|| "this".into()),
            _ => "unknown".into(),
        }
    }

    /// Map Bock type names to TS equivalents.
    /// True when the real `core.compare.Ordering` enum is reachable in this
    /// program (its `Less` variant is a registered user enum variant). When
    /// `core.compare` is `use`d, the actual `Ordering` union type is emitted and
    /// imported; otherwise the prelude form (a bare structural `{ _tag: … }`
    /// union) is used. Mirrors the rust/go backends.
    fn ordering_enum_reachable(&self) -> bool {
        self.enum_variants
            .get("Less")
            .is_some_and(|info| info.enum_name == "Ordering")
    }

    fn map_type_name(&self, name: &str) -> String {
        match name {
            "Int" => "number".into(),
            "Float" => "number".into(),
            "Bool" => "boolean".into(),
            "String" => "string".into(),
            // Bock `Char` has no distinct TS runtime type; a Char literal
            // (`'A'`) emits as a single-character string and every Char method
            // (`to_upper`, `is_alpha`, …) is implemented as a string op, so the
            // type maps to `string` for consistency.
            "Char" => "string".into(),
            "Void" | "Unit" => "void".into(),
            "List" => "Array".into(),
            "Map" => "Map".into(),
            "Set" => "Set".into(),
            "Any" => "any".into(),
            "Never" => "never".into(),
            // `Result[T, E]` lowers to the tagged-union runtime type, mirroring
            // `Optional[T]` → `BockOption<T>` (see `RESULT_RUNTIME_TS`).
            "Result" => "BockResult".into(),
            // The spelled-out `Optional[T]` (a named type application, distinct
            // from the `T?` shorthand handled by the `TypeOptional` arm) must
            // also lower to the tagged runtime union `BockOption<T>`, matching
            // the emitted `{ _tag: "Some", _0: v }` / `{ _tag: "None" }` value
            // representation. Without this it emitted a bare `Optional<T>`
            // (TS2304, undefined name).
            "Optional" => "BockOption".into(),
            // §18.3.1 builtin time types: a `Duration` value is stored as a
            // signed-nanosecond `number`, and an `Instant` likewise lowers to a
            // `number` (`performance.now() * 1e6`). They are NOT user-defined
            // types, so as annotations (e.g. on a `Clock` handler's
            // `now_monotonic()` / `sleep(duration: Duration)`) they must render
            // `number`, not the undefined identifiers `Duration`/`Instant`.
            "Duration" | "Instant" => "number".into(),
            // The prelude `Ordering` enum: when the real `core.compare.Ordering`
            // is NOT reachable (no `use core.compare`), its variants lower to a
            // bare structural tagged union (`{ _tag: "Less" }` …), so a
            // `-> Ordering` annotation must render that union — the bare
            // `Ordering` is an undefined TS name (TS2304). When the enum IS
            // reachable the imported `Ordering` union type is in scope and keeps
            // its name. (Q-prelude-impl-missing-import.)
            "Ordering" if !self.ordering_enum_reachable() => {
                "({ _tag: \"Less\" } | { _tag: \"Equal\" } | { _tag: \"Greater\" })".into()
            }
            other => other.into(),
        }
    }

    /// Emit an AST TypeExpr to a TS type string (for record fields).
    fn ast_type_to_ts(&self, ty: &TypeExpr) -> String {
        match ty {
            TypeExpr::Named { path, args, .. } => {
                let name = path
                    .segments
                    .iter()
                    .map(|s| s.name.as_str())
                    .collect::<Vec<_>>()
                    .join(".");
                let ts_name = self.map_type_name(&name);
                if args.is_empty() {
                    ts_name
                } else {
                    let arg_strs: Vec<String> =
                        args.iter().map(|a| self.ast_type_to_ts(a)).collect();
                    format!("{ts_name}<{}>", arg_strs.join(", "))
                }
            }
            TypeExpr::Tuple { elems, .. } => {
                let elem_strs: Vec<String> = elems.iter().map(|e| self.ast_type_to_ts(e)).collect();
                format!("[{}]", elem_strs.join(", "))
            }
            TypeExpr::Function { params, ret, .. } => {
                let param_strs: Vec<String> = params
                    .iter()
                    .enumerate()
                    .map(|(i, p)| format!("arg{i}: {}", self.ast_type_to_ts(p)))
                    .collect();
                format!(
                    "({}) => {}",
                    param_strs.join(", "),
                    self.ast_type_to_ts(ret)
                )
            }
            TypeExpr::Optional { inner, .. } => {
                // See the `TypeOptional` arm of `type_to_ts`: the tagged
                // Optional union must match the emitted tagged-object value.
                format!("BockOption<{}>", self.ast_type_to_ts(inner))
            }
            TypeExpr::SelfType { .. } => "this".into(),
        }
    }

    /// Emit generic parameter list: `<T, U extends Foo>`.
    /// Resolve the generic params that apply to an `impl` target: the impl's own
    /// params when present (`impl[T] Box[T] { ... }`), else the params declared
    /// on the target record/enum (`impl Box { ... }` where `T` lives on
    /// `record Box[T]`). Empty for a non-generic target.
    fn impl_target_generics(
        &self,
        impl_params: &[bock_ast::GenericParam],
        target_name: &str,
    ) -> Vec<bock_ast::GenericParam> {
        if !impl_params.is_empty() {
            return impl_params.to_vec();
        }
        self.generic_decls
            .get(target_name)
            .cloned()
            .unwrap_or_default()
    }

    /// Render a *use-site* generic argument list (`<T>`, `<T, U>`) — the bare
    /// param names, no `extends` bounds — for a type reference like `Box<T>`.
    /// Empty string for no params.
    fn generic_param_args(&self, params: &[bock_ast::GenericParam]) -> String {
        if params.is_empty() {
            return String::new();
        }
        let names: Vec<&str> = params.iter().map(|p| p.name.name.as_str()).collect();
        format!("<{}>", names.join(", "))
    }

    /// Render the combined generic-parameter declaration for an impl method's
    /// prototype function: the target type's params (with bounds) followed by
    /// the method's own params. Used because the prototype assignment lives
    /// outside the class, so its function must re-declare the class's `<T>`.
    fn merge_generic_params_to_ts(
        &self,
        target_params: &[bock_ast::GenericParam],
        method_params: &[bock_ast::GenericParam],
    ) -> String {
        let mut merged = target_params.to_vec();
        merged.extend(method_params.iter().cloned());
        self.generic_params_to_ts(&merged)
    }

    fn generic_params_to_ts(&self, params: &[bock_ast::GenericParam]) -> String {
        if params.is_empty() {
            return String::new();
        }
        let items: Vec<String> = params
            .iter()
            .map(|p| {
                // Drop a compiler-provided sealed-core bound (`Equatable`/…) with no
                // user `impl`: TS has no such type, so `<T extends Equatable>` fails
                // `tsc` with TS2304 (GAP-C). The method is lowered to a native
                // operator (`===`/comparison) by `try_emit_trait_bound_bridge`, so
                // an unconstrained `<T>` is correct. A user-declared trait of the
                // same name keeps its `extends` bound.
                let bounds: Vec<String> = p
                    .bounds
                    .iter()
                    .map(|b| {
                        b.segments
                            .iter()
                            .map(|s| s.name.as_str())
                            .collect::<Vec<_>>()
                            .join(".")
                    })
                    .filter(|name| {
                        !crate::generator::is_unimplemented_sealed_core_trait(
                            name,
                            &self.trait_decls,
                        )
                    })
                    .collect();
                if bounds.is_empty() {
                    p.name.name.clone()
                } else {
                    format!("{} extends {}", p.name.name, bounds.join(" & "))
                }
            })
            .collect();
        format!("<{}>", items.join(", "))
    }

    /// Use-site type-argument list for a generic declaration: just the
    /// parameter names (`<T>`, `<T, U>`), with no `extends` bounds. This is the
    /// form a *reference* to the generic type takes (e.g. a variant interface
    /// named inside its enum's union alias, or a constructor factory's return
    /// type), as distinct from [`Self::generic_params_to_ts`], which is the
    /// *declaration* form that carries the bounds.
    ///
    /// Mismatching the two — declaring `interface Box_Full<T>` but referencing
    /// it as a bare `Box_Full` in the union alias `type Box<T> = Box_Full | …`
    /// — fails `tsc` with TS2314 ("Generic type requires N type argument(s)"),
    /// because the alias body supplies the wrong type-argument arity. Both the
    /// union alias and the variant factories must reference each variant
    /// interface at its declared arity, which this helper supplies.
    fn generic_args_to_ts(&self, params: &[bock_ast::GenericParam]) -> String {
        if params.is_empty() {
            return String::new();
        }
        let items: Vec<String> = params.iter().map(|p| p.name.name.clone()).collect();
        format!("<{}>", items.join(", "))
    }

    // ── Top-level dispatch ──────────────────────────────────────────────────

    fn emit_node(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        self.mark_span(node.span);
        match &node.kind {
            NodeKind::Module { items, imports, .. } => {
                // Field/method name-collision set (camelCased). Pre-seeded
                // program-wide by `generate_project` so a call site in one file
                // agrees with the renamed method declared in another; extended
                // here so the single-module `generate_module` path (no pre-seed)
                // is also covered.
                self.field_method_collisions
                    .extend(crate::generator::collect_record_field_names(
                        node,
                        to_camel_case,
                    ));
                if self.per_module {
                    // Per-module native-import path (the real build): each module
                    // is emitted to its own `.ts` file and the runtime
                    // types/helpers live in the shared `_bock_runtime.ts`. Record
                    // which the module references; `generate_project` emits them
                    // once into the shared module, and `emit_esm_imports` imports
                    // them here.
                    if module_uses_optional(items) {
                        self.needs_runtime_optional = true;
                    }
                    if module_uses_result(items) {
                        self.needs_runtime_result = true;
                    }
                    if module_uses_concurrency(items) {
                        self.needs_runtime_concurrency = true;
                    }
                    if module_uses_range(items) {
                        self.needs_runtime_range = true;
                    }
                    if module_uses_eq(items) {
                        self.needs_runtime_eq = true;
                    }
                    if module_uses_compare(items) {
                        self.needs_runtime_compare = true;
                    }
                    if module_uses_str(items) {
                        self.needs_runtime_str = true;
                    }
                    self.emit_esm_imports(imports)?;
                } else {
                    // Single-module self-contained emit (`generate_module`, used
                    // by unit tests): the module's runtime preludes are inlined
                    // into this one file and `ImportDecl`s are dropped. Each
                    // prelude is inlined at most once, gated on a ctx flag (a
                    // duplicate `type Option<T>` would be a TS redeclaration).
                    if !self.optional_runtime_emitted && module_uses_optional(items) {
                        self.buf.push_str(OPTIONAL_RUNTIME_TS);
                        self.buf.push('\n');
                        self.optional_runtime_emitted = true;
                    }
                    if !self.result_runtime_emitted && module_uses_result(items) {
                        self.buf.push_str(RESULT_RUNTIME_TS);
                        self.buf.push('\n');
                        self.result_runtime_emitted = true;
                    }
                    if !self.concurrency_runtime_emitted && module_uses_concurrency(items) {
                        self.buf.push_str(CONCURRENCY_RUNTIME_TS);
                        self.buf.push('\n');
                        self.concurrency_runtime_emitted = true;
                    }
                    if !self.range_runtime_emitted && module_uses_range(items) {
                        self.buf.push_str(RANGE_RUNTIME_TS);
                        self.buf.push('\n');
                        self.range_runtime_emitted = true;
                    }
                    if !self.compare_runtime_emitted && module_uses_compare(items) {
                        self.buf.push_str(COMPARE_RUNTIME_TS);
                        self.buf.push('\n');
                        self.compare_runtime_emitted = true;
                    }
                    if !self.str_runtime_emitted && module_uses_str(items) {
                        self.buf.push_str(STR_RUNTIME_TS);
                        self.buf.push('\n');
                        self.str_runtime_emitted = true;
                    }
                    if !self.eq_runtime_emitted && module_uses_eq(items) {
                        self.buf.push_str(EQ_RUNTIME_TS);
                        self.buf.push('\n');
                        self.eq_runtime_emitted = true;
                    }
                }
                // `@test` functions are transpiled separately into Vitest/Jest
                // test files (project mode, §20.6.2 — see `generate_tests`), never
                // into the runtime module tree: their `expect(...)` assertion DSL
                // has no runtime definition in the emitted source.
                let mut first = true;
                for item in items.iter() {
                    if crate::generator::fn_is_test(item) {
                        continue;
                    }
                    if !first {
                        self.buf.push('\n');
                    }
                    first = false;
                    self.emit_node(item)?;
                }
                // Per-module path: re-export enum-variant values (everything else
                // exports inline). Emitted once after all items.
                if self.per_module {
                    self.emit_trailing_exports();
                }
                Ok(())
            }
            NodeKind::ImportDecl { .. } => {
                // Resolved by the real ESM imports emitted up front by
                // `emit_esm_imports` (per-module path), or dropped entirely in
                // the single-module self-contained path. Either way a no-op here.
                Ok(())
            }
            NodeKind::FnDecl {
                visibility,
                is_async,
                name,
                generic_params,
                params,
                return_type,
                effect_clause,
                where_clause,
                body,
                ..
            } => {
                // Fold any `where`-clause trait bounds onto the generic params so
                // the `<T extends Bound>` constraint is emitted for a
                // `where`-bounded fn — local or imported (the imported fn is
                // emitted in its own module file with its reconstructed
                // `where`-clause, PR #286).
                let merged = crate::generator::merge_where_bounds_into_generics(
                    generic_params,
                    where_clause,
                );
                self.emit_fn_decl(
                    *visibility,
                    *is_async,
                    &name.name,
                    &merged,
                    params,
                    return_type.as_deref(),
                    effect_clause,
                    body,
                    false,
                )
            }
            NodeKind::RecordDecl {
                visibility,
                name,
                generic_params,
                fields,
                ..
            } => {
                let export = if matches!(visibility, Visibility::Public) {
                    "export "
                } else {
                    ""
                };
                let generics = self.generic_params_to_ts(generic_params);
                self.record_names.insert(name.name.clone());
                if fields.is_empty() {
                    self.writeln(&format!("{export}class {}{generics} {{}}", name.name));
                } else {
                    self.writeln(&format!("{export}class {}{generics} {{", name.name));
                    self.indent += 1;
                    for f in fields {
                        let ty = self.ast_type_to_ts(&f.ty);
                        self.writeln(&format!("{}: {};", f.name.name, ty));
                    }
                    let init_fields: Vec<String> = fields
                        .iter()
                        .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
                        .collect();
                    let destructure: Vec<&str> =
                        fields.iter().map(|f| f.name.name.as_str()).collect();
                    self.writeln(&format!(
                        "constructor({{ {} }}: {{ {} }}) {{",
                        destructure.join(", "),
                        init_fields.join("; "),
                    ));
                    self.indent += 1;
                    for fname in &destructure {
                        self.writeln(&format!("this.{fname} = {fname};"));
                    }
                    self.indent -= 1;
                    self.writeln("}");
                    self.indent -= 1;
                    self.writeln("}");
                }
                Ok(())
            }
            NodeKind::EnumDecl {
                visibility,
                name,
                generic_params,
                variants,
                ..
            } => {
                let export = if matches!(visibility, Visibility::Public) {
                    "export "
                } else {
                    ""
                };
                let generics = self.generic_params_to_ts(generic_params);
                // Use-site type-argument list (`<T>`, no bounds) for referencing
                // each variant interface inside the union alias. The variant
                // interfaces are declared as `Box_Full<T>` / `Box_Empty<T>`
                // (carrying `generics`), so the alias body must reference them
                // at the same arity or `tsc` rejects with TS2314.
                let type_args = self.generic_args_to_ts(generic_params);

                // Emit discriminated union type
                let variant_names: Vec<String> = variants
                    .iter()
                    .filter_map(|v| {
                        if let NodeKind::EnumVariant { name: vn, .. } = &v.kind {
                            Some(format!("{}_{}{type_args}", name.name, vn.name))
                        } else {
                            None
                        }
                    })
                    .collect();
                if !variant_names.is_empty() {
                    self.writeln(&format!(
                        "{export}type {}{generics} = {};",
                        name.name,
                        variant_names.join(" | "),
                    ));
                    self.buf.push('\n');
                }

                // Emit interface + factory for each variant
                for variant in variants {
                    self.emit_enum_variant(&name.name, generic_params, variant)?;
                }
                Ok(())
            }
            NodeKind::ClassDecl {
                visibility,
                name,
                generic_params,
                fields,
                methods,
                ..
            } => {
                // Register the class's positional field order so a `class`
                // literal lowers to `new Name(...)` (see `class_fields`). A
                // pre-pass already seeds this across the reachable set; re-record
                // here so the single-module emit path is correct even when the
                // pre-pass is not run.
                self.class_fields.insert(
                    name.name.clone(),
                    fields.iter().map(|f| f.name.name.clone()).collect(),
                );
                let export = if matches!(visibility, Visibility::Public) {
                    "export "
                } else {
                    ""
                };
                let generics = self.generic_params_to_ts(generic_params);
                self.writeln(&format!("{export}class {}{generics} {{", name.name));
                self.indent += 1;
                // Fields
                for f in fields {
                    let ty = self.ast_type_to_ts(&f.ty);
                    self.writeln(&format!("{}: {};", f.name.name, ty));
                }
                if !fields.is_empty() {
                    self.buf.push('\n');
                }
                // Constructor
                let ctor_params: Vec<String> = fields
                    .iter()
                    .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
                    .collect();
                self.writeln(&format!("constructor({}) {{", ctor_params.join(", ")));
                self.indent += 1;
                for f in fields {
                    self.writeln(&format!("this.{} = {};", f.name.name, f.name.name));
                }
                self.indent -= 1;
                self.writeln("}");
                // Methods
                for method in methods {
                    self.buf.push('\n');
                    self.emit_class_method(method)?;
                }
                self.indent -= 1;
                self.writeln("}");
                Ok(())
            }
            NodeKind::TraitDecl {
                visibility,
                name,
                generic_params,
                methods,
                ..
            } => {
                let export = if matches!(visibility, Visibility::Public) {
                    "export "
                } else {
                    ""
                };
                let generics = self.generic_params_to_ts(generic_params);
                // The trait-self type: the interface name applied to its own
                // generic params, e.g. `Comparable<T>` for `trait Comparable[T]`.
                // The leading `self` param of every trait method is typed to
                // this (it is the receiver), and a bare `other: Self` resolves to
                // it too — without a type, `tsc --strict` flags `self` as an
                // implicit `any` (Q-ts-codegen). Mirrors how an `ImplBlock` types
                // `self` as the impl target.
                let trait_self_ty =
                    format!("{}{}", name.name, self.generic_param_args(generic_params));
                self.writeln(&format!("{export}interface {}{generics} {{", name.name));
                self.indent += 1;
                for (i, method) in methods.iter().enumerate() {
                    if i > 0 {
                        self.buf.push('\n');
                    }
                    if let NodeKind::FnDecl {
                        name,
                        generic_params: method_generics,
                        params,
                        return_type,
                        ..
                    } = &method.kind
                    {
                        let m_generics = self.generic_params_to_ts(method_generics);
                        let param_list = self.collect_trait_typed_params(params, &trait_self_ty);
                        let ret = return_type
                            .as_ref()
                            .map(|r| self.type_to_ts(r))
                            .unwrap_or_else(|| "void".into());
                        self.writeln(&format!(
                            "{}{m_generics}({}): {};",
                            self.ts_method_name(&name.name),
                            param_list.join(", "),
                            ret,
                        ));
                    }
                }
                self.indent -= 1;
                self.writeln("}");
                Ok(())
            }
            NodeKind::ImplBlock {
                generic_params,
                trait_path,
                trait_args,
                target,
                methods,
                ..
            } => {
                let target_base = self.type_expr_to_string(target);
                // The target's generic params — from the impl's own list when
                // present, else from the record/enum decl (`impl Box { ... }`
                // where `T` is declared on `record Box[T]`). `target_name` is
                // `Box<T>`, used for the merged interface head and the
                // `self: Box<T>` receiver param. Each prototype function
                // re-declares `<T>` (it is a free function outside the class
                // scope) via `merge_generic_params_to_ts`.
                let target_params = self.impl_target_generics(generic_params, &target_base);
                let target_name =
                    format!("{target_base}{}", self.generic_param_args(&target_params));
                // Trait default methods (codegen-completeness P2): for an
                // `impl Trait for Type` block, the trait's default methods that
                // this impl does not override must also be attached to the
                // target's prototype — the trait interface declares only their
                // signatures. We synthesize them exactly like the impl's own
                // methods (same interface sig + prototype function); a default
                // body that calls another trait method via `self.other()`
                // resolves through the same merged interface.
                let default_methods: Vec<AIRNode> = trait_path
                    .as_ref()
                    .map(|tp| {
                        crate::generator::inherited_default_methods(&self.trait_decls, tp, methods)
                    })
                    .unwrap_or_default();
                // Each entry carries whether it is a *synthesized default*
                // method (`true`). The `Self` type renders as the concrete
                // target (`trait_self_subst`) rather than `this` for ALL impl
                // methods — synthesized trait defaults AND the impl's own
                // inherent methods alike — since each emits as a free prototype
                // function (`Target.prototype.m = function(...): Self`) where
                // `this` is not a valid type annotation (TS2526). The boolean is
                // still threaded for clarity / future per-kind handling.
                let all_methods: Vec<(&AIRNode, bool)> = methods
                    .iter()
                    .map(|m| (m, false))
                    .chain(default_methods.iter().map(|m| (m, true)))
                    .collect();
                // Methods are attached via `Target.prototype.m = function(...)`.
                // For `tsc` to accept `p.m(...)` at call sites, the class type
                // must declare those members. We emit a declaration-merging
                // `interface Target { ... }` whose signatures mirror the
                // prototype functions exactly — crucially including the leading
                // `self` parameter (the AIR lowerer prepends the receiver as the
                // first argument and keeps `self` as a declared param, so the
                // call site is `p.m(p, ...)`). The untyped `self` param is typed
                // as the impl target, which also removes the implicit-`any`
                // error inside each method body.
                let mut iface_sigs: Vec<String> = Vec::new();
                for (method, _is_default) in &all_methods {
                    // Associated functions (no `self`, e.g. a `From` impl's
                    // `from`) are static members, declared via a merged
                    // `namespace` below — not instance methods on the interface.
                    if crate::generator::is_associated_impl_method(method, &self.effect_ops) {
                        continue;
                    }
                    if let NodeKind::FnDecl {
                        is_async,
                        name,
                        generic_params,
                        params,
                        return_type,
                        effect_clause,
                        ..
                    } = &method.kind
                    {
                        // `Self` → the concrete target for every impl method (see
                        // `all_methods`). The merged-interface signature MUST
                        // match the prototype function's signature exactly, so the
                        // same substitution is applied in both loops; a mismatch
                        // (e.g. `this` here, `Target` there) is a declaration-merge
                        // error.
                        let prev_subst = self.trait_self_subst.take();
                        self.trait_self_subst = Some(target_name.clone());
                        let generics = self.generic_params_to_ts(generic_params);
                        let mut all_params = self.collect_impl_typed_params(params, &target_name);
                        if let Some(ep) = self.effects_param(effect_clause) {
                            all_params.push(ep);
                        }
                        let ret_str = build_ts_return_type(
                            *is_async,
                            return_type.as_deref().map(|r| self.type_to_ts(r)),
                        );
                        self.trait_self_subst = prev_subst;
                        iface_sigs.push(format!(
                            "{}{generics}({}){ret_str};",
                            self.ts_method_name(&name.name),
                            all_params.join(", "),
                        ));
                    }
                }
                // The declaration-merging `interface` must be `export`ed exactly
                // when the target `class` is — TS rejects a merged declaration
                // whose members disagree on export-ness (TS2395).
                let iface_export = if self.exported_types.contains(&target_base) {
                    "export "
                } else {
                    ""
                };
                // A §18.2 prelude (compiler-sealed) trait with no user `trait`
                // decl — `Comparable`/`Equatable`/`Displayable`/`Hashable` used
                // without a `use core.compare` — emits NO TS interface, so an
                // `interface Foo extends Comparable` references an undefined name
                // (TS2304). Treat such an impl like a trait-less one: emit the
                // concrete method signatures on the merged interface, but with no
                // `extends` clause (TS dispatches the method by structural typing,
                // and `Ordering` maps to its runtime form via `type_to_ts`).
                // (Q-prelude-impl-missing-import.)
                let prelude_trait = trait_path.as_ref().is_some_and(|tp| {
                    tp.segments.last().is_some_and(|seg| {
                        crate::generator::is_unimplemented_sealed_core_trait(
                            &seg.name,
                            &self.trait_decls,
                        )
                    })
                });
                if let Some(tp) = trait_path.as_ref().filter(|_| !prelude_trait) {
                    let trait_base = tp
                        .segments
                        .iter()
                        .map(|s| s.name.as_str())
                        .collect::<Vec<_>>()
                        .join(".");
                    // The trait may itself be generic (`trait P[T]`), emitted as
                    // `interface P<T>`. The `extends` clause must carry the
                    // impl's trait type arguments (`impl P[T] for R[T]` →
                    // `extends P<T>`); without them `tsc` rejects with TS2314
                    // ("Generic type 'P<T>' requires 1 type argument(s)"). The
                    // args are type-expression AIR nodes; render each to its TS
                    // form. Empty `trait_args` ⇒ a non-generic trait, no `<...>`.
                    let trait_name = if trait_args.is_empty() {
                        trait_base
                    } else {
                        let arg_strs: Vec<String> =
                            trait_args.iter().map(|a| self.type_to_ts(a)).collect();
                        format!("{trait_base}<{}>", arg_strs.join(", "))
                    };
                    // An impl whose methods are *all associated* (no instance
                    // methods — e.g. `From`, whose only method `from(value)` takes
                    // no `self`) contributes no merged-interface members, and its
                    // trait carries no instance contract for `new Target()` to
                    // satisfy. Skip the `interface … extends Trait` entirely: an
                    // empty `extends Trait` body would still reference the trait
                    // interface, which may not be in scope (a prelude trait like
                    // `From` is not emitted into the consuming module).
                    if iface_sigs.is_empty() {
                        self.writeln(&format!("// impl {trait_name} for {target_name}"));
                    } else {
                        // Declaration merging: `extends Trait` keeps
                        // `new Target()` assignable to the trait's interface
                        // type, while the concrete signatures (with `self`) make
                        // `p.m(p)` resolve.
                        self.writeln(&format!(
                            "{iface_export}interface {target_name} extends {trait_name} {{"
                        ));
                        self.indent += 1;
                        for sig in &iface_sigs {
                            self.writeln(sig);
                        }
                        self.indent -= 1;
                        self.writeln("}");
                        self.writeln(&format!("// impl {trait_name} for {target_name}"));
                    }
                } else if iface_sigs.is_empty() {
                    self.writeln(&format!("// impl {target_name}"));
                } else {
                    self.writeln(&format!("{iface_export}interface {target_name} {{"));
                    self.indent += 1;
                    for sig in &iface_sigs {
                        self.writeln(sig);
                    }
                    self.indent -= 1;
                    self.writeln("}");
                    self.writeln(&format!("// impl {target_name}"));
                }
                for (method, _is_default) in &all_methods {
                    // Associated functions are emitted as merged-`namespace`
                    // static members below, not prototype instance methods.
                    if crate::generator::is_associated_impl_method(method, &self.effect_ops) {
                        continue;
                    }
                    if let NodeKind::FnDecl {
                        is_async,
                        name,
                        generic_params,
                        params,
                        return_type,
                        effect_clause,
                        body,
                        ..
                    } = &method.kind
                    {
                        // Every impl method emits as a free prototype function
                        // (`Target.prototype.m = function(...)`), where `this` is
                        // not a valid type. So a `Self` type — whether in a
                        // synthesized trait default (`other: Self`) or the impl's
                        // own inherent method (`fn combine(self, ...) -> Self`) —
                        // must render as the concrete target. This matches the
                        // merged-interface signature emitted above.
                        let prev_subst = self.trait_self_subst.take();
                        self.trait_self_subst = Some(target_name.clone());
                        let async_kw = if *is_async { "async " } else { "" };
                        // The prototype assignment lives outside the class scope,
                        // so the function itself must re-declare the target's
                        // generic params (`function<T>(self: Box<T>): T`) — they
                        // are NOT in scope from the class. Merge them with the
                        // method's own generics. The `.prototype` reference uses
                        // the *bare* type name (`Box.prototype`, never
                        // `Box<T>.prototype`, which is not valid TS).
                        let generics =
                            self.merge_generic_params_to_ts(&target_params, generic_params);
                        let param_list = self.collect_impl_typed_params(params, &target_name);
                        let effects_param = self.effects_param(effect_clause);
                        let mut all_params = param_list;
                        if let Some(ep) = effects_param {
                            all_params.push(ep);
                        }
                        let ret_str = build_ts_return_type(
                            *is_async,
                            return_type.as_deref().map(|r| self.type_to_ts(r)),
                        );
                        self.writeln(&format!(
                            "{target_base}.prototype.{} = {async_kw}function{generics}({}){ret_str} {{",
                            self.ts_method_name(&name.name),
                            all_params.join(", "),
                        ));
                        self.indent += 1;
                        let old_handler_vars = self.current_handler_vars.clone();
                        let expanded = self.expand_effect_names(effect_clause);
                        for ename in &expanded {
                            self.current_handler_vars
                                .insert(ename.clone(), to_camel_case(ename));
                        }
                        self.emit_block_body(body)?;
                        self.current_handler_vars = old_handler_vars;
                        self.indent -= 1;
                        self.writeln("};");
                        // Restore after the whole method (signature + body) is
                        // emitted, so any `Self` annotation in the body also
                        // resolves to the concrete target.
                        self.trait_self_subst = prev_subst;
                    }
                }
                // Associated functions (`Type.method(...)`, no `self`) are static
                // members. A merged `namespace Target { export function m(...) }`
                // both declares the static on `typeof Target` (so `tsc` accepts
                // the `Target.m(...)` call) and provides the implementation —
                // unlike a bare `Target.m = function(...)` assignment, which tsc
                // rejects (TS2339, the property is undeclared on the class type).
                let assoc_methods: Vec<&AIRNode> = all_methods
                    .iter()
                    .filter(|(m, _)| {
                        crate::generator::is_associated_impl_method(m, &self.effect_ops)
                    })
                    .map(|(m, _)| *m)
                    .collect();
                if !assoc_methods.is_empty() {
                    let ns_export = if self.exported_types.contains(&target_base) {
                        "export "
                    } else {
                        ""
                    };
                    self.writeln(&format!("{ns_export}namespace {target_base} {{"));
                    self.indent += 1;
                    for method in assoc_methods {
                        if let NodeKind::FnDecl {
                            is_async,
                            name,
                            generic_params,
                            params,
                            return_type,
                            effect_clause,
                            body,
                            ..
                        } = &method.kind
                        {
                            let prev_subst = self.trait_self_subst.take();
                            self.trait_self_subst = Some(target_name.clone());
                            let async_kw = if *is_async { "async " } else { "" };
                            let generics =
                                self.merge_generic_params_to_ts(&target_params, generic_params);
                            let param_list = self.collect_impl_typed_params(params, &target_name);
                            let effects_param = self.effects_param(effect_clause);
                            let mut all_params = param_list;
                            if let Some(ep) = effects_param {
                                all_params.push(ep);
                            }
                            let ret_str = build_ts_return_type(
                                *is_async,
                                return_type.as_deref().map(|r| self.type_to_ts(r)),
                            );
                            self.writeln(&format!(
                                "export {async_kw}function {}{generics}({}){ret_str} {{",
                                self.ts_method_name(&name.name),
                                all_params.join(", "),
                            ));
                            self.indent += 1;
                            let old_handler_vars = self.current_handler_vars.clone();
                            let expanded = self.expand_effect_names(effect_clause);
                            for ename in &expanded {
                                self.current_handler_vars
                                    .insert(ename.clone(), to_camel_case(ename));
                            }
                            self.emit_block_body(body)?;
                            self.current_handler_vars = old_handler_vars;
                            self.indent -= 1;
                            self.writeln("}");
                            self.trait_self_subst = prev_subst;
                        }
                    }
                    self.indent -= 1;
                    self.writeln("}");
                }
                Ok(())
            }
            NodeKind::EffectDecl {
                visibility,
                name,
                generic_params,
                components,
                operations,
                ..
            } => {
                if !components.is_empty() {
                    let comp_names: Vec<String> = components
                        .iter()
                        .map(|tp| {
                            tp.segments
                                .last()
                                .map_or("effect".to_string(), |s| s.name.clone())
                        })
                        .collect();
                    self.writeln(&format!(
                        "// composite effect {} = {}",
                        name.name,
                        comp_names.join(" + ")
                    ));
                    self.composite_effects.insert(name.name.clone(), comp_names);
                    return Ok(());
                }
                // Record effect operations for Call → handler.op rewriting.
                for op in operations {
                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
                        self.effect_ops
                            .insert(op_name.name.clone(), name.name.clone());
                    }
                }
                self.effect_names.insert(name.name.clone());
                // Effects → TS interface
                let export = if matches!(visibility, Visibility::Public) {
                    "export "
                } else {
                    ""
                };
                let generics = self.generic_params_to_ts(generic_params);
                self.writeln(&format!("{export}interface {}{generics} {{", name.name));
                self.indent += 1;
                for op in operations {
                    if let NodeKind::FnDecl {
                        name,
                        params,
                        return_type,
                        ..
                    } = &op.kind
                    {
                        let param_list = self.collect_typed_params(params);
                        let ret = return_type
                            .as_ref()
                            .map(|r| self.type_to_ts(r))
                            .unwrap_or_else(|| "void".into());
                        self.writeln(&format!(
                            "{}({}): {};",
                            name.name,
                            param_list.join(", "),
                            ret,
                        ));
                    }
                }
                self.indent -= 1;
                self.writeln("}");
                Ok(())
            }
            NodeKind::TypeAlias {
                visibility,
                name,
                generic_params,
                ty,
                ..
            } => {
                let export = if matches!(visibility, Visibility::Public) {
                    "export "
                } else {
                    ""
                };
                let generics = self.generic_params_to_ts(generic_params);
                let ty_str = self.type_to_ts(ty);
                self.writeln(&format!("{export}type {}{generics} = {ty_str};", name.name));
                Ok(())
            }
            NodeKind::ConstDecl {
                visibility,
                name,
                ty,
                value,
                ..
            } => {
                let export = if matches!(visibility, Visibility::Public) {
                    "export "
                } else {
                    ""
                };
                let ty_str = self.type_to_ts(ty);
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}{export}const {}: {ty_str} = ", name.name);
                self.emit_expr(value)?;
                self.buf.push_str(";\n");
                Ok(())
            }
            NodeKind::ModuleHandle { effect, handler } => {
                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
                let var_name = format!("__{}", to_camel_case(effect_name));
                let type_name = effect_name;
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}const {var_name}: {type_name} = ");
                self.emit_expr(handler)?;
                self.buf.push_str(";\n");
                // Register as ambient handler so same-module calls pick it up.
                self.current_handler_vars
                    .insert(effect_name.to_string(), var_name);
                Ok(())
            }
            NodeKind::PropertyTest { name, body, .. } => {
                self.writeln(&format!("// property test: {name}"));
                self.writeln("// (property tests are not emitted in TS output)");
                let _ = body;
                Ok(())
            }
            // Statement / expression nodes at top level:
            NodeKind::LetBinding { .. }
            | NodeKind::If { .. }
            | NodeKind::For { .. }
            | NodeKind::While { .. }
            | NodeKind::Loop { .. }
            | NodeKind::Return { .. }
            | NodeKind::Break { .. }
            | NodeKind::Continue
            | NodeKind::Guard { .. }
            | NodeKind::Match { .. }
            | NodeKind::Block { .. }
            | NodeKind::HandlingBlock { .. }
            | NodeKind::Assign { .. } => self.emit_stmt(node),
            // A bare `expr?` statement (`save_task(task)?`): the Ok/Some payload
            // is discarded, but the `?` still early-returns the Err/None from the
            // enclosing fn. Lower the early-return guard and drop the payload.
            NodeKind::Propagate { expr } => {
                self.emit_propagate(expr)?;
                Ok(())
            }
            // Expression nodes that appear as statements:
            _ => {
                self.write_indent();
                self.emit_expr(node)?;
                self.buf.push_str(";\n");
                Ok(())
            }
        }
    }

    // ── Function declarations ───────────────────────────────────────────────

    #[allow(clippy::too_many_arguments)]
    fn emit_fn_decl(
        &mut self,
        visibility: Visibility,
        is_async: bool,
        name: &str,
        generic_params: &[bock_ast::GenericParam],
        params: &[AIRNode],
        return_type: Option<&AIRNode>,
        effect_clause: &[bock_ast::TypePath],
        body: &AIRNode,
        _is_method: bool,
    ) -> Result<(), CodegenError> {
        let export = if matches!(visibility, Visibility::Public) {
            "export "
        } else {
            ""
        };
        let async_kw = if is_async { "async " } else { "" };
        let generics = self.generic_params_to_ts(generic_params);
        let param_list = self.collect_typed_params(params);
        let effects_param = self.effects_param(effect_clause);
        let mut all_params = param_list;
        if let Some(ep) = effects_param {
            all_params.push(ep);
        }
        let ret_str = build_ts_return_type(is_async, return_type.map(|r| self.type_to_ts(r)));
        if !effect_clause.is_empty() {
            let effect_names = self.expand_effect_names(effect_clause);
            self.fn_effects.insert(name.to_string(), effect_names);
        }
        let ts_name = ts_value_ident(name);
        self.writeln(&format!(
            "{export}{async_kw}function {ts_name}{generics}({}){ret_str} {{",
            all_params.join(", "),
        ));
        self.indent += 1;
        let old_handler_vars = self.current_handler_vars.clone();
        let expanded = self.expand_effect_names(effect_clause);
        for ename in &expanded {
            self.current_handler_vars
                .insert(ename.clone(), to_camel_case(ename));
        }
        let prev_prop_tag = self.current_fn_propagate_tag;
        self.current_fn_propagate_tag = Self::propagate_tag_for_return(return_type);
        self.emit_fn_body_seeded(params, body)?;
        self.current_fn_propagate_tag = prev_prop_tag;
        self.current_handler_vars = old_handler_vars;
        self.indent -= 1;
        self.writeln("}");
        Ok(())
    }

    fn emit_class_method(&mut self, method: &AIRNode) -> Result<(), CodegenError> {
        if let NodeKind::FnDecl {
            is_async,
            name,
            generic_params,
            params,
            return_type,
            effect_clause,
            body,
            ..
        } = &method.kind
        {
            let async_kw = if *is_async { "async " } else { "" };
            let generics = self.generic_params_to_ts(generic_params);
            let param_list = self.collect_typed_params(params);
            let effects_param = self.effects_param(effect_clause);
            let mut all_params = param_list;
            if let Some(ep) = effects_param {
                all_params.push(ep);
            }
            let ret_str = build_ts_return_type(
                *is_async,
                return_type.as_deref().map(|r| self.type_to_ts(r)),
            );
            let method_name = self.ts_method_name(&to_camel_case(&name.name));
            self.writeln(&format!(
                "{async_kw}{method_name}{generics}({}){ret_str} {{",
                all_params.join(", "),
            ));
            self.indent += 1;
            let old_handler_vars = self.current_handler_vars.clone();
            let expanded = self.expand_effect_names(effect_clause);
            for ename in &expanded {
                self.current_handler_vars
                    .insert(ename.clone(), to_camel_case(ename));
            }
            let prev_prop_tag = self.current_fn_propagate_tag;
            self.current_fn_propagate_tag = Self::propagate_tag_for_return(return_type.as_deref());
            self.emit_fn_body_seeded(params, body)?;
            self.current_fn_propagate_tag = prev_prop_tag;
            self.current_handler_vars = old_handler_vars;
            self.indent -= 1;
            self.writeln("}");
        }
        Ok(())
    }

    /// Collect a lambda's parameters, typing any *un-annotated* param as `any`.
    ///
    /// Bock lambdas usually omit param types (`(x) => …`); the AIR carries no
    /// inferred type, so an un-annotated emit (`(x) =>`) is an implicit `any`
    /// that `tsc --strict` (`noImplicitAny`) rejects (TS7006). An explicit `any`
    /// is always accepted and never loses correctness — at a contextually-typed
    /// call site (`.filter((m) => …)`) TS would have inferred `m` anyway, and the
    /// explicit annotation is compatible; at an un-contextual site (`const f =
    /// (x) => …`) it is the only thing that type-checks. Params that *do* carry a
    /// declared type keep it.
    fn collect_lambda_params(&self, params: &[AIRNode]) -> Vec<String> {
        params
            .iter()
            .filter_map(|p| {
                let NodeKind::Param { pattern, ty, .. } = &p.kind else {
                    return None;
                };
                let name = self.pattern_to_binding_name(pattern);
                let ty_str = match ty {
                    Some(t) => format!(": {}", self.type_to_ts(t)),
                    None => ": any".to_string(),
                };
                Some(format!("{name}{ty_str}"))
            })
            .collect()
    }

    /// Collect typed parameter names: `name: Type`.
    fn collect_typed_params(&self, params: &[AIRNode]) -> Vec<String> {
        params
            .iter()
            .filter_map(|p| {
                if let NodeKind::Param {
                    pattern,
                    ty,
                    default,
                } = &p.kind
                {
                    let name = self.pattern_to_binding_name(pattern);
                    let ty_str = ty
                        .as_ref()
                        .map(|t| format!(": {}", self.type_to_ts(t)))
                        .unwrap_or_default();
                    if let Some(def) = default {
                        let mut ctx = TsEmitCtx::new();
                        ctx.indent = self.indent;
                        ctx.enum_variants = self.enum_variants.clone();
                        if ctx.emit_expr_to_string(def).is_ok() {
                            let (def_str, _) = ctx.finish();
                            return Some(format!("{name}{ty_str} = {def_str}"));
                        }
                    }
                    Some(format!("{name}{ty_str}"))
                } else {
                    None
                }
            })
            .collect()
    }

    /// Collect typed parameters for an `impl` method, typing an untyped
    /// receiver (`self`) parameter as the impl target.
    ///
    /// Bock impl methods declare `self` with no type annotation
    /// (`fn sum(self)`), and the AIR lowerer keeps it as a real parameter while
    /// prepending the receiver at call sites (`p.sum(p)`). Without a type, `tsc`
    /// flags `self` as implicit `any`; we substitute the target type so the
    /// method body (`self.x`) and the declaration-merged interface both
    /// type-check. Non-`self` params, and a `self` that already carries an
    /// explicit type, are handled exactly as [`Self::collect_typed_params`].
    fn collect_impl_typed_params(&self, params: &[AIRNode], target_name: &str) -> Vec<String> {
        params
            .iter()
            .filter_map(|p| {
                let NodeKind::Param {
                    pattern,
                    ty,
                    default,
                } = &p.kind
                else {
                    return None;
                };
                let name = self.pattern_to_binding_name(pattern);
                let ty_str = match ty {
                    Some(t) => format!(": {}", self.type_to_ts(t)),
                    None if name == "self" => format!(": {target_name}"),
                    None => String::new(),
                };
                if let Some(def) = default {
                    let mut ctx = TsEmitCtx::new();
                    ctx.indent = self.indent;
                    ctx.enum_variants = self.enum_variants.clone();
                    if ctx.emit_expr_to_string(def).is_ok() {
                        let (def_str, _) = ctx.finish();
                        return Some(format!("{name}{ty_str} = {def_str}"));
                    }
                }
                Some(format!("{name}{ty_str}"))
            })
            .collect()
    }

    /// Collect typed parameters for a **trait declaration** method, typing an
    /// untyped receiver (`self`) as the trait's own interface type
    /// (`trait_self_ty`, e.g. `Comparable<T>`).
    ///
    /// A trait method declares `self` with no annotation (`fn compare(self,
    /// other: Self)`); the AIR lowerer keeps it as a real leading parameter. In
    /// the emitted interface the untyped `self` would otherwise be `tsc
    /// --strict`'s implicit `any`. Typing it to the trait-self type makes the
    /// interface method signature well-typed and keeps it compatible (via
    /// declaration-merging method bivariance) with the concrete `self: Target`
    /// the `ImplBlock` arm emits. A `self` that already carries an explicit type,
    /// and all non-`self` params, are handled exactly as
    /// [`Self::collect_typed_params`] (where a `Self` annotation maps to `this`,
    /// the implementing type — correct for `other: Self`).
    fn collect_trait_typed_params(&self, params: &[AIRNode], trait_self_ty: &str) -> Vec<String> {
        params
            .iter()
            .filter_map(|p| {
                let NodeKind::Param {
                    pattern,
                    ty,
                    default,
                } = &p.kind
                else {
                    return None;
                };
                let name = self.pattern_to_binding_name(pattern);
                let ty_str = match ty {
                    Some(t) => format!(": {}", self.type_to_ts(t)),
                    None if name == "self" => format!(": {trait_self_ty}"),
                    None => String::new(),
                };
                if let Some(def) = default {
                    let mut ctx = TsEmitCtx::new();
                    ctx.indent = self.indent;
                    ctx.enum_variants = self.enum_variants.clone();
                    if ctx.emit_expr_to_string(def).is_ok() {
                        let (def_str, _) = ctx.finish();
                        return Some(format!("{name}{ty_str} = {def_str}"));
                    }
                }
                Some(format!("{name}{ty_str}"))
            })
            .collect()
    }

    fn emit_expr_to_string(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        self.emit_expr(node)
    }

    /// Expand effect names, replacing composite effects with their components.
    fn expand_effect_names(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
        let mut result = Vec::new();
        for tp in effects {
            let name = tp
                .segments
                .last()
                .map_or("effect".to_string(), |s| s.name.clone());
            if let Some(components) = self.composite_effects.get(&name) {
                result.extend(components.iter().cloned());
            } else {
                result.push(name);
            }
        }
        result
    }

    /// The in-scope `Clock` effect handler variable, if one is installed.
    ///
    /// When `Some`, the `Clock` time operations (`Instant.now`, `sleep`,
    /// `elapsed`) are routed through the handler instead of inlining the host
    /// primitive (Q-clock-handler-routing, §18.3.1/§18.4); when `None`, no
    /// handler is in scope and the default host primitive is emitted.
    fn clock_handler_var(&self) -> Option<&str> {
        self.current_handler_vars.get("Clock").map(String::as_str)
    }

    /// Effects → typed destructured parameter object: `{ log, clock }: { log: Log, clock: Clock }`.
    fn effects_param(&self, effects: &[bock_ast::TypePath]) -> Option<String> {
        if effects.is_empty() {
            return None;
        }
        let expanded = self.expand_effect_names(effects);
        if expanded.is_empty() {
            return None;
        }
        let names: Vec<String> = expanded.iter().map(|n| to_camel_case(n)).collect();
        let type_entries: Vec<String> = expanded
            .iter()
            .zip(names.iter())
            .map(|(orig, camel)| format!("{camel}: {orig}"))
            .collect();
        Some(format!(
            "{{ {} }}: {{ {} }}",
            names.join(", "),
            type_entries.join(", ")
        ))
    }

    /// Build a `{ effect: handler_var, ... }` argument for calling an effectful function.
    fn build_effects_call_arg_ts(&self, fn_name: &str) -> Option<String> {
        let effects = self.fn_effects.get(fn_name)?;
        let entries: Vec<String> = effects
            .iter()
            .filter_map(|e| {
                let handler_var = self.current_handler_vars.get(e)?;
                let param_name = to_camel_case(e);
                Some(format!("{param_name}: {handler_var}"))
            })
            .collect();
        if entries.is_empty() {
            return None;
        }
        Some(format!("{{ {} }}", entries.join(", ")))
    }

    // ── Enum variant interfaces + factories ──────────────────────────────────

    fn emit_enum_variant(
        &mut self,
        enum_name: &str,
        generic_params: &[bock_ast::GenericParam],
        variant: &AIRNode,
    ) -> Result<(), CodegenError> {
        if let NodeKind::EnumVariant { name, payload } = &variant.kind {
            let vname = &name.name;
            let generics = self.generic_params_to_ts(generic_params);
            // Use-site type-argument list (`<T>`, no bounds) for the variant's
            // *references* — its constructor-factory return type, and (for a
            // unit variant) its `const` annotation. Declaring the interface
            // `Box_Full<T>` but returning a bare `Box_Full` from the factory
            // fails `tsc` with TS2314; the return type must carry the same args.
            let type_args = self.generic_args_to_ts(generic_params);
            // Default-parameter form of the generic params, so a generic *unit*
            // variant — whose type param is phantom (unused in the variant
            // body) — can be both referenced with explicit args inside the
            // union alias (`Box_Empty<T>`) AND named with zero args on its
            // frozen `const` (`const Box_Empty: Box_Empty`). Without the
            // `= unknown` default the zero-arg const annotation fails TS2314.
            let unit_generics = if generic_params.is_empty() {
                String::new()
            } else {
                let items: Vec<String> = generic_params
                    .iter()
                    .map(|p| format!("{} = unknown", p.name.name))
                    .collect();
                format!("<{}>", items.join(", "))
            };
            let qualified = format!("{enum_name}_{vname}");

            match payload {
                EnumVariantPayload::Unit => {
                    // Interface for unit variant
                    self.writeln(&format!(
                        "interface {qualified}{unit_generics} {{ readonly _tag: \"{vname}\"; }}"
                    ));
                    self.writeln(&format!(
                        "const {qualified}: {qualified} = Object.freeze({{ _tag: \"{vname}\" as const }});"
                    ));
                }
                EnumVariantPayload::Struct(fields) => {
                    // Interface for struct variant
                    self.writeln(&format!("interface {qualified}{generics} {{"));
                    self.indent += 1;
                    self.writeln(&format!("readonly _tag: \"{vname}\";"));
                    for f in fields {
                        let ty = self.ast_type_to_ts(&f.ty);
                        self.writeln(&format!("readonly {}: {};", f.name.name, ty));
                    }
                    self.indent -= 1;
                    self.writeln("}");
                    let field_params: Vec<String> = fields
                        .iter()
                        .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
                        .collect();
                    let field_names: Vec<&str> =
                        fields.iter().map(|f| f.name.name.as_str()).collect();
                    self.writeln(&format!(
                        "function {qualified}{generics}({}): {qualified}{type_args} {{",
                        field_params.join(", "),
                    ));
                    self.indent += 1;
                    self.writeln(&format!(
                        "return {{ _tag: \"{vname}\" as const, {} }};",
                        field_names.join(", "),
                    ));
                    self.indent -= 1;
                    self.writeln("}");
                }
                EnumVariantPayload::Tuple(elems) => {
                    // Interface for tuple variant
                    self.writeln(&format!("interface {qualified}{generics} {{"));
                    self.indent += 1;
                    self.writeln(&format!("readonly _tag: \"{vname}\";"));
                    for (i, elem) in elems.iter().enumerate() {
                        let ty = self.type_to_ts(elem);
                        self.writeln(&format!("readonly _{i}: {ty};"));
                    }
                    self.indent -= 1;
                    self.writeln("}");
                    let param_decls: Vec<String> = elems
                        .iter()
                        .enumerate()
                        .map(|(i, e)| format!("_{i}: {}", self.type_to_ts(e)))
                        .collect();
                    let param_names: Vec<String> =
                        (0..elems.len()).map(|i| format!("_{i}")).collect();
                    self.writeln(&format!(
                        "function {qualified}{generics}({}): {qualified}{type_args} {{",
                        param_decls.join(", "),
                    ));
                    self.indent += 1;
                    self.writeln(&format!(
                        "return {{ _tag: \"{vname}\" as const, {} }};",
                        param_names
                            .iter()
                            .enumerate()
                            .map(|(i, p)| format!("_{i}: {p}"))
                            .collect::<Vec<_>>()
                            .join(", ")
                    ));
                    self.indent -= 1;
                    self.writeln("}");
                }
            }
        }
        Ok(())
    }

    // ── Statements ──────────────────────────────────────────────────────────

    fn emit_stmt(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        self.mark_span(node.span);
        match &node.kind {
            NodeKind::LetBinding {
                is_mut,
                pattern,
                ty,
                value,
                ..
            } => {
                let kw = if *is_mut { "let" } else { "const" };
                let binding = self.pattern_to_ts_destructure(pattern);
                let ty_ts = ty.as_ref().map(|t| self.type_to_ts(t));
                let ty_str = ty_ts.as_ref().map(|t| format!(": {t}")).unwrap_or_default();
                // Declare-only temp from the shared value-CF hoist: emit a bare
                // `let name;` (no initialiser); the relocated control flow assigns
                // it on every non-diverging path.
                if node.metadata.contains_key(crate::generator::DECL_ONLY_META) {
                    let ind = self.indent_str();
                    let _ = writeln!(self.buf, "{ind}let {binding}{ty_str};");
                    return Ok(());
                }
                // `let name = expr?` — the `?` unwraps the Ok/Some payload and
                // early-returns the Err/None from the enclosing fn. Lower the
                // unwrap in statement position (a temp + early-return guard),
                // then bind `name` to the payload access. See `emit_propagate`.
                if let NodeKind::Propagate { expr } = &value.kind {
                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
                        let ts_name = ts_value_ident(&name.name);
                        let access = self.emit_propagate(expr)?;
                        let kw = if *is_mut || self.simple_let_needs_let(&ts_name) {
                            "let"
                        } else {
                            "const"
                        };
                        if self.simple_let_redeclared(&ts_name) {
                            let ind = self.indent_str();
                            let _ = writeln!(self.buf, "{ind}{ts_name} = {access};");
                        } else {
                            self.mark_simple_let_declared(&ts_name);
                            let ind = self.indent_str();
                            let _ = writeln!(self.buf, "{ind}{kw} {ts_name}{ty_str} = {access};");
                        }
                        return Ok(());
                    }
                    // A non-simple destructuring binding over a `?` value: unwrap
                    // into a temp, then destructure from it.
                    let access = self.emit_propagate(expr)?;
                    let ind = self.indent_str();
                    let _ = writeln!(self.buf, "{ind}{kw} {binding}{ty_str} = {access};");
                    return Ok(());
                }
                // A simple `let name = …` is subject to TS redeclaration rules
                // (TS2451). Bock allows re-binding the same name in one scope
                // (shadowing); TS does not, so the second-and-later binding of a
                // simple name becomes a plain assignment, and the first
                // declaration uses `let` (not `const`) when the name is later
                // re-bound/assigned. Mirrors the js backend (#217). The `?`
                // unwrap (`Propagate`) value takes a separate statement-form path
                // above, so it is excluded here.
                if let NodeKind::BindPat { name, .. } = &pattern.kind {
                    if !matches!(value.kind, NodeKind::Propagate { .. }) {
                        let ts_name = ts_value_ident(&name.name);
                        if self.simple_let_redeclared(&ts_name) {
                            let ind = self.indent_str();
                            let _ = write!(self.buf, "{ind}{ts_name} = ");
                            self.emit_let_value(value, ty_ts)?;
                            self.buf.push_str(";\n");
                            return Ok(());
                        }
                        if !self.value_needs_stmt_form(value) {
                            let needs_let = *is_mut || self.simple_let_needs_let(&ts_name);
                            let kw = if needs_let { "let" } else { "const" };
                            self.mark_simple_let_declared(&ts_name);
                            let ind = self.indent_str();
                            let _ = write!(self.buf, "{ind}{kw} {ts_name}{ty_str} = ");
                            self.emit_let_value(value, ty_ts)?;
                            self.buf.push_str(";\n");
                            return Ok(());
                        }
                    }
                }
                // A control-flow initialiser with no TS expression form (a `loop`,
                // or a value `if`/`match` with a `return`/`break` arm) is lowered
                // in statement position: declare the binding `let`, then emit the
                // control flow assigning into it. The original binding must be
                // `let` (not `const`) so the arms can assign it (cluster-1 fix).
                if self.value_needs_stmt_form(value) {
                    let ind = self.indent_str();
                    // A plain identifier binding can be reassigned; a destructuring
                    // pattern cannot be split, so this path only applies to simple
                    // bindings (the only shape these examples produce).
                    let _ = writeln!(self.buf, "{ind}let {binding}{ty_str};");
                    return self.emit_value_in_stmt_pos(value, &ValueSink::Assign(binding));
                }
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}{kw} {binding}{ty_str} = ");
                // `emit_let_value` records the binding's declared type as the
                // expected type for the value (so a value-position `match`/`if`
                // IIFE annotates its arrow return and hoists a bare-identifier
                // scrutinee — see `current_expected_type`) and, for a
                // single-variant enum construction, widens the initialiser to the
                // declared union so the binding does not narrow to the
                // construction variant (TS2678 on a later sibling-variant match).
                self.emit_let_value(value, ty_ts)?;
                self.buf.push_str(";\n");
                Ok(())
            }
            NodeKind::If {
                let_pattern,
                condition,
                then_block,
                else_block,
            } => {
                if let Some(pat) = let_pattern {
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}if (");
                    self.emit_expr(condition)?;
                    self.buf.push_str(" != null) {\n");
                    self.indent += 1;
                    let binding = self.pattern_to_ts_destructure(pat);
                    self.writeln(&format!("const {binding} = "));
                    self.emit_block_body(then_block)?;
                    self.indent -= 1;
                } else {
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}if (");
                    self.emit_expr(condition)?;
                    self.buf.push_str(") {\n");
                    self.indent += 1;
                    self.emit_block_body(then_block)?;
                    self.indent -= 1;
                }
                if let Some(else_b) = else_block {
                    if matches!(else_b.kind, NodeKind::If { .. }) {
                        let ind = self.indent_str();
                        let _ = write!(self.buf, "{ind}}} else ");
                        self.emit_stmt(else_b)?;
                        return Ok(());
                    }
                    self.writeln("} else {");
                    self.indent += 1;
                    self.emit_block_body(else_b)?;
                    self.indent -= 1;
                }
                self.writeln("}");
                Ok(())
            }
            NodeKind::For {
                pattern,
                iterable,
                body,
            } => {
                let binding = self.pattern_to_ts_destructure(pattern);
                self.emit_loop_label_prefix(body);
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}for (const {binding} of ");
                self.emit_expr(iterable)?;
                self.buf.push_str(") {\n");
                self.indent += 1;
                self.emit_loop_body(body)?;
                self.indent -= 1;
                self.writeln("}");
                self.pop_loop_frame();
                Ok(())
            }
            NodeKind::While { condition, body } => {
                self.emit_loop_label_prefix(body);
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}while (");
                self.emit_expr(condition)?;
                self.buf.push_str(") {\n");
                self.indent += 1;
                self.emit_loop_body(body)?;
                self.indent -= 1;
                self.writeln("}");
                self.pop_loop_frame();
                Ok(())
            }
            NodeKind::Loop { body } => {
                self.emit_loop_label_prefix(body);
                self.writeln("while (true) {");
                self.indent += 1;
                self.emit_loop_body(body)?;
                self.indent -= 1;
                self.writeln("}");
                self.pop_loop_frame();
                Ok(())
            }
            NodeKind::Return { value } => {
                if let Some(val) = value {
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}return ");
                    self.emit_expr(val)?;
                    self.buf.push_str(";\n");
                } else {
                    self.writeln("return;");
                }
                Ok(())
            }
            NodeKind::Break { value } => {
                if let Some(val) = value {
                    // In a value-position loop (`let r = loop { … break v … }`),
                    // `break v` delivers the loop's value through the loop sink
                    // (`r = v;`) before breaking. Without a sink (an ordinary
                    // statement loop) the value is dropped — emit it as a comment
                    // so it remains visible but inert.
                    if let Some(sink) = self.innermost_loop_value_sink() {
                        self.emit_sink_value(val, &sink)?;
                    } else {
                        let ind = self.indent_str();
                        let _ = write!(self.buf, "{ind}/* break value: ");
                        self.emit_expr(val)?;
                        self.buf.push_str(" */\n");
                    }
                }
                if self.switch_label_depth > 0 {
                    if let Some(label) = self.innermost_loop_label() {
                        self.writeln(&format!("break {label};"));
                        return Ok(());
                    }
                }
                self.writeln("break;");
                Ok(())
            }
            NodeKind::Continue => {
                if self.switch_label_depth > 0 {
                    if let Some(label) = self.innermost_loop_label() {
                        self.writeln(&format!("continue {label};"));
                        return Ok(());
                    }
                }
                self.writeln("continue;");
                Ok(())
            }
            NodeKind::Guard {
                let_pattern,
                condition,
                else_block,
            } => {
                if let Some(pat) = let_pattern {
                    // `guard (let pat = expr) else { … }`: evaluate `expr` once,
                    // run the else (which must diverge) when `pat` does not
                    // match, then bind `pat`'s names into the *enclosing* scope
                    // so they are in scope for the statements after the guard.
                    // Mirrors the js lowering (#217).
                    self.match_temp_counter += 1;
                    let tmp = format!("__guard{}", self.match_temp_counter);
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}const {tmp} = ");
                    self.emit_expr(condition)?;
                    self.buf.push_str(";\n");
                    let test = self.pattern_test_ts(pat, &tmp);
                    // A bare bind / wildcard pattern always matches → no `if`.
                    if !test.is_empty() {
                        let ind = self.indent_str();
                        let _ = writeln!(self.buf, "{ind}if (!({test})) {{");
                        self.indent += 1;
                        self.emit_block_body(else_block)?;
                        self.indent -= 1;
                        self.writeln("}");
                    }
                    // Bindings land in the enclosing scope (no nested block), so
                    // they are visible to the statements following the guard.
                    self.pattern_binds_ts(pat, &tmp)?;
                } else {
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}if (!(");
                    self.emit_expr(condition)?;
                    self.buf.push_str(")) {\n");
                    self.indent += 1;
                    self.emit_block_body(else_block)?;
                    self.indent -= 1;
                    self.writeln("}");
                }
                Ok(())
            }
            NodeKind::Match { scrutinee, arms } => self.emit_match(scrutinee, arms, false),
            NodeKind::Block { stmts, tail } => {
                // A statement-position block is its own TS `{}` lexical scope, so
                // it gets its own `let` scope frame (a name re-bound inside is
                // independent of the enclosing block's bindings).
                self.writeln("{");
                self.indent += 1;
                self.enter_let_scope(node);
                for s in stmts {
                    self.emit_node(s)?;
                }
                if let Some(t) = tail {
                    self.write_indent();
                    self.emit_expr(t)?;
                    self.buf.push_str(";\n");
                }
                self.leave_let_scope();
                self.indent -= 1;
                self.writeln("}");
                Ok(())
            }
            NodeKind::HandlingBlock { handlers, body } => {
                // handling block → scoped handler instantiation. The emitted
                // `{ … }` is its own TS lexical block, so it gets a fresh `let`
                // scope frame: a name first bound in one `handling` block and
                // re-bound in a *sibling* `handling` block is two independent
                // declarations (each block-scoped), not a redeclaration. Without
                // a fresh frame the redeclaration tracker would carry the prior
                // block's `declared` set into this one and rewrite the second
                // `let x = …` into a bare `x = …` — a name that went out of
                // scope when the first block closed (TS2304 / strict-mode
                // ReferenceError).
                self.writeln("{");
                self.indent += 1;
                self.enter_let_scope(body);
                let old_handler_vars = self.current_handler_vars.clone();
                for h in handlers {
                    let effect_name = h
                        .effect
                        .segments
                        .last()
                        .map_or("effect", |s| s.name.as_str());
                    let var_name = format!("__{}", to_camel_case(effect_name));
                    let type_name = effect_name;
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}const {var_name}: {type_name} = ");
                    self.emit_expr(&h.handler)?;
                    self.buf.push_str(";\n");
                    self.current_handler_vars
                        .insert(effect_name.to_string(), var_name);
                }
                if let NodeKind::Block { stmts, tail } = &body.kind {
                    for s in stmts {
                        self.emit_node(s)?;
                    }
                    if let Some(t) = tail {
                        self.write_indent();
                        self.emit_expr(t)?;
                        self.buf.push_str(";\n");
                    }
                } else {
                    self.emit_stmt(body)?;
                }
                self.current_handler_vars = old_handler_vars;
                self.leave_let_scope();
                self.indent -= 1;
                self.writeln("}");
                Ok(())
            }
            NodeKind::Assign { op, target, value } => {
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}");
                self.emit_expr(target)?;
                let op_str = match op {
                    AssignOp::Assign => "=",
                    AssignOp::AddAssign => "+=",
                    AssignOp::SubAssign => "-=",
                    AssignOp::MulAssign => "*=",
                    AssignOp::DivAssign => "/=",
                    AssignOp::RemAssign => "%=",
                };
                let _ = write!(self.buf, " {op_str} ");
                self.emit_expr(value)?;
                self.buf.push_str(";\n");
                Ok(())
            }
            _ => {
                self.write_indent();
                self.emit_expr(node)?;
                self.buf.push_str(";\n");
                Ok(())
            }
        }
    }

    /// Lower the `?` propagation operator (`inner?`) in statement position.
    ///
    /// Emits a temp holding `inner` once, then an early-return guard that
    /// returns the temp unchanged from the enclosing fn when the value is the
    /// failure variant (`Err` for `Result`, `None` for `Optional`), and returns
    /// the *access expression* for the success payload (`__propN._0`) so the
    /// caller can bind/use the unwrapped value. The runtime representation is a
    /// tagged object (`{ _tag, _0 }`) shared with `match`/method lowering, so the
    /// same guard covers both container kinds: the failure value *is* the temp
    /// (an `Err`/`None`), so `return __propN` propagates it as-is, and the
    /// payload is always `._0`. Implements the standard Rust-like `?` semantics
    /// (early-return on failure, unwrap on success).
    fn emit_propagate(&mut self, inner: &AIRNode) -> Result<String, CodegenError> {
        self.match_temp_counter += 1;
        let tmp = format!("__prop{}", self.match_temp_counter);
        let ind = self.indent_str();
        let _ = write!(self.buf, "{ind}const {tmp} = ");
        self.emit_expr(inner)?;
        self.buf.push_str(";\n");
        // Failure variant → propagate the container unchanged. When the enclosing
        // fn's return type tells us the failure tag (`Err` for `Result`, `None`
        // for `Optional`), test that single discriminant: it is a real member of
        // the value's tag union, so TS both accepts the comparison *and* narrows
        // the success access (`{tmp}._0`) to the payload type after the guard.
        let test = match self.current_fn_propagate_tag {
            Some(tag) => format!("{tmp}._tag === \"{tag}\""),
            // Unknown container kind (no typed return) → fall back to testing
            // both failure tags with the `._tag` widened to `string`, so the
            // (always-false but legal) cross-container arm does not trip a TS2367
            // "no overlap" error. This path forgoes narrowing; the typed-return
            // path above (which every example hits) keeps it.
            None => {
                format!("({tmp}._tag as string) === \"Err\" || ({tmp}._tag as string) === \"None\"")
            }
        };
        let ind = self.indent_str();
        let _ = writeln!(self.buf, "{ind}if ({test}) {{");
        self.indent += 1;
        let ind = self.indent_str();
        // `as never` lets the `Err`/`None` container satisfy the fn's declared
        // return type without re-narrowing it here (the value is already the
        // correct container shape; the cast only quiets the structural check).
        let _ = writeln!(self.buf, "{ind}return {tmp} as never;");
        self.indent -= 1;
        self.writeln("}");
        Ok(format!("{tmp}._0"))
    }

    // ── Expressions ─────────────────────────────────────────────────────────

    fn emit_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        self.mark_span(node.span);
        match &node.kind {
            NodeKind::Literal { lit } => {
                match lit {
                    Literal::Int(s) => self.buf.push_str(s),
                    Literal::Float(s) => self.buf.push_str(s),
                    Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
                    Literal::Char(s) => {
                        self.buf.push('\'');
                        self.buf.push_str(s);
                        self.buf.push('\'');
                    }
                    Literal::String(s) => {
                        self.buf.push('"');
                        self.buf.push_str(&escape_js_string(s));
                        self.buf.push('"');
                    }
                    Literal::Unit => self.buf.push_str("undefined"),
                }
                Ok(())
            }
            NodeKind::Identifier { name } => {
                if name.name == "None" {
                    self.buf.push_str("{ _tag: \"None\" as const }");
                } else if let Some(variant) = crate::generator::ordering_variant(&name.name) {
                    // Prelude `Ordering` variant → an inline tagged object (the
                    // self-contained representation the primitive-bridge
                    // `compare` and the `_tag`-switch match also use).
                    let _ = write!(self.buf, "{{ _tag: \"{variant}\" as const }}");
                } else if let Some(enum_name) = self
                    .user_variant_for_name(&name.name)
                    .map(|i| i.enum_name.clone())
                {
                    // A bare unit-variant reference (`Red`) → the frozen
                    // `{enum}_{variant}` const.
                    let _ = write!(self.buf, "{enum_name}_{}", name.name);
                } else if self.const_names.contains(&name.name) {
                    // A module-scope `const` is emitted verbatim at its
                    // declaration; spell its use site identically (the
                    // `to_camel_case` transform would mangle `FIZZ_NUM` → `fizzNUM`).
                    self.buf.push_str(&name.name);
                } else {
                    self.buf.push_str(&ts_value_ident(&name.name));
                }
                Ok(())
            }
            NodeKind::BinaryOp { op, left, right } => {
                // `+` on two `List[T]` operands is concatenation: spread both into
                // a fresh array (`[...a, ...b]`). TS rejects `+` on `T[]`
                // (`Operator '+' cannot be applied to T[]`); the spread preserves
                // the element type.
                if matches!(op, BinOp::Add) && crate::generator::is_list_concat(node, left, right) {
                    self.buf.push_str("[...");
                    self.emit_expr(left)?;
                    self.buf.push_str(", ...");
                    self.emit_expr(right)?;
                    self.buf.push(']');
                    return Ok(());
                }
                // Integer `/` and `%` (DQ23, §3.6): TS `/` is float division and
                // `Math.trunc(a / 0)` yields `Infinity` rather than aborting, so
                // lower to a self-contained IIFE that aborts on a zero divisor and
                // truncates toward zero. TS `%` already takes the dividend's sign,
                // so the remainder needs only the zero-abort. Arrow parameters are
                // `number`-annotated for strict mode; both operands are passed as
                // arguments so each is evaluated exactly once.
                if matches!(op, BinOp::Div | BinOp::Rem) && crate::generator::is_int_arith(node) {
                    let body = if matches!(op, BinOp::Div) {
                        "Math.trunc(__a / __b)"
                    } else {
                        "__a % __b"
                    };
                    self.buf.push_str("((__a: number, __b: number): number => { if (__b === 0) { throw new Error(\"integer division or modulo by zero\"); } return ");
                    self.buf.push_str(body);
                    self.buf.push_str("; })(");
                    self.emit_expr(left)?;
                    self.buf.push_str(", ");
                    self.emit_expr(right)?;
                    self.buf.push(')');
                    return Ok(());
                }
                // Ordering operators on a user `Comparable` type lower through
                // `compare` (TS rejects `<` on two objects at compile time, and the
                // runtime would coerce to `NaN`). Reads the tagged `Ordering` off
                // `._tag`, mirroring how a hand-written `a.compare(b)` lowers (the
                // receiver is passed as both the method receiver and the explicit
                // `self` argument).
                if crate::generator::is_user_compare(node) {
                    if let Some((tag, is_eq)) = crate::generator::user_compare_variant(*op) {
                        let recv = self.expr_to_string(left)?;
                        let other = self.expr_to_string(right)?;
                        let eq = if is_eq { "===" } else { "!==" };
                        let _ = write!(
                            self.buf,
                            "(({recv}).compare({recv}, {other})._tag {eq} \"{tag}\")"
                        );
                        return Ok(());
                    }
                }
                // DQ29 (§18.5 structural Equatable): a stamped `==`/`!=`
                // cannot use native `===` (reference identity on objects).
                // The `"impl"` lane dispatches through the explicit
                // `impl Equatable`'s `eq` (receiver doubled as the explicit
                // `self` argument, matching the method-call lowering —
                // Q-js-user-equality-reference, #339); the structural lanes
                // lower through the `__bockEq` runtime helper.
                if matches!(op, BinOp::Eq | BinOp::Ne) {
                    if let Some(kind) = crate::generator::user_eq_kind(node) {
                        let recv = self.expr_to_string(left)?;
                        let other = self.expr_to_string(right)?;
                        let neg = if *op == BinOp::Ne { "!" } else { "" };
                        if kind == "impl" {
                            let _ = write!(self.buf, "{neg}(({recv}).eq({recv}, {other}))");
                        } else {
                            let _ = write!(self.buf, "{neg}__bockEq({recv}, {other})");
                        }
                        return Ok(());
                    }
                }
                self.buf.push('(');
                self.emit_expr(left)?;
                let op_str = match op {
                    BinOp::Add => " + ",
                    BinOp::Sub => " - ",
                    BinOp::Mul => " * ",
                    BinOp::Div => " / ",
                    BinOp::Rem => " % ",
                    BinOp::Pow => " ** ",
                    BinOp::Eq => " === ",
                    BinOp::Ne => " !== ",
                    BinOp::Lt => " < ",
                    BinOp::Le => " <= ",
                    BinOp::Gt => " > ",
                    BinOp::Ge => " >= ",
                    BinOp::And => " && ",
                    BinOp::Or => " || ",
                    BinOp::BitAnd => " & ",
                    BinOp::BitOr => " | ",
                    BinOp::BitXor => " ^ ",
                    BinOp::Compose => " /* >> */ ",
                    BinOp::Is => " instanceof ",
                };
                self.buf.push_str(op_str);
                self.emit_expr(right)?;
                self.buf.push(')');
                Ok(())
            }
            NodeKind::UnaryOp { op, operand } => {
                let op_str = match op {
                    UnaryOp::Neg => "-",
                    UnaryOp::Not => "!",
                    UnaryOp::BitNot => "~",
                };
                self.buf.push_str(op_str);
                self.emit_expr(operand)?;
                Ok(())
            }
            NodeKind::Call { callee, args, .. } => {
                if let Some(code) = self.map_prelude_call(callee, args)? {
                    self.buf.push_str(&code);
                    return Ok(());
                }
                if self.try_emit_prelude_ctor(callee, args)? {
                    return Ok(());
                }
                if self.try_emit_time_assoc_call(callee, args)? {
                    return Ok(());
                }
                if self.try_emit_time_desugared_method(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_concurrency_call(callee, args)? {
                    return Ok(());
                }
                // Map/Set dispatch precedes the List recogniser so the
                // overlapping method names route by `recv_kind`, not by name.
                if self.try_emit_map_method(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_set_method(node, callee, args)? {
                    return Ok(());
                }
                // String method dispatch runs *before* the List recogniser so the
                // overlapping `len`/`contains`/`is_empty` names route by the
                // checker's `recv_kind = "Primitive:String"`, not by name alone.
                if self.try_emit_string_method(node, callee, args)? {
                    return Ok(());
                }
                // Numeric/Char/Bool primitive methods (`to_float`/`abs`/`sqrt`/…)
                // likewise route by the checker's `recv_kind = "Primitive:Int|…"`
                // before the generic fall-through, which would emit `n.to_float(n)`.
                if self.try_emit_numeric_method(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_list_mutating_method(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_list_inplace_mutator(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_list_method(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_list_functional_method(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_primitive_bridge(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_trait_bound_bridge(node, callee, args)? {
                    return Ok(());
                }
                if self.try_emit_container_method(node, callee, args)? {
                    return Ok(());
                }
                // Rewrite bare effect operation calls: log(...) → handler.log(...)
                if let NodeKind::Identifier { name } = &callee.kind {
                    if let Some(effect_name) = self.effect_ops.get(&name.name).cloned() {
                        if let Some(handler_var) =
                            self.current_handler_vars.get(&effect_name).cloned()
                        {
                            let _ = write!(self.buf, "{}.{}", handler_var, name.name);
                            self.buf.push('(');
                            for (i, arg) in args.iter().enumerate() {
                                if i > 0 {
                                    self.buf.push_str(", ");
                                }
                                self.emit_expr(&arg.value)?;
                            }
                            self.buf.push(')');
                            return Ok(());
                        }
                    }
                }
                // Q-prim-assoc: a primitive associated-conversion call
                // (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`)
                // lowers to TS's native conversion, NOT the static-member form
                // below (`Float.from` is undefined on the host `number`).
                if self.try_emit_primitive_conversion(node, callee, args)? {
                    return Ok(());
                }
                // An associated-function call (`Type.method(args)` — stamped by
                // the lowerer, no `self` prepended) resolves to the merged
                // `namespace Type { export function method(...) }` static member.
                // Emit `Type.method(args)` with the type name preserved (it names
                // the namespace/class, not a value); the generic fall-through
                // would camel-case it into a non-existent value.
                if crate::generator::is_associated_call(node) {
                    if let NodeKind::FieldAccess { object, field } = &callee.kind {
                        if let NodeKind::Identifier { name: type_name } = &object.kind {
                            let _ = write!(
                                self.buf,
                                "{}.{}",
                                type_name.name,
                                self.ts_method_name(&field.name)
                            );
                            self.buf.push('(');
                            for (i, arg) in args.iter().enumerate() {
                                if i > 0 {
                                    self.buf.push_str(", ");
                                }
                                self.emit_expr(&arg.value)?;
                            }
                            self.buf.push(')');
                            return Ok(());
                        }
                    }
                }
                // A trait/record method call lowers to `Call(FieldAccess(recv,
                // method), [recv, ...])` (the receiver is re-passed as `self`,
                // sharing the receiver's NodeId — see `desugared_self_call`).
                // When the method name collides with a field name, the *method*
                // was renamed at its declaration (`<name>Method`); rename the
                // call's member access to match so it resolves. A genuine field
                // *read* (bare `FieldAccess`, not in call position) and a
                // field-closure call `(p.f)(x)` (distinct receiver nodes) keep
                // the field name. Shared policy with go/js/py. The prototype
                // function takes an explicit `self`, so all `args` are kept.
                if let NodeKind::FieldAccess { object, field } = &callee.kind {
                    if crate::generator::desugared_self_call(callee, args).is_some() {
                        // The prototype/merged-interface declarations spell the
                        // method with the raw Bock name, so disambiguate the call
                        // against the raw name to match (and the generic
                        // fall-through emits the field raw too).
                        let renamed = self.ts_method_name(&field.name);
                        if renamed != field.name {
                            self.emit_expr(object)?;
                            let _ = write!(self.buf, ".{renamed}");
                            self.buf.push('(');
                            for (i, arg) in args.iter().enumerate() {
                                if i > 0 {
                                    self.buf.push_str(", ");
                                }
                                self.emit_expr(&arg.value)?;
                            }
                            self.buf.push(')');
                            return Ok(());
                        }
                    }
                }
                // Pass handler args to effectful function calls.
                let effects_arg = if let NodeKind::Identifier { name } = &callee.kind {
                    self.build_effects_call_arg_ts(&name.name)
                } else {
                    None
                };
                self.emit_callee(callee)?;
                self.buf.push('(');
                for (i, arg) in args.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.emit_expr(&arg.value)?;
                }
                if let Some(ea) = effects_arg {
                    if !args.is_empty() {
                        self.buf.push_str(", ");
                    }
                    self.buf.push_str(&ea);
                }
                self.buf.push(')');
                Ok(())
            }
            NodeKind::MethodCall {
                receiver,
                method,
                args,
                ..
            } => {
                if self.try_emit_time_method(receiver, &method.name, args)? {
                    return Ok(());
                }
                self.emit_expr(receiver)?;
                let _ = write!(
                    self.buf,
                    ".{}",
                    self.ts_method_name(&to_camel_case(&method.name))
                );
                self.buf.push('(');
                for (i, arg) in args.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.emit_expr(&arg.value)?;
                }
                self.buf.push(')');
                Ok(())
            }
            NodeKind::FieldAccess { object, field } => {
                self.emit_expr(object)?;
                let _ = write!(self.buf, ".{}", field.name);
                Ok(())
            }
            NodeKind::Index { object, index } => {
                self.emit_expr(object)?;
                self.buf.push('[');
                self.emit_expr(index)?;
                self.buf.push(']');
                Ok(())
            }
            NodeKind::Lambda { params, body } => {
                let param_list = self.collect_lambda_params(params);
                let _ = write!(self.buf, "({}) => ", param_list.join(", "));
                if matches!(body.kind, NodeKind::Block { .. }) {
                    self.buf.push_str("{\n");
                    self.indent += 1;
                    // A lambda body is a fresh function-body tail context: its
                    // tail is the lambda's return value, so clear any active
                    // statement-position sink (e.g. a `Discard` from an enclosing
                    // loop body) for the duration of the body.
                    let prev_sink = self.value_sink.take();
                    let r = self.emit_block_body(body);
                    self.value_sink = prev_sink;
                    r?;
                    self.indent -= 1;
                    self.write_indent();
                    self.buf.push('}');
                } else {
                    self.emit_expr(body)?;
                }
                Ok(())
            }
            NodeKind::Pipe { left, right } => self.emit_pipe(left, right),
            NodeKind::Compose { left, right } => {
                // `f >> g` → `((x: any) => g(f(x)))`. A composed callee
                // (`left`/`right`) that is itself a `Compose`/`Lambda` must be
                // parenthesized: a bare arrow `(x) => …` followed by `(x)` parses
                // as `(x) => (…(x))`, binding the call to the arrow's body rather
                // than invoking the arrow. `emit_callee` wraps those forms. In
                // practice the AIR lowers `>>` to a `Lambda` before codegen (so
                // chained `>>` reaches the `Call` arm, not here), making this a
                // defensive fall-through.
                let _ = write!(self.buf, "((x: any) => ");
                self.emit_callee(right)?;
                self.buf.push('(');
                self.emit_callee(left)?;
                self.buf.push_str("(x)))");
                Ok(())
            }
            NodeKind::Await { expr } => {
                self.buf.push_str("(await ");
                self.emit_expr(expr)?;
                self.buf.push(')');
                Ok(())
            }
            NodeKind::Propagate { expr } => {
                // `expr?` in *expression* position (nested inside a larger
                // expression). Early-return has no expression form in TS, so the
                // statement-position lowerings (LetBinding value, bare statement,
                // block tail — see `emit_propagate`) handle the common cases. Here
                // we can only unwrap the payload (`._0`); the Err/None branch is
                // *not* propagated, so this is a partial lowering. The examples in
                // scope never hit it (their `?` is always statement-positioned).
                // Tracked as OPEN: nested expression-position `?`.
                self.buf.push('(');
                self.emit_expr(expr)?;
                self.buf.push_str(")._0");
                Ok(())
            }
            NodeKind::Range { lo, hi, inclusive } => {
                if *inclusive {
                    self.buf.push_str("rangeInclusive(");
                } else {
                    self.buf.push_str("range(");
                }
                self.emit_expr(lo)?;
                self.buf.push_str(", ");
                self.emit_expr(hi)?;
                self.buf.push(')');
                Ok(())
            }
            NodeKind::RecordConstruct {
                path,
                fields,
                spread,
            } => {
                // A struct-variant construction (`Circle { radius: 2.0 }`) →
                // the `{enum}_{variant}(field, ..)` factory, in field decl
                // order. Plain records keep their object/class form.
                let struct_variant = if spread.is_none() {
                    self.user_variant_for_path(path).and_then(|info| {
                        if let crate::generator::VariantPayloadKind::Struct(field_order) =
                            &info.payload
                        {
                            Some((info.enum_name.clone(), field_order.clone()))
                        } else {
                            None
                        }
                    })
                } else {
                    None
                };
                if let Some((enum_name, field_order)) = struct_variant {
                    let variant = path.segments.last().map_or("", |s| s.name.as_str());
                    let _ = write!(self.buf, "{enum_name}_{variant}(");
                    for (i, fname) in field_order.iter().enumerate() {
                        if i > 0 {
                            self.buf.push_str(", ");
                        }
                        let supplied = fields.iter().find(|f| &f.name.name == fname);
                        match supplied.and_then(|f| f.value.as_ref()) {
                            Some(val) => self.emit_expr(val)?,
                            None => self.buf.push_str(&ts_value_ident(fname)),
                        }
                    }
                    self.buf.push(')');
                    return Ok(());
                }
                let type_name = path.segments.last().map(|s| s.name.as_str()).unwrap_or("");
                // A Bock `class` lowers to a *positional* `constructor(a, b)`
                // (unlike a record's destructured `constructor({ a, b })`), so a
                // class literal must construct as `new T(a_value, b_value)` with
                // values ordered by the *declared* field order — not the literal's
                // field order, and not a bare object literal (whose inherent/trait
                // methods are not on the object-literal type, so `tsc` rejects the
                // method call). Falls through to the record/object path only when
                // this is not a known class.
                if let Some(field_order) = self.class_fields.get(type_name).cloned() {
                    let _ = write!(self.buf, "new {type_name}(");
                    for (i, fname) in field_order.iter().enumerate() {
                        if i > 0 {
                            self.buf.push_str(", ");
                        }
                        let supplied = fields.iter().find(|f| &f.name.name == fname);
                        match supplied.and_then(|f| f.value.as_ref()) {
                            Some(val) => self.emit_expr(val)?,
                            // A field present in the literal as shorthand
                            // (`T { label }` ≡ `T { label: label }`) — the RHS is
                            // a value reference; otherwise (field omitted, only
                            // possible with a `..base` spread) read it off `base`.
                            None if supplied.is_some() => {
                                self.buf.push_str(&ts_value_ident(fname));
                            }
                            None => match spread {
                                Some(sp) => {
                                    self.emit_expr(sp)?;
                                    let _ = write!(self.buf, ".{}", ts_value_ident(fname));
                                }
                                None => self.buf.push_str("undefined"),
                            },
                        }
                    }
                    self.buf.push(')');
                    return Ok(());
                }
                let is_class = self.record_names.contains(type_name);
                if is_class {
                    let _ = write!(self.buf, "new {type_name}(");
                    if fields.is_empty() && spread.is_none() {
                        self.buf.push(')');
                        return Ok(());
                    }
                }
                if let Some(sp) = spread {
                    self.buf.push_str("{ ...");
                    self.emit_expr(sp)?;
                    if !fields.is_empty() {
                        self.buf.push_str(", ");
                    }
                } else {
                    self.buf.push_str("{ ");
                }
                for (i, f) in fields.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    if let Some(val) = &f.value {
                        let _ = write!(self.buf, "{}: ", f.name.name);
                        self.emit_expr(val)?;
                    } else {
                        self.buf.push_str(&f.name.name);
                    }
                }
                self.buf.push_str(" }");
                if is_class {
                    self.buf.push(')');
                }
                Ok(())
            }
            NodeKind::ListLiteral { elems } => {
                self.buf.push('[');
                for (i, e) in elems.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.emit_expr(e)?;
                }
                self.buf.push(']');
                Ok(())
            }
            NodeKind::MapLiteral { entries } => {
                self.buf.push_str("new Map([");
                for (i, entry) in entries.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.buf.push('[');
                    self.emit_expr(&entry.key)?;
                    self.buf.push_str(", ");
                    self.emit_expr(&entry.value)?;
                    self.buf.push(']');
                }
                self.buf.push_str("])");
                Ok(())
            }
            NodeKind::SetLiteral { elems } => {
                self.buf.push_str("new Set([");
                for (i, e) in elems.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.emit_expr(e)?;
                }
                self.buf.push_str("])");
                Ok(())
            }
            NodeKind::TupleLiteral { elems } => {
                // TS tuples are arrays with typed positions.
                self.buf.push('[');
                for (i, e) in elems.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.emit_expr(e)?;
                }
                self.buf.push(']');
                Ok(())
            }
            NodeKind::Interpolation { parts } => {
                self.buf.push('`');
                for part in parts {
                    match part {
                        AirInterpolationPart::Literal(s) => {
                            self.buf.push_str(&escape_template_literal(s));
                        }
                        AirInterpolationPart::Expr(expr) => {
                            // Q-displayable-interpolation-dispatch: render through
                            // `__bockStr` so a user value with a `Displayable`
                            // impl (its `to_string` method) shows via that method,
                            // not `[object Object]`. Primitives fall back to
                            // native `String(x)`.
                            self.needs_runtime_str = true;
                            self.buf.push_str("${__bockStr(");
                            self.emit_expr(expr)?;
                            self.buf.push_str(")}");
                        }
                    }
                }
                self.buf.push('`');
                Ok(())
            }
            NodeKind::Placeholder => {
                self.buf.push('_');
                Ok(())
            }
            NodeKind::Unreachable => {
                self.buf
                    .push_str("(() => { throw new Error(\"unreachable\"); })()");
                Ok(())
            }
            NodeKind::ResultConstruct { variant, value } => {
                // Use the `_0` payload key — the same shape the surface
                // `Ok(..)`/`Err(..)` construction (`try_emit_prelude_ctor`) emits
                // and the `Result` match reads — so construction and match agree
                // (the old `value`/`error` keys were never read by the match).
                let tag = match variant {
                    ResultVariant::Ok => "Ok",
                    ResultVariant::Err => "Err",
                };
                let _ = write!(self.buf, "{{ _tag: \"{tag}\" as const, _0: ");
                if let Some(v) = value {
                    self.emit_expr(v)?;
                } else {
                    self.buf.push_str("undefined");
                }
                self.buf.push_str(" }");
                Ok(())
            }
            NodeKind::Assign { op, target, value } => {
                self.emit_expr(target)?;
                let op_str = match op {
                    AssignOp::Assign => " = ",
                    AssignOp::AddAssign => " += ",
                    AssignOp::SubAssign => " -= ",
                    AssignOp::MulAssign => " *= ",
                    AssignOp::DivAssign => " /= ",
                    AssignOp::RemAssign => " %= ",
                };
                self.buf.push_str(op_str);
                self.emit_expr(value)?;
                Ok(())
            }
            NodeKind::If {
                condition,
                then_block,
                else_block,
                ..
            } => {
                // Ternary for expression-position if.
                self.buf.push('(');
                self.emit_expr(condition)?;
                self.buf.push_str(" ? ");
                self.emit_block_as_expr(then_block)?;
                self.buf.push_str(" : ");
                if let Some(eb) = else_block {
                    self.emit_block_as_expr(eb)?;
                } else {
                    self.buf.push_str("undefined");
                }
                self.buf.push(')');
                Ok(())
            }
            NodeKind::Block { stmts, tail } => {
                // IIFE. The IIFE body is its own TS lexical scope, so it gets its
                // own `let` scope frame — a name re-bound across two *sibling*
                // IIFEs (e.g. two arms of an expression-position `match`) is two
                // independent declarations, not a redeclaration.
                self.buf.push_str("(() => {\n");
                self.indent += 1;
                self.enter_let_scope(node);
                for s in stmts {
                    self.emit_node(s)?;
                }
                if let Some(t) = tail {
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}return ");
                    self.emit_expr(t)?;
                    self.buf.push_str(";\n");
                }
                self.leave_let_scope();
                self.indent -= 1;
                self.write_indent();
                self.buf.push_str("})()");
                Ok(())
            }
            NodeKind::Match { scrutinee, arms } => {
                // Expression-position match → IIFE. When the value is consumed
                // into a typed binding (`let x: T = match …`), annotate the
                // arrow's return type (`(() : T => {…})()`) so a `T` distinct
                // from the enclosing function's inferred return is respected, and
                // force-hoist a bare-identifier scrutinee so `switch (s)` does not
                // narrow `s` to the case literal inside arm bodies (TS2367). The
                // expected type is taken here so it scopes to this IIFE only and
                // does not leak into the (separately typed) arm-body expressions.
                let expected = self.current_expected_type.take();
                let arrow_ret = expected
                    .as_deref()
                    .map(|t| format!(" : {t}"))
                    .unwrap_or_default();
                let force_hoist = expected.is_some();
                let _ = write!(self.buf, "((){arrow_ret} => {{");
                self.buf.push('\n');
                self.indent += 1;
                // The IIFE arrow returns the matched arm's value, so its arm
                // bodies deliver through a `Return` sink — *not* whatever
                // statement-position sink is active outside (a `Discard` from an
                // enclosing loop/statement context, or an `Assign` from an outer
                // statement-form lowering). Reset for the IIFE body, restore after
                // so the outer sink is untouched.
                let prev_sink = self.value_sink.replace(ValueSink::Return);
                let r = self.emit_match(scrutinee, arms, force_hoist);
                self.value_sink = prev_sink;
                r?;
                self.indent -= 1;
                self.write_indent();
                self.buf.push_str("})()");
                self.current_expected_type = expected;
                Ok(())
            }
            // Ownership: erase
            NodeKind::Move { expr }
            | NodeKind::Borrow { expr }
            | NodeKind::MutableBorrow { expr } => self.emit_expr(expr),
            // Effect operation invocation
            NodeKind::EffectOp {
                effect,
                operation,
                args,
            } => {
                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
                let _ = write!(
                    self.buf,
                    "{}.{}",
                    to_camel_case(effect_name),
                    operation.name
                );
                self.buf.push('(');
                for (i, arg) in args.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    self.emit_expr(&arg.value)?;
                }
                self.buf.push(')');
                Ok(())
            }
            // Type expressions in expression position: emit the TS type
            NodeKind::TypeNamed { .. }
            | NodeKind::TypeTuple { .. }
            | NodeKind::TypeFunction { .. }
            | NodeKind::TypeOptional { .. }
            | NodeKind::TypeSelf => {
                let ty_str = self.type_to_ts(node);
                let _ = write!(self.buf, "/* {ty_str} */");
                Ok(())
            }
            NodeKind::EffectRef { path } => {
                let name = path
                    .segments
                    .iter()
                    .map(|s| s.name.as_str())
                    .collect::<Vec<_>>()
                    .join(".");
                self.buf.push_str(&name);
                Ok(())
            }
            NodeKind::Error => {
                self.buf.push_str("/* error */");
                Ok(())
            }
            _ => {
                self.buf.push_str("/* unsupported */");
                Ok(())
            }
        }
    }

    // ── Match → switch ──────────────────────────────────────────────────────

    /// Lower a `match`. `force_hoist` requests that even a bare-identifier
    /// scrutinee be hoisted into a single `const __matchN = …;` temp before the
    /// `switch` — set for an expression-position match consumed into a typed
    /// binding, so the `switch` narrows the temp (not the original binding) and
    /// arm bodies re-referencing the scrutinee do not trip TS2367. Statement-
    /// position calls pass `false`, preserving the inline `switch (s)` fast-path.
    fn emit_match(
        &mut self,
        scrutinee: &AIRNode,
        arms: &[AIRNode],
        force_hoist: bool,
    ) -> Result<(), CodegenError> {
        // Guards, or-patterns, tuple patterns, and nested constructor/record
        // patterns cannot be expressed by the flat `switch` below. Lower those
        // to an if/else-if chain. Additive: the proven Optional / Result /
        // user-enum / value `switch` fast-path is kept for everything else (see
        // `match_needs_ifchain`). The if-chain already casts the scrutinee root
        // to `as any`, which itself defeats the TS2367 narrowing, so `force_hoist`
        // is irrelevant there.
        if crate::generator::match_needs_ifchain(arms) {
            return self.emit_match_ifchain(scrutinee, arms);
        }

        // ADT (dispatch on `._tag`) when any arm is a constructor pattern or a
        // record pattern naming a registered enum variant. The record-pattern
        // case is the struct-payload variant the prior `ConstructorPat`-only
        // check missed (DV14).
        let is_adt = arms.iter().any(|arm| {
            let NodeKind::MatchArm { pattern, .. } = &arm.kind else {
                return false;
            };
            match &pattern.kind {
                NodeKind::ConstructorPat { .. } => true,
                NodeKind::RecordPat { path, .. } => self.user_variant_for_path(path).is_some(),
                _ => false,
            }
        });

        // Hoist a non-trivial scrutinee into a single `const __matchN = …;` so it
        // is evaluated once and TS narrowing on `__matchN._tag` reaches the
        // payload access `__matchN._0` in the arm bodies. A bare identifier is
        // already a stable reference — normally left inline. But `force_hoist`
        // (an expression-position value match) hoists it too, so `switch
        // (__matchN)` narrows the temp rather than the original binding, keeping
        // arm bodies that re-reference the scrutinee free of TS2367.
        let temp = if matches!(scrutinee.kind, NodeKind::Identifier { .. }) && !force_hoist {
            None
        } else {
            self.match_temp_counter += 1;
            let name = format!("__match{}", self.match_temp_counter);
            let ind = self.indent_str();
            let _ = write!(self.buf, "{ind}const {name} = ");
            self.emit_expr(scrutinee)?;
            self.buf.push_str(";\n");
            Some(name)
        };

        let ind = self.indent_str();
        let _ = write!(self.buf, "{ind}switch (");
        self.emit_scrutinee_ref(scrutinee, temp.as_deref())?;
        if is_adt {
            self.buf.push_str("._tag) {\n");
        } else {
            self.buf.push_str(") {\n");
        }
        self.indent += 1;
        self.switch_label_depth += 1;
        for arm in arms {
            self.emit_match_arm(arm, is_adt, scrutinee, temp.as_deref())?;
        }
        self.switch_label_depth -= 1;
        self.indent -= 1;
        self.writeln("}");
        Ok(())
    }

    /// Emit a reference to the match scrutinee: the hoisted temp name when one
    /// was introduced, else the scrutinee expression inline (a bare identifier).
    fn emit_scrutinee_ref(
        &mut self,
        scrutinee: &AIRNode,
        temp: Option<&str>,
    ) -> Result<(), CodegenError> {
        match temp {
            Some(name) => {
                self.buf.push_str(name);
                Ok(())
            }
            None => self.emit_expr(scrutinee),
        }
    }

    /// Render the scrutinee reference (hoisted temp name, else the inline
    /// scrutinee expression) to a `String` instead of straight to `self.buf`,
    /// by swapping in a scratch buffer for the duration. Used by the ADT-arm
    /// payload bindings to build the narrowing cast around the reference.
    fn scrutinee_ref_string(
        &mut self,
        scrutinee: &AIRNode,
        temp: Option<&str>,
    ) -> Result<String, CodegenError> {
        let saved = std::mem::take(&mut self.buf);
        let result = self.emit_scrutinee_ref(scrutinee, temp);
        let rendered = std::mem::replace(&mut self.buf, saved);
        result.map(|()| rendered)
    }

    /// Wrap a scrutinee reference in a discriminated-union narrowing cast so a
    /// constructor/record arm's payload binding is pinned to the matched
    /// variant's member type — `(<ref> as Extract<typeof <ref>, { _tag:
    /// "<variant>" }>)`.
    ///
    /// TS narrows `s` to the matched member inside a reachable `case "<variant>":`
    /// arm, so `s._0` is the payload type. But narrowing is control-flow-scoped:
    /// when an earlier statement-position `match` has every arm `return`, TS
    /// marks the rest of the function unreachable and stops narrowing, so a later
    /// arm's bare `s._0` widens back to the full union payload and tripping
    /// TS2345 (e.g. `T | E` passed to a `T` parameter — the task-api blocker).
    /// `Extract<typeof s, { _tag: "<variant>" }>` selects the matched member
    /// structurally, independent of reachability, so the binding never widens.
    /// `Extract` on a non-union or `any` scrutinee is a no-op / `any`, and on a
    /// non-matching member yields `never` (whose `._N` assigns anywhere), so the
    /// cast is sound for every ADT scrutinee shape.
    fn narrowing_cast(scrutinee_ref: &str, variant: &str) -> String {
        format!("({scrutinee_ref} as Extract<typeof {scrutinee_ref}, {{ _tag: \"{variant}\" }}>)")
    }

    /// Emit a TS label before a loop iff a contained statement-arm `match`
    /// needs to `break`/`continue` the loop. Pair with [`Self::pop_loop_frame`].
    /// Also pushes a `None` loop result-sink frame; a value-position loop (see
    /// [`Self::emit_value_in_stmt_pos`]) overwrites it with [`Self::set_loop_value_sink`].
    fn emit_loop_label_prefix(&mut self, body: &AIRNode) {
        if crate::generator::loop_needs_break_label(body) {
            self.loop_label_counter += 1;
            let label = format!("__bockLoop{}", self.loop_label_counter);
            self.writeln(&format!("{label}:"));
            self.loop_labels.push(Some(label));
        } else {
            self.loop_labels.push(None);
        }
        self.loop_value_sinks.push(None);
    }

    /// Pop the loop frame pushed by [`Self::emit_loop_label_prefix`] (both the
    /// label and the result-sink entry).
    fn pop_loop_frame(&mut self) {
        self.loop_labels.pop();
        self.loop_value_sinks.pop();
    }

    /// Set the innermost loop's result sink — where `break v` writes the loop's
    /// value (`<binding> = v;` / `return v;`). Called right after
    /// [`Self::emit_loop_label_prefix`] for a value-position loop.
    fn set_loop_value_sink(&mut self, sink: ValueSink) {
        if let Some(top) = self.loop_value_sinks.last_mut() {
            *top = Some(sink);
        }
    }

    /// The innermost loop's result sink, if it is a value-position loop.
    fn innermost_loop_value_sink(&self) -> Option<ValueSink> {
        self.loop_value_sinks.last().and_then(Clone::clone)
    }

    /// Label of the innermost loop, if one was allocated.
    fn innermost_loop_label(&self) -> Option<&str> {
        self.loop_labels.last().and_then(|l| l.as_deref())
    }

    fn emit_match_arm(
        &mut self,
        arm: &AIRNode,
        is_adt: bool,
        scrutinee: &AIRNode,
        temp: Option<&str>,
    ) -> Result<(), CodegenError> {
        if let NodeKind::MatchArm {
            pattern,
            guard,
            body,
        } = &arm.kind
        {
            match &pattern.kind {
                NodeKind::WildcardPat => {
                    self.writeln("default: {");
                }
                NodeKind::BindPat { name, is_mut } if !is_adt => {
                    self.writeln("default: {");
                    self.indent += 1;
                    // A `mut x =>` arm reassigns the binding in its body, so it
                    // must be `let` — emitting `const` trips TS2588 ("cannot
                    // assign to a constant").
                    let kw = if *is_mut { "let" } else { "const" };
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}{kw} {} = ", name.name);
                    self.emit_scrutinee_ref(scrutinee, temp)?;
                    self.buf.push_str(";\n");
                    self.indent -= 1;
                }
                NodeKind::LiteralPat { lit } => {
                    let ind = self.indent_str();
                    let _ = write!(self.buf, "{ind}case ");
                    match lit {
                        Literal::Int(s) => self.buf.push_str(s),
                        Literal::Float(s) => self.buf.push_str(s),
                        Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
                        Literal::Char(s) => {
                            self.buf.push('\'');
                            self.buf.push_str(s);
                            self.buf.push('\'');
                        }
                        Literal::String(s) => {
                            self.buf.push('"');
                            self.buf.push_str(&escape_js_string(s));
                            self.buf.push('"');
                        }
                        Literal::Unit => self.buf.push_str("undefined"),
                    }
                    self.buf.push_str(": {\n");
                }
                NodeKind::ConstructorPat { path, fields } => {
                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
                    self.writeln(&format!("case \"{variant_name}\": {{"));
                    if !fields.is_empty() {
                        self.indent += 1;
                        // Cast the scrutinee to the matched variant member so each
                        // payload binding stays the variant's payload type even in
                        // control-flow-unreachable arms (see `narrowing_cast`).
                        let scrut_ref = self.scrutinee_ref_string(scrutinee, temp)?;
                        let cast = Self::narrowing_cast(&scrut_ref, variant_name);
                        for (i, field) in fields.iter().enumerate() {
                            let binding = self.pattern_to_binding_name(field);
                            let ind = self.indent_str();
                            let _ = writeln!(self.buf, "{ind}const {binding} = {cast}._{i};");
                        }
                        self.indent -= 1;
                    }
                }
                NodeKind::RecordPat { path, fields, .. } => {
                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
                    if is_adt {
                        self.writeln(&format!("case \"{variant_name}\": {{"));
                    } else {
                        self.writeln("default: {");
                    }
                    if !fields.is_empty() {
                        self.indent += 1;
                        // For a struct-payload enum variant (`is_adt`), pin each
                        // field binding to the matched member via the narrowing
                        // cast so it survives control-flow-unreachable arms (see
                        // `narrowing_cast`). A non-ADT record destructure binds off
                        // the un-narrowed scrutinee as before.
                        let scrut_ref = self.scrutinee_ref_string(scrutinee, temp)?;
                        let access = if is_adt {
                            Self::narrowing_cast(&scrut_ref, variant_name)
                        } else {
                            scrut_ref
                        };
                        for f in fields {
                            let field_name = &f.name.name;
                            let binding = match &f.pattern {
                                Some(pat) => self.pattern_to_binding_name(pat),
                                None => field_name.clone(),
                            };
                            let ind = self.indent_str();
                            let _ =
                                writeln!(self.buf, "{ind}const {binding} = {access}.{field_name};");
                        }
                        self.indent -= 1;
                    }
                }
                _ => {
                    self.writeln("default: {");
                }
            }

            self.indent += 1;
            if let Some(g) = guard {
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}if (!(");
                self.emit_expr(g)?;
                self.buf.push_str(")) break;\n");
            }
            self.emit_block_body(body)?;
            self.writeln("break;");
            self.indent -= 1;
            self.writeln("}");
        }
        Ok(())
    }

    // ── Match → if/else-if chain (guards, or-/tuple/nested patterns) ──────────

    /// Lower a `match` whose arms cannot be expressed by a flat `switch` (see
    /// [`crate::generator::match_needs_ifchain`]) to an `if (<test>) { <binds>;
    /// <body> } else if …` chain. Mirrors the JS lowering; the only difference
    /// is that TS access paths into the scrutinee are cast through `as any` so a
    /// nested access (`__matchN._0._tag`) typechecks without relying on
    /// discriminated-union narrowing flowing through `&&`.
    fn emit_match_ifchain(
        &mut self,
        scrutinee: &AIRNode,
        arms: &[AIRNode],
    ) -> Result<(), CodegenError> {
        // Single-evaluation root. A bare identifier is stable; anything else is
        // hoisted into `__matchN`. The access root the tests/binds descend from
        // is cast to `any` so nested field access typechecks under `tsc`.
        let root: String = if let NodeKind::Identifier { name } = &scrutinee.kind {
            format!("({} as any)", name.name)
        } else {
            self.match_temp_counter += 1;
            let name = format!("__match{}", self.match_temp_counter);
            let ind = self.indent_str();
            let _ = write!(self.buf, "{ind}const {name} = ");
            self.emit_expr(scrutinee)?;
            self.buf.push_str(";\n");
            format!("({name} as any)")
        };

        let mut first = true;
        let mut closed = false;
        let arm_count = arms.len();
        for (idx, arm) in arms.iter().enumerate() {
            let NodeKind::MatchArm {
                pattern,
                guard,
                body,
            } = &arm.kind
            else {
                continue;
            };
            let test = self.pattern_test_ts(pattern, &root);
            let is_catch_all = matches!(
                pattern.kind,
                NodeKind::WildcardPat | NodeKind::BindPat { .. }
            );
            let is_last = idx + 1 == arm_count;
            // See the JS lowering: an unguarded catch-all *or* the final
            // unguarded arm becomes the unconditional `else`, closing the chain
            // so a value-returning function typechecks under `tsc`.
            let unconditional = guard.is_none() && (is_catch_all || is_last);
            let ind = self.indent_str();
            if unconditional {
                if first {
                    let _ = writeln!(self.buf, "{ind}{{");
                } else {
                    let _ = writeln!(self.buf, "{ind}else {{");
                }
                closed = true;
            } else {
                let mut cond = if test.is_empty() {
                    "true".to_string()
                } else {
                    test
                };
                if let Some(g) = guard {
                    let g_str = self.expr_to_string(g)?;
                    let binds = self.pattern_binds_to_string_ts(pattern, &root);
                    let guard_test = if binds.is_empty() {
                        format!("({g_str})")
                    } else {
                        format!("(() => {{ {binds}return ({g_str}); }})()")
                    };
                    if cond == "true" {
                        cond = guard_test;
                    } else {
                        cond = format!("{cond} && {guard_test}");
                    }
                }
                if first {
                    let _ = writeln!(self.buf, "{ind}if ({cond}) {{");
                } else {
                    let _ = writeln!(self.buf, "{ind}else if ({cond}) {{");
                }
            }
            first = false;
            self.indent += 1;
            self.pattern_binds_ts(pattern, &root)?;
            self.emit_block_body(body)?;
            self.indent -= 1;
            self.writeln("}");
        }
        if !closed && !first {
            self.writeln("else { throw new Error(\"non-exhaustive match\"); }");
        }
        Ok(())
    }

    /// Build the boolean test that selects `pat` against the TS expression
    /// `access` (already `any`-typed by the caller). Mirrors `pattern_test_js`.
    fn pattern_test_ts(&self, pat: &AIRNode, access: &str) -> String {
        match &pat.kind {
            NodeKind::WildcardPat | NodeKind::BindPat { .. } => String::new(),
            NodeKind::LiteralPat { lit } => {
                format!("{access} === {}", ts_literal(lit))
            }
            NodeKind::ConstructorPat { path, fields } => {
                let variant = path.segments.last().map_or("_", |s| s.name.as_str());
                let mut tests = vec![format!("{access}._tag === \"{variant}\"")];
                for (i, field) in fields.iter().enumerate() {
                    let sub = self.pattern_test_ts(field, &format!("{access}._{i}"));
                    if !sub.is_empty() {
                        tests.push(sub);
                    }
                }
                tests.join(" && ")
            }
            NodeKind::RecordPat { path, fields, .. } => {
                let variant = path.segments.last().map_or("_", |s| s.name.as_str());
                let mut tests = Vec::new();
                if self.user_variant_for_path(path).is_some() {
                    tests.push(format!("{access}._tag === \"{variant}\""));
                }
                for f in fields {
                    if let Some(p) = &f.pattern {
                        let sub = self.pattern_test_ts(p, &format!("{access}.{}", f.name.name));
                        if !sub.is_empty() {
                            tests.push(sub);
                        }
                    }
                }
                if tests.is_empty() {
                    String::new()
                } else {
                    tests.join(" && ")
                }
            }
            NodeKind::TuplePat { elems } => {
                let mut tests = vec![format!("Array.isArray({access})")];
                for (i, e) in elems.iter().enumerate() {
                    let sub = self.pattern_test_ts(e, &format!("{access}[{i}]"));
                    if !sub.is_empty() {
                        tests.push(sub);
                    }
                }
                tests.join(" && ")
            }
            NodeKind::ListPat { elems, rest } => {
                // `[a, b]` requires an array of exactly len(elems); `[a, ..rest]`
                // requires at least len(elems). Element sub-patterns are tested
                // positionally; the rest binds the slice and adds no test.
                // Mirrors `pattern_test_js`.
                let n = elems.len();
                let len_test = if rest.is_some() {
                    format!("{access}.length >= {n}")
                } else {
                    format!("{access}.length === {n}")
                };
                let mut tests = vec![format!("Array.isArray({access})"), len_test];
                for (i, e) in elems.iter().enumerate() {
                    let sub = self.pattern_test_ts(e, &format!("{access}[{i}]"));
                    if !sub.is_empty() {
                        tests.push(sub);
                    }
                }
                tests.join(" && ")
            }
            NodeKind::RangePat { lo, hi, inclusive } => {
                // `lo..hi` → `access >= lo && access < hi`; `lo..=hi` uses `<=`.
                // Mirrors `pattern_test_js`.
                let lo_s = range_bound_to_ts(lo);
                let hi_s = range_bound_to_ts(hi);
                let upper = if *inclusive { "<=" } else { "<" };
                format!("{access} >= {lo_s} && {access} {upper} {hi_s}")
            }
            NodeKind::OrPat { alternatives } => {
                let alts: Vec<String> = alternatives
                    .iter()
                    .map(|a| {
                        let t = self.pattern_test_ts(a, access);
                        if t.is_empty() {
                            "true".to_string()
                        } else {
                            format!("({t})")
                        }
                    })
                    .collect();
                alts.join(" || ")
            }
            _ => String::new(),
        }
    }

    /// Emit the `const <name> = <access…>;` bindings introduced by `pat`.
    /// Mirrors `pattern_binds_js`.
    fn pattern_binds_ts(&mut self, pat: &AIRNode, access: &str) -> Result<(), CodegenError> {
        match &pat.kind {
            // Skip a self-binding (`const n = n` / `const n = (n as any)`): when
            // an arm's bind name equals the scrutinee access — e.g.
            // `match n { n if … }`, whose if-chain root is the `(n as any)` cast —
            // the name already refers to the value. Emitting `const n = (n as any)`
            // shadows the parameter and the RHS reads the just-declared `const`
            // (TS2448, used before declaration). The guard lets such a binding
            // fall through to the no-op `_` arm.
            NodeKind::BindPat { name, .. } if !ts_bind_is_self_reference(&name.name, access) => {
                let ind = self.indent_str();
                let _ = writeln!(
                    self.buf,
                    "{ind}const {} = {access};",
                    ts_value_ident(&name.name)
                );
            }
            NodeKind::ConstructorPat { fields, .. } => {
                for (i, field) in fields.iter().enumerate() {
                    self.pattern_binds_ts(field, &format!("{access}._{i}"))?;
                }
            }
            NodeKind::RecordPat { fields, .. } => {
                for f in fields {
                    let field_access = format!("{access}.{}", f.name.name);
                    match &f.pattern {
                        Some(p) => self.pattern_binds_ts(p, &field_access)?,
                        None => {
                            let ind = self.indent_str();
                            let _ =
                                writeln!(self.buf, "{ind}const {} = {field_access};", f.name.name);
                        }
                    }
                }
            }
            NodeKind::TuplePat { elems } => {
                for (i, e) in elems.iter().enumerate() {
                    self.pattern_binds_ts(e, &format!("{access}[{i}]"))?;
                }
            }
            NodeKind::ListPat { elems, rest } => {
                for (i, e) in elems.iter().enumerate() {
                    self.pattern_binds_ts(e, &format!("{access}[{i}]"))?;
                }
                // `..rest` binds the remaining elements as a slice; a bare `..`
                // (RestPat) or absent rest binds nothing. Mirrors `pattern_binds_js`.
                if let Some(r) = rest {
                    if let NodeKind::BindPat { name, .. } = &r.kind {
                        let ind = self.indent_str();
                        let _ = writeln!(
                            self.buf,
                            "{ind}const {} = {access}.slice({});",
                            ts_value_ident(&name.name),
                            elems.len()
                        );
                    }
                }
            }
            NodeKind::OrPat { alternatives } => {
                if let Some(first) = alternatives.first() {
                    self.pattern_binds_ts(first, access)?;
                }
            }
            _ => {}
        }
        Ok(())
    }

    /// Collect `pat`'s bindings as a single-line `const … = …; ` string for the
    /// guard-evaluating IIFE. Mirrors `pattern_binds_to_string_js`.
    fn pattern_binds_to_string_ts(&self, pat: &AIRNode, access: &str) -> String {
        let mut out = String::new();
        self.collect_binds_ts(pat, access, &mut out);
        out
    }

    fn collect_binds_ts(&self, pat: &AIRNode, access: &str, out: &mut String) {
        match &pat.kind {
            NodeKind::BindPat { name, .. } => {
                // Skip a self-binding (`const n = (n as any)`) — redundant and a
                // TS2448 TDZ error inside the guard-evaluating IIFE. See
                // `pattern_binds_ts`.
                if ts_bind_is_self_reference(&name.name, access) {
                    return;
                }
                let _ = write!(out, "const {} = {access}; ", ts_value_ident(&name.name));
            }
            NodeKind::ConstructorPat { fields, .. } => {
                for (i, field) in fields.iter().enumerate() {
                    self.collect_binds_ts(field, &format!("{access}._{i}"), out);
                }
            }
            NodeKind::RecordPat { fields, .. } => {
                for f in fields {
                    let field_access = format!("{access}.{}", f.name.name);
                    match &f.pattern {
                        Some(p) => self.collect_binds_ts(p, &field_access, out),
                        None => {
                            let _ = write!(out, "const {} = {field_access}; ", f.name.name);
                        }
                    }
                }
            }
            NodeKind::TuplePat { elems } => {
                for (i, e) in elems.iter().enumerate() {
                    self.collect_binds_ts(e, &format!("{access}[{i}]"), out);
                }
            }
            NodeKind::ListPat { elems, rest } => {
                for (i, e) in elems.iter().enumerate() {
                    self.collect_binds_ts(e, &format!("{access}[{i}]"), out);
                }
                if let Some(r) = rest {
                    if let NodeKind::BindPat { name, .. } = &r.kind {
                        let _ = write!(
                            out,
                            "const {} = {access}.slice({}); ",
                            ts_value_ident(&name.name),
                            elems.len()
                        );
                    }
                }
            }
            NodeKind::OrPat { alternatives } => {
                if let Some(first) = alternatives.first() {
                    self.collect_binds_ts(first, access, out);
                }
            }
            _ => {}
        }
    }

    // ── Pipe operator ───────────────────────────────────────────────────────

    fn emit_pipe(&mut self, left: &AIRNode, right: &AIRNode) -> Result<(), CodegenError> {
        if let NodeKind::Call { callee, args, .. } = &right.kind {
            let has_placeholder = args
                .iter()
                .any(|a| matches!(a.value.kind, NodeKind::Placeholder));
            if has_placeholder {
                self.emit_callee(callee)?;
                self.buf.push('(');
                for (i, arg) in args.iter().enumerate() {
                    if i > 0 {
                        self.buf.push_str(", ");
                    }
                    if matches!(arg.value.kind, NodeKind::Placeholder) {
                        self.emit_expr(left)?;
                    } else {
                        self.emit_expr(&arg.value)?;
                    }
                }
                self.buf.push(')');
                return Ok(());
            }
        }
        // `right` is a callee, so parenthesize it when it is a bare arrow
        // (`Lambda`/`Compose`) — otherwise the trailing `(left)` binds to the
        // arrow body instead of invoking it.
        self.emit_callee(right)?;
        self.buf.push('(');
        self.emit_expr(left)?;
        self.buf.push(')');
        Ok(())
    }

    /// Emit an expression in **callee** position, parenthesizing it when its
    /// surface syntax would otherwise swallow the trailing argument list.
    ///
    /// The case that matters is a bare arrow callee: `(x) => body` followed by
    /// `(arg)` parses in TS as `(x) => (body(arg))` — the call binds to the body,
    /// never invoking the arrow. Wrapping it as `((x) => body)(arg)` makes the
    /// call apply to the arrow itself. This arises when the AIR compose desugar
    /// (`f >> g` → `(__compose_x) => g(f(__compose_x))`) **nests**: a chained
    /// `>>` lowers the inner compose to a `Lambda` (or a `Compose` still awaiting
    /// lowering), which then appears as the callee `f`/`g` inside the call.
    /// Mirrors the python (`emit_callee`) and rust (`emit_callee_rs`) backends.
    fn emit_callee(&mut self, callee: &AIRNode) -> Result<(), CodegenError> {
        if matches!(
            callee.kind,
            NodeKind::Lambda { .. } | NodeKind::Compose { .. }
        ) {
            self.buf.push('(');
            self.emit_expr(callee)?;
            self.buf.push(')');
            Ok(())
        } else {
            self.emit_expr(callee)
        }
    }

    // ── Helpers ─────────────────────────────────────────────────────────────

    /// Returns true if `ts_name` has already been declared in the innermost
    /// `let` scope, so a further binding of it must be a plain assignment rather
    /// than a `const`/`let` re-declaration (which TS rejects with TS2451).
    fn simple_let_redeclared(&self, ts_name: &str) -> bool {
        self.let_scopes
            .last()
            .is_some_and(|s| s.declared.contains(ts_name))
    }

    /// Returns true if `ts_name` is re-bound or assigned later in its block, so
    /// its first declaration must use `let` (not `const`) to allow reassignment.
    fn simple_let_needs_let(&self, ts_name: &str) -> bool {
        self.let_scopes
            .last()
            .is_some_and(|s| s.needs_let.contains(ts_name))
    }

    /// Record that `ts_name` has now been declared in the innermost `let` scope.
    fn mark_simple_let_declared(&mut self, ts_name: &str) {
        if let Some(s) = self.let_scopes.last_mut() {
            s.declared.insert(ts_name.to_string());
        }
    }

    /// Push a fresh `let` scope for a TS block, pre-scanning `block`'s direct
    /// statements to find which simple `let`-bound names are re-bound or
    /// assigned within the block (so their first declaration emits `let`). Only
    /// the block's own statements are scanned — nested blocks open their own
    /// scopes, so a name re-bound only in a nested block does not force `let`
    /// here. Mirrors the js backend (#217).
    fn enter_let_scope(&mut self, block: &AIRNode) {
        let mut needs_let = HashSet::new();
        if let NodeKind::Block { stmts, tail } = &block.kind {
            let mut seen: HashSet<String> = HashSet::new();
            let mut visit = |n: &AIRNode, needs_let: &mut HashSet<String>| match &n.kind {
                NodeKind::LetBinding { pattern, .. } => {
                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
                        let ts = ts_value_ident(&name.name);
                        // A re-binding of an already-seen name needs `let`.
                        if !seen.insert(ts.clone()) {
                            needs_let.insert(ts);
                        }
                    }
                }
                NodeKind::Assign { target, .. } => {
                    if let NodeKind::Identifier { name } = &target.kind {
                        needs_let.insert(ts_value_ident(&name.name));
                    }
                }
                _ => {}
            };
            for s in stmts {
                visit(s, &mut needs_let);
            }
            if let Some(t) = tail {
                visit(t, &mut needs_let);
            }
        }
        self.let_scopes.push(LetScope {
            declared: HashSet::new(),
            needs_let,
        });
    }

    /// Pop the innermost `let` scope pushed by [`Self::enter_let_scope`].
    fn leave_let_scope(&mut self) {
        self.let_scopes.pop();
    }

    fn emit_block_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        self.enter_let_scope(node);
        let r = self.emit_block_body_inner(node);
        self.leave_let_scope();
        r
    }

    /// Emit a **loop body** (`for`/`while`/`loop`). A loop body is statement
    /// position: its tail expression is discarded (Bock loops evaluate to Unit;
    /// the body's value is not the function's value). The default
    /// [`Self::emit_block_body`] treats a tail as a function-body return, which
    /// for a loop body would `return console.log(i);` — aborting the function on
    /// the first iteration. Activating a [`ValueSink::Discard`] for the body's
    /// duration routes the tail to a bare expression statement instead. The sink
    /// is saved/restored so it never leaks past the loop, and any nested lambda
    /// clears it (a lambda body's tail is genuinely returned — see the
    /// `NodeKind::Lambda` arm). A `break v` value still flows through the
    /// separate per-loop `loop_value_sinks` stack, not this discard sink.
    fn emit_loop_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        let prev_sink = self.value_sink.replace(ValueSink::Discard);
        let r = self.emit_block_body(node);
        self.value_sink = prev_sink;
        r
    }

    /// Emit a function/method body whose top-level `let` scope is pre-seeded with
    /// the function's `params` as already-declared names. A Bock `let x = …` that
    /// shadows a parameter `x` is the same block scope as the TS parameter, so it
    /// must lower to a plain assignment (`x = …`) rather than a `let`/`const`
    /// redeclaration (which TS rejects). Mirrors the js backend (#217).
    fn emit_fn_body_seeded(
        &mut self,
        params: &[AIRNode],
        body: &AIRNode,
    ) -> Result<(), CodegenError> {
        self.enter_let_scope(body);
        if let Some(scope) = self.let_scopes.last_mut() {
            for p in params {
                if let NodeKind::Param { pattern, .. } = &p.kind {
                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
                        let ts = ts_value_ident(&name.name);
                        scope.needs_let.insert(ts.clone());
                        scope.declared.insert(ts);
                    }
                }
            }
        }
        let r = self.emit_block_body_inner(body);
        self.leave_let_scope();
        r
    }

    fn emit_block_body_inner(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        if let NodeKind::Block { stmts, tail } = &node.kind {
            // Every non-tail statement is statement position: its value is
            // discarded. A statement-position `if`/`match` whose branch/arm
            // bodies end in an expression (e.g. `println(...)`) must NOT `return`
            // that value — doing so aborts the function before the statements
            // after the `if`/`match` run. Activate a `Discard` sink for the
            // non-tail statements (restored before the tail, which keeps the
            // function-body-return semantics). A nested loop/lambda overrides
            // this sink within its own body, so the discard applies only to the
            // immediate statement-position control flow.
            let prev_sink = self.value_sink.replace(ValueSink::Discard);
            let mut stmt_res = Ok(());
            for s in stmts {
                stmt_res = self.emit_node(s);
                if stmt_res.is_err() {
                    break;
                }
            }
            self.value_sink = prev_sink;
            stmt_res?;
            if let Some(t) = tail {
                if crate::generator::node_is_statement(t) {
                    self.emit_node(t)?;
                    return Ok(());
                }
                // A diverging-intrinsic tail (`todo()`/`unreachable()`) lowers to
                // a bare `throw` statement — emitting `return throw …` is invalid
                // TS (TS1109). Emit it as a statement instead.
                if self.call_is_diverging(t) {
                    self.write_indent();
                    self.emit_expr(t)?;
                    self.buf.push_str(";\n");
                    return Ok(());
                }
                // A tail-position `expr?`: emit the early-return guard, then
                // `return` the unwrapped payload (the fn's value). Through the
                // sink when one is active (statement-position control flow).
                if let NodeKind::Propagate { expr } = &t.kind {
                    let access = self.emit_propagate(expr)?;
                    if let Some(sink) = self.value_sink.clone() {
                        return self.emit_sink_value_str(&access, &sink);
                    }
                    let ind = self.indent_str();
                    let _ = writeln!(self.buf, "{ind}return {access};");
                    return Ok(());
                }
                // Expression-position control flow whose branches carry
                // statements (a value `if`/`match` with a `return` arm, or a
                // `loop` producing its value via `break v`) has no TS expression
                // form. Lower it in statement position, delivering each value
                // tail through the active sink (default `return`; cluster-1 fix;
                // see `emit_value_in_stmt_pos`).
                if self.value_needs_stmt_form(t) {
                    let sink = self.value_sink.clone().unwrap_or(ValueSink::Return);
                    return self.emit_value_in_stmt_pos(t, &sink);
                }
                if let NodeKind::Match { scrutinee, arms } = &t.kind {
                    if crate::generator::match_has_statement_arm(arms) {
                        self.emit_match(scrutinee, arms, false)?;
                        return Ok(());
                    }
                }
                self.emit_block_tail_value(t)?;
            }
        } else if crate::generator::node_is_statement(node) {
            self.emit_node(node)?;
        } else if self.call_is_diverging(node) {
            self.write_indent();
            self.emit_expr(node)?;
            self.buf.push_str(";\n");
        } else if self.value_needs_stmt_form(node) {
            let sink = self.value_sink.clone().unwrap_or(ValueSink::Return);
            self.emit_value_in_stmt_pos(node, &sink)?;
        } else if let NodeKind::Match { scrutinee, arms } = &node.kind {
            if crate::generator::match_has_statement_arm(arms) {
                self.emit_match(scrutinee, arms, false)?;
            } else {
                self.emit_block_tail_value(node)?;
            }
        } else {
            self.emit_block_tail_value(node)?;
        }
        Ok(())
    }

    /// Deliver a block's value tail. When a [`ValueSink`] is active (the block is
    /// a `match`-arm body inside a statement-position control-flow lowering), the
    /// value flows through it (`return v` / `binding = v`); otherwise the default
    /// function-body behavior — `return v` — applies.
    fn emit_block_tail_value(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        if let Some(sink) = self.value_sink.clone() {
            return self.emit_sink_value(node, &sink);
        }
        let ind = self.indent_str();
        let _ = write!(self.buf, "{ind}return ");
        self.emit_expr(node)?;
        self.buf.push_str(";\n");
        Ok(())
    }

    /// True when a `Call` is to a diverging intrinsic (`todo()` / `unreachable()`),
    /// which lowers to a bare `throw new Error(...)` *statement* — never produces
    /// a value. Emitting it after `return ` (`return throw …`) is invalid TS, so
    /// callers in value-tail position must emit it as a statement instead.
    fn call_is_diverging(&self, node: &AIRNode) -> bool {
        if let NodeKind::Call { callee, .. } = &node.kind {
            if let NodeKind::Identifier { name } = &callee.kind {
                return matches!(name.name.as_str(), "todo" | "unreachable");
            }
        }
        false
    }

    /// True when `node` (in *value* position) contains control flow that has no
    /// TypeScript expression form — a `Loop` (its value arrives via `break v`), or
    /// an `if`/`match`/`block` any of whose value-tails is a `return`/`break`/
    /// `continue`, a diverging intrinsic call, or (recursively) a nested
    /// control-flow value that itself needs statement form. Such a value must be
    /// emitted in **statement** position (assigning the result to a binding, or
    /// `return`ing it) via [`Self::emit_value_in_stmt_pos`] — the ternary /
    /// value-IIFE lowering would emit `/* unsupported */` for the control-flow
    /// tail. The recursion is what makes a *nested* `if … else if … else { return
    /// … }` chain (chat-protocol) trigger the statement lowering, not just a
    /// single-level `if`.
    fn value_needs_stmt_form(&self, node: &AIRNode) -> bool {
        match &node.kind {
            // A loop never has an expression form; its value comes from `break v`.
            NodeKind::Loop { .. } => true,
            NodeKind::If {
                then_block,
                else_block,
                let_pattern: None,
                ..
            } => {
                self.branch_needs_stmt_form(then_block)
                    || else_block
                        .as_deref()
                        .is_some_and(|e| self.branch_needs_stmt_form(e))
            }
            NodeKind::Match { arms, .. } => arms.iter().any(|arm| {
                matches!(&arm.kind, NodeKind::MatchArm { body, .. } if self.branch_needs_stmt_form(body))
            }),
            NodeKind::Block { tail, .. } => {
                tail.as_deref().is_some_and(|t| self.value_needs_stmt_form(t))
            }
            _ => false,
        }
    }

    /// True when a branch / arm body (an `if`/`match`/`loop`/`block` arm, or a
    /// bare expression) used in value position requires statement-form lowering:
    /// its value tail is a diverging statement, or it is itself control flow that
    /// needs statement form. Drives the recursion in [`Self::value_needs_stmt_form`].
    fn branch_needs_stmt_form(&self, node: &AIRNode) -> bool {
        self.value_tail_diverges(node) || self.value_needs_stmt_form(node)
    }

    /// True when the *value tail* of `node` is a diverging statement — a
    /// `return`/`break`/`continue` node, a diverging intrinsic call, or (for a
    /// block) a tail that itself diverges. Drives [`Self::branch_needs_stmt_form`]:
    /// a value-position branch whose tail is one of these cannot be a ternary/IIFE
    /// arm.
    fn value_tail_diverges(&self, node: &AIRNode) -> bool {
        match &node.kind {
            NodeKind::Return { .. } | NodeKind::Break { .. } | NodeKind::Continue => true,
            NodeKind::Call { .. } => self.call_is_diverging(node),
            NodeKind::Block { stmts, tail } => match tail {
                Some(t) => self.value_tail_diverges(t),
                // A block with no tail (ends in a statement) yields no value.
                None => stmts.last().is_some_and(|s| self.value_tail_diverges(s)),
            },
            _ => false,
        }
    }

    /// Emit a value-position expression that [`Self::value_needs_stmt_form`] has
    /// flagged, in **statement** position, delivering its result via `sink`
    /// (`return <v>;` or `<name> = <v>;`). Diverging tails (`return`/`break`/
    /// `continue`/`todo()`) emit their own statement and ignore the sink. This is
    /// the TS-side desugar of expression-position control flow whose branches
    /// carry statements; it keeps `return`/`break` in the enclosing function/loop
    /// scope (an IIFE would capture them) — see the cluster-1 OPEN in the PR body
    /// proposing a shared AIR desugar.
    fn emit_value_in_stmt_pos(
        &mut self,
        node: &AIRNode,
        sink: &ValueSink,
    ) -> Result<(), CodegenError> {
        match &node.kind {
            // A diverging statement: emit it directly, ignore the sink.
            NodeKind::Return { .. } | NodeKind::Break { .. } | NodeKind::Continue => {
                self.emit_node(node)
            }
            NodeKind::Call { .. } if self.call_is_diverging(node) => {
                self.write_indent();
                self.emit_expr(node)?;
                self.buf.push_str(";\n");
                Ok(())
            }
            NodeKind::Loop { body } => {
                self.emit_loop_label_prefix(body);
                // The loop's value arrives through `break v` (recorded in the
                // separate `loop_value_sinks` stack), NOT the body's tail — the
                // body is statement position, so its tail is discarded via
                // `emit_loop_body`.
                self.set_loop_value_sink(sink.clone());
                self.writeln("while (true) {");
                self.indent += 1;
                self.emit_loop_body(body)?;
                self.indent -= 1;
                self.writeln("}");
                self.pop_loop_frame();
                Ok(())
            }
            NodeKind::If {
                let_pattern: None,
                condition,
                then_block,
                else_block,
            } => {
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}if (");
                self.emit_expr(condition)?;
                self.buf.push_str(") {\n");
                self.indent += 1;
                self.emit_value_in_stmt_pos(then_block, sink)?;
                self.indent -= 1;
                if let Some(else_b) = else_block {
                    if matches!(
                        else_b.kind,
                        NodeKind::If {
                            let_pattern: None,
                            ..
                        }
                    ) {
                        let ind = self.indent_str();
                        let _ = write!(self.buf, "{ind}}} else ");
                        // Re-dispatch the nested `if` without re-opening a brace.
                        self.emit_value_in_stmt_pos_else_if(else_b, sink)?;
                        return Ok(());
                    }
                    self.writeln("} else {");
                    self.indent += 1;
                    self.emit_value_in_stmt_pos(else_b, sink)?;
                    self.indent -= 1;
                }
                self.writeln("}");
                Ok(())
            }
            NodeKind::Match { scrutinee, arms } => {
                let prev = self.value_sink.replace(sink.clone());
                let res = self.emit_match(scrutinee, arms, false);
                self.value_sink = prev;
                res
            }
            NodeKind::Block { stmts, tail } => {
                for s in stmts {
                    self.emit_node(s)?;
                }
                if let Some(t) = tail {
                    self.emit_value_in_stmt_pos(t, sink)?;
                }
                Ok(())
            }
            // A plain value: deliver it through the sink.
            _ => self.emit_sink_value(node, sink),
        }
    }

    /// Helper for the `else if` chain in [`Self::emit_value_in_stmt_pos`]: emits an
    /// `if (...) { … } else …` after the caller has already written `} else `.
    fn emit_value_in_stmt_pos_else_if(
        &mut self,
        node: &AIRNode,
        sink: &ValueSink,
    ) -> Result<(), CodegenError> {
        let NodeKind::If {
            condition,
            then_block,
            else_block,
            ..
        } = &node.kind
        else {
            return self.emit_value_in_stmt_pos(node, sink);
        };
        self.buf.push_str("if (");
        self.emit_expr(condition)?;
        self.buf.push_str(") {\n");
        self.indent += 1;
        self.emit_value_in_stmt_pos(then_block, sink)?;
        self.indent -= 1;
        if let Some(else_b) = else_block {
            if matches!(
                else_b.kind,
                NodeKind::If {
                    let_pattern: None,
                    ..
                }
            ) {
                let ind = self.indent_str();
                let _ = write!(self.buf, "{ind}}} else ");
                self.emit_value_in_stmt_pos_else_if(else_b, sink)?;
                return Ok(());
            }
            self.writeln("} else {");
            self.indent += 1;
            self.emit_value_in_stmt_pos(else_b, sink)?;
            self.indent -= 1;
        }
        self.writeln("}");
        Ok(())
    }

    /// Deliver a finished value expression `node` through `sink`.
    fn emit_sink_value(&mut self, node: &AIRNode, sink: &ValueSink) -> Result<(), CodegenError> {
        let ind = self.indent_str();
        match sink {
            ValueSink::Return => {
                let _ = write!(self.buf, "{ind}return ");
                self.emit_expr(node)?;
                self.buf.push_str(";\n");
            }
            ValueSink::Assign(name) => {
                let _ = write!(self.buf, "{ind}{name} = ");
                self.emit_expr(node)?;
                self.buf.push_str(";\n");
            }
            ValueSink::Discard => {
                let _ = write!(self.buf, "{ind}");
                self.emit_expr(node)?;
                self.buf.push_str(";\n");
            }
        }
        Ok(())
    }

    /// Deliver an already-rendered value expression `value` through `sink`
    /// (`return value;` / `name = value;`). Used by the tail-position `?`
    /// lowering, whose unwrapped payload is a literal access string rather than
    /// an [`AIRNode`].
    fn emit_sink_value_str(&mut self, value: &str, sink: &ValueSink) -> Result<(), CodegenError> {
        let ind = self.indent_str();
        match sink {
            ValueSink::Return => {
                let _ = writeln!(self.buf, "{ind}return {value};");
            }
            ValueSink::Assign(name) => {
                let _ = writeln!(self.buf, "{ind}{name} = {value};");
            }
            ValueSink::Discard => {
                let _ = writeln!(self.buf, "{ind}{value};");
            }
        }
        Ok(())
    }

    fn emit_block_as_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
        if let NodeKind::Block { stmts, tail } = &node.kind {
            if stmts.is_empty() {
                if let Some(t) = tail {
                    return self.emit_expr(t);
                }
            }
        }
        self.emit_expr(node)
    }

    fn pattern_to_binding_name(&self, pat: &AIRNode) -> String {
        match &pat.kind {
            NodeKind::BindPat { name, .. } => ts_value_ident(&name.name),
            NodeKind::WildcardPat => "_".into(),
            NodeKind::TuplePat { elems } => {
                format!(
                    "[{}]",
                    elems
                        .iter()
                        .map(|e| self.pattern_to_binding_name(e))
                        .collect::<Vec<_>>()
                        .join(", ")
                )
            }
            NodeKind::RecordPat { fields, .. } => {
                format!(
                    "{{ {} }}",
                    fields
                        .iter()
                        .map(|f| to_camel_case(&f.name.name).to_string())
                        .collect::<Vec<_>>()
                        .join(", ")
                )
            }
            _ => "_".into(),
        }
    }

    fn pattern_to_ts_destructure(&self, pat: &AIRNode) -> String {
        self.pattern_to_binding_name(pat)
    }

    fn type_expr_to_string(&self, node: &AIRNode) -> String {
        match &node.kind {
            NodeKind::TypeNamed { path, .. } => path
                .segments
                .iter()
                .map(|s| s.name.as_str())
                .collect::<Vec<_>>()
                .join("."),
            NodeKind::Identifier { name } => name.name.clone(),
            _ => "Unknown".into(),
        }
    }
}

// ─── Utility functions ───────────────────────────────────────────────────────

/// Build the `: T` return-type clause for a TS function signature, wrapping
/// the inner type in `Promise<...>` when the function is async. An async
/// function with no declared return type is typed `Promise<void>`.
fn build_ts_return_type(is_async: bool, inner: Option<String>) -> String {
    match (is_async, inner) {
        (true, Some(t)) => format!(": Promise<{t}>"),
        (true, None) => ": Promise<void>".to_string(),
        (false, Some(t)) => format!(": {t}"),
        (false, None) => String::new(),
    }
}

/// Returns true if `name` is the identifier of a Duration or Instant instance
/// method. Used to recognise `d.as_millis()` / `i.elapsed()` calls during codegen.
fn is_time_method_name(name: &str) -> bool {
    matches!(
        name,
        "as_nanos"
            | "as_millis"
            | "as_seconds"
            | "is_zero"
            | "is_negative"
            | "abs"
            | "elapsed"
            | "duration_since"
    )
}

/// Convert a name to `camelCase` (handles `snake_case`, `PascalCase`, and already `camelCase`).
/// Convert a Bock *value* identifier (a param, local binding, or free-function
/// name) to its TS form: `camelCase`, then escaped against the TS reserved-word
/// set so a binding named e.g. `default`/`type` emits `default_`/`type_` rather
/// than the illegal bare keyword. Apply at every value declaration and reference
/// site so the escaped name is used uniformly; member/method names use bare
/// [`to_camel_case`]. See [`crate::generator::escape_target_keyword`].
///
/// On top of the shared reserved-word escape, TS modules are emitted in *strict
/// mode* (ES modules always are), which makes `eval` and `arguments` illegal as
/// binding names — a `function eval(...)` declaration is rejected with `TS1215:
/// Invalid use of 'eval'` even though `eval` is not a keyword. The shared
/// keyword set is target-agnostic and JS (non-module/sloppy-mode) accepts these
/// names, so they are escaped here, in the TS-only funnel, with the same
/// trailing-`_` mangle the keyword escape uses (`eval` → `eval_`). Applied at
/// the single value-ident funnel so declarations and references stay in sync.
fn ts_value_ident(name: &str) -> String {
    let escaped = crate::generator::escape_target_keyword(
        &to_camel_case(name),
        crate::generator::KeywordTarget::Ts,
    );
    if matches!(escaped.as_str(), "eval" | "arguments") {
        format!("{escaped}_")
    } else {
        escaped
    }
}

/// True when binding `bind_name` against `access` would be a self-reference:
/// the access is either the bare TS name (`n`) or its if-chain cast form
/// (`(n as any)`). Emitting `const n = (n as any)` shadows the parameter and
/// reads the freshly-declared `const` in its own initialiser (TS2448), so such
/// a binding must be skipped. Mirrors the JS `js != access` self-bind guard.
fn ts_bind_is_self_reference(bind_name: &str, access: &str) -> bool {
    let ts = ts_value_ident(bind_name);
    access == ts || access == format!("({ts} as any)")
}

/// Spell `name` the way the TS backend emits the symbol's declaration / call
/// sites for an `import`/`export` specifier in the per-module path: a function
/// is camelCased and keyword-escaped via [`ts_value_ident`]; any other kind
/// (records, enum variants, classes, traits, effects, consts, type aliases)
/// keeps its raw name.
fn ts_esm_emit_name(name: &str, is_fn: bool) -> String {
    if is_fn {
        ts_value_ident(name)
    } else {
        name.to_string()
    }
}

fn to_camel_case(s: &str) -> String {
    if s.is_empty() || s == "_" {
        return s.to_string();
    }
    // If already camelCase (starts lowercase, no underscores), return as-is.
    if !s.contains('_') && s.starts_with(|c: char| c.is_lowercase()) {
        return s.to_string();
    }
    // If it's snake_case, convert to camelCase.
    if s.contains('_') {
        let parts: Vec<&str> = s.split('_').filter(|p| !p.is_empty()).collect();
        if parts.is_empty() {
            return s.to_string();
        }
        let mut result = parts[0].to_lowercase();
        for part in &parts[1..] {
            let mut chars = part.chars();
            if let Some(first) = chars.next() {
                result.push(
                    first
                        .to_uppercase()
                        .next()
                        .expect("uppercase yields at least one char"),
                );
                result.extend(chars);
            }
        }
        return result;
    }
    // If PascalCase, lowercase first letter.
    let mut chars = s.chars();
    let first = chars.next().expect("non-empty string guaranteed by caller");
    let mut result = first.to_lowercase().to_string();
    result.extend(chars);
    result
}

/// Escape special characters in a JS/TS string literal.
fn escape_js_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            _ => out.push(ch),
        }
    }
    out
}

/// Render a literal as a TS value expression — used by the if-chain match
/// lowering to compare a scrutinee against a literal pattern (`<access> === …`).
/// Render a `RangePat` bound (`lo`/`hi`) as a TS expression. Range bounds are
/// literals (`1..10`) or a const identifier (`MIN..MAX`); anything else falls
/// back to the wrapped literal/identifier text, or `0` for an unrecognised node.
/// Mirrors `range_bound_to_js`.
fn range_bound_to_ts(node: &AIRNode) -> String {
    match &node.kind {
        NodeKind::LiteralPat { lit } => ts_literal(lit),
        NodeKind::Literal { lit } => ts_literal(lit),
        NodeKind::Identifier { name } => ts_value_ident(&name.name),
        _ => "0".to_string(),
    }
}

fn ts_literal(lit: &Literal) -> String {
    match lit {
        Literal::Int(s) | Literal::Float(s) => s.clone(),
        Literal::Bool(b) => {
            if *b {
                "true".to_string()
            } else {
                "false".to_string()
            }
        }
        Literal::Char(s) => format!("'{s}'"),
        Literal::String(s) => format!("\"{}\"", escape_js_string(s)),
        Literal::Unit => "undefined".to_string(),
    }
}

/// Escape special characters in a JS/TS template literal.
fn escape_template_literal(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '`' => out.push_str("\\`"),
            '\\' => out.push_str("\\\\"),
            '$' => out.push_str("\\$"),
            _ => out.push(ch),
        }
    }
    out
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use bock_air::{AirArg, AirRecordField};
    use bock_ast::{GenericParam, Ident, TypeExpr, TypePath};
    use bock_errors::{FileId, Span};

    fn span() -> Span {
        Span {
            file: FileId(0),
            start: 0,
            end: 0,
        }
    }

    fn ident(name: &str) -> Ident {
        Ident {
            name: name.to_string(),
            span: span(),
        }
    }

    fn type_path(segments: &[&str]) -> TypePath {
        TypePath {
            segments: segments.iter().map(|s| ident(s)).collect(),
            span: span(),
        }
    }

    fn node(id: u32, kind: NodeKind) -> AIRNode {
        AIRNode::new(id, span(), kind)
    }

    fn int_lit(id: u32, val: &str) -> AIRNode {
        node(
            id,
            NodeKind::Literal {
                lit: Literal::Int(val.into()),
            },
        )
    }

    fn str_lit(id: u32, val: &str) -> AIRNode {
        node(
            id,
            NodeKind::Literal {
                lit: Literal::String(val.into()),
            },
        )
    }

    fn id_node(id: u32, name: &str) -> AIRNode {
        node(id, NodeKind::Identifier { name: ident(name) })
    }

    fn bind_pat(id: u32, name: &str) -> AIRNode {
        node(
            id,
            NodeKind::BindPat {
                name: ident(name),
                is_mut: false,
            },
        )
    }

    fn typed_param_node(id: u32, name: &str, ty_name: &str) -> AIRNode {
        node(
            id,
            NodeKind::Param {
                pattern: Box::new(bind_pat(id + 100, name)),
                ty: Some(Box::new(node(
                    id + 200,
                    NodeKind::TypeNamed {
                        path: type_path(&[ty_name]),
                        args: vec![],
                    },
                ))),
                default: None,
            },
        )
    }

    fn type_node(id: u32, name: &str) -> AIRNode {
        node(
            id,
            NodeKind::TypeNamed {
                path: type_path(&[name]),
                args: vec![],
            },
        )
    }

    /// A parameter with no type annotation (e.g. the `self` receiver of an
    /// impl method, which Bock declares as bare `self`).
    fn untyped_param_node(id: u32, name: &str) -> AIRNode {
        node(
            id,
            NodeKind::Param {
                pattern: Box::new(bind_pat(id + 100, name)),
                ty: None,
                default: None,
            },
        )
    }

    fn block(id: u32, stmts: Vec<AIRNode>, tail: Option<AIRNode>) -> AIRNode {
        node(
            id,
            NodeKind::Block {
                stmts,
                tail: tail.map(Box::new),
            },
        )
    }

    fn module(imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
        node(
            0,
            NodeKind::Module {
                path: None,
                annotations: vec![],
                imports,
                items,
            },
        )
    }

    fn gen(module: &AIRNode) -> String {
        let gen = TsGenerator::new();
        let result = gen.generate_module(module).unwrap();
        result.files[0].content.clone()
    }

    fn make_generic_param(name: &str) -> GenericParam {
        GenericParam {
            id: 0,
            span: span(),
            name: ident(name),
            bounds: vec![],
        }
    }

    fn make_bounded_generic_param(name: &str, bounds: &[&str]) -> GenericParam {
        GenericParam {
            id: 0,
            span: span(),
            name: ident(name),
            bounds: bounds.iter().map(|b| type_path(&[b])).collect(),
        }
    }

    fn make_type_expr(name: &str) -> TypeExpr {
        TypeExpr::Named {
            id: 0,
            span: span(),
            path: type_path(&[name]),
            args: vec![],
        }
    }

    fn make_record_field(name: &str, ty_name: &str) -> bock_ast::RecordDeclField {
        bock_ast::RecordDeclField {
            id: 0,
            span: span(),
            name: ident(name),
            ty: make_type_expr(ty_name),
            default: None,
        }
    }

    // ── Basic tests ─────────────────────────────────────────────────────────

    #[test]
    fn implements_code_generator_trait() {
        let gen = TsGenerator::new();
        assert_eq!(gen.target().id, "ts");
    }

    #[test]
    fn empty_module() {
        let m = module(vec![], vec![]);
        let out = gen(&m);
        assert_eq!(out, "");
    }

    #[test]
    fn generate_project_uses_source_mirrored_path_for_ts() {
        let gen = TsGenerator::new();
        let m = module(vec![], vec![]);
        let src_path = std::path::Path::new("src/lib.bock");
        let result = gen.generate_project(&[(&m, src_path)]).unwrap();
        assert_eq!(result.files[0].path, std::path::PathBuf::from("lib.ts"));
    }

    /// A module node with a declared dotted `path`, for the per-module tests.
    fn module_with_path(path: &[&str], imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
        node(
            0,
            NodeKind::Module {
                path: Some(bock_ast::ModulePath {
                    segments: path.iter().map(|s| ident(s)).collect(),
                    span: span(),
                }),
                annotations: vec![],
                imports,
                items,
            },
        )
    }

    /// An `import <path>.{ name }` AIR node (single-item `Named` import).
    fn import_named(id: u32, path: &[&str], name: &str) -> AIRNode {
        node(
            id,
            NodeKind::ImportDecl {
                path: bock_ast::ModulePath {
                    segments: path.iter().map(|s| ident(s)).collect(),
                    span: span(),
                },
                items: bock_ast::ImportItems::Named(vec![bock_ast::ImportedName {
                    span: span(),
                    name: ident(name),
                    alias: None,
                }]),
            },
        )
    }

    /// A bare `fn <name>() -> <tail>` declaration with the given visibility.
    fn fn_decl_tail(id: u32, vis: Visibility, name: &str, tail: AIRNode) -> AIRNode {
        node(
            id,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: vis,
                is_async: false,
                name: ident(name),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(id + 1, vec![], Some(tail))),
            },
        )
    }

    #[test]
    fn per_module_emits_native_esm_import_tree_ts() {
        // entry `module main` uses `mathutil.add_one`; emission must produce
        // `main.ts` (with `import { addOne } from "./mathutil.ts"` — the relative
        // specifier references the *emitted* `.ts` source directly) and
        // `mathutil.ts`. The `package.json` run affordance is emitted by the
        // scaffolder (project mode), NOT codegen (S6a / DV18).
        let call = node(
            10,
            NodeKind::Call {
                callee: Box::new(id_node(11, "add_one")),
                args: vec![bock_air::AirArg {
                    label: None,
                    value: int_lit(12, "6"),
                }],
                type_args: vec![],
            },
        );
        let main_mod = module_with_path(
            &["main"],
            vec![import_named(5, &["mathutil"], "add_one")],
            vec![fn_decl_tail(1, Visibility::Private, "main", call)],
        );
        let util_mod = module_with_path(
            &["mathutil"],
            vec![],
            vec![fn_decl_tail(
                20,
                Visibility::Public,
                "add_one",
                int_lit(22, "7"),
            )],
        );

        let gen = TsGenerator::new();
        let out = gen
            .generate_project(&[
                (&main_mod, std::path::Path::new("src/main.bock")),
                (&util_mod, std::path::Path::new("src/mathutil.bock")),
            ])
            .unwrap();
        let by_name = |p: &str| out.files.iter().find(|f| f.path == std::path::Path::new(p));
        let main_file = by_name("main.ts").expect("main.ts emitted");
        let util_file = by_name("mathutil.ts").expect("mathutil.ts emitted");
        // Codegen no longer emits the run affordance (S6a / DV18) — the
        // scaffolder owns the `package.json` in project mode.
        assert!(
            by_name("package.json").is_none(),
            "codegen must NOT emit package.json — the scaffolder owns it (S6a)"
        );
        assert!(
            main_file
                .content
                .contains("import { addOne } from \"./mathutil.ts\";"),
            "main.ts must import the camelCased fn via the `.ts` specifier; got:\n{}",
            main_file.content
        );
        assert!(
            util_file.content.contains("export function addOne("),
            "mathutil.ts must export the fn inline; got:\n{}",
            util_file.content
        );
    }

    #[test]
    fn per_module_imports_enum_type_via_import_type_ts() {
        // entry `module main` references a `public enum Color` declared in
        // `module palette` *as a type* (a function param annotation). The enum's
        // type name has no JS binding, so TS must `import type { Color }` — not a
        // value import. The variants are re-exported by `palette.ts` and lower
        // inline as tagged objects.
        let color_enum = node(
            40,
            NodeKind::EnumDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Color"),
                generic_params: vec![],
                variants: vec![
                    node(
                        41,
                        NodeKind::EnumVariant {
                            name: ident("Red"),
                            payload: EnumVariantPayload::Unit,
                        },
                    ),
                    node(
                        42,
                        NodeKind::EnumVariant {
                            name: ident("Blue"),
                            payload: EnumVariantPayload::Unit,
                        },
                    ),
                ],
            },
        );
        let palette_mod = module_with_path(&["palette"], vec![], vec![color_enum]);
        // `fn paint(c: Color) -> Int { 0 }` — `Color` appears as a type only.
        let paint = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("main"),
                generic_params: vec![],
                params: vec![typed_param_node(2, "c", "Color")],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(4, vec![], Some(int_lit(5, "0")))),
            },
        );
        let main_mod = module_with_path(
            &["main"],
            vec![import_named(6, &["palette"], "Color")],
            vec![paint],
        );

        let gen = TsGenerator::new();
        let out = gen
            .generate_project(&[
                (&main_mod, std::path::Path::new("src/main.bock")),
                (&palette_mod, std::path::Path::new("src/palette.bock")),
            ])
            .unwrap();
        let by_name = |p: &str| out.files.iter().find(|f| f.path == std::path::Path::new(p));
        let main_file = by_name("main.ts").expect("main.ts emitted");
        let palette_file = by_name("palette.ts").expect("palette.ts emitted");
        assert!(
            main_file
                .content
                .contains("import type { Color } from \"./palette.ts\";"),
            "main.ts must `import type` the enum type; got:\n{}",
            main_file.content
        );
        assert!(
            palette_file
                .content
                .contains("export { Color_Blue, Color_Red };"),
            "palette.ts must re-export the enum variant values; got:\n{}",
            palette_file.content
        );
    }

    // ── Type annotations ────────────────────────────────────────────────────

    #[test]
    fn function_with_type_annotations() {
        let body = block(10, vec![], Some(id_node(11, "x")));
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: false,
                name: ident("add"),
                generic_params: vec![],
                params: vec![
                    typed_param_node(2, "x", "Int"),
                    typed_param_node(3, "y", "Int"),
                ],
                return_type: Some(Box::new(type_node(4, "Int"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("x: number"), "got: {out}");
        assert!(out.contains("y: number"), "got: {out}");
        assert!(out.contains("): number"), "got: {out}");
        assert!(out.contains("export function add"));
    }

    #[test]
    fn function_without_type_annotations() {
        let body = block(10, vec![], Some(int_lit(11, "42")));
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("answer"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("function answer()"), "got: {out}");
        assert!(!out.contains("export"), "got: {out}");
    }

    // ── Generics ────────────────────────────────────────────────────────────

    #[test]
    fn function_with_generics() {
        let body = block(10, vec![], Some(id_node(11, "x")));
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("identity"),
                generic_params: vec![make_generic_param("T")],
                params: vec![typed_param_node(2, "x", "T")],
                return_type: Some(Box::new(type_node(3, "T"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("function identity<T>"), "got: {out}");
        assert!(out.contains("x: T"), "got: {out}");
        assert!(out.contains("): T"), "got: {out}");
    }

    #[test]
    fn generics_with_bounds() {
        let body = block(10, vec![], Some(id_node(11, "x")));
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("sorted"),
                generic_params: vec![make_bounded_generic_param("T", &["Comparable"])],
                params: vec![typed_param_node(2, "x", "T")],
                return_type: Some(Box::new(type_node(3, "T"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        // GAP-C: `Comparable` is a sealed-core trait with no user `impl` in this
        // module, so the `extends Comparable` bound (no such TS type) is dropped to
        // a bare `<T>`; the `.compare` call is lowered to a native operator. A
        // user-declared `Comparable` would keep its `extends` (see use_core_compare).
        assert!(
            out.contains("function sorted<T>(x: T): T {") && !out.contains("extends"),
            "got: {out}"
        );
    }

    // ── Traits → Interfaces ─────────────────────────────────────────────────

    #[test]
    fn trait_becomes_interface() {
        let method = node(
            2,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: false,
                name: ident("area"),
                generic_params: vec![],
                params: vec![],
                return_type: Some(Box::new(type_node(3, "Float"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(4, vec![], None)),
            },
        );
        let trait_decl = node(
            1,
            NodeKind::TraitDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_platform: false,
                name: ident("Shape"),
                generic_params: vec![],
                associated_types: vec![],
                methods: vec![method],
            },
        );
        let out = gen(&module(vec![], vec![trait_decl]));
        assert!(out.contains("export interface Shape"), "got: {out}");
        assert!(out.contains("area(): number"), "got: {out}");
    }

    #[test]
    fn trait_with_generics() {
        let method = node(
            2,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: false,
                name: ident("compare"),
                generic_params: vec![],
                params: vec![typed_param_node(3, "other", "T")],
                return_type: Some(Box::new(type_node(4, "Int"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(5, vec![], None)),
            },
        );
        let trait_decl = node(
            1,
            NodeKind::TraitDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_platform: false,
                name: ident("Comparable"),
                generic_params: vec![make_generic_param("T")],
                associated_types: vec![],
                methods: vec![method],
            },
        );
        let out = gen(&module(vec![], vec![trait_decl]));
        assert!(out.contains("interface Comparable<T>"), "got: {out}");
        assert!(out.contains("compare(other: T): number"), "got: {out}");
    }

    #[test]
    fn trait_decl_self_param_typed_as_trait_interface() {
        // P2 item 2: a trait method whose leading `self` is untyped must be
        // typed as the trait's own interface type (here `Eq`) — otherwise `tsc
        // --strict` flags `self` as an implicit `any`.
        let method = node(
            2,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: false,
                name: ident("equals"),
                generic_params: vec![],
                params: vec![untyped_param_node(3, "self")],
                return_type: Some(Box::new(type_node(4, "Bool"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(5, vec![], None)),
            },
        );
        let trait_decl = node(
            1,
            NodeKind::TraitDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_platform: false,
                name: ident("Eq"),
                generic_params: vec![],
                associated_types: vec![],
                methods: vec![method],
            },
        );
        let out = gen(&module(vec![], vec![trait_decl]));
        assert!(
            out.contains("equals(self: Eq): boolean"),
            "trait `self` should be typed as the trait interface, got: {out}"
        );
    }

    // ── Records → Interfaces ────────────────────────────────────────────────

    #[test]
    fn record_becomes_interface_and_factory() {
        let record = node(
            1,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Point"),
                generic_params: vec![],
                fields: vec![
                    make_record_field("x", "Float"),
                    make_record_field("y", "Float"),
                ],
            },
        );
        let out = gen(&module(vec![], vec![record]));
        assert!(out.contains("export class Point"), "got: {out}");
        assert!(out.contains("x: number"), "got: {out}");
        assert!(out.contains("y: number"), "got: {out}");
        assert!(
            out.contains("constructor({ x, y }: { x: number; y: number })"),
            "got: {out}"
        );
        assert!(out.contains("this.x = x;"), "got: {out}");
        assert!(out.contains("this.y = y;"), "got: {out}");
    }

    // ── Enums → Discriminated unions ────────────────────────────────────────

    #[test]
    fn enum_becomes_discriminated_union() {
        let variants = vec![
            node(
                2,
                NodeKind::EnumVariant {
                    name: ident("None"),
                    payload: EnumVariantPayload::Unit,
                },
            ),
            node(
                3,
                NodeKind::EnumVariant {
                    name: ident("Some"),
                    payload: EnumVariantPayload::Struct(vec![make_record_field("value", "T")]),
                },
            ),
        ];
        let enum_decl = node(
            1,
            NodeKind::EnumDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Option"),
                generic_params: vec![make_generic_param("T")],
                variants,
            },
        );
        let out = gen(&module(vec![], vec![enum_decl]));
        // Union type: each variant reference carries the enum's type args
        // (`Option_None<T> | Option_Some<T>`), at the same arity the variant
        // interfaces declare — referencing them bare fails `tsc` with TS2314
        // (Q-ts-generic-enum-codegen).
        assert!(
            out.contains("export type Option<T> = Option_None<T> | Option_Some<T>;"),
            "got: {out}"
        );
        // Unit variant: a phantom (unused) type param gets an `= unknown`
        // default so the zero-arg `const Option_None: Option_None` annotation is
        // valid while the union still references it as `Option_None<T>`.
        assert!(
            out.contains("interface Option_None<T = unknown>"),
            "got: {out}"
        );
        assert!(out.contains("readonly _tag: \"None\""), "got: {out}");
        assert!(
            out.contains("const Option_None: Option_None = "),
            "got: {out}"
        );
        // Struct variant: interface and factory return type carry consistent
        // arity (`Option_Some<T>` … `: Option_Some<T>`).
        assert!(out.contains("interface Option_Some<T>"), "got: {out}");
        assert!(out.contains("readonly value: T"), "got: {out}");
        assert!(
            out.contains("function Option_Some<T>(value: T): Option_Some<T>"),
            "got: {out}"
        );
    }

    /// Q-ts-variant-constructed-let-typing: a type-annotated binding initialised
    /// by a single variant construction must widen the initialiser to the
    /// declared enum *union* (`Gate_Open(7) as Gate`), not narrow `g` to the
    /// construction variant — otherwise a later `match g` on a sibling variant
    /// fails `tsc --noEmit` (TS2678). Covers all three payload shapes: struct
    /// (`RecordConstruct`), tuple (`Call` on a variant name), and unit
    /// (bare `Identifier`).
    #[test]
    fn typed_let_binding_widens_variant_construct_to_declared_union() {
        let gate = node(
            1,
            NodeKind::EnumDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Gate"),
                generic_params: vec![],
                variants: vec![
                    node(
                        2,
                        NodeKind::EnumVariant {
                            name: ident("Open"),
                            payload: EnumVariantPayload::Struct(vec![make_record_field(
                                "level", "Int",
                            )]),
                        },
                    ),
                    node(
                        3,
                        NodeKind::EnumVariant {
                            name: ident("Closed"),
                            payload: EnumVariantPayload::Tuple(vec![type_node(4, "Int")]),
                        },
                    ),
                    node(
                        5,
                        NodeKind::EnumVariant {
                            name: ident("Locked"),
                            payload: EnumVariantPayload::Unit,
                        },
                    ),
                ],
            },
        );
        // let g: Gate = Open { level: 7 }   (struct variant)
        let open = node(
            10,
            NodeKind::RecordConstruct {
                path: type_path(&["Open"]),
                fields: vec![bock_air::AirRecordField {
                    name: ident("level"),
                    value: Some(Box::new(int_lit(11, "7"))),
                }],
                spread: None,
            },
        );
        let let_g = node(
            12,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(13, "g")),
                ty: Some(Box::new(type_node(14, "Gate"))),
                value: Box::new(open),
            },
        );
        // let c: Gate = Closed(3)   (tuple variant)
        let closed = node(
            20,
            NodeKind::Call {
                callee: Box::new(id_node(21, "Closed")),
                args: vec![AirArg {
                    label: None,
                    value: int_lit(22, "3"),
                }],
                type_args: vec![],
            },
        );
        let let_c = node(
            23,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(24, "c")),
                ty: Some(Box::new(type_node(25, "Gate"))),
                value: Box::new(closed),
            },
        );
        // let k: Gate = Locked   (unit variant)
        let let_k = node(
            30,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(31, "k")),
                ty: Some(Box::new(type_node(32, "Gate"))),
                value: Box::new(id_node(33, "Locked")),
            },
        );
        let f = ts_fn_decl(
            40,
            "main",
            vec![],
            None,
            block(41, vec![let_g, let_c, let_k], None),
        );
        let out = gen(&module(vec![], vec![gate, f]));
        assert!(
            out.contains("const g: Gate = Gate_Open(7) as Gate;"),
            "struct-variant binding must widen to the declared union, got:\n{out}"
        );
        assert!(
            out.contains("const c: Gate = Gate_Closed(3) as Gate;"),
            "tuple-variant binding must widen to the declared union, got:\n{out}"
        );
        assert!(
            out.contains("const k: Gate = Gate_Locked as Gate;"),
            "unit-variant binding must widen to the declared union, got:\n{out}"
        );
    }

    /// The widening cast is *scoped to the matching declared type*: a binding
    /// annotated with a type that is not the constructed variant's enum union
    /// (here a record `Point`, not an enum) emits unchanged — no spurious cast.
    #[test]
    fn typed_let_binding_no_widening_for_non_enum_construct() {
        let point = node(
            1,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Point"),
                generic_params: vec![],
                fields: vec![make_record_field("x", "Int")],
            },
        );
        let ctor = node(
            10,
            NodeKind::RecordConstruct {
                path: type_path(&["Point"]),
                fields: vec![bock_air::AirRecordField {
                    name: ident("x"),
                    value: Some(Box::new(int_lit(11, "5"))),
                }],
                spread: None,
            },
        );
        let let_p = node(
            12,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(13, "p")),
                ty: Some(Box::new(type_node(14, "Point"))),
                value: Box::new(ctor),
            },
        );
        let f = ts_fn_decl(20, "main", vec![], None, block(21, vec![let_p], None));
        let out = gen(&module(vec![], vec![point, f]));
        assert!(
            !out.contains(" as Point"),
            "a record (non-enum) binding must not get a widening cast, got:\n{out}"
        );
    }

    // ── Type aliases ────────────────────────────────────────────────────────

    #[test]
    fn type_alias_emitted() {
        let alias = node(
            1,
            NodeKind::TypeAlias {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("UserId"),
                generic_params: vec![],
                ty: Box::new(type_node(2, "String")),
                where_clause: vec![],
            },
        );
        let out = gen(&module(vec![], vec![alias]));
        assert!(out.contains("export type UserId = string;"), "got: {out}");
    }

    #[test]
    fn generic_type_alias() {
        let alias = node(
            1,
            NodeKind::TypeAlias {
                annotations: vec![],
                visibility: Visibility::Private,
                name: ident("Pair"),
                generic_params: vec![make_generic_param("A"), make_generic_param("B")],
                ty: Box::new(node(
                    2,
                    NodeKind::TypeTuple {
                        elems: vec![type_node(3, "A"), type_node(4, "B")],
                    },
                )),
                where_clause: vec![],
            },
        );
        let out = gen(&module(vec![], vec![alias]));
        assert!(out.contains("type Pair<A, B> = [A, B];"), "got: {out}");
    }

    // ── Effects → typed parameters ──────────────────────────────────────────

    #[test]
    fn effects_as_typed_params() {
        let body = block(10, vec![], None);
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("process"),
                generic_params: vec![],
                params: vec![typed_param_node(2, "data", "String")],
                return_type: None,
                effect_clause: vec![type_path(&["Log"]), type_path(&["Clock"])],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(
            out.contains("{ log, clock }: { log: Log, clock: Clock }"),
            "got: {out}"
        );
    }

    /// Q-clock-handler-routing: inside a `with Clock` function the §18.3.1 time
    /// builtins route through the in-scope `clock` handler — `Instant.now()` →
    /// `clock.now_monotonic()`, `sleep(d)` → `clock.sleep(d)`,
    /// `start.elapsed()` → `clock.now_monotonic() - start` — NOT the inlined
    /// host primitives (`performance.now()` / `setTimeout`). The `Duration` /
    /// `Instant` annotations also render `number` (not the undefined identifiers)
    /// so the handler impl type-checks under `tsc --noEmit`.
    #[test]
    fn clock_time_ops_route_through_handler() {
        let out = gen(&module(vec![], vec![clock_timed_fn()]));
        assert!(out.contains("clock.now_monotonic()"), "got: {out}");
        assert!(out.contains("clock.sleep("), "got: {out}");
        assert!(
            !out.contains("performance.now()"),
            "host clock primitive leaked past the handler: {out}"
        );
        assert!(
            !out.contains("setTimeout"),
            "host sleep primitive leaked past the handler: {out}"
        );
    }

    /// `Duration` / `Instant` used as type annotations must render `number`
    /// (their value representation), not the undefined identifiers, so a
    /// `Clock` handler impl type-checks (Q-clock-handler-routing supporting fix).
    #[test]
    fn builtin_time_types_map_to_number() {
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("span"),
                generic_params: vec![],
                params: vec![typed_param_node(2, "d", "Duration")],
                return_type: Some(Box::new(type_node(3, "Instant"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(10, vec![], None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("d: number"), "Duration annotation: {out}");
        assert!(out.contains("): number"), "Instant annotation: {out}");
        assert!(!out.contains(": Duration"), "leaked Duration type: {out}");
        assert!(!out.contains(": Instant"), "leaked Instant type: {out}");
    }

    /// Builds `fn timed() with Clock { let start = Instant.now(); sleep(
    /// Duration.millis(1)); let d = start.elapsed() }` — the `with Clock` clause
    /// puts the `clock` handler in scope so the time builtins route through it.
    fn clock_timed_fn() -> AIRNode {
        let instant_now = node(
            40,
            NodeKind::Call {
                callee: Box::new(node(
                    41,
                    NodeKind::FieldAccess {
                        object: Box::new(id_node(42, "Instant")),
                        field: ident("now"),
                    },
                )),
                args: vec![],
                type_args: vec![],
            },
        );
        let duration_millis = node(
            50,
            NodeKind::Call {
                callee: Box::new(node(
                    51,
                    NodeKind::FieldAccess {
                        object: Box::new(id_node(52, "Duration")),
                        field: ident("millis"),
                    },
                )),
                args: vec![AirArg {
                    label: None,
                    value: int_lit(53, "1"),
                }],
                type_args: vec![],
            },
        );
        let sleep_call = node(
            60,
            NodeKind::Call {
                callee: Box::new(id_node(61, "sleep")),
                args: vec![AirArg {
                    label: None,
                    value: duration_millis,
                }],
                type_args: vec![],
            },
        );
        let elapsed_call = node(
            70,
            NodeKind::MethodCall {
                receiver: Box::new(id_node(71, "start")),
                method: ident("elapsed"),
                type_args: vec![],
                args: vec![],
            },
        );
        let body = block(
            30,
            vec![
                node(
                    31,
                    NodeKind::LetBinding {
                        is_mut: false,
                        pattern: Box::new(bind_pat(32, "start")),
                        ty: None,
                        value: Box::new(instant_now),
                    },
                ),
                sleep_call,
                node(
                    33,
                    NodeKind::LetBinding {
                        is_mut: false,
                        pattern: Box::new(bind_pat(34, "d")),
                        ty: None,
                        value: Box::new(elapsed_call),
                    },
                ),
            ],
            None,
        );
        node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("timed"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![type_path(&["Clock"])],
                where_clause: vec![],
                body: Box::new(body),
            },
        )
    }

    // ── Async functions ─────────────────────────────────────────────────────

    #[test]
    fn async_function_with_types() {
        let body = block(10, vec![], Some(str_lit(11, "done")));
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: true,
                name: ident("fetch"),
                generic_params: vec![],
                params: vec![typed_param_node(2, "url", "String")],
                return_type: Some(Box::new(type_node(3, "String"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("export async function fetch"), "got: {out}");
        assert!(out.contains("url: string"), "got: {out}");
        // Async declared return type is wrapped in Promise<T>.
        assert!(out.contains("): Promise<string>"), "got: {out}");
    }

    #[test]
    fn async_function_without_return_type_is_promise_void() {
        let body = block(10, vec![], None);
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: true,
                name: ident("tick"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("async function tick()"), "got: {out}");
        assert!(out.contains("): Promise<void>"), "got: {out}");
    }

    #[test]
    fn sync_function_return_type_unchanged() {
        let body = block(10, vec![], Some(str_lit(11, "done")));
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("hello"),
                generic_params: vec![],
                params: vec![],
                return_type: Some(Box::new(type_node(2, "String"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("function hello(): string"), "got: {out}");
        assert!(!out.contains("Promise"), "got: {out}");
    }

    #[test]
    fn entry_invocation_async_main_ts() {
        let inv = TsGenerator::new().entry_invocation(true).unwrap();
        assert!(inv.contains("async () =>"));
        assert!(inv.contains("await main()"));
    }

    #[test]
    fn generate_project_async_main_wraps_entry_ts() {
        let main_fn = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: true,
                name: ident("main"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(2, vec![], None)),
            },
        );
        let m = module(vec![], vec![main_fn]);
        let gen = TsGenerator::new();
        let src_path = std::path::Path::new("src/main.bock");
        let out = gen.generate_project(&[(&m, src_path)]).unwrap();
        let src = &out.files[0].content;
        assert_eq!(out.files[0].path, std::path::PathBuf::from("main.ts"));
        assert!(src.contains("async function main()"), "got: {src}");
        assert!(
            src.contains("(async () => { await main(); })();"),
            "got: {src}"
        );
    }

    // ── Let bindings with type annotations ──────────────────────────────────

    #[test]
    fn let_binding_with_type() {
        let stmt = node(
            1,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(2, "x")),
                ty: Some(Box::new(type_node(3, "Int"))),
                value: Box::new(int_lit(4, "42")),
            },
        );
        let f = node(
            5,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("test"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(6, vec![stmt], None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("const x: number = 42;"), "got: {out}");
    }

    #[test]
    fn mutable_binding_with_type() {
        let stmt = node(
            1,
            NodeKind::LetBinding {
                is_mut: true,
                pattern: Box::new(bind_pat(2, "count")),
                ty: Some(Box::new(type_node(3, "Int"))),
                value: Box::new(int_lit(4, "0")),
            },
        );
        let f = node(
            5,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("test"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(6, vec![stmt], None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("let count: number = 0;"), "got: {out}");
    }

    // ── Type mapping ────────────────────────────────────────────────────────

    #[test]
    fn type_mapping_primitives() {
        let ctx = TsEmitCtx::new();
        assert_eq!(ctx.map_type_name("Int"), "number");
        assert_eq!(ctx.map_type_name("Float"), "number");
        assert_eq!(ctx.map_type_name("Bool"), "boolean");
        assert_eq!(ctx.map_type_name("String"), "string");
        assert_eq!(ctx.map_type_name("Void"), "void");
        assert_eq!(ctx.map_type_name("Unit"), "void");
        assert_eq!(ctx.map_type_name("List"), "Array");
        assert_eq!(ctx.map_type_name("CustomType"), "CustomType");
    }

    #[test]
    fn optional_type_emitted() {
        // `T?` must lower to the tagged `BockOption<T>` union (not `T | null`):
        // the runtime value is `{ _tag: "Some", _0: v }` / `{ _tag: "None" }`,
        // so the type has to describe that for `tsc` to accept it (Q-ts-codegen).
        let ctx = TsEmitCtx::new();
        let opt = node(
            1,
            NodeKind::TypeOptional {
                inner: Box::new(type_node(2, "String")),
            },
        );
        assert_eq!(ctx.type_to_ts(&opt), "BockOption<string>");
    }

    #[test]
    fn generic_type_args() {
        let ctx = TsEmitCtx::new();
        let list_of_int = node(
            1,
            NodeKind::TypeNamed {
                path: type_path(&["List"]),
                args: vec![type_node(2, "Int")],
            },
        );
        assert_eq!(ctx.type_to_ts(&list_of_int), "Array<number>");
    }

    #[test]
    fn function_type() {
        let ctx = TsEmitCtx::new();
        let fn_type = node(
            1,
            NodeKind::TypeFunction {
                params: vec![type_node(2, "Int"), type_node(3, "String")],
                ret: Box::new(type_node(4, "Bool")),
                effects: vec![],
            },
        );
        assert_eq!(
            ctx.type_to_ts(&fn_type),
            "(arg0: number, arg1: string) => boolean"
        );
    }

    #[test]
    fn tuple_type() {
        let ctx = TsEmitCtx::new();
        let tuple = node(
            1,
            NodeKind::TypeTuple {
                elems: vec![type_node(2, "Int"), type_node(3, "String")],
            },
        );
        assert_eq!(ctx.type_to_ts(&tuple), "[number, string]");
    }

    // ── Constants with types ────────────────────────────────────────────────

    #[test]
    fn const_with_type() {
        let c = node(
            1,
            NodeKind::ConstDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("PI"),
                ty: Box::new(type_node(2, "Float")),
                value: Box::new(node(
                    3,
                    NodeKind::Literal {
                        lit: Literal::Float("3.14159".into()),
                    },
                )),
            },
        );
        let out = gen(&module(vec![], vec![c]));
        assert!(
            out.contains("export const PI: number = 3.14159;"),
            "got: {out}"
        );
    }

    // ── Class with types ────────────────────────────────────────────────────

    #[test]
    fn class_with_typed_fields() {
        let class = node(
            1,
            NodeKind::ClassDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Point"),
                generic_params: vec![],
                base: None,
                traits: vec![],
                fields: vec![
                    make_record_field("x", "Float"),
                    make_record_field("y", "Float"),
                ],
                methods: vec![],
            },
        );
        let out = gen(&module(vec![], vec![class]));
        assert!(out.contains("export class Point"), "got: {out}");
        assert!(out.contains("x: number;"), "got: {out}");
        assert!(out.contains("y: number;"), "got: {out}");
        assert!(
            out.contains("constructor(x: number, y: number)"),
            "got: {out}"
        );
    }

    /// A `class T { a, b }` literal must construct via the class's **positional**
    /// constructor — `new T(a_value, b_value)` in *field-declaration order* — not
    /// the bare object literal the record path emits (which `tsc` rejects with
    /// `TS2339: Property '<method>' does not exist`). Q-class-codegen.
    #[test]
    fn class_literal_constructs_positionally() {
        let class = node(
            1,
            NodeKind::ClassDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Button"),
                generic_params: vec![],
                base: None,
                traits: vec![],
                fields: vec![
                    make_record_field("label", "String"),
                    make_record_field("on_click", "String"),
                ],
                methods: vec![],
            },
        );
        // Supply fields OUT of declaration order; the emitter must reorder to
        // declaration order for the positional constructor.
        let construct = node(
            10,
            NodeKind::RecordConstruct {
                path: type_path(&["Button"]),
                fields: vec![
                    AirRecordField {
                        name: ident("on_click"),
                        value: Some(Box::new(str_lit(11, "click"))),
                    },
                    AirRecordField {
                        name: ident("label"),
                        value: Some(Box::new(str_lit(12, "Submit"))),
                    },
                ],
                spread: None,
            },
        );
        let main_fn = node(
            20,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("main"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(
                    21,
                    vec![node(
                        22,
                        NodeKind::LetBinding {
                            is_mut: false,
                            pattern: Box::new(bind_pat(23, "b")),
                            ty: None,
                            value: Box::new(construct),
                        },
                    )],
                    None,
                )),
            },
        );
        let out = gen(&module(vec![], vec![class, main_fn]));
        assert!(
            out.contains(r#"new Button("Submit", "click")"#),
            "expected positional `new Button(...)` in declaration order, got:\n{out}"
        );
        assert!(
            !out.contains("{ label:"),
            "class literal must not emit a bare object literal:\n{out}"
        );
    }

    // ── Effect declarations → interfaces ────────────────────────────────────

    #[test]
    fn effect_becomes_interface() {
        let op = node(
            2,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: false,
                name: ident("log"),
                generic_params: vec![],
                params: vec![typed_param_node(3, "msg", "String")],
                return_type: Some(Box::new(type_node(4, "Void"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(5, vec![], None)),
            },
        );
        let effect = node(
            1,
            NodeKind::EffectDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Logger"),
                generic_params: vec![],
                components: vec![],
                operations: vec![op],
            },
        );
        let out = gen(&module(vec![], vec![effect]));
        assert!(out.contains("interface Logger"), "got: {out}");
        assert!(out.contains("log(msg: string): void"), "got: {out}");
    }

    // ── Ownership erasure ───────────────────────────────────────────────────

    #[test]
    fn ownership_erased() {
        let move_expr = node(
            1,
            NodeKind::Move {
                expr: Box::new(id_node(2, "x")),
            },
        );
        let borrow_expr = node(
            3,
            NodeKind::Borrow {
                expr: Box::new(id_node(4, "y")),
            },
        );
        let stmts = vec![
            node(
                5,
                NodeKind::LetBinding {
                    is_mut: false,
                    pattern: Box::new(bind_pat(6, "a")),
                    ty: None,
                    value: Box::new(move_expr),
                },
            ),
            node(
                7,
                NodeKind::LetBinding {
                    is_mut: false,
                    pattern: Box::new(bind_pat(8, "b")),
                    ty: None,
                    value: Box::new(borrow_expr),
                },
            ),
        ];
        let f = node(
            9,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("test"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(10, stmts, None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("const a = x;"), "got: {out}");
        assert!(out.contains("const b = y;"), "got: {out}");
    }

    // ── String interpolation ────────────────────────────────────────────────

    #[test]
    fn string_interpolation() {
        let interp = node(
            1,
            NodeKind::Interpolation {
                parts: vec![
                    AirInterpolationPart::Literal("Hello, ".into()),
                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
                    AirInterpolationPart::Literal("!".into()),
                ],
            },
        );
        let stmt = node(
            3,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(4, "msg")),
                ty: None,
                value: Box::new(interp),
            },
        );
        let f = node(
            5,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("test"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(6, vec![stmt], None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        // Rendered through `__bockStr` so a user `Displayable` value dispatches
        // through its `to_string` (Q-displayable-interpolation-dispatch).
        assert!(out.contains("`Hello, ${__bockStr(name)}!`"), "got: {out}");
    }

    // ── Collections ─────────────────────────────────────────────────────────

    #[test]
    fn collections() {
        let list = node(
            1,
            NodeKind::ListLiteral {
                elems: vec![int_lit(2, "1"), int_lit(3, "2"), int_lit(4, "3")],
            },
        );
        let map = node(
            5,
            NodeKind::MapLiteral {
                entries: vec![bock_air::AirMapEntry {
                    key: str_lit(6, "a"),
                    value: int_lit(7, "1"),
                }],
            },
        );
        let set = node(
            8,
            NodeKind::SetLiteral {
                elems: vec![int_lit(9, "1"), int_lit(10, "2")],
            },
        );
        let stmts = vec![
            node(
                11,
                NodeKind::LetBinding {
                    is_mut: false,
                    pattern: Box::new(bind_pat(12, "xs")),
                    ty: None,
                    value: Box::new(list),
                },
            ),
            node(
                13,
                NodeKind::LetBinding {
                    is_mut: false,
                    pattern: Box::new(bind_pat(14, "m")),
                    ty: None,
                    value: Box::new(map),
                },
            ),
            node(
                15,
                NodeKind::LetBinding {
                    is_mut: false,
                    pattern: Box::new(bind_pat(16, "s")),
                    ty: None,
                    value: Box::new(set),
                },
            ),
        ];
        let f = node(
            17,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("test"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(18, stmts, None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("[1, 2, 3]"), "got: {out}");
        assert!(out.contains("new Map("), "got: {out}");
        assert!(out.contains("new Set("), "got: {out}");
    }

    // ── Result types with as const ──────────────────────────────────────────

    #[test]
    fn result_construct_has_as_const() {
        let ok = node(
            1,
            NodeKind::ResultConstruct {
                variant: ResultVariant::Ok,
                value: Some(Box::new(int_lit(2, "42"))),
            },
        );
        let stmt = node(
            3,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(4, "r")),
                ty: None,
                value: Box::new(ok),
            },
        );
        let f = node(
            5,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("test"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(6, vec![stmt], None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        // Reconciled on the `_0` payload key the `Result` match reads.
        assert!(
            out.contains("{ _tag: \"Ok\" as const, _0: 42 }"),
            "got: {out}"
        );
    }

    // ── Record construct ────────────────────────────────────────────────────

    #[test]
    fn record_construct() {
        let rc = node(
            1,
            NodeKind::RecordConstruct {
                path: type_path(&["Point"]),
                fields: vec![
                    AirRecordField {
                        name: ident("x"),
                        value: Some(Box::new(int_lit(2, "1"))),
                    },
                    AirRecordField {
                        name: ident("y"),
                        value: Some(Box::new(int_lit(3, "2"))),
                    },
                ],
                spread: None,
            },
        );
        let stmt = node(
            4,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(5, "p")),
                ty: None,
                value: Box::new(rc),
            },
        );
        let f = node(
            6,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("test"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(7, vec![stmt], None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(out.contains("{ x: 1, y: 2 }"), "got: {out}");
    }

    #[test]
    fn to_camel_case_converts_snake_case() {
        assert_eq!(to_camel_case("create_user"), "createUser");
        assert_eq!(to_camel_case("get_all_items"), "getAllItems");
        assert_eq!(to_camel_case("Log"), "log");
        assert_eq!(to_camel_case("createUser"), "createUser");
        assert_eq!(to_camel_case("_"), "_");
        assert_eq!(to_camel_case(""), "");
    }

    #[test]
    fn snake_case_fn_becomes_camel_case_ts() {
        let body = block(2, vec![], Some(int_lit(3, "42")));
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("create_user"),
                generic_params: vec![],
                params: vec![typed_param_node(4, "name", "String")],
                return_type: Some(Box::new(type_node(5, "Int"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(
            out.contains("function createUser("),
            "expected camelCase function name, got: {out}"
        );
        assert!(
            out.contains("name: string"),
            "expected type annotations, got: {out}"
        );
    }

    // ── Prelude function mapping tests ──────────────────────────────────────

    /// Helper: generate TypeScript for a module with a `main` function containing a single call.
    fn gen_prelude_call(func_name: &str, arg: AIRNode) -> String {
        let call = node(
            10,
            NodeKind::Call {
                callee: Box::new(id_node(11, func_name)),
                args: vec![AirArg {
                    label: None,
                    value: arg,
                }],
                type_args: vec![],
            },
        );
        let body = block(2, vec![call], None);
        let f = node(
            1,
            NodeKind::FnDecl {
                name: ident("main"),
                params: vec![],
                return_type: None,
                body: Box::new(body),
                generic_params: vec![],
                visibility: Visibility::Private,
                annotations: vec![],
                effect_clause: vec![],
                where_clause: vec![],
                is_async: false,
            },
        );
        gen(&module(vec![], vec![f]))
    }

    /// Helper: generate TypeScript for a nullary prelude call (no args).
    fn gen_prelude_call_no_args(func_name: &str) -> String {
        let call = node(
            10,
            NodeKind::Call {
                callee: Box::new(id_node(11, func_name)),
                args: vec![],
                type_args: vec![],
            },
        );
        let body = block(2, vec![call], None);
        let f = node(
            1,
            NodeKind::FnDecl {
                name: ident("main"),
                params: vec![],
                return_type: None,
                body: Box::new(body),
                generic_params: vec![],
                visibility: Visibility::Private,
                annotations: vec![],
                effect_clause: vec![],
                where_clause: vec![],
                is_async: false,
            },
        );
        gen(&module(vec![], vec![f]))
    }

    #[test]
    fn prelude_println_maps_to_console_log() {
        let out = gen_prelude_call("println", str_lit(12, "hello"));
        assert!(
            out.contains("console.log("),
            "println should map to console.log, got: {out}"
        );
        assert!(
            !out.contains("println("),
            "should not emit bare println(, got: {out}"
        );
    }

    #[test]
    fn prelude_print_maps_to_process_stdout() {
        let out = gen_prelude_call("print", str_lit(12, "hello"));
        assert!(
            out.contains("process.stdout.write(String("),
            "print should map to process.stdout.write, got: {out}"
        );
    }

    #[test]
    fn prelude_debug_maps_to_console_debug() {
        let out = gen_prelude_call("debug", str_lit(12, "val"));
        assert!(
            out.contains("console.debug("),
            "debug should map to console.debug, got: {out}"
        );
    }

    #[test]
    fn prelude_assert_maps_to_throw() {
        let arg = node(
            12,
            NodeKind::Literal {
                lit: Literal::Bool(true),
            },
        );
        let out = gen_prelude_call("assert", arg);
        assert!(
            out.contains("if (!true) throw new Error(\"assertion failed\")"),
            "assert should map to if-throw, got: {out}"
        );
    }

    #[test]
    fn prelude_todo_maps_to_throw_not_implemented() {
        let out = gen_prelude_call_no_args("todo");
        assert!(
            out.contains("throw new Error(\"not implemented\")"),
            "todo should map to throw, got: {out}"
        );
    }

    #[test]
    fn prelude_unreachable_maps_to_throw_unreachable() {
        let out = gen_prelude_call_no_args("unreachable");
        assert!(
            out.contains("throw new Error(\"unreachable\")"),
            "unreachable should map to throw, got: {out}"
        );
    }

    #[test]
    fn non_prelude_call_passes_through() {
        let out = gen_prelude_call("my_custom_func", str_lit(12, "arg"));
        assert!(
            out.contains("myCustomFunc("),
            "non-prelude call should use camelCase, got: {out}"
        );
    }

    #[test]
    fn handling_block_passes_handlers_to_effectful_call() {
        use bock_air::AirHandlerPair;

        let effect_decl = node(
            1,
            NodeKind::EffectDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Logger"),
                generic_params: vec![],
                components: vec![],
                operations: vec![node(
                    2,
                    NodeKind::FnDecl {
                        annotations: vec![],
                        visibility: Visibility::Public,
                        is_async: false,
                        name: ident("log"),
                        generic_params: vec![],
                        params: vec![typed_param_node(3, "msg", "String")],
                        return_type: None,
                        effect_clause: vec![],
                        where_clause: vec![],
                        body: Box::new(block(4, vec![], None)),
                    },
                )],
            },
        );

        let inner_fn = node(
            10,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("inner"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![type_path(&["Logger"])],
                where_clause: vec![],
                body: Box::new(block(12, vec![], Some(str_lit(13, "hello")))),
            },
        );

        let call_inner = node(
            20,
            NodeKind::Call {
                callee: Box::new(id_node(21, "inner")),
                args: vec![],
                type_args: vec![],
            },
        );
        let handling = node(
            30,
            NodeKind::HandlingBlock {
                handlers: vec![AirHandlerPair {
                    effect: type_path(&["Logger"]),
                    handler: Box::new(node(
                        31,
                        NodeKind::Call {
                            callee: Box::new(id_node(32, "StdoutLogger")),
                            args: vec![],
                            type_args: vec![],
                        },
                    )),
                }],
                body: Box::new(block(33, vec![], Some(call_inner))),
            },
        );
        let main_fn = node(
            40,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("main"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(41, vec![handling], None)),
            },
        );

        let out = gen(&module(vec![], vec![effect_decl, inner_fn, main_fn]));
        // TS: inner({ logger: __logger })
        assert!(
            out.contains("inner({ logger: __logger })"),
            "handling block should pass handler to effectful call, got: {out}"
        );
        assert!(
            out.contains("const __logger: Logger = stdoutLogger()"),
            "handling block should instantiate handler with type, got: {out}"
        );
    }

    #[test]
    fn sibling_handling_blocks_do_not_share_let_scope() {
        use bock_air::AirHandlerPair;

        // Two *sibling* `handling` blocks, each `let part = …` under the SAME
        // name. Each block lowers to its own `{ … }` TS lexical scope, so both
        // must declare a fresh `const part` — neither may be rewritten into a
        // bare `part = …` assignment against the other (a name that went out of
        // scope when the first block closed; `tsc` would reject it as TS2304).
        // Regression for Q-js-handling-let-redeclaration.
        let make_handling = |id: u32, val: &str| {
            node(
                id,
                NodeKind::HandlingBlock {
                    handlers: vec![AirHandlerPair {
                        effect: type_path(&["Logger"]),
                        handler: Box::new(node(
                            id + 1,
                            NodeKind::Call {
                                callee: Box::new(id_node(id + 2, "StdoutLogger")),
                                args: vec![],
                                type_args: vec![],
                            },
                        )),
                    }],
                    body: Box::new(block(
                        id + 3,
                        vec![node(
                            id + 4,
                            NodeKind::LetBinding {
                                is_mut: false,
                                pattern: Box::new(bind_pat(id + 5, "part")),
                                ty: None,
                                value: Box::new(str_lit(id + 6, val)),
                            },
                        )],
                        Some(id_node(id + 7, "part")),
                    )),
                },
            )
        };
        let main_fn = node(
            40,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("main"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(
                    41,
                    vec![make_handling(50, "first"), make_handling(70, "second")],
                    None,
                )),
            },
        );

        let out = gen(&module(vec![], vec![main_fn]));
        assert_eq!(
            out.matches("const part = ").count(),
            2,
            "each sibling handling block should declare its own `const part`, got: {out}"
        );
        assert!(
            !out.contains("\n  part = "),
            "no sibling handling block may rewrite its `let part` into a bare \
             assignment, got: {out}"
        );
    }

    #[test]
    fn record_becomes_class() {
        let rec = node(
            1,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("ConsoleLogger"),
                generic_params: vec![],
                fields: vec![],
            },
        );
        let out = gen(&module(vec![], vec![rec]));
        assert!(
            out.contains("export class ConsoleLogger {}"),
            "empty record should be an empty exported class, got: {out}"
        );
    }

    #[test]
    fn impl_emits_interface_extension_for_declaration_merging() {
        use bock_air::AirHandlerPair;
        let _ = AirHandlerPair {
            effect: type_path(&["X"]),
            handler: Box::new(id_node(0, "x")),
        };

        let effect_decl = node(
            1,
            NodeKind::EffectDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Logger"),
                generic_params: vec![],
                components: vec![],
                operations: vec![node(
                    2,
                    NodeKind::FnDecl {
                        annotations: vec![],
                        visibility: Visibility::Public,
                        is_async: false,
                        name: ident("log"),
                        generic_params: vec![],
                        params: vec![typed_param_node(3, "msg", "String")],
                        return_type: None,
                        effect_clause: vec![],
                        where_clause: vec![],
                        body: Box::new(block(4, vec![], None)),
                    },
                )],
            },
        );

        let rec = node(
            5,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("StdLogger"),
                generic_params: vec![],
                fields: vec![],
            },
        );

        let impl_block = node(
            10,
            NodeKind::ImplBlock {
                annotations: vec![],
                trait_path: Some(type_path(&["Logger"])),
                trait_args: vec![],
                target: Box::new(type_node(11, "StdLogger")),
                generic_params: vec![],
                methods: vec![node(
                    12,
                    NodeKind::FnDecl {
                        annotations: vec![],
                        visibility: Visibility::Public,
                        is_async: false,
                        name: ident("log"),
                        generic_params: vec![],
                        // An instance method leads with `self` (as real lowering
                        // produces). A method with no `self` is an associated
                        // function, emitted as a `namespace` static, not an
                        // instance member on the merged interface.
                        params: vec![
                            untyped_param_node(15, "self"),
                            typed_param_node(13, "msg", "String"),
                        ],
                        return_type: None,
                        effect_clause: vec![],
                        where_clause: vec![],
                        body: Box::new(block(14, vec![], None)),
                    },
                )],
                where_clause: vec![],
            },
        );

        let out = gen(&module(vec![], vec![effect_decl, rec, impl_block]));
        assert!(
            out.contains("interface StdLogger extends Logger {"),
            "impl should emit interface extension for declaration merging, got: {out}"
        );
        // The merged interface also declares the concrete method signatures so
        // `x.log(...)` call sites resolve against the class type.
        assert!(
            out.contains("log(msg: string): void;"),
            "merged interface should declare the method signature, got: {out}"
        );
        assert!(
            out.contains("StdLogger.prototype.log"),
            "impl should attach method to prototype, got: {out}"
        );
    }

    #[test]
    fn generic_trait_impl_extends_clause_carries_type_args() {
        // GAP-A: `impl P[T] for R[T]` for a generic `trait P[T]` must emit
        // `interface R<T> extends P<T>` — the `extends` clause carries the
        // impl's trait type-args. Without them `tsc` rejects with TS2314.
        let rec = node(
            1,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("R"),
                generic_params: vec![make_generic_param("T")],
                fields: vec![make_record_field("v", "T")],
            },
        );
        let impl_block = node(
            10,
            NodeKind::ImplBlock {
                annotations: vec![],
                trait_path: Some(type_path(&["P"])),
                trait_args: vec![node(
                    11,
                    NodeKind::TypeNamed {
                        path: type_path(&["T"]),
                        args: vec![],
                    },
                )],
                target: Box::new(node(
                    12,
                    NodeKind::TypeNamed {
                        path: type_path(&["R"]),
                        args: vec![node(
                            13,
                            NodeKind::TypeNamed {
                                path: type_path(&["T"]),
                                args: vec![],
                            },
                        )],
                    },
                )),
                generic_params: vec![],
                methods: vec![node(
                    14,
                    NodeKind::FnDecl {
                        annotations: vec![],
                        visibility: Visibility::Public,
                        is_async: false,
                        name: ident("f"),
                        generic_params: vec![],
                        params: vec![untyped_param_node(15, "self")],
                        return_type: None,
                        effect_clause: vec![],
                        where_clause: vec![],
                        body: Box::new(block(16, vec![], None)),
                    },
                )],
                where_clause: vec![],
            },
        );
        let out = gen(&module(vec![], vec![rec, impl_block]));
        assert!(
            out.contains("interface R<T> extends P<T> {"),
            "generic trait impl's extends clause must carry `<T>`, got: {out}"
        );
    }

    #[test]
    fn optional_named_type_maps_to_bock_option() {
        // The spelled-out `Optional[T]` named type must lower to `BockOption<T>`
        // (matching the `T?` shorthand and the emitted tagged value), not a
        // bare `Optional<T>` (TS2304 undefined name).
        let ctx = TsEmitCtx::new();
        let opt = node(
            1,
            NodeKind::TypeNamed {
                path: type_path(&["Optional"]),
                args: vec![node(
                    2,
                    NodeKind::TypeNamed {
                        path: type_path(&["String"]),
                        args: vec![],
                    },
                )],
            },
        );
        assert_eq!(ctx.type_to_ts(&opt), "BockOption<string>");
    }

    #[test]
    fn impl_self_method_typed_and_declaration_merged() {
        // Q-ts-codegen defect 1: an inherent impl method with a bare `self`
        // receiver. The AIR keeps `self` as a real param and prepends the
        // receiver at call sites. TS must (a) type `self` as the impl target
        // (no implicit `any`) and (b) declare the method on the class via a
        // merged interface so `p.sum(p)` type-checks.
        let rec = node(
            1,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Point"),
                generic_params: vec![],
                fields: vec![make_record_field("x", "Int"), make_record_field("y", "Int")],
            },
        );
        let body = block(
            20,
            vec![],
            Some(node(
                21,
                NodeKind::BinaryOp {
                    op: BinOp::Add,
                    left: Box::new(node(
                        22,
                        NodeKind::FieldAccess {
                            object: Box::new(id_node(23, "self")),
                            field: ident("x"),
                        },
                    )),
                    right: Box::new(node(
                        24,
                        NodeKind::FieldAccess {
                            object: Box::new(id_node(25, "self")),
                            field: ident("y"),
                        },
                    )),
                },
            )),
        );
        let impl_block = node(
            10,
            NodeKind::ImplBlock {
                annotations: vec![],
                trait_path: None,
                trait_args: vec![],
                target: Box::new(type_node(11, "Point")),
                generic_params: vec![],
                methods: vec![node(
                    12,
                    NodeKind::FnDecl {
                        annotations: vec![],
                        visibility: Visibility::Public,
                        is_async: false,
                        name: ident("sum"),
                        generic_params: vec![],
                        params: vec![untyped_param_node(13, "self")],
                        return_type: Some(Box::new(type_node(14, "Int"))),
                        effect_clause: vec![],
                        where_clause: vec![],
                        body: Box::new(body),
                    },
                )],
                where_clause: vec![],
            },
        );
        let out = gen(&module(vec![], vec![rec, impl_block]));
        assert!(
            out.contains("interface Point {"),
            "inherent impl should emit a declaration-merging interface, got: {out}"
        );
        assert!(
            out.contains("sum(self: Point): number;"),
            "merged interface should declare the self-typed method, got: {out}"
        );
        assert!(
            out.contains("Point.prototype.sum = function(self: Point): number {"),
            "prototype function should type the self param as the target, got: {out}"
        );
    }

    /// A plain inherent `impl` method that names `Self` in its return AND its
    /// parameter type must render `Self` as the concrete target (`Counter`), not
    /// the `this` type. Each impl method emits as a free prototype function
    /// (`Counter.prototype.m = function(...): this`), and `tsc` rejects a `this`
    /// type outside a class/interface member (TS2526). Before the P4 fix
    /// `trait_self_subst` was set only for synthesized trait *default* methods;
    /// an inherent-impl `Self` lowered to `this`. Both the merged-interface
    /// signature and the prototype function must agree, or declaration merging
    /// breaks.
    #[test]
    fn self_in_plain_impl_resolves_to_target_not_this() {
        let rec = node(
            1,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Counter"),
                generic_params: vec![],
                fields: vec![make_record_field("value", "Int")],
            },
        );
        let other_param = node(
            30,
            NodeKind::Param {
                pattern: Box::new(bind_pat(31, "other")),
                ty: Some(Box::new(node(32, NodeKind::TypeSelf))),
                default: None,
            },
        );
        let impl_block = node(
            10,
            NodeKind::ImplBlock {
                annotations: vec![],
                trait_path: None,
                trait_args: vec![],
                target: Box::new(type_node(11, "Counter")),
                generic_params: vec![],
                methods: vec![node(
                    12,
                    NodeKind::FnDecl {
                        annotations: vec![],
                        visibility: Visibility::Public,
                        is_async: false,
                        name: ident("combine"),
                        generic_params: vec![],
                        params: vec![untyped_param_node(13, "self"), other_param],
                        return_type: Some(Box::new(node(14, NodeKind::TypeSelf))),
                        effect_clause: vec![],
                        where_clause: vec![],
                        body: Box::new(block(15, vec![], None)),
                    },
                )],
                where_clause: vec![],
            },
        );
        let out = gen(&module(vec![], vec![rec, impl_block]));
        assert!(
            !out.contains(": this"),
            "Self must not lower to the `this` type (TS2526), got: {out}"
        );
        assert!(
            out.contains("combine(self: Counter, other: Counter): Counter;"),
            "merged interface should render Self as the target in param & return, got: {out}"
        );
        assert!(
            out.contains(
                "Counter.prototype.combine = function(self: Counter, other: Counter): Counter {"
            ),
            "prototype function should render Self as the target in param & return, got: {out}"
        );
    }

    #[test]
    fn optional_runtime_prelude_and_value_type_agree() {
        // Q-ts-codegen defect 2: the Optional *type* and *value* must agree.
        // A function returning `Int?` gets `BockOption<number>`, the prelude
        // type is emitted, and `Some`/`None` lower to the matching tagged
        // objects.
        let body = block(
            20,
            vec![],
            Some(node(
                21,
                NodeKind::Call {
                    callee: Box::new(id_node(22, "Some")),
                    args: vec![AirArg {
                        label: None,
                        value: int_lit(23, "7"),
                    }],
                    type_args: vec![],
                },
            )),
        );
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("pick"),
                generic_params: vec![],
                params: vec![],
                return_type: Some(Box::new(node(
                    2,
                    NodeKind::TypeOptional {
                        inner: Box::new(type_node(3, "Int")),
                    },
                ))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(
            out.contains("type BockOption<T> ="),
            "Optional runtime type prelude should be emitted, got: {out}"
        );
        assert!(
            out.contains("): BockOption<number> {"),
            "Optional return type should be BockOption<number>, got: {out}"
        );
        assert!(
            out.contains("{ _tag: \"Some\" as const, _0: 7 }"),
            "Some should lower to the matching tagged-object value, got: {out}"
        );
    }

    /// A `match` whose scrutinee is a call (not a bare identifier) must hoist it
    /// into a single `const __matchN = …;`. Re-emitting the call inline at the
    /// switch head and in each payload binding both double-evaluated it and
    /// defeated TS discriminated-union narrowing (TS2339 on `_0`, since
    /// `f()._0` is a fresh, un-narrowed expression). The hoisted temp is a
    /// stable reference TS narrows correctly.
    #[test]
    fn match_call_scrutinee_hoisted_to_temp() {
        // match f() { Some(x) => x; None => 0 }
        let scrutinee = node(
            10,
            NodeKind::Call {
                callee: Box::new(id_node(11, "f")),
                args: vec![],
                type_args: vec![],
            },
        );
        let some_arm = node(
            20,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    21,
                    NodeKind::ConstructorPat {
                        path: type_path(&["Some"]),
                        fields: vec![bind_pat(22, "x")],
                    },
                )),
                guard: None,
                body: Box::new(block(23, vec![], Some(id_node(24, "x")))),
            },
        );
        let none_arm = node(
            30,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    31,
                    NodeKind::ConstructorPat {
                        path: type_path(&["None"]),
                        fields: vec![],
                    },
                )),
                guard: None,
                body: Box::new(block(32, vec![], Some(int_lit(33, "0")))),
            },
        );
        let match_stmt = node(
            40,
            NodeKind::Match {
                scrutinee: Box::new(scrutinee),
                arms: vec![some_arm, none_arm],
            },
        );
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("run"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(2, vec![match_stmt], None)),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(
            out.contains("const __match1 = f();"),
            "call scrutinee should be hoisted to a temp, got: {out}"
        );
        assert!(
            out.contains("switch (__match1._tag)"),
            "switch should dispatch on the hoisted temp, got: {out}"
        );
        assert!(
            out.contains(
                r#"const x = (__match1 as Extract<typeof __match1, { _tag: "Some" }>)._0;"#
            ),
            "payload binding should read the hoisted temp through the narrowing cast, got: {out}"
        );
        // The call must not be re-emitted inline (single evaluation).
        assert!(
            !out.contains("f()._tag") && !out.contains("f()._0"),
            "call scrutinee must not be re-emitted inline, got: {out}"
        );
    }

    /// Q-match-exprpos (P4): an expression-position value `match` over a bare
    /// identifier, bound into a typed `let`. The IIFE arrow is annotated with the
    /// binding type (`(() : boolean => …)()`), and the bare scrutinee is hoisted
    /// into a temp (`const __matchN = n; switch (__matchN) …`) so the `switch`
    /// narrows the temp — not the original `n`. Without the hoist, `switch (n)`
    /// narrows `n` to the case literal inside each arm, so an arm body
    /// re-referencing `n` (`n === <other-literal>`) trips TS2367.
    #[test]
    fn expr_position_value_match_hoists_scrutinee_and_annotates_iife() {
        // let flag: Bool = match n { 0 => n; _ => n }   (in a fn returning Int)
        let zero_arm = node(
            20,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    21,
                    NodeKind::LiteralPat {
                        lit: Literal::Int("0".into()),
                    },
                )),
                guard: None,
                body: Box::new(block(22, vec![], Some(id_node(23, "n")))),
            },
        );
        let default_arm = node(
            30,
            NodeKind::MatchArm {
                pattern: Box::new(node(31, NodeKind::WildcardPat)),
                guard: None,
                body: Box::new(block(32, vec![], Some(id_node(33, "n")))),
            },
        );
        let m = node(
            40,
            NodeKind::Match {
                scrutinee: Box::new(id_node(41, "n")),
                arms: vec![zero_arm, default_arm],
            },
        );
        let let_flag = node(
            50,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(51, "flag")),
                ty: Some(Box::new(type_node(52, "Int"))),
                value: Box::new(m),
            },
        );
        let f = node(
            1,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("classify"),
                generic_params: vec![],
                params: vec![{
                    node(
                        2,
                        NodeKind::Param {
                            pattern: Box::new(bind_pat(3, "n")),
                            ty: Some(Box::new(type_node(4, "Int"))),
                            default: None,
                        },
                    )
                }],
                return_type: Some(Box::new(type_node(5, "Int"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(6, vec![let_flag], Some(id_node(7, "flag")))),
            },
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(
            out.contains("(() : number => {"),
            "value-position match IIFE arrow should be annotated with the binding type, got: {out}"
        );
        assert!(
            out.contains("const __match1 = n;"),
            "bare-identifier scrutinee must be hoisted in expression position, got: {out}"
        );
        assert!(
            out.contains("switch (__match1)"),
            "switch should dispatch on the hoisted temp, not the original binding, got: {out}"
        );
    }

    // ── Generic impl interface-merge (DV12 / P1-b2) ───────────────────────────

    fn generic_param(id: u32, name: &str) -> GenericParam {
        GenericParam {
            id,
            span: span(),
            name: ident(name),
            bounds: vec![],
        }
    }

    fn named_type(id: u32, name: &str) -> AIRNode {
        node(
            id,
            NodeKind::TypeNamed {
                path: type_path(&[name]),
                args: vec![],
            },
        )
    }

    /// `record Box[T] { value: T }`.
    fn generic_box_record() -> AIRNode {
        node(
            10,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                name: ident("Box"),
                generic_params: vec![generic_param(11, "T")],
                fields: vec![bock_ast::RecordDeclField {
                    id: 12,
                    span: span(),
                    name: ident("value"),
                    ty: TypeExpr::Named {
                        id: 13,
                        span: span(),
                        path: type_path(&["T"]),
                        args: vec![],
                    },
                    default: None,
                }],
            },
        )
    }

    /// `impl Box { fn get(self) -> T { return self.value } }`.
    fn generic_box_impl() -> AIRNode {
        let self_param = node(
            20,
            NodeKind::Param {
                pattern: Box::new(bind_pat(21, "self")),
                ty: None,
                default: None,
            },
        );
        let body = block(
            22,
            vec![],
            Some(node(
                23,
                NodeKind::Return {
                    value: Some(Box::new(node(
                        24,
                        NodeKind::FieldAccess {
                            object: Box::new(id_node(25, "self")),
                            field: ident("value"),
                        },
                    ))),
                },
            )),
        );
        let method = node(
            26,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("get"),
                generic_params: vec![],
                params: vec![self_param],
                return_type: Some(Box::new(named_type(27, "T"))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        node(
            30,
            NodeKind::ImplBlock {
                annotations: vec![],
                generic_params: vec![],
                trait_path: None,
                trait_args: vec![],
                target: Box::new(named_type(31, "Box")),
                where_clause: vec![],
                methods: vec![method],
            },
        )
    }

    #[test]
    fn generic_impl_merges_onto_generic_class() {
        // `impl Box { ... }` for `record Box[T]` must declaration-merge onto the
        // generic class: `interface Box<T>`, `self: Box<T>`, and a prototype
        // function that re-declares `<T>` (it lives outside the class scope).
        let out = gen(&module(
            vec![],
            vec![generic_box_record(), generic_box_impl()],
        ));
        assert!(
            out.contains("interface Box<T> {"),
            "merged interface should carry `<T>`, got: {out}"
        );
        assert!(
            out.contains("get(self: Box<T>): T;"),
            "interface signature should type `self` as `Box<T>`, got: {out}"
        );
        assert!(
            out.contains("Box.prototype.get = function<T>(self: Box<T>): T {"),
            "prototype function should re-declare `<T>` and reference `Box.prototype`, got: {out}"
        );
    }

    #[test]
    fn method_colliding_with_field_is_disambiguated() {
        // trait Error { fn message(self) -> String }
        let trait_decl = node(
            1,
            NodeKind::TraitDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_platform: false,
                name: ident("Error"),
                generic_params: vec![],
                associated_types: vec![],
                methods: vec![node(
                    2,
                    NodeKind::FnDecl {
                        annotations: vec![],
                        visibility: Visibility::Public,
                        is_async: false,
                        name: ident("message"),
                        generic_params: vec![],
                        params: vec![typed_param_node(3, "self", "Error")],
                        return_type: Some(Box::new(node(
                            4,
                            NodeKind::TypeNamed {
                                path: type_path(&["String"]),
                                args: vec![],
                            },
                        ))),
                        effect_clause: vec![],
                        where_clause: vec![],
                        body: Box::new(block(5, vec![], None)),
                    },
                )],
            },
        );
        // record SimpleError { message: String }
        let record_decl = node(
            10,
            NodeKind::RecordDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("SimpleError"),
                generic_params: vec![],
                fields: vec![make_record_field("message", "String")],
            },
        );
        // impl Error for SimpleError { fn message(self) -> String { self.message } }
        let method = node(
            20,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: false,
                name: ident("message"),
                generic_params: vec![],
                params: vec![typed_param_node(21, "self", "SimpleError")],
                return_type: Some(Box::new(node(
                    22,
                    NodeKind::TypeNamed {
                        path: type_path(&["String"]),
                        args: vec![],
                    },
                ))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(
                    23,
                    vec![],
                    Some(node(
                        24,
                        NodeKind::FieldAccess {
                            object: Box::new(id_node(25, "self")),
                            field: ident("message"),
                        },
                    )),
                )),
            },
        );
        let impl_block = node(
            30,
            NodeKind::ImplBlock {
                annotations: vec![],
                target: Box::new(node(
                    31,
                    NodeKind::TypeNamed {
                        path: type_path(&["SimpleError"]),
                        args: vec![],
                    },
                )),
                trait_path: Some(type_path(&["Error"])),
                trait_args: vec![],
                generic_params: vec![],
                where_clause: vec![],
                methods: vec![method],
            },
        );
        // fn read(e: SimpleError) -> String { e.message() }
        let read_fn = node(
            40,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                is_async: false,
                name: ident("read"),
                generic_params: vec![],
                params: vec![typed_param_node(41, "e", "SimpleError")],
                return_type: Some(Box::new(node(
                    42,
                    NodeKind::TypeNamed {
                        path: type_path(&["String"]),
                        args: vec![],
                    },
                ))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(block(
                    43,
                    vec![],
                    Some(node(
                        44,
                        NodeKind::Call {
                            callee: Box::new(node(
                                45,
                                NodeKind::FieldAccess {
                                    // The lowerer reuses the *same* receiver node
                                    // in both the field-access object and the self
                                    // arg; `desugared_self_call` keys on the shared
                                    // NodeId, so the test must too.
                                    object: Box::new(id_node(46, "e")),
                                    field: ident("message"),
                                },
                            )),
                            type_args: vec![],
                            args: vec![AirArg {
                                label: None,
                                value: id_node(46, "e"),
                            }],
                        },
                    )),
                )),
            },
        );
        let out = gen(&module(
            vec![],
            vec![trait_decl, record_decl, impl_block, read_fn],
        ));
        // The class field stays `message`.
        assert!(
            out.contains("message: string;"),
            "class field should remain `message: string`, got: {out}"
        );
        // The trait interface, merged interface, and prototype rename to
        // `messageMethod` so the field and method no longer share an identifier.
        assert!(
            out.contains("messageMethod(self: Error): string;"),
            "trait interface should declare `messageMethod`, got: {out}"
        );
        assert!(
            out.contains("messageMethod(self: SimpleError): string;"),
            "merged interface should declare `messageMethod`, got: {out}"
        );
        assert!(
            out.contains("SimpleError.prototype.messageMethod = "),
            "prototype method should be `messageMethod`, got: {out}"
        );
        // Call site renamed; field read in the body untouched.
        assert!(
            out.contains(".messageMethod(e)"),
            "call site should be `.messageMethod(e)`, got: {out}"
        );
        assert!(
            out.contains("return self.message;"),
            "method body should read the field `self.message`, got: {out}"
        );
        // No bare duplicate-identifier `message(...)` method declaration remains.
        assert!(
            !out.contains("message(self: SimpleError)"),
            "must NOT emit a `message(...)` method colliding with the field, got: {out}"
        );
    }

    /// `fn f() { let x = if (c) { 1 } else { return 0 }  x }` — value-position
    /// `if` with a diverging else. The shared value-CF hoist lowers it to a
    /// declare-then-assign temp, never `/* unsupported */` or an IIFE capturing
    /// the `return`.
    fn diverging_value_if_fn() -> AIRNode {
        let then_b = block(2, vec![], Some(int_lit(3, "1")));
        let ret = node(
            5,
            NodeKind::Return {
                value: Some(Box::new(int_lit(6, "0"))),
            },
        );
        let else_b = block(4, vec![], Some(ret));
        let if_node = node(
            1,
            NodeKind::If {
                let_pattern: None,
                condition: Box::new(id_node(7, "c")),
                then_block: Box::new(then_b),
                else_block: Some(Box::new(else_b)),
            },
        );
        let let_x = node(
            10,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(11, "x")),
                ty: None,
                value: Box::new(if_node),
            },
        );
        let body = block(20, vec![let_x], Some(id_node(21, "x")));
        let f = node(
            30,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident("f"),
                generic_params: vec![],
                params: vec![],
                return_type: None,
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        );
        module(vec![], vec![f])
    }

    #[test]
    fn diverging_value_if_hoists_to_stmt_form_no_iife() {
        let out = gen(&diverging_value_if_fn());
        assert!(
            !out.contains("/* unsupported */"),
            "diverging value-if must not emit `/* unsupported */`, got: {out}"
        );
        assert!(
            out.contains("bockCf0 = 1"),
            "value arm must assign the temp, got: {out}"
        );
        assert!(
            out.contains("return 0"),
            "diverging arm must keep its return (not wrapped in an IIFE), got: {out}"
        );
    }

    // ── ts codegen fixes (examples audit): guard-let, let-shadow, `?` ─────────

    /// `let [mut] name = value` binding node.
    fn ts_let_binding(id: u32, name: &str, is_mut: bool, value: AIRNode) -> AIRNode {
        node(
            id,
            NodeKind::LetBinding {
                is_mut,
                pattern: Box::new(node(
                    id + 1,
                    NodeKind::BindPat {
                        name: ident(name),
                        is_mut,
                    },
                )),
                ty: None,
                value: Box::new(value),
            },
        )
    }

    /// A no-arg call `callee()`.
    fn ts_call(id: u32, callee: &str) -> AIRNode {
        node(
            id,
            NodeKind::Call {
                callee: Box::new(id_node(id + 1, callee)),
                args: vec![],
                type_args: vec![],
            },
        )
    }

    /// A `fn name(params) -> ret { body }` declaration. `ret` is an optional
    /// return-type name (e.g. `Result`/`Optional`) for the `?` lowering tests.
    fn ts_fn_decl(
        id: u32,
        name: &str,
        params: Vec<AIRNode>,
        ret: Option<&str>,
        body: AIRNode,
    ) -> AIRNode {
        node(
            id,
            NodeKind::FnDecl {
                annotations: vec![],
                visibility: Visibility::Private,
                is_async: false,
                name: ident(name),
                generic_params: vec![],
                params,
                return_type: ret.map(|r| Box::new(type_node(id + 500, r))),
                effect_clause: vec![],
                where_clause: vec![],
                body: Box::new(body),
            },
        )
    }

    #[test]
    fn ts_guard_let_binds_into_enclosing_scope() {
        // fn run() { guard (let Ok(guess) = parse()) else { return }; guess }
        let guard = node(
            10,
            NodeKind::Guard {
                let_pattern: Some(Box::new(node(
                    11,
                    NodeKind::ConstructorPat {
                        path: type_path(&["Ok"]),
                        fields: vec![bind_pat(12, "guess")],
                    },
                ))),
                condition: Box::new(ts_call(13, "parse")),
                else_block: Box::new(block(
                    15,
                    vec![node(16, NodeKind::Return { value: None })],
                    None,
                )),
            },
        );
        let body = block(2, vec![guard], Some(id_node(20, "guess")));
        let out = gen(&module(
            vec![],
            vec![ts_fn_decl(1, "run", vec![], None, body)],
        ));
        // The guard evaluates the scrutinee once into a temp, tests the Ok tag,
        // diverges in the else, then binds `guess` into the enclosing scope.
        assert!(
            out.contains("const __guard1 = parse();"),
            "guard-let must evaluate the scrutinee once into a temp, got: {out}"
        );
        assert!(
            out.contains("if (!(__guard1._tag === \"Ok\"))"),
            "guard-let must test the pattern's tag, got: {out}"
        );
        assert!(
            out.contains("const guess = __guard1._0;"),
            "guard-let must bind the payload into the enclosing scope, got: {out}"
        );
        // The binding must NOT be inside the else block (it follows the guard).
        let bind_pos = out.find("const guess = __guard1._0;").unwrap();
        let else_pos = out.find("if (!(__guard1._tag").unwrap();
        assert!(
            bind_pos > else_pos,
            "the binding must follow the guard's else, got: {out}"
        );
    }

    #[test]
    fn ts_let_shadow_rebinds_to_assignment_not_redeclaration() {
        // fn run() { let acc = 1; let acc = 2; let acc = 3 }
        let body = block(
            2,
            vec![
                ts_let_binding(10, "acc", false, int_lit(11, "1")),
                ts_let_binding(20, "acc", false, int_lit(21, "2")),
                ts_let_binding(30, "acc", false, int_lit(31, "3")),
            ],
            None,
        );
        let out = gen(&module(
            vec![],
            vec![ts_fn_decl(1, "run", vec![], None, body)],
        ));
        // First binding declares `let` (re-bound later), subsequent ones assign.
        assert!(
            out.contains("let acc = 1;"),
            "first re-bound `let` should declare with `let`, got: {out}"
        );
        assert!(
            out.contains("acc = 2;") && out.contains("acc = 3;"),
            "later bindings should be plain assignments, got: {out}"
        );
        assert!(
            !out.contains("const acc"),
            "a re-bound binding must not emit `const acc` (TS2451), got: {out}"
        );
        // Exactly one declaration of `acc` (the first `let`); no redeclaration.
        assert_eq!(
            out.matches("let acc").count(),
            1,
            "exactly one `let acc` declaration expected, got: {out}"
        );
    }

    #[test]
    fn ts_let_shadow_of_param_lowers_to_assignment() {
        // fn run(x: Int) { let x = 1; x }  — `let x` shadows the param in the
        // same TS block scope, so it must be an assignment, not a redeclaration.
        let body = block(
            2,
            vec![ts_let_binding(10, "x", false, int_lit(11, "1"))],
            Some(id_node(20, "x")),
        );
        let f = ts_fn_decl(1, "run", vec![typed_param_node(3, "x", "Int")], None, body);
        let out = gen(&module(vec![], vec![f]));
        assert!(
            out.contains("x = 1;") && !out.contains("const x = 1;") && !out.contains("let x = 1;"),
            "a `let` shadowing a param must lower to assignment, got: {out}"
        );
    }

    #[test]
    fn ts_propagate_let_unwraps_and_early_returns_result() {
        // fn run() -> Result { let v = f()?; v }
        let body = block(
            2,
            vec![ts_let_binding(
                10,
                "v",
                false,
                node(
                    12,
                    NodeKind::Propagate {
                        expr: Box::new(ts_call(13, "f")),
                    },
                ),
            )],
            Some(id_node(20, "v")),
        );
        let out = gen(&module(
            vec![],
            vec![ts_fn_decl(1, "run", vec![], Some("Result"), body)],
        ));
        assert!(
            out.contains("const __prop1 = f();"),
            "`?` must evaluate the inner once into a temp, got: {out}"
        );
        // Result return → single `Err` discriminant (preserves TS narrowing).
        assert!(
            out.contains("if (__prop1._tag === \"Err\") {"),
            "`?` in a Result fn must guard on the Err tag, got: {out}"
        );
        assert!(
            out.contains("return __prop1 as never;"),
            "`?` must early-return the failure container, got: {out}"
        );
        assert!(
            out.contains("const v = __prop1._0;"),
            "`?` must bind the unwrapped payload, got: {out}"
        );
    }

    #[test]
    fn ts_propagate_optional_guards_on_none_tag() {
        // fn run() -> Optional { let v = f()?; v }
        let body = block(
            2,
            vec![ts_let_binding(
                10,
                "v",
                false,
                node(
                    12,
                    NodeKind::Propagate {
                        expr: Box::new(ts_call(13, "f")),
                    },
                ),
            )],
            Some(id_node(20, "v")),
        );
        let out = gen(&module(
            vec![],
            vec![ts_fn_decl(1, "run", vec![], Some("Optional"), body)],
        ));
        assert!(
            out.contains("if (__prop1._tag === \"None\") {"),
            "`?` in an Optional fn must guard on the None tag, got: {out}"
        );
    }

    #[test]
    fn ts_propagate_bare_statement_early_returns_discards_payload() {
        // fn run() -> Result { f()?; g() }  — the `?` value is discarded but the
        // early-return guard still fires.
        let body = block(
            2,
            vec![node(
                10,
                NodeKind::Propagate {
                    expr: Box::new(ts_call(11, "f")),
                },
            )],
            Some(ts_call(20, "g")),
        );
        let out = gen(&module(
            vec![],
            vec![ts_fn_decl(1, "run", vec![], Some("Result"), body)],
        ));
        assert!(
            out.contains("const __prop1 = f();"),
            "a bare `expr?` statement must still hoist + guard, got: {out}"
        );
        assert!(
            out.contains("if (__prop1._tag === \"Err\") {")
                && out.contains("return __prop1 as never;"),
            "a bare `expr?` must early-return on failure, got: {out}"
        );
        // The discarded payload is not bound to anything.
        assert!(
            !out.contains("__prop1._0"),
            "a bare `expr?` discards the payload (no `._0` use), got: {out}"
        );
    }

    /// A list-pattern `match` (`[] / [only] / [first, ..rest]`) must route to the
    /// if-chain (the shared recogniser now flags `ListPat`), with a length test
    /// per arm and positional element / `..rest` slice binds — not the broken
    /// `switch` fast-path (duplicate `default:`, unbound `only`/`first`/`rest`).
    #[test]
    fn ts_list_pattern_match_lowers_to_ifchain_with_binds() {
        // match items { [] => "e"; [only] => only?; [first, ..rest] => first? }
        let empty_arm = node(
            20,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    21,
                    NodeKind::ListPat {
                        elems: vec![],
                        rest: None,
                    },
                )),
                guard: None,
                body: Box::new(block(22, vec![], Some(str_lit(23, "empty")))),
            },
        );
        let single_arm = node(
            30,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    31,
                    NodeKind::ListPat {
                        elems: vec![bind_pat(32, "only")],
                        rest: None,
                    },
                )),
                guard: None,
                body: Box::new(block(33, vec![], Some(id_node(34, "only")))),
            },
        );
        let head_rest_arm = node(
            40,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    41,
                    NodeKind::ListPat {
                        elems: vec![bind_pat(42, "first")],
                        rest: Some(Box::new(bind_pat(43, "rest"))),
                    },
                )),
                guard: None,
                body: Box::new(block(44, vec![], Some(id_node(45, "first")))),
            },
        );
        // A trailing wildcard keeps `[first, ..rest]` a *conditional* arm so its
        // `length >= 1` test is emitted (a final arm becomes the bare `else`).
        let else_arm = node(
            46,
            NodeKind::MatchArm {
                pattern: Box::new(node(47, NodeKind::WildcardPat)),
                guard: None,
                body: Box::new(block(48, vec![], Some(str_lit(49, "other")))),
            },
        );
        let match_stmt = node(
            50,
            NodeKind::Match {
                scrutinee: Box::new(id_node(51, "items")),
                arms: vec![empty_arm, single_arm, head_rest_arm, else_arm],
            },
        );
        let f = ts_fn_decl(
            1,
            "describeList",
            vec![typed_param_node(2, "items", "Array")],
            Some("String"),
            block(3, vec![match_stmt], None),
        );
        let out = gen(&module(vec![], vec![f]));
        // No switch fast-path; arms are an if/else-if chain.
        assert!(
            !out.contains("switch ("),
            "list-pattern match must not use the switch fast-path, got: {out}"
        );
        // Empty list: exact-length test.
        assert!(
            out.contains(".length === 0"),
            "`[]` arm should test length === 0, got: {out}"
        );
        // `[only]`: length 1 and binds `only`.
        assert!(
            out.contains(".length === 1") && out.contains("const only = "),
            "`[only]` should test length === 1 and bind `only`, got: {out}"
        );
        // `[first, ..rest]`: length >= 1, binds `first` and `rest` (a slice).
        assert!(
            out.contains(".length >= 1"),
            "`[first, ..rest]` should test length >= 1, got: {out}"
        );
        assert!(
            out.contains("const first = ")
                && out.contains("const rest = ")
                && out.contains(".slice(1)"),
            "`[first, ..rest]` should bind `first` and a `rest = …slice(1)`, got: {out}"
        );
    }

    /// A range-pattern `match` (`1..10`, `100..=200`) must route to the if-chain
    /// and emit a relational bounds test (`>= lo && < hi` exclusive, `<=` for
    /// inclusive) — not a `switch` whose every arm collapses to `default:`.
    #[test]
    fn ts_range_pattern_match_lowers_to_ifchain_with_bounds() {
        // match n { 1..10 => "a"; 10..=20 => "b"; _ => "c" }
        let lo_arm = node(
            20,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    21,
                    NodeKind::RangePat {
                        lo: Box::new(int_lit(22, "1")),
                        hi: Box::new(int_lit(23, "10")),
                        inclusive: false,
                    },
                )),
                guard: None,
                body: Box::new(block(24, vec![], Some(str_lit(25, "a")))),
            },
        );
        let hi_arm = node(
            30,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    31,
                    NodeKind::RangePat {
                        lo: Box::new(int_lit(32, "10")),
                        hi: Box::new(int_lit(33, "20")),
                        inclusive: true,
                    },
                )),
                guard: None,
                body: Box::new(block(34, vec![], Some(str_lit(35, "b")))),
            },
        );
        let else_arm = node(
            40,
            NodeKind::MatchArm {
                pattern: Box::new(node(41, NodeKind::WildcardPat)),
                guard: None,
                body: Box::new(block(42, vec![], Some(str_lit(43, "c")))),
            },
        );
        let match_stmt = node(
            50,
            NodeKind::Match {
                scrutinee: Box::new(id_node(51, "n")),
                arms: vec![lo_arm, hi_arm, else_arm],
            },
        );
        let f = ts_fn_decl(
            1,
            "classifyRange",
            vec![typed_param_node(2, "n", "Int")],
            Some("String"),
            block(3, vec![match_stmt], None),
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(
            !out.contains("switch ("),
            "range-pattern match must not use the switch fast-path, got: {out}"
        );
        // Exclusive `1..10` → `>= 1 && < 10`.
        assert!(
            out.contains(">= 1") && out.contains("< 10"),
            "`1..10` should test `>= 1 && < 10`, got: {out}"
        );
        // Inclusive `10..=20` → `>= 10 && <= 20`.
        assert!(
            out.contains(">= 10") && out.contains("<= 20"),
            "`10..=20` should test `>= 10 && <= 20`, got: {out}"
        );
    }

    /// Q-ts-match-narrowing: a constructor-pattern arm's payload binding casts
    /// the scrutinee through `Extract<typeof s, { _tag: "<variant>" }>` before
    /// reading `._N`. TS control-flow narrowing on `s._tag` reaches the arm body
    /// *only while the arm is reachable*; when an earlier statement-position
    /// `match` has every arm `return`, TS marks everything after it unreachable
    /// and stops narrowing, so a later arm's `const x = s._0` widens back to the
    /// full union payload (`T | E`) — passing it to a `T`-typed callee is TS2345.
    /// The `Extract` cast pins the binding to the matched variant's payload type
    /// regardless of reachability, so it never widens.
    #[test]
    fn ts_match_narrow_payload_binding_casts_through_extract() {
        // match r { Ok(x) => x; Err(e) => e }  over a bare identifier `r`.
        let ok_arm = node(
            20,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    21,
                    NodeKind::ConstructorPat {
                        path: type_path(&["Ok"]),
                        fields: vec![bind_pat(22, "x")],
                    },
                )),
                guard: None,
                body: Box::new(block(23, vec![], Some(id_node(24, "x")))),
            },
        );
        let err_arm = node(
            30,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    31,
                    NodeKind::ConstructorPat {
                        path: type_path(&["Err"]),
                        fields: vec![bind_pat(32, "e")],
                    },
                )),
                guard: None,
                body: Box::new(block(33, vec![], Some(id_node(34, "e")))),
            },
        );
        let match_stmt = node(
            40,
            NodeKind::Match {
                scrutinee: Box::new(id_node(41, "r")),
                arms: vec![ok_arm, err_arm],
            },
        );
        let f = ts_fn_decl(
            1,
            "run",
            vec![typed_param_node(2, "r", "Result")],
            None,
            block(3, vec![match_stmt], None),
        );
        let out = gen(&module(vec![], vec![f]));
        assert!(
            out.contains(r#"const x = (r as Extract<typeof r, { _tag: "Ok" }>)._0;"#),
            "Ok-arm payload binding should cast through Extract for narrowing, got: {out}"
        );
        assert!(
            out.contains(r#"const e = (r as Extract<typeof r, { _tag: "Err" }>)._0;"#),
            "Err-arm payload binding should cast through Extract for narrowing, got: {out}"
        );
    }

    // ── Statement-position tails must be discarded, not `return`ed ───────────
    //
    // A loop / statement-`if` / statement-`match` body's final expression is
    // discarded in Bock (these are statements, not the function's value). The
    // TS backend's `emit_block_body` had treated the tail as a function-body
    // return, so e.g. `for i in … { println(i) }` emitted
    // `for (… ) { return console.log(i); }` — the `return` aborts the function
    // on the first iteration (the loop runs once, then the fn exits). These
    // tests pin the discard behaviour for each statement context.

    /// Build a `println(<arg>)` call node.
    fn println_call(id: u32, arg: AIRNode) -> AIRNode {
        node(
            id,
            NodeKind::Call {
                callee: Box::new(id_node(id + 1, "println")),
                args: vec![AirArg {
                    label: None,
                    value: arg,
                }],
                type_args: vec![],
            },
        )
    }

    #[test]
    fn ts_for_loop_body_tail_call_is_discarded_not_returned() {
        // fn main() { for i in 1..=3 { println(i) } }
        let range = node(
            20,
            NodeKind::Range {
                lo: Box::new(int_lit(21, "1")),
                hi: Box::new(int_lit(22, "3")),
                inclusive: true,
            },
        );
        let loop_body = block(30, vec![], Some(println_call(31, id_node(33, "i"))));
        let for_loop = node(
            10,
            NodeKind::For {
                pattern: Box::new(bind_pat(11, "i")),
                iterable: Box::new(range),
                body: Box::new(loop_body),
            },
        );
        let f = ts_fn_decl(1, "main", vec![], None, block(2, vec![for_loop], None));
        let out = gen(&module(vec![], vec![f]));
        assert!(
            !out.contains("return console.log"),
            "a for-loop body's tail call must be a discarded statement, not a \
             `return` (which aborts the loop after one iteration); got:\n{out}"
        );
        assert!(
            out.contains("console.log(i);"),
            "the loop body should still emit the call as a statement; got:\n{out}"
        );
    }

    #[test]
    fn ts_while_loop_body_tail_call_is_discarded_not_returned() {
        // fn main() { while (true) { println("x") } }
        let cond = node(
            20,
            NodeKind::Literal {
                lit: Literal::Bool(true),
            },
        );
        let loop_body = block(30, vec![], Some(println_call(31, str_lit(33, "x"))));
        let while_loop = node(
            10,
            NodeKind::While {
                condition: Box::new(cond),
                body: Box::new(loop_body),
            },
        );
        let f = ts_fn_decl(1, "main", vec![], None, block(2, vec![while_loop], None));
        let out = gen(&module(vec![], vec![f]));
        assert!(
            !out.contains("return console.log"),
            "a while-loop body's tail call must be a discarded statement, not a \
             `return`; got:\n{out}"
        );
    }

    #[test]
    fn ts_statement_match_arm_tail_call_is_discarded_not_returned() {
        // fn run(r: Result) { match r { Ok(v) => println(v); Err(e) => println(e) } }
        // — the `match` is a non-tail statement (a sibling block follows), so its
        // arm tails are discarded, not returned.
        let ok_arm = node(
            20,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    21,
                    NodeKind::ConstructorPat {
                        path: type_path(&["Ok"]),
                        fields: vec![bind_pat(22, "v")],
                    },
                )),
                guard: None,
                body: Box::new(block(23, vec![], Some(println_call(24, id_node(26, "v"))))),
            },
        );
        let err_arm = node(
            30,
            NodeKind::MatchArm {
                pattern: Box::new(node(
                    31,
                    NodeKind::ConstructorPat {
                        path: type_path(&["Err"]),
                        fields: vec![bind_pat(32, "e")],
                    },
                )),
                guard: None,
                body: Box::new(block(33, vec![], Some(println_call(34, id_node(36, "e"))))),
            },
        );
        let match_stmt = node(
            40,
            NodeKind::Match {
                scrutinee: Box::new(id_node(41, "r")),
                arms: vec![ok_arm, err_arm],
            },
        );
        // A trailing statement makes the match non-tail (statement position).
        let trailer = println_call(50, str_lit(52, "done"));
        let f = ts_fn_decl(
            1,
            "run",
            vec![typed_param_node(2, "r", "Result")],
            None,
            block(3, vec![match_stmt], Some(trailer)),
        );
        let out = gen(&module(vec![], vec![f]));
        // The arm bodies must emit the call as a bare statement (`console.log(v);`),
        // never `return console.log(v);` — a `return` inside the `switch` would
        // skip the code that follows the match.
        assert!(
            out.contains("console.log(v);") && out.contains("console.log(e);"),
            "statement-position match arms should emit their tail call as a \
             discarded statement; got:\n{out}"
        );
        assert!(
            !out.contains("return console.log(v);") && !out.contains("return console.log(e);"),
            "a statement-position match arm's tail call must be a discarded \
             statement, not a `return` (which would skip the code after the \
             match); got:\n{out}"
        );
    }

    // ── Cross-module enum-variant glob import (inventory-system) ─────────────
    //
    // `module main` does `use models.*` and references a bare enum variant
    // (`Electronics`) declared in `module models`. The variant emits as the
    // value-const `Category_Electronics`, which must be imported from
    // `./models.ts`. The shared import scan keys on the value-name but the AIR
    // references the bare source name; the shared collector
    // (`generator::implicit_esm_imports_for`) now probes the variant's bare
    // source name too, so the import is produced for js and ts alike.

    /// A glob `use <path>.*` import AIR node.
    fn import_glob(id: u32, path: &[&str]) -> AIRNode {
        node(
            id,
            NodeKind::ImportDecl {
                path: bock_ast::ModulePath {
                    segments: path.iter().map(|s| ident(s)).collect(),
                    span: span(),
                },
                items: bock_ast::ImportItems::Glob,
            },
        )
    }

    #[test]
    fn ts_glob_imported_enum_variant_is_imported() {
        // module models { public enum Category { Electronics Clothing } }
        let category_enum = node(
            40,
            NodeKind::EnumDecl {
                annotations: vec![],
                visibility: Visibility::Public,
                name: ident("Category"),
                generic_params: vec![],
                variants: vec![
                    node(
                        41,
                        NodeKind::EnumVariant {
                            name: ident("Electronics"),
                            payload: EnumVariantPayload::Unit,
                        },
                    ),
                    node(
                        42,
                        NodeKind::EnumVariant {
                            name: ident("Clothing"),
                            payload: EnumVariantPayload::Unit,
                        },
                    ),
                ],
            },
        );
        let models_mod = module_with_path(&["models"], vec![], vec![category_enum]);

        // module main { use models.*  fn main() { let c = Electronics } }
        let let_c = node(
            10,
            NodeKind::LetBinding {
                is_mut: false,
                pattern: Box::new(bind_pat(11, "c")),
                ty: None,
                value: Box::new(id_node(12, "Electronics")),
            },
        );
        let main_fn = ts_fn_decl(1, "main", vec![], None, block(2, vec![let_c], None));
        let main_mod =
            module_with_path(&["main"], vec![import_glob(5, &["models"])], vec![main_fn]);

        let gen = TsGenerator::new();
        let out = gen
            .generate_project(&[
                (&main_mod, std::path::Path::new("src/main.bock")),
                (&models_mod, std::path::Path::new("src/models.bock")),
            ])
            .unwrap();
        let main_file = out
            .files
            .iter()
            .find(|f| f.path == std::path::Path::new("main.ts"))
            .expect("main.ts emitted");
        assert!(
            main_file.content.contains("Category_Electronics")
                && main_file.content.contains(r#"from "./models.ts""#),
            "main.ts must import the glob-referenced enum variant \
             `Category_Electronics` from ./models.ts; got:\n{}",
            main_file.content
        );
        // It must be a value import (not `import type`) — the variant is a const.
        assert!(
            !main_file
                .content
                .contains("import type { Category_Electronics"),
            "the variant const must be a value import, not `import type`; got:\n{}",
            main_file.content
        );
    }
}
