Skip to main content

bock_codegen/
ts.rs

1//! TypeScript code generator — rule-based (Tier 2) transpilation from AIR to TS.
2//!
3//! Extends the JavaScript codegen with:
4//! - Type annotations on parameters, return types, and bindings
5//! - Generics → TS generics (preserved, not erased)
6//! - Traits → TS interfaces
7//! - Algebraic types → discriminated union types + tagged objects
8//! - Type aliases → `type X = ...`
9
10use std::collections::{HashMap, HashSet};
11use std::fmt::Write;
12use std::path::PathBuf;
13
14use bock_air::{AIRNode, AirInterpolationPart, EnumVariantPayload, NodeKind, ResultVariant};
15use bock_ast::{AssignOp, BinOp, Literal, TypeExpr, UnaryOp, Visibility};
16use bock_errors::Span;
17use bock_types::AIRModule;
18
19use crate::error::CodegenError;
20use crate::generator::{CodeGenerator, GeneratedCode, OutputFile, SourceMap, SourceMapping};
21use crate::profile::TargetProfile;
22
23/// Runtime helpers injected when `Channel` / `spawn` appear in a module.
24/// See the analogous `CONCURRENCY_RUNTIME_JS` in `js.rs`.
25/// Conservative module scan — if the serialized AIR mentions `Channel`
26/// or `spawn`, emit the runtime prelude. Unused helpers are trivially
27/// dead-code eliminated by downstream TS tooling.
28fn module_uses_concurrency(items: &[AIRNode]) -> bool {
29    items.iter().any(|n| {
30        let s = format!("{n:?}");
31        s.contains("\"Channel\"") || s.contains("\"spawn\"")
32    })
33}
34
35/// Runtime type for Bock `Optional[T]` in TypeScript. The *value*
36/// representation is a tagged object — `{ _tag: "Some", _0: v }` or
37/// `{ _tag: "None" }` (see [`TsEmitCtx::try_emit_prelude_ctor`] and the `None`
38/// identifier in [`TsEmitCtx::emit_expr`]) — so the *type* must be the matching
39/// discriminated union, not `T | null`. This mirrors the Go `__bockOption`
40/// runtime added in the codegen-correctness workstream: type and value agree,
41/// a `match` lowered to `switch (x._tag)` narrows correctly, and the two-variant
42/// union is provably exhaustive (so a `string`-returning match needs no
43/// `default`).
44const OPTIONAL_RUNTIME_TS: &str = "\
45// ── Bock Optional runtime ──
46type BockOption<T> =
47  | { readonly _tag: \"Some\"; readonly _0: T }
48  | { readonly _tag: \"None\" };
49";
50
51/// True if the module references `Optional`, `Some`, or `None` anywhere — or
52/// calls `pop`, whose DQ30 lowering produces a `BockOption<T>`-typed IIFE —
53/// so the Optional runtime type prelude must be emitted. A cheap structural
54/// scan over the debug rendering, mirroring [`module_uses_concurrency`] and
55/// the Go backend's `go_module_uses_optional`. Over-matching (e.g. a user
56/// method named `pop`) only emits/imports an unused type alias, which is
57/// harmless under the scaffolded tsconfig.
58fn module_uses_optional(items: &[AIRNode]) -> bool {
59    items.iter().any(|n| {
60        let s = format!("{n:?}");
61        s.contains("\"Optional\"")
62            || s.contains("TypeOptional")
63            || s.contains("\"Some\"")
64            || s.contains("\"None\"")
65            || s.contains("\"pop\"")
66    })
67}
68
69/// Runtime type for Bock `Result[T, E]` in TypeScript. The value representation
70/// is a tagged object — `{ _tag: "Ok", _0: v }` or `{ _tag: "Err", _0: e }`
71/// (see [`TsEmitCtx::try_emit_prelude_ctor`], the `ResultConstruct` arm, and the
72/// `Result`-match lowering) — so the type is the matching discriminated union,
73/// not the structural `Ok`/`Err` aliases that previously went undefined. Both
74/// arms carry the payload under the same `_0` key the match reads, so a `match r
75/// { Ok(v) => …; Err(e) => … }` lowered to `switch (r._tag)` narrows correctly
76/// and the two-variant union is provably exhaustive (no `default` needed). This
77/// mirrors [`OPTIONAL_RUNTIME_TS`].
78const RESULT_RUNTIME_TS: &str = "\
79// ── Bock Result runtime ──
80type BockResult<T, E> =
81  | { readonly _tag: \"Ok\"; readonly _0: T }
82  | { readonly _tag: \"Err\"; readonly _0: E };
83";
84
85/// True if the module references `Result`, `Ok`, or `Err` anywhere, so the
86/// `Result` runtime type prelude must be emitted. Mirrors [`module_uses_optional`].
87fn module_uses_result(items: &[AIRNode]) -> bool {
88    items.iter().any(|n| {
89        let s = format!("{n:?}");
90        s.contains("\"Result\"")
91            || s.contains("ResultConstruct")
92            || s.contains("\"Ok\"")
93            || s.contains("\"Err\"")
94    })
95}
96
97const CONCURRENCY_RUNTIME_TS: &str = "\
98// ── Bock concurrency runtime ──
99type __BockChannel<T> = {
100  send(v: T): void;
101  recv(): Promise<T>;
102  close(): void;
103};
104const __bockChannelNew = <T>(): [__BockChannel<T>, __BockChannel<T>] => {
105  const queue: T[] = [];
106  const waiters: Array<(v: T) => void> = [];
107  const ch: __BockChannel<T> = {
108    send(v: T) {
109      if (waiters.length > 0) { waiters.shift()!(v); } else { queue.push(v); }
110    },
111    recv(): Promise<T> {
112      return new Promise<T>((resolve) => {
113        if (queue.length > 0) { resolve(queue.shift()!); }
114        else { waiters.push(resolve); }
115      });
116    },
117    close() {}
118  };
119  return [ch, ch];
120};
121const __bockSpawn = <T>(x: Promise<T>): Promise<T> => x;
122";
123
124/// Runtime helpers for Bock range expressions (`0..n` / `0..=n`) in TypeScript.
125/// TS has no native range value, so `for i in 0..n` lowers to
126/// `for (const i of range(0, n))`. `range` is half-open, `rangeInclusive`
127/// inclusive — matching Python's `range(lo, hi)` / `range(lo, hi + 1)` and
128/// Rust's `lo..hi` / `lo..=hi`. Emitted once into the shared `_bock_runtime.ts`
129/// (per-module path) or inlined at most once (single-module path), gated on a
130/// ctx flag (mirrors [`OPTIONAL_RUNTIME_TS`]).
131const RANGE_RUNTIME_TS: &str = "\
132// ── Bock range runtime ──
133const range = (lo: number, hi: number): number[] => { const r: number[] = []; for (let i = lo; i < hi; i++) r.push(i); return r; };
134const rangeInclusive = (lo: number, hi: number): number[] => { const r: number[] = []; for (let i = lo; i <= hi; i++) r.push(i); return r; };
135";
136
137/// True if the module references a `Range` node anywhere (so the range runtime
138/// prelude must be emitted). Mirrors [`module_uses_optional`]. `RangePat` does
139/// not contain the `Range {` substring, so match-arm range patterns do not
140/// trigger the (value-only) helpers.
141fn module_uses_range(items: &[AIRNode]) -> bool {
142    items.iter().any(|n| format!("{n:?}").contains("Range {"))
143}
144
145/// Runtime helper for DQ29 structural equality in TypeScript — the typed twin
146/// of the JS backend's `__bockEq` (see `EQ_RUNTIME_JS` in `js.rs` for the full
147/// semantics): `===` on two objects is reference identity, so every stamped
148/// `==`/`!=` (lanes `"structural"`/`"deep"`/`"generic"` of
149/// [`crate::generator::user_eq_kind`]) and every `a.eq(b)` bridge call on an
150/// `Equatable`-bounded generic lowers through this deep, order-independent
151/// (for `Map`/`Set`), explicit-impl-honoring comparison. Non-objects fall back
152/// to `===`, preserving IEEE `NaN !== NaN`. Emitted once into the shared
153/// `_bock_runtime.ts` (per-module path) or inlined at most once
154/// (single-module path), gated on a ctx flag (mirrors [`RANGE_RUNTIME_TS`]).
155const EQ_RUNTIME_TS: &str = "\
156// ── Bock structural equality runtime ──
157const __bockEq = (a: unknown, b: unknown): boolean => {
158  if (a === b) return true;
159  if (typeof a !== \"object\" || typeof b !== \"object\" || a === null || b === null) {
160    return a === b;
161  }
162  const ea = (a as { eq?: unknown }).eq;
163  const eb = (b as { eq?: unknown }).eq;
164  if (typeof ea === \"function\" && typeof eb === \"function\") {
165    return (ea as (s: unknown, o: unknown) => boolean).call(a, a, b);
166  }
167  if (Array.isArray(a)) {
168    if (!Array.isArray(b) || a.length !== b.length) return false;
169    for (let i = 0; i < a.length; i++) { if (!__bockEq(a[i], b[i])) return false; }
170    return true;
171  }
172  if (a instanceof Map) {
173    if (!(b instanceof Map) || a.size !== b.size) return false;
174    for (const [k, v] of a) {
175      if (b.has(k)) { if (!__bockEq(b.get(k), v)) return false; continue; }
176      let found = false;
177      for (const [bk, bv] of b) { if (__bockEq(k, bk) && __bockEq(v, bv)) { found = true; break; } }
178      if (!found) return false;
179    }
180    return true;
181  }
182  if (a instanceof Set) {
183    if (!(b instanceof Set) || a.size !== b.size) return false;
184    for (const x of a) {
185      if (b.has(x)) continue;
186      let found = false;
187      for (const y of b) { if (__bockEq(x, y)) { found = true; break; } }
188      if (!found) return false;
189    }
190    return true;
191  }
192  const ra = a as Record<string, unknown>;
193  const rb = b as Record<string, unknown>;
194  const ka = Object.keys(ra);
195  const kb = Object.keys(rb);
196  if (ka.length !== kb.length) return false;
197  for (const k of ka) { if (!__bockEq(ra[k], rb[k])) return false; }
198  return true;
199};
200";
201
202/// True if the module contains an equality that must lower through
203/// [`EQ_RUNTIME_TS`]'s `__bockEq` — a non-`"impl"` `user_eq` stamp or an
204/// `Equatable`-bounded bridge call. Mirrors the JS backend's scan (see
205/// `js_module_uses_eq` in `js.rs`).
206fn module_uses_eq(items: &[AIRNode]) -> bool {
207    items.iter().any(|n| {
208        let dbg = format!("{n:?}");
209        dbg.contains("\"user_eq\": String(\"structural\")")
210            || dbg.contains("\"user_eq\": String(\"deep\")")
211            || dbg.contains("\"user_eq\": String(\"deep_custom\")")
212            || dbg.contains("\"user_eq\": String(\"generic\")")
213            || dbg.contains("TraitBound:Equatable")
214    })
215}
216
217/// The structural comparison runtime: lower a bounded `T: Comparable`'s
218/// `a.compare(b)` through this so it dispatches to the instantiated type's own
219/// `compare` method when present (a record/enum carrying an `impl Comparable`),
220/// falling back to the native ternary for a primitive instantiation. Without it
221/// the bounded bridge emitted the native ternary unconditionally, so `(a) < (b)`
222/// on two RECORDS is always `false` and `(a) === (b)` is reference identity —
223/// every comparison wrongly returned `Greater`, mis-ordering `max`/`min`/sort
224/// over a user `Comparable` type. The emitted `compare` method takes
225/// `(self, other)`, so the helper calls `a.compare(a, b)`. Mirrors
226/// [`EQ_RUNTIME_TS`]'s `__bockEq`. (Q-bounded-comparable-codegen.)
227const COMPARE_RUNTIME_TS: &str = "// ── Bock structural comparison runtime ──
228type __BockOrdering = { _tag: \"Less\" } | { _tag: \"Equal\" } | { _tag: \"Greater\" };
229const __bockCompare = (a: any, b: any): __BockOrdering => {
230  if (a !== null && typeof a === \"object\" && typeof a.compare === \"function\") {
231    return a.compare(a, b);
232  }
233  return (a < b ? { _tag: \"Less\" } : (a === b ? { _tag: \"Equal\" } : { _tag: \"Greater\" }));
234};
235";
236
237/// True if the module contains a bounded `T: Comparable` `compare` bridge call,
238/// so [`COMPARE_RUNTIME_TS`]'s `__bockCompare` must be emitted. Mirrors
239/// [`module_uses_eq`].
240fn module_uses_compare(items: &[AIRNode]) -> bool {
241    items.iter().any(|n| {
242        let dbg = format!("{n:?}");
243        dbg.contains("TraitBound:Comparable")
244    })
245}
246
247/// The display-string runtime: lower a `${expr}` interpolation part through this
248/// so a user value with a `Displayable` impl (its emitted `to_string` method) is
249/// rendered via that method, not the structural `[object Object]`. Primitives /
250/// arrays / plain objects fall back to native `String(x)`. The user `to_string`
251/// is `T.prototype.to_string = function(self) { … }` (snake_case, distinct from
252/// JS `toString`), so the helper detects it by `typeof x.to_string ===
253/// "function"` and calls `x.to_string(x)`. Mirrors `STR_RUNTIME_JS` in `js.rs`.
254/// (Q-displayable-interpolation-dispatch.)
255const STR_RUNTIME_TS: &str = "// ── Bock display-string runtime ──
256const __bockStr = (x: any): string => {
257  if (x !== null && typeof x === \"object\" && typeof x.to_string === \"function\") {
258    return x.to_string(x);
259  }
260  return String(x);
261};
262";
263
264/// True if the module contains a string interpolation (`${expr}`), so
265/// [`STR_RUNTIME_TS`]'s `__bockStr` must be emitted. Mirrors [`module_uses_eq`].
266fn module_uses_str(items: &[AIRNode]) -> bool {
267    items.iter().any(|n| {
268        let dbg = format!("{n:?}");
269        dbg.contains("Interpolation")
270    })
271}
272
273/// The shared per-module runtime module name (without extension). In the
274/// per-module (native-import) emission path the Optional/Result runtime *types*
275/// (`BockOption`, `BockResult`) and the concurrency / range runtime *helpers*
276/// (`__bockChannelNew`, `range`, …) live in one file — `_bock_runtime.ts` at
277/// the build root — and every emitted module imports the names it references.
278/// A single shared definition avoids redeclaring `type BockOption` / `const
279/// range` across files (a TS redeclaration error).
280const RUNTIME_MODULE_TS: &str = "_bock_runtime";
281#[derive(Debug)]
282pub struct TsGenerator {
283    profile: TargetProfile,
284}
285
286impl TsGenerator {
287    /// Creates a new TypeScript code generator.
288    #[must_use]
289    pub fn new() -> Self {
290        Self {
291            profile: TargetProfile::typescript(),
292        }
293    }
294}
295
296impl Default for TsGenerator {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302impl CodeGenerator for TsGenerator {
303    fn target(&self) -> &TargetProfile {
304        &self.profile
305    }
306
307    fn generate_module(&self, module: &AIRModule) -> Result<GeneratedCode, CodegenError> {
308        // Shared pre-pass: hoist value-position diverging control flow (see
309        // `hoist_value_cf`) into declare-then-assign temp blocks.
310        let module =
311            &crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(module.clone()));
312        let mut ctx = TsEmitCtx::new();
313        ctx.enum_variants =
314            crate::generator::collect_enum_variants(&[(module, std::path::Path::new(""))]);
315        ctx.generic_decls =
316            crate::generator::collect_generic_decls(&[(module, std::path::Path::new(""))]);
317        ctx.trait_decls =
318            crate::generator::collect_trait_decls(&[(module, std::path::Path::new(""))]);
319        ctx.exported_types =
320            crate::generator::collect_exported_type_names(&[(module, std::path::Path::new(""))]);
321        ctx.class_fields =
322            crate::generator::collect_class_fields(&[(module, std::path::Path::new(""))]);
323        ctx.const_names =
324            crate::generator::collect_const_names(&[(module, std::path::Path::new(""))]);
325        ctx.emit_node(module)?;
326        let (content, mappings) = ctx.finish();
327        let source_map = SourceMap {
328            generated_file: String::new(),
329            mappings,
330            ..Default::default()
331        };
332        Ok(GeneratedCode {
333            files: vec![OutputFile {
334                path: PathBuf::new(),
335                content,
336                source_map: Some(source_map),
337            }],
338        })
339    }
340
341    fn entry_invocation(&self, main_is_async: bool) -> Option<String> {
342        if main_is_async {
343            Some("(async () => { await main(); })();\n".to_string())
344        } else {
345            Some("main();\n".to_string())
346        }
347    }
348
349    /// Emit a per-module **native ES-module import tree** (spec §20.6.1; DQ19
350    /// resolved): each reachable module → its own `.ts` file, cross-module
351    /// references resolved with real ESM `import`/`import type`. Mirrors the JS
352    /// backend's per-module path (see [`crate::js`]) plus TS's type level:
353    /// Optional/Result runtime *types* and concurrency/range runtime *helpers*
354    /// are emitted once into a shared `_bock_runtime.ts`, imported per file. The
355    /// minimal `package.json` `{"type":"module"}` run affordance — which makes
356    /// the `.ts` source tree run under `node --experimental-strip-types` — is
357    /// emitted by the **scaffolder** in project mode (S6a / DV18), not by codegen,
358    /// so `--source-only` output is bare source.
359    ///
360    /// Output-path mapping is keyed on each module's *declared* path
361    /// (`module core.option` ⇒ `core/option.ts`, imported `"./core/option.ts"` —
362    /// ESM specifiers reference the *emitted* `.ts` source directly so node
363    /// strip-types resolves them verbatim; `tsc` accepts the `.ts` extension via
364    /// `rewriteRelativeImportExtensions`, which also rewrites it to `.js` on
365    /// emit). The entry module is always `main.ts`.
366    fn generate_project(
367        &self,
368        modules: &[(&AIRModule, &std::path::Path)],
369    ) -> Result<GeneratedCode, CodegenError> {
370        // Shared pre-pass: hoist value-position diverging control flow on every
371        // module before registry collection or emission (see `hoist_value_cf`).
372        let hoisted: Vec<(AIRModule, &std::path::Path)> = modules
373            .iter()
374            .map(|(m, p)| {
375                (
376                    crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(
377                        (*m).clone(),
378                    )),
379                    *p,
380                )
381            })
382            .collect();
383        let modules: Vec<(&AIRModule, &std::path::Path)> =
384            hoisted.iter().map(|(m, p)| (m, *p)).collect();
385        let modules = modules.as_slice();
386        // Emit only modules the entry program actually `use`s (plus the entry
387        // itself), dependency-ordered — never the prelude-only stdlib.
388        let reachable = crate::generator::reachable_modules(modules);
389        let modules = reachable.as_slice();
390        if modules.is_empty() {
391            return Ok(GeneratedCode { files: vec![] });
392        }
393
394        let entry_idx = modules
395            .iter()
396            .position(|(m, _)| crate::generator::module_declares_main_fn(m))
397            .unwrap_or(modules.len() - 1);
398
399        let enum_variants = crate::generator::collect_enum_variants(modules);
400        let generic_decls = crate::generator::collect_generic_decls(modules);
401        let trait_decls = crate::generator::collect_trait_decls(modules);
402        let exported_types = crate::generator::collect_exported_type_names(modules);
403        let record_names = crate::generator::collect_record_names(modules);
404        let class_fields = crate::generator::collect_class_fields(modules);
405        let const_names = crate::generator::collect_const_names(modules);
406        let public_symbols = crate::generator::collect_public_symbols_for_esm(modules);
407        // Program-wide field/method name-collision set (camelCased). Built across
408        // *all* reachable modules so a call site in `main.ts` to a renamed method
409        // declared in `core/error.ts` agrees with that declaration.
410        let mut field_method_collisions = HashSet::new();
411        for (module, _) in modules {
412            field_method_collisions.extend(crate::generator::collect_record_field_names(
413                module,
414                to_camel_case,
415            ));
416        }
417
418        let main_is_async = modules
419            .iter()
420            .any(|(m, _)| crate::generator::module_main_fn_is_async(m));
421        let invocation = self.entry_invocation(main_is_async);
422
423        let mut files: Vec<OutputFile> = Vec::with_capacity(modules.len() + 2);
424        let mut runtime_optional = false;
425        let mut runtime_result = false;
426        let mut runtime_concurrency = false;
427        let mut runtime_range = false;
428        let mut runtime_eq = false;
429        let mut runtime_compare = false;
430        let mut runtime_str = false;
431
432        for (i, (module, source_path)) in modules.iter().enumerate() {
433            let own_path = crate::generator::module_path_string(module).unwrap_or_default();
434            let mut ctx = TsEmitCtx::new();
435            ctx.per_module = true;
436            ctx.enum_variants = enum_variants.clone();
437            ctx.generic_decls = generic_decls.clone();
438            ctx.trait_decls = trait_decls.clone();
439            ctx.exported_types = exported_types.clone();
440            // Record names need the whole reachable set so a cross-module record
441            // construction lowers to `new Name(...)` (see the JS backend note).
442            ctx.record_names = record_names.clone();
443            // Class field-orders likewise: a cross-module `class` construction
444            // must lower to its positional `new Name(...)`.
445            ctx.class_fields = class_fields.clone();
446            ctx.const_names = const_names.clone();
447            ctx.field_method_collisions = field_method_collisions.clone();
448            ctx.seed_effect_registries(modules);
449            ctx.self_module_path = if i == entry_idx {
450                String::new()
451            } else {
452                own_path.clone()
453            };
454            // The shared collector now matches a glob-imported (`use models.*`)
455            // enum variant by its bare source name as well as its emitted
456            // `Enum_Variant` value-name, so the cross-module variant import is
457            // produced here for js and ts alike (no TS-local recovery needed).
458            ctx.implicit_imports =
459                crate::generator::implicit_esm_imports_for(module, &public_symbols, &own_path);
460            ctx.public_symbols = public_symbols.clone();
461            ctx.enum_variant_exports = crate::generator::enum_variant_value_names(module);
462            ctx.emit_node(module)?;
463            runtime_optional |= ctx.needs_runtime_optional;
464            runtime_result |= ctx.needs_runtime_result;
465            runtime_concurrency |= ctx.needs_runtime_concurrency;
466            runtime_range |= ctx.needs_runtime_range;
467            runtime_eq |= ctx.needs_runtime_eq;
468            runtime_compare |= ctx.needs_runtime_compare;
469            runtime_str |= ctx.needs_runtime_str;
470            let (mut content, mappings) = ctx.finish();
471
472            if i == entry_idx && crate::generator::module_declares_main_fn(module) {
473                if let Some(invoc) = invocation.as_ref() {
474                    if !content.is_empty() && !content.ends_with('\n') {
475                        content.push('\n');
476                    }
477                    content.push_str(invoc);
478                }
479            }
480
481            let out_path = self.module_output_path(module, source_path, i == entry_idx);
482            let generated_file = out_path
483                .file_name()
484                .and_then(|s| s.to_str())
485                .unwrap_or("")
486                .to_string();
487            files.push(OutputFile {
488                path: out_path,
489                content,
490                source_map: Some(SourceMap {
491                    generated_file,
492                    mappings,
493                    ..Default::default()
494                }),
495            });
496        }
497
498        // Shared runtime module: Optional/Result types and concurrency/range
499        // helpers, each `export`ed (types via `export type`, values via `export
500        // const`) so consuming modules `import` / `import type` them.
501        if runtime_optional
502            || runtime_result
503            || runtime_concurrency
504            || runtime_range
505            || runtime_eq
506            || runtime_compare
507            || runtime_str
508        {
509            let mut content = String::new();
510            if runtime_optional {
511                content.push_str(&export_runtime_decls(OPTIONAL_RUNTIME_TS));
512                content.push('\n');
513            }
514            if runtime_result {
515                content.push_str(&export_runtime_decls(RESULT_RUNTIME_TS));
516                content.push('\n');
517            }
518            if runtime_concurrency {
519                content.push_str(&export_runtime_decls(CONCURRENCY_RUNTIME_TS));
520                content.push('\n');
521            }
522            if runtime_range {
523                content.push_str(&export_runtime_decls(RANGE_RUNTIME_TS));
524                content.push('\n');
525            }
526            if runtime_compare {
527                content.push_str(&export_runtime_decls(COMPARE_RUNTIME_TS));
528                content.push('\n');
529            }
530            if runtime_str {
531                content.push_str(&export_runtime_decls(STR_RUNTIME_TS));
532                content.push('\n');
533            }
534            if runtime_eq {
535                content.push_str(&export_runtime_decls(EQ_RUNTIME_TS));
536                content.push('\n');
537            }
538            files.push(OutputFile {
539                path: PathBuf::from(format!("{RUNTIME_MODULE_TS}.ts")),
540                content,
541                source_map: Some(SourceMap {
542                    generated_file: format!("{RUNTIME_MODULE_TS}.ts"),
543                    ..Default::default()
544                }),
545            });
546        }
547
548        // Run-affordance emission moved to the project-mode scaffolder (S6a /
549        // DV18): codegen emits only the per-module `.ts` *source* tree in all
550        // modes; the `package.json` `{"type":"module"}` run affordance — which
551        // makes the `.ts` source tree run as ES modules under `node
552        // --experimental-strip-types main.ts` — is emitted by `JsScaffolder`
553        // (shared js/ts) in project mode only (never under `--source-only`). The
554        // TS-only `tsconfig.json` (with `rewriteRelativeImportExtensions`) is
555        // emitted by the TS scaffolder. See `scaffold.rs`.
556
557        Ok(GeneratedCode { files })
558    }
559
560    /// Transpile `@test` functions into a Vitest/Jest `bock.test.ts` file (S7).
561    ///
562    /// Identical in shape to the JS backend's `generate_tests` (shared via
563    /// [`crate::generator::js_ts_generate_tests`]); only the file extension and
564    /// the concrete TS emit context differ. `framework` selects Vitest (default)
565    /// vs Jest.
566    fn generate_tests(
567        &self,
568        modules: &[(&AIRModule, &std::path::Path)],
569        framework: &str,
570    ) -> Result<crate::generator::TestArtifacts, CodegenError> {
571        crate::generator::js_ts_generate_tests(
572            modules,
573            framework,
574            &self.target().conventions.file_extension,
575            // TS/ESM specifiers reference the emitted `.ts` source directly so the
576            // tree runs verbatim under `node --experimental-strip-types` (which
577            // does not rewrite `.js`→`.ts`); `tsc` accepts the `.ts` specifier via
578            // `rewriteRelativeImportExtensions` (emitted into the scaffolded
579            // tsconfig), which also rewrites it to `.js` on emit.
580            "ts",
581            |module, source_path, is_entry| self.module_output_path(module, source_path, is_entry),
582            |ctx_modules| {
583                let mut ctx = TsEmitCtx::new();
584                ctx.per_module = true;
585                ctx.enum_variants = crate::generator::collect_enum_variants(ctx_modules);
586                ctx.generic_decls = crate::generator::collect_generic_decls(ctx_modules);
587                ctx.trait_decls = crate::generator::collect_trait_decls(ctx_modules);
588                ctx.exported_types = crate::generator::collect_exported_type_names(ctx_modules);
589                ctx.record_names = crate::generator::collect_record_names(ctx_modules);
590                ctx.class_fields = crate::generator::collect_class_fields(ctx_modules);
591                ctx.const_names = crate::generator::collect_const_names(ctx_modules);
592                let mut field_method_collisions = HashSet::new();
593                for (module, _) in ctx_modules {
594                    field_method_collisions.extend(crate::generator::collect_record_field_names(
595                        module,
596                        to_camel_case,
597                    ));
598                }
599                ctx.field_method_collisions = field_method_collisions;
600                ctx.seed_effect_registries(ctx_modules);
601                Box::new(TsTestEmitter { ctx })
602            },
603        )
604    }
605}
606
607/// Adapter wrapping a TS [`TsEmitCtx`] so the shared js/ts test-file builder can
608/// drive expression lowering without depending on the private context type.
609struct TsTestEmitter {
610    ctx: TsEmitCtx,
611}
612
613impl crate::generator::JsTsExprEmitter for TsTestEmitter {
614    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
615        self.ctx.expr_to_string(node)
616    }
617}
618
619impl TsGenerator {
620    /// Output path for one module in the per-module native-import tree. The entry
621    /// module is always `main.ts`; every other module is placed at the path
622    /// mirrored from its declared module-path (`core.option` ⇒ `core/option.ts`).
623    fn module_output_path(
624        &self,
625        module: &AIRModule,
626        source_path: &std::path::Path,
627        is_entry: bool,
628    ) -> PathBuf {
629        if is_entry {
630            return crate::generator::derive_output_path(source_path, self.target());
631        }
632        match crate::generator::module_path_string(module) {
633            Some(path) if !path.is_empty() => {
634                let rel: PathBuf = path.split('.').collect();
635                rel.with_extension(&self.target().conventions.file_extension)
636            }
637            _ => crate::generator::derive_output_path(source_path, self.target()),
638        }
639    }
640}
641
642/// Rewrite a TS runtime-prelude string so each **top-level** declaration is
643/// exported: a `type NAME` line becomes `export type NAME`, a `const NAME` line
644/// becomes `export const NAME`. Used to build the shared `_bock_runtime.ts`,
645/// whose helpers/types consuming modules import. Only column-0 declarations are
646/// rewritten; indented inner lines (a helper body's local `const`) are left
647/// untouched.
648fn export_runtime_decls(runtime: &str) -> String {
649    runtime
650        .lines()
651        .map(|line| {
652            if let Some(rest) = line.strip_prefix("type ") {
653                format!("export type {rest}")
654            } else if let Some(rest) = line.strip_prefix("const ") {
655                format!("export const {rest}")
656            } else {
657                line.to_string()
658            }
659        })
660        .collect::<Vec<_>>()
661        .join("\n")
662}
663
664// ─── Emission context ────────────────────────────────────────────────────────
665
666/// Where the value produced by a statement-position control-flow expression
667/// (lowered by [`TsEmitCtx::emit_value_in_stmt_pos`]) should go. Either `return`
668/// it (the expression was a function-body tail) or assign it to a named binding
669/// (the expression initialised a `let`/`const`). A diverging tail
670/// (`return`/`break`/`continue`/`todo()`) ignores the sink.
671#[derive(Clone)]
672enum ValueSink {
673    /// `return <value>;`
674    Return,
675    /// `<name> = <value>;`
676    Assign(String),
677    /// `<value>;` — the value is discarded (the expression is a statement-
678    /// position tail: a loop body, a statement-`if`/`match` arm, etc., whose
679    /// value Bock drops). Distinct from [`ValueSink::Return`]: emitting `return`
680    /// here would abort the enclosing function (e.g. exit a `for` loop after one
681    /// iteration), so the value is emitted as a bare expression statement.
682    Discard,
683}
684
685/// Internal state for TypeScript emission.
686struct TsEmitCtx {
687    buf: String,
688    indent: usize,
689    /// Maps effect operation name → effect type name (e.g., "log" → "Logger").
690    effect_ops: HashMap<String, String>,
691    /// Maps effect type name → current handler variable name in scope.
692    current_handler_vars: HashMap<String, String>,
693    /// Maps function name → effect type names from its `with` clause.
694    fn_effects: HashMap<String, Vec<String>>,
695    /// Maps composite effect name → component effect names.
696    composite_effects: HashMap<String, Vec<String>>,
697    /// Names of records declared in this module (emitted as classes).
698    record_names: HashSet<String>,
699    /// Names of `class` declarations mapped to their **field names in
700    /// declaration order**, pre-scanned across the reachable program. A Bock
701    /// `class` emits a *positional* `constructor(a, b)` (unlike a record's
702    /// destructured `constructor({ a, b })`), so a `class` literal `T { a: x, b:
703    /// y }` must lower to `new T(x, y)` with arguments ordered by the declared
704    /// field order — not the bare object literal the record path emits (which
705    /// `tsc` rejects: the inherent/trait methods are not on an object-literal
706    /// type). See [`crate::generator::collect_class_fields`].
707    class_fields: HashMap<String, Vec<String>>,
708    /// Declared names of module-scope `const`s, pre-scanned across the reachable
709    /// program; emitted verbatim at both declaration and use so the two agree
710    /// (the `to_camel_case` transform would mangle a SCREAMING_SNAKE use site).
711    /// See [`crate::generator::collect_const_names`].
712    const_names: HashSet<String>,
713    /// Names of effects declared in this module (for typing handler vars).
714    effect_names: HashSet<String>,
715    /// 1-indexed current line in `buf`, maintained incrementally.
716    cur_line: u32,
717    /// 1-indexed current column (char count) in `buf`, maintained incrementally.
718    cur_col: u32,
719    /// Byte offset in `buf` up to which (cur_line, cur_col) is accurate.
720    scan_pos: usize,
721    /// Last (gen_line, gen_col) we recorded — avoids duplicate mappings.
722    last_marked: Option<(u32, u32)>,
723    /// Collected source-map entries (populated via [`Self::mark_span`]).
724    mappings: Vec<SourceMapping>,
725    /// Loop-label stack — see [`crate::generator::loop_needs_break_label`].
726    /// `break` inside a `switch` exits the switch, so a statement-arm `match`
727    /// that wants to `break`/`continue` an enclosing loop uses a labelled jump.
728    loop_labels: Vec<Option<String>>,
729    /// Depth of statement-arm `switch` emission; > 0 routes `break`/`continue`
730    /// to the innermost labelled loop.
731    switch_label_depth: usize,
732    /// Monotonic counter for unique loop-label names.
733    loop_label_counter: usize,
734    /// Monotonic counter for unique `match` scrutinee temporaries. A non-trivial
735    /// scrutinee (a call, etc.) is hoisted into `const __matchN = <scrutinee>;`
736    /// once, so it is evaluated a single time and — crucially for TypeScript —
737    /// the discriminated-union narrowing on `__matchN._tag` flows to `__matchN._0`
738    /// in the arm bodies. Re-emitting the scrutinee expression inline (the prior
739    /// behavior) both double-evaluated it and defeated narrowing (TS2339 on `_0`).
740    match_temp_counter: usize,
741    /// Set once the Optional runtime prelude has been emitted in the
742    /// single-module self-contained path ([`TsGenerator::generate_module`]), so
743    /// a module referencing it more than once still inlines it at most once (a
744    /// duplicate `type Option<T>` is a TS redeclaration error). The per-module
745    /// project path emits the runtime once into the shared `_bock_runtime.ts`.
746    optional_runtime_emitted: bool,
747    /// Set once the `Result` runtime prelude has been emitted; deduped exactly as
748    /// [`Self::optional_runtime_emitted`] (a duplicate `type BockResult<T, E>` is
749    /// a TS redeclaration error).
750    result_runtime_emitted: bool,
751    /// Set once the concurrency runtime prelude has been emitted; deduped exactly
752    /// as [`Self::optional_runtime_emitted`].
753    concurrency_runtime_emitted: bool,
754    /// Set once the range runtime prelude ([`RANGE_RUNTIME_TS`]) has been
755    /// emitted; deduped exactly as [`Self::optional_runtime_emitted`] (a
756    /// duplicate `const range` is a redeclaration error).
757    range_runtime_emitted: bool,
758    /// Set once the structural-equality runtime ([`EQ_RUNTIME_TS`]) has been
759    /// emitted; deduped exactly as [`Self::range_runtime_emitted`].
760    eq_runtime_emitted: bool,
761    /// Set once the structural-comparison runtime ([`COMPARE_RUNTIME_TS`]) has
762    /// been emitted in the single-module self-contained path. Deduped exactly as
763    /// [`Self::eq_runtime_emitted`]. (Q-bounded-comparable-codegen.)
764    compare_runtime_emitted: bool,
765    /// Set once the display-string runtime ([`STR_RUNTIME_TS`]) has been emitted
766    /// in the single-module self-contained path. (Q-displayable-interpolation-dispatch.)
767    str_runtime_emitted: bool,
768    /// User-enum-variant registry (DV14). Same role as the JS backend's: route
769    /// a unit-variant reference to the `{enum}_{variant}` const, a struct/tuple
770    /// construction to the factory, and recognise `RecordPat` arms as ADT.
771    /// Built-in Optional/Result pre-seeds are filtered out where bespoke
772    /// lowering applies. Pre-scanned across the reached modules.
773    enum_variants: crate::generator::EnumVariantRegistry,
774    /// Generic-type declaration registry: a record/enum/class name → its
775    /// declared generic params. An `impl Box { ... }` block carries no generic
776    /// params of its own (the `T` is declared on `record Box[T]`); this lets the
777    /// declaration-merged `interface Box<T>` and the `self: Box<T>` param type
778    /// recover them so the merge lands on the generic class. Pre-scanned across
779    /// the reached modules (mirrors [`Self::enum_variants`]).
780    generic_decls: crate::generator::GenericDeclRegistry,
781    /// Trait-declaration registry: a trait name → its declared generic params
782    /// and methods. Used at each `impl Trait for Type` site to recover the
783    /// trait's *default* methods (those carrying a body) so they can be
784    /// synthesized onto the implementing type's prototype — the trait interface
785    /// alone declares only signatures, so a type relying on an inherited default
786    /// would otherwise have no such method. Pre-scanned across the reached modules.
787    trait_decls: crate::generator::TraitDeclRegistry,
788    /// When `Some(target)`, a `Self` type (`TypeSelf`) renders as `target`
789    /// rather than the default `this`. Set while emitting ANY `impl` method onto
790    /// a concrete target — a synthesized trait default (`other: Self`) AND the
791    /// impl's own inherent methods (`fn combine(self, ...) -> Self`) alike. Each
792    /// emits as a free prototype function (`Target.prototype.m = function(...)`)
793    /// where `this` is not a legal type annotation (`tsc` rejects it with
794    /// TS2526), so the concrete target name must be substituted in both the
795    /// prototype function and the matching merged-interface signature. Cleared
796    /// (None) everywhere else, so trait-*interface* methods keep rendering `Self`
797    /// as `this` (valid inside an interface member).
798    trait_self_subst: Option<String>,
799    /// Names of `public` (exported) top-level types. The declaration-merging
800    /// `interface Target { ... }` an `impl` emits must be `export`ed exactly
801    /// when the `Target` class is — TS requires all declarations in a merged
802    /// declaration to agree on export-ness. Pre-scanned across the reached modules.
803    exported_types: std::collections::HashSet<String>,
804    /// The TS type a value-position expression is being assigned *into* (the
805    /// declared type of a `let x: T = <value>`), when known. Set around the
806    /// `LetBinding` value emit. An expression-position `match` lowers to an IIFE
807    /// (`(() => { switch (s) { … } })()`); when the value is consumed into a
808    /// typed binding this annotates the IIFE arrow's return type (`(() : T =>
809    /// {…})()`) and — crucially — signals that a value-`switch` over a bare
810    /// identifier scrutinee must be hoisted into a temp (`const __matchN = s;
811    /// switch (__matchN) …`). Without the hoist, `switch (s)` narrows `s` to the
812    /// case's literal type inside each arm, so an arm body re-referencing `s`
813    /// (`s === <other-literal>`) trips TS2367 ("no overlap"). Hoisting means the
814    /// switch narrows the temp while arm bodies still see the original (un-
815    /// narrowed) `s`. `None` outside a typed value-binding context; restored
816    /// after the value so it never leaks to a sibling/outer expression.
817    current_expected_type: Option<String>,
818    /// True in the **per-module native-import** emission path
819    /// ([`TsGenerator::generate_project`], the sole real-build path). When set,
820    /// the `Module` arm emits real ESM `import`/`import type` for cross-module
821    /// references, records which shared-runtime helpers/types the module needs
822    /// (instead of inlining them), and a trailing `export { … }` re-exports the
823    /// module's enum-variant value names (every other declaration kind already
824    /// exports inline). When clear, the module is emitted as a single
825    /// self-contained file with its runtime preludes inlined — the
826    /// [`TsGenerator::generate_module`] path used by unit tests.
827    per_module: bool,
828    /// In the per-module path, records that this module references the Optional /
829    /// Result runtime *types* (`BockOption` / `BockResult`) — so they are emitted
830    /// once into the shared `_bock_runtime.ts` and this module `import type`s them.
831    needs_runtime_optional: bool,
832    needs_runtime_result: bool,
833    /// As above, for the concurrency / range / structural-equality runtime
834    /// *values* (`__bockChannelNew` / `range` / `__bockEq`).
835    needs_runtime_concurrency: bool,
836    needs_runtime_range: bool,
837    needs_runtime_eq: bool,
838    /// As [`Self::needs_runtime_eq`], for the bounded-`Comparable`
839    /// structural-comparison runtime (`__bockCompare`).
840    /// (Q-bounded-comparable-codegen.)
841    needs_runtime_compare: bool,
842    /// As [`Self::needs_runtime_eq`], for the display-string runtime
843    /// (`__bockStr`). (Q-displayable-interpolation-dispatch.)
844    needs_runtime_str: bool,
845    /// Implicit cross-module imports for the per-module path — names this module
846    /// references but neither declares locally nor imports via an explicit `use`.
847    /// Computed in `generate_project`; emitted as ESM imports by the `Module` arm.
848    implicit_imports: Vec<crate::generator::ImplicitEsmImport>,
849    /// Map of every reachable public symbol → declaring module + kind, used to
850    /// spell an explicit `use`d name the way its declaration emits it.
851    public_symbols: HashMap<String, crate::generator::EsmSymbol>,
852    /// The declared dotted module-path of the file currently being emitted, or
853    /// empty for the entry file (always `main.ts` at the build root). Drives the
854    /// relative-specifier computation for every emitted `import`.
855    self_module_path: String,
856    /// Public enum-variant value names this module declares (`Color_Red`, …),
857    /// re-exported in a trailing `export { … }`. Unlike records/classes/aliases
858    /// (which export inline), enum-variant interfaces/consts/factories are emitted
859    /// without `export`, so cross-module references need the trailing re-export.
860    /// Computed in `generate_project`.
861    enum_variant_exports: Vec<String>,
862    /// Camel-cased record/class field names across the reachable program, used to
863    /// disambiguate a method whose camelCased name collides with a field name
864    /// (`core.error`'s `message` field + `message()` method). TS rejects a class
865    /// field and a (declaration-merged) interface method sharing a name with a
866    /// "Duplicate identifier" error, so the *method* is renamed (`messageMethod`)
867    /// at the trait-interface signature, the merged-interface signature, the
868    /// prototype attachment, the class-body method, and every call site via
869    /// [`Self::ts_method_name`]; the field keeps its name. Pre-seeded program-wide
870    /// by `generate_project` (and extended per-module by the `Module` arm for the
871    /// single-module `generate_module` path). Shared policy with go/js/py.
872    field_method_collisions: HashSet<String>,
873    /// Active value-sink while a statement-position control-flow expression is
874    /// being emitted (see [`ValueSink`] and [`Self::emit_value_in_stmt_pos`]).
875    /// A `match` arm body emitted under this sink delivers its value through it
876    /// (`return v` / `binding = v`) instead of the default `return v`, so an
877    /// expression-position `match` containing a `return`/`break` arm lowers to a
878    /// statement `switch` whose value-arms feed the binding. `None` everywhere
879    /// else; saved/restored around each statement-position lowering so it never
880    /// leaks into an unrelated (IIFE-lowered) match.
881    value_sink: Option<ValueSink>,
882    /// Per-loop result sink, pushed/popped alongside [`Self::loop_labels`]. When
883    /// a `loop` is lowered in value position (`let r = loop { … break v … }`),
884    /// the innermost entry holds the sink that `break v` writes into (`r = v;
885    /// break;`). `None` for an ordinary statement-position loop, where `break v`
886    /// has no value destination. Indexed by loop nesting, so an inner ordinary
887    /// loop inside a value loop does not capture the outer sink.
888    loop_value_sinks: Vec<Option<ValueSink>>,
889    /// Failure tag of the *enclosing function's* result/optional return type,
890    /// driving the `?` (`Propagate`) early-return guard. `"Err"` when the fn
891    /// returns `Result[T, E]`, `"None"` when it returns `Optional[T]` / `T?`,
892    /// `None` otherwise. Set in [`Self::emit_fn_decl`] / [`Self::emit_class_method`]
893    /// from the declared return type and restored after the body, so the guard
894    /// tests the single discriminant that actually exists on the value — which
895    /// also lets TS narrow the success access (`._0`) to the payload type.
896    current_fn_propagate_tag: Option<&'static str>,
897    /// Per-TS-lexical-block stack of simple `let`/`const` binding state. Bock
898    /// permits re-binding (`let x = …; let x = …`) which shadows the prior
899    /// binding in the same scope; TS `const`/`let` forbid re-declaration in one
900    /// block scope (TS2451). Each frame records the TS idents already declared in
901    /// the block and, of those, which need `let` (because they are re-bound or
902    /// assigned later). The first declaration of a re-bound name uses `let`;
903    /// every subsequent binding of the same name emits a plain assignment
904    /// (`x = …`) rather than a redeclaration. Mirrors the js backend (#217). See
905    /// [`LetScope`].
906    let_scopes: Vec<LetScope>,
907}
908
909/// One TS lexical block's `let`/`const` binding state — see
910/// [`TsEmitCtx::let_scopes`].
911#[derive(Default)]
912struct LetScope {
913    /// Simple TS idents already emitted as a declaration in this block.
914    declared: HashSet<String>,
915    /// Of the block's simple bindings, those that are re-bound or assigned and
916    /// so must be declared with `let` (not `const`) at their first declaration.
917    needs_let: HashSet<String>,
918}
919
920impl TsEmitCtx {
921    fn new() -> Self {
922        Self {
923            buf: String::with_capacity(4096),
924            indent: 0,
925            effect_ops: HashMap::new(),
926            current_handler_vars: HashMap::new(),
927            fn_effects: HashMap::new(),
928            composite_effects: HashMap::new(),
929            record_names: HashSet::new(),
930            class_fields: HashMap::new(),
931            const_names: HashSet::new(),
932            effect_names: HashSet::new(),
933            cur_line: 1,
934            cur_col: 1,
935            scan_pos: 0,
936            last_marked: None,
937            mappings: Vec::new(),
938            loop_labels: Vec::new(),
939            switch_label_depth: 0,
940            loop_label_counter: 0,
941            match_temp_counter: 0,
942            optional_runtime_emitted: false,
943            result_runtime_emitted: false,
944            concurrency_runtime_emitted: false,
945            range_runtime_emitted: false,
946            eq_runtime_emitted: false,
947            compare_runtime_emitted: false,
948            str_runtime_emitted: false,
949            enum_variants: crate::generator::EnumVariantRegistry::new(),
950            generic_decls: crate::generator::GenericDeclRegistry::new(),
951            trait_decls: crate::generator::TraitDeclRegistry::new(),
952            trait_self_subst: None,
953            exported_types: std::collections::HashSet::new(),
954            current_expected_type: None,
955            per_module: false,
956            needs_runtime_optional: false,
957            needs_runtime_result: false,
958            needs_runtime_concurrency: false,
959            needs_runtime_range: false,
960            needs_runtime_eq: false,
961            needs_runtime_compare: false,
962            needs_runtime_str: false,
963            implicit_imports: Vec::new(),
964            public_symbols: HashMap::new(),
965            self_module_path: String::new(),
966            enum_variant_exports: Vec::new(),
967            field_method_collisions: HashSet::new(),
968            value_sink: None,
969            loop_value_sinks: Vec::new(),
970            current_fn_propagate_tag: None,
971            let_scopes: Vec::new(),
972        }
973    }
974
975    /// Classify a function's declared return type into the failure tag its `?`
976    /// operator early-returns: `Some("Err")` for `Result[…]`, `Some("None")` for
977    /// `Optional[…]` / `T?`, `None` for anything else.
978    fn propagate_tag_for_return(ret: Option<&AIRNode>) -> Option<&'static str> {
979        match ret.map(|r| &r.kind) {
980            Some(NodeKind::TypeOptional { .. }) => Some("None"),
981            Some(NodeKind::TypeNamed { path, .. }) => {
982                match path.segments.last().map(|s| s.name.as_str()) {
983                    Some("Result") => Some("Err"),
984                    Some("Optional") => Some("None"),
985                    _ => None,
986                }
987            }
988            _ => None,
989        }
990    }
991
992    /// Disambiguate an *already-rendered* method member name against the
993    /// program's field names: when the rendered name collides with a field name,
994    /// append a `Method` suffix (`message` → `messageMethod`). Returns the name
995    /// unchanged otherwise.
996    ///
997    /// TS renders method names two ways — declarations use the raw Bock name
998    /// (`name.name`) while call sites use `to_camel_case` — so this helper takes
999    /// whatever each site already produced and only adds the suffix, preserving
1000    /// each site's existing casing. For the field/method collisions this targets
1001    /// (single-word field names like `message`, where raw == camelCase) the two
1002    /// renderings agree, so the renamed method resolves at the trait-interface
1003    /// signature, the merged-interface signature, the prototype attachment, the
1004    /// class-body method, and every call site alike. Shared policy with go/js/py
1005    /// (see [`crate::generator::disambiguate_method_name`]).
1006    fn ts_method_name(&self, rendered: &str) -> String {
1007        crate::generator::disambiguate_method_name(
1008            rendered.to_string(),
1009            &self.field_method_collisions,
1010            "Method",
1011        )
1012    }
1013
1014    fn finish(self) -> (String, Vec<SourceMapping>) {
1015        (self.buf, self.mappings)
1016    }
1017
1018    /// Pre-seed the effect registries (`effect_ops`, `composite_effects`,
1019    /// `effect_names`) from every module's top-level `EffectDecl`s. The
1020    /// per-module path emits each module in its own context, so a bare op used in
1021    /// one module whose effect is declared in another must be recognised without
1022    /// having emitted the declaring module first. Mirrors how `enum_variants` /
1023    /// `trait_decls` are collected across the reached modules and the JS /
1024    /// Python backends' equivalents.
1025    fn seed_effect_registries(&mut self, modules: &[(&AIRModule, &std::path::Path)]) {
1026        for (module, _) in modules {
1027            let NodeKind::Module { items, .. } = &module.kind else {
1028                continue;
1029            };
1030            for item in items {
1031                let NodeKind::EffectDecl {
1032                    name,
1033                    components,
1034                    operations,
1035                    ..
1036                } = &item.kind
1037                else {
1038                    continue;
1039                };
1040                if !components.is_empty() {
1041                    let comp_names: Vec<String> = components
1042                        .iter()
1043                        .map(|tp| {
1044                            tp.segments
1045                                .last()
1046                                .map_or("effect".to_string(), |s| s.name.clone())
1047                        })
1048                        .collect();
1049                    self.composite_effects.insert(name.name.clone(), comp_names);
1050                    continue;
1051                }
1052                self.effect_names.insert(name.name.clone());
1053                for op in operations {
1054                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
1055                        self.effect_ops
1056                            .insert(op_name.name.clone(), name.name.clone());
1057                    }
1058                }
1059            }
1060        }
1061    }
1062
1063    /// Emit the per-module ESM `import` statements at the top of the file: the
1064    /// shared-runtime import (Optional/Result types via `import type`,
1065    /// concurrency/range helpers via a value `import`), the explicit cross-module
1066    /// `use` imports, and the implicit prelude imports. Grouped one statement per
1067    /// source module, with the relative specifier computed from this file's
1068    /// declared module-path. ESM specifiers reference the *emitted* `.ts` source
1069    /// directly (resolved verbatim by node strip-types; accepted by `tsc` via
1070    /// `rewriteRelativeImportExtensions`, which rewrites it to `.js` on emit).
1071    fn emit_esm_imports(&mut self, imports: &[AIRNode]) -> Result<(), CodegenError> {
1072        // ESM specifiers reference the emitted `.ts` source directly (not a `.js`
1073        // that is never written). `node --experimental-strip-types` resolves
1074        // specifiers verbatim against files on disk — it does not rewrite `.js` →
1075        // `.ts` — so a `.js` specifier `ERR_MODULE_NOT_FOUND`s on any *value*
1076        // import (type-only imports are erased by the strip pass, so they happened
1077        // to work, but value imports of runtime helpers / cross-module functions
1078        // did not). A `.ts` specifier runs verbatim under node *and* type-checks
1079        // under `tsc` when `rewriteRelativeImportExtensions` is set (emitted into
1080        // the scaffolded `tsconfig.json`; see `scaffold.rs::tsconfig_json`) — that
1081        // flag also rewrites the specifier to `.js` on emit so the conformance
1082        // `tsc -p .` → `node main.js` plan still runs.
1083        let ext = "ts";
1084
1085        // Shared runtime: type-only and value imports, split so a type name never
1086        // appears in a value `import` (which would be a runtime error if elided
1087        // away to nothing, and is cleaner under `--strict`).
1088        let mut type_names: Vec<&str> = Vec::new();
1089        if self.needs_runtime_optional {
1090            type_names.push("BockOption");
1091        }
1092        if self.needs_runtime_result {
1093            type_names.push("BockResult");
1094        }
1095        let mut value_names: Vec<&str> = Vec::new();
1096        if self.needs_runtime_concurrency {
1097            value_names.extend(["__bockChannelNew", "__bockSpawn"]);
1098        }
1099        if self.needs_runtime_range {
1100            value_names.extend(["range", "rangeInclusive"]);
1101        }
1102        if self.needs_runtime_eq {
1103            value_names.push("__bockEq");
1104        }
1105        if self.needs_runtime_compare {
1106            value_names.push("__bockCompare");
1107        }
1108        if self.needs_runtime_str {
1109            value_names.push("__bockStr");
1110        }
1111        if !type_names.is_empty() || !value_names.is_empty() {
1112            let spec = crate::generator::esm_relative_specifier(
1113                &self.self_module_path,
1114                RUNTIME_MODULE_TS,
1115                ext,
1116            );
1117            if !type_names.is_empty() {
1118                self.writeln(&format!(
1119                    "import type {{ {} }} from \"{spec}\";",
1120                    type_names.join(", ")
1121                ));
1122            }
1123            if !value_names.is_empty() {
1124                self.writeln(&format!(
1125                    "import {{ {} }} from \"{spec}\";",
1126                    value_names.join(", ")
1127                ));
1128            }
1129        }
1130
1131        // Explicit `use` imports → real ESM imports. A `use`d *function* is
1132        // imported under its camelCased name; other kinds keep their raw name.
1133        // Accumulate cross-module imports grouped by declaring module-path, split
1134        // into value imports (`import { … }`) and type-only imports
1135        // (`import type { … }`) — a TS-only distinction so an enum *type*, trait
1136        // interface, or type alias never appears in a value import.
1137        let mut value_by_module: std::collections::BTreeMap<String, Vec<String>> =
1138            std::collections::BTreeMap::new();
1139        let mut type_by_module: std::collections::BTreeMap<String, Vec<String>> =
1140            std::collections::BTreeMap::new();
1141
1142        for import in imports {
1143            if let NodeKind::ImportDecl { path, items } = &import.kind {
1144                let target_path = path
1145                    .segments
1146                    .iter()
1147                    .map(|s| s.name.as_str())
1148                    .collect::<Vec<_>>()
1149                    .join(".");
1150                if target_path.is_empty() {
1151                    continue;
1152                }
1153                if let bock_ast::ImportItems::Named(named) = items {
1154                    for n in named {
1155                        // A `use`d runtime-prelude *value* name lowers inline and
1156                        // is never a real export of the declaring module.
1157                        if crate::generator::ESM_RUNTIME_PRELUDE_NAMES
1158                            .contains(&n.name.name.as_str())
1159                        {
1160                            continue;
1161                        }
1162                        let kind = self.public_symbols.get(&n.name.name).map(|s| s.kind);
1163                        let is_fn = matches!(kind, Some(crate::generator::EsmDeclKind::Function));
1164                        let src = ts_esm_emit_name(&n.name.name, is_fn);
1165                        let rendered = match &n.alias {
1166                            Some(alias) => {
1167                                let dst = ts_esm_emit_name(&alias.name, is_fn);
1168                                if dst == src {
1169                                    src
1170                                } else {
1171                                    format!("{src} as {dst}")
1172                                }
1173                            }
1174                            None => src,
1175                        };
1176                        let type_only = kind.is_some_and(|k| k.is_ts_type_only());
1177                        if type_only {
1178                            type_by_module.entry(target_path.clone()).or_default()
1179                        } else {
1180                            value_by_module.entry(target_path.clone()).or_default()
1181                        }
1182                        .push(rendered);
1183                    }
1184                }
1185                // `use Foo` / `use Foo.*` resolve via implicit imports by name.
1186            }
1187        }
1188
1189        // Implicit imports: prelude-visible names referenced but not explicitly
1190        // `use`d, routed value vs type-only the same way.
1191        for imp in &self.implicit_imports {
1192            let rendered = ts_esm_emit_name(&imp.name, imp.is_fn());
1193            if imp.kind.is_ts_type_only() {
1194                type_by_module.entry(imp.module_path.clone()).or_default()
1195            } else {
1196                value_by_module.entry(imp.module_path.clone()).or_default()
1197            }
1198            .push(rendered);
1199        }
1200
1201        for (module_path, mut names) in value_by_module {
1202            names.sort_unstable();
1203            names.dedup();
1204            let spec =
1205                crate::generator::esm_relative_specifier(&self.self_module_path, &module_path, ext);
1206            self.writeln(&format!(
1207                "import {{ {} }} from \"{spec}\";",
1208                names.join(", ")
1209            ));
1210        }
1211        for (module_path, mut names) in type_by_module {
1212            names.sort_unstable();
1213            names.dedup();
1214            let spec =
1215                crate::generator::esm_relative_specifier(&self.self_module_path, &module_path, ext);
1216            self.writeln(&format!(
1217                "import type {{ {} }} from \"{spec}\";",
1218                names.join(", ")
1219            ));
1220        }
1221        Ok(())
1222    }
1223
1224    /// Emit the trailing `export { … }` for this module's public enum-variant
1225    /// value names (`Color_Red`, …). Every other public declaration kind exports
1226    /// inline in TS; enum-variant interfaces/consts/factories do not, so they are
1227    /// re-exported here for cross-module references. Emits nothing when there is
1228    /// nothing to re-export.
1229    fn emit_trailing_exports(&mut self) {
1230        if self.enum_variant_exports.is_empty() {
1231            return;
1232        }
1233        let mut names = self.enum_variant_exports.clone();
1234        names.sort_unstable();
1235        names.dedup();
1236        if !self.buf.is_empty() && !self.buf.ends_with('\n') {
1237            self.buf.push('\n');
1238        }
1239        self.writeln(&format!("export {{ {} }};", names.join(", ")));
1240    }
1241
1242    /// Variant info for `path` when its last segment is a registered *user*
1243    /// enum variant (built-in Optional/Result pre-seeds excluded — those go
1244    /// through the bespoke tagged-object lowering).
1245    fn user_variant_for_path(
1246        &self,
1247        path: &bock_ast::TypePath,
1248    ) -> Option<&crate::generator::EnumVariantInfo> {
1249        let info = crate::generator::registered_variant(&self.enum_variants, path)?;
1250        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
1251            return None;
1252        }
1253        Some(info)
1254    }
1255
1256    /// As [`Self::user_variant_for_path`] but keyed by a bare identifier name.
1257    fn user_variant_for_name(&self, name: &str) -> Option<&crate::generator::EnumVariantInfo> {
1258        let info = self.enum_variants.get(name)?;
1259        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
1260            return None;
1261        }
1262        Some(info)
1263    }
1264
1265    /// If `value` is a *construction of a single user-enum variant* — a struct
1266    /// variant (`Open { … }`, a [`NodeKind::RecordConstruct`]), a unit variant
1267    /// (`Open`, a bare [`NodeKind::Identifier`]), or a tuple variant (`Open(…)`,
1268    /// a [`NodeKind::Call`] on a bare variant name) — return the *declaring
1269    /// enum's* name (the union type), else `None`.
1270    ///
1271    /// Used by [`Self::emit_stmt`] to widen the initialiser of a type-annotated
1272    /// `let`/`const` binding back to its declared union: `const g: Gate =
1273    /// Gate_Open(7)` otherwise narrows `g` to the construction variant
1274    /// (`Gate_Open`), and a later `match g` on the sibling variant fails `tsc
1275    /// --noEmit` with TS2678 (the narrowed literal `_tag` is "not comparable" to
1276    /// the sibling's). The declared annotation must win; widening the
1277    /// initialiser (`Gate_Open(7) as Gate`) defeats the const-init narrowing so
1278    /// the declared union type is what `g` carries at every use site.
1279    fn variant_construct_enum(&self, value: &AIRNode) -> Option<String> {
1280        match &value.kind {
1281            // Struct variant: `Open { level: 7 }`.
1282            NodeKind::RecordConstruct { path, spread, .. } if spread.is_none() => self
1283                .user_variant_for_path(path)
1284                .map(|i| i.enum_name.clone()),
1285            // Unit variant: a bare `Open`.
1286            NodeKind::Identifier { name } => self
1287                .user_variant_for_name(&name.name)
1288                .map(|i| i.enum_name.clone()),
1289            // Tuple variant: `Open(7)` — a call whose callee is a bare variant
1290            // name (the only call shape that constructs a variant; a method or
1291            // assoc call has a `FieldAccess` callee, not an `Identifier`).
1292            NodeKind::Call { callee, .. } => match &callee.kind {
1293                NodeKind::Identifier { name } => self
1294                    .user_variant_for_name(&name.name)
1295                    .map(|i| i.enum_name.clone()),
1296                _ => None,
1297            },
1298            _ => None,
1299        }
1300    }
1301
1302    /// Whether a type-annotated binding's initialiser needs a widening cast to
1303    /// its declared type, i.e. `value` constructs a single variant of the enum
1304    /// the binding is declared as. The declared TS type string (`ty_ts`) must
1305    /// name exactly that enum's union — a binding annotated with an unrelated
1306    /// type, or one whose value is not a single-variant construction, is left
1307    /// untouched.
1308    fn binding_needs_union_widening(&self, value: &AIRNode, ty_ts: Option<&str>) -> bool {
1309        match (ty_ts, self.variant_construct_enum(value)) {
1310            (Some(declared), Some(enum_name)) => declared == enum_name,
1311            _ => false,
1312        }
1313    }
1314
1315    /// Emit a `let`/`const` binding's initialiser `value` under the binding's
1316    /// declared TS type `ty_ts`, restoring the prior expected type afterwards so
1317    /// it never leaks to a sibling/nested expression.
1318    ///
1319    /// `ty_ts` is recorded as [`Self::current_expected_type`] for the value emit
1320    /// (so a value-position `match`/`if` IIFE annotates its arrow return and
1321    /// hoists a bare scrutinee — see [`Self::current_expected_type`]). When the
1322    /// value is a single-variant construction of the declared enum union, the
1323    /// emitted initialiser is widened with an `as <Union>` cast so the binding
1324    /// carries the declared union type rather than the narrower construction
1325    /// variant (see [`Self::variant_construct_enum`]).
1326    fn emit_let_value(
1327        &mut self,
1328        value: &AIRNode,
1329        ty_ts: Option<String>,
1330    ) -> Result<(), CodegenError> {
1331        // The widening cast (`Gate_Open(7) as Gate`) only applies when the
1332        // declared type names the enum the value constructs a variant of; an
1333        // unrelated annotation, or a non-construction value, emits unchanged.
1334        let widen_to = self
1335            .binding_needs_union_widening(value, ty_ts.as_deref())
1336            .then(|| ty_ts.clone())
1337            .flatten();
1338        let prev_expected = self.current_expected_type.take();
1339        self.current_expected_type = ty_ts;
1340        self.emit_expr(value)?;
1341        if let Some(union) = widen_to {
1342            let _ = write!(self.buf, " as {union}");
1343        }
1344        self.current_expected_type = prev_expected;
1345        Ok(())
1346    }
1347
1348    /// Bring `cur_line` / `cur_col` up to date with everything appended to
1349    /// `buf` since the last sync.
1350    fn sync_pos(&mut self) {
1351        if self.scan_pos >= self.buf.len() {
1352            return;
1353        }
1354        let slice = &self.buf[self.scan_pos..];
1355        for ch in slice.chars() {
1356            if ch == '\n' {
1357                self.cur_line += 1;
1358                self.cur_col = 1;
1359            } else {
1360                self.cur_col += 1;
1361            }
1362        }
1363        self.scan_pos = self.buf.len();
1364    }
1365
1366    fn mark_span(&mut self, span: Span) {
1367        if span.start == 0 && span.end == 0 {
1368            return;
1369        }
1370        self.sync_pos();
1371        let key = (self.cur_line, self.cur_col);
1372        if self.last_marked == Some(key) {
1373            return;
1374        }
1375        self.last_marked = Some(key);
1376        self.mappings.push(SourceMapping {
1377            gen_line: self.cur_line,
1378            gen_col: self.cur_col,
1379            src_line: 0,
1380            src_col: 0,
1381            src_offset: span.start as u32,
1382            src_file_id: span.file.0,
1383        });
1384    }
1385
1386    fn indent_str(&self) -> String {
1387        "  ".repeat(self.indent)
1388    }
1389
1390    fn write_indent(&mut self) {
1391        let indent = self.indent_str();
1392        self.buf.push_str(&indent);
1393    }
1394
1395    fn writeln(&mut self, s: &str) {
1396        self.write_indent();
1397        self.buf.push_str(s);
1398        self.buf.push('\n');
1399    }
1400
1401    // ── Prelude function mapping ──────────────────────────────────────────
1402
1403    /// Emit an expression into a temporary buffer and return the string.
1404    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
1405        let start = self.buf.len();
1406        let saved_line = self.cur_line;
1407        let saved_col = self.cur_col;
1408        let saved_scan = self.scan_pos;
1409        let saved_marked = self.last_marked;
1410        let mappings_len = self.mappings.len();
1411        self.emit_expr(node)?;
1412        let s = self.buf[start..].to_string();
1413        self.buf.truncate(start);
1414        self.cur_line = saved_line;
1415        self.cur_col = saved_col;
1416        self.scan_pos = saved_scan;
1417        self.last_marked = saved_marked;
1418        self.mappings.truncate(mappings_len);
1419        Ok(s)
1420    }
1421
1422    /// Map Bock prelude functions to TypeScript equivalents.
1423    fn map_prelude_call(
1424        &mut self,
1425        callee: &AIRNode,
1426        args: &[bock_air::AirArg],
1427    ) -> Result<Option<String>, CodegenError> {
1428        let name = match &callee.kind {
1429            NodeKind::Identifier { name } => name.name.as_str(),
1430            _ => return Ok(None),
1431        };
1432        let arg_strs: Vec<String> = args
1433            .iter()
1434            .map(|a| self.expr_to_string(&a.value))
1435            .collect::<Result<_, _>>()?;
1436        let code = match name {
1437            "println" => {
1438                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1439                format!("console.log({a})")
1440            }
1441            "print" => {
1442                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1443                format!("process.stdout.write(String({a}))")
1444            }
1445            "debug" => {
1446                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1447                format!("console.debug({a})")
1448            }
1449            "assert" => {
1450                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1451                format!("if (!{a}) throw new Error(\"assertion failed\")")
1452            }
1453            "todo" => "throw new Error(\"not implemented\")".to_string(),
1454            "unreachable" => "throw new Error(\"unreachable\")".to_string(),
1455            "sleep" => {
1456                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1457                // Route through an installed `Clock` handler if one is in scope;
1458                // otherwise fall through to the host primitive (default).
1459                if let Some(handler) = self.clock_handler_var() {
1460                    format!("{handler}.sleep({a})")
1461                } else {
1462                    format!("new Promise<void>((__r) => setTimeout(__r, Math.floor(({a}) / 1e6)))")
1463                }
1464            }
1465            _ => return Ok(None),
1466        };
1467        Ok(Some(code))
1468    }
1469
1470    /// Recognise `Duration.xxx(...)` / `Instant.xxx(...)` associated-function
1471    /// calls and emit inline arithmetic. Durations are plain numbers
1472    /// (nanoseconds); Instants are numbers representing ns since
1473    /// `performance.timeOrigin`. Returns `Ok(true)` if the call was emitted.
1474    fn try_emit_time_assoc_call(
1475        &mut self,
1476        callee: &AIRNode,
1477        args: &[bock_air::AirArg],
1478    ) -> Result<bool, CodegenError> {
1479        let NodeKind::FieldAccess { object, field } = &callee.kind else {
1480            return Ok(false);
1481        };
1482        let NodeKind::Identifier { name: type_name } = &object.kind else {
1483            return Ok(false);
1484        };
1485        let arg_strs: Vec<String> = args
1486            .iter()
1487            .map(|a| self.expr_to_string(&a.value))
1488            .collect::<Result<_, _>>()?;
1489        let arg0 = || arg_strs.first().cloned().unwrap_or_default();
1490        let code = match (type_name.name.as_str(), field.name.as_str()) {
1491            ("Duration", "zero") => "0".to_string(),
1492            ("Duration", "nanos") => arg0(),
1493            ("Duration", "micros") => format!("(({}) * 1000)", arg0()),
1494            ("Duration", "millis") => format!("(({}) * 1000000)", arg0()),
1495            ("Duration", "seconds") => format!("(({}) * 1000000000)", arg0()),
1496            ("Duration", "minutes") => format!("(({}) * 60000000000)", arg0()),
1497            ("Duration", "hours") => format!("(({}) * 3600000000000)", arg0()),
1498            ("Instant", "now") => {
1499                // Route through an installed `Clock` handler's `now_monotonic`
1500                // op if one is in scope; otherwise emit the host primitive.
1501                if let Some(handler) = self.clock_handler_var() {
1502                    format!("{handler}.now_monotonic()")
1503                } else {
1504                    "(performance.now() * 1000000)".to_string()
1505                }
1506            }
1507            _ => return Ok(false),
1508        };
1509        self.buf.push_str(&code);
1510        Ok(true)
1511    }
1512
1513    /// Recognise `Channel.new()`, `spawn(...)`, and method calls on a
1514    /// channel value (`send`, `recv`, `close`) and emit the TS runtime
1515    /// helper equivalents.
1516    fn try_emit_concurrency_call(
1517        &mut self,
1518        callee: &AIRNode,
1519        args: &[bock_air::AirArg],
1520    ) -> Result<bool, CodegenError> {
1521        if let NodeKind::Identifier { name } = &callee.kind {
1522            if name.name == "spawn" {
1523                self.buf.push_str("__bockSpawn(");
1524                for (i, arg) in args.iter().enumerate() {
1525                    if i > 0 {
1526                        self.buf.push_str(", ");
1527                    }
1528                    self.emit_expr(&arg.value)?;
1529                }
1530                self.buf.push(')');
1531                return Ok(true);
1532            }
1533        }
1534        let NodeKind::FieldAccess { object, field } = &callee.kind else {
1535            return Ok(false);
1536        };
1537        if let NodeKind::Identifier { name: type_name } = &object.kind {
1538            if type_name.name == "Channel" && field.name == "new" {
1539                self.buf.push_str("__bockChannelNew()");
1540                return Ok(true);
1541            }
1542        }
1543        if matches!(field.name.as_str(), "send" | "recv" | "close") {
1544            // First arg is the receiver duplicate (from desugaring) — skip.
1545            self.emit_expr(object)?;
1546            let _ = write!(self.buf, ".{}", field.name);
1547            self.buf.push('(');
1548            for (i, arg) in args.iter().skip(1).enumerate() {
1549                if i > 0 {
1550                    self.buf.push_str(", ");
1551                }
1552                self.emit_expr(&arg.value)?;
1553            }
1554            self.buf.push(')');
1555            return Ok(true);
1556        }
1557        Ok(false)
1558    }
1559
1560    /// Recognise desugared method calls `Call(FieldAccess(recv, m), [recv, ...args])`
1561    /// on Duration/Instant values and emit inline arithmetic.
1562    ///
1563    /// `node` is the full `Call` AIR node, consulted only to *exclude* primitive
1564    /// receivers: [`is_time_method_name`] alone is ambiguous (`abs` is both
1565    /// `Duration.abs` and `Int.abs`/`Float.abs`), so when the checker has stamped
1566    /// `recv_kind = "Primitive:<Ty>"` this is a numeric method, not a time method —
1567    /// bail so [`Self::try_emit_numeric_method`] handles it.
1568    fn try_emit_time_desugared_method(
1569        &mut self,
1570        node: &AIRNode,
1571        callee: &AIRNode,
1572        args: &[bock_air::AirArg],
1573    ) -> Result<bool, CodegenError> {
1574        if crate::generator::primitive_recv_kind(node).is_some() {
1575            return Ok(false);
1576        }
1577        let NodeKind::FieldAccess { object, field } = &callee.kind else {
1578            return Ok(false);
1579        };
1580        if let NodeKind::Identifier { name } = &object.kind {
1581            if matches!(name.name.as_str(), "Duration" | "Instant") {
1582                return Ok(false);
1583            }
1584        }
1585        if !is_time_method_name(&field.name) {
1586            return Ok(false);
1587        }
1588        let remaining: Vec<bock_air::AirArg> = args.iter().skip(1).cloned().collect();
1589        self.try_emit_time_method(object, &field.name, &remaining)
1590    }
1591
1592    /// Recognise instance methods on Duration/Instant values and emit inline
1593    /// arithmetic.
1594    fn try_emit_time_method(
1595        &mut self,
1596        receiver: &AIRNode,
1597        method: &str,
1598        args: &[bock_air::AirArg],
1599    ) -> Result<bool, CodegenError> {
1600        let recv_str = self.expr_to_string(receiver)?;
1601        let arg_strs: Vec<String> = args
1602            .iter()
1603            .map(|a| self.expr_to_string(&a.value))
1604            .collect::<Result<_, _>>()?;
1605        let code = match method {
1606            "as_nanos" => format!("({recv_str})"),
1607            "as_millis" => format!("Math.floor(({recv_str}) / 1000000)"),
1608            "as_seconds" => format!("Math.floor(({recv_str}) / 1000000000)"),
1609            "is_zero" => format!("(({recv_str}) === 0)"),
1610            "is_negative" => format!("(({recv_str}) < 0)"),
1611            "abs" => format!("Math.abs({recv_str})"),
1612            "elapsed" => {
1613                // `instant.elapsed()` is derived: `now - instant`. Route the
1614                // "now" read through an installed `Clock` handler if in scope;
1615                // otherwise read the host monotonic clock (default).
1616                if let Some(handler) = self.clock_handler_var() {
1617                    format!("({handler}.now_monotonic() - ({recv_str}))")
1618                } else {
1619                    format!("((performance.now() * 1000000) - ({recv_str}))")
1620                }
1621            }
1622            "duration_since" => {
1623                let other = arg_strs.first().cloned().unwrap_or_default();
1624                format!("(({recv_str}) - ({other}))")
1625            }
1626            _ => return Ok(false),
1627        };
1628        self.buf.push_str(&code);
1629        Ok(true)
1630    }
1631
1632    /// Emit Some/Ok/Err calls as tagged-object constructions, matching the
1633    /// representation used for user-defined enum variants. Returns true if
1634    /// the call was handled.
1635    fn try_emit_prelude_ctor(
1636        &mut self,
1637        callee: &AIRNode,
1638        args: &[bock_air::AirArg],
1639    ) -> Result<bool, CodegenError> {
1640        let name = match &callee.kind {
1641            NodeKind::Identifier { name } => name.name.as_str(),
1642            _ => return Ok(false),
1643        };
1644        if !matches!(name, "Some" | "Ok" | "Err") {
1645            return Ok(false);
1646        }
1647        let _ = write!(self.buf, "{{ _tag: \"{name}\" as const");
1648        if let Some(arg) = args.first() {
1649            self.buf.push_str(", _0: ");
1650            self.emit_expr(&arg.value)?;
1651        }
1652        self.buf.push_str(" }");
1653        Ok(true)
1654    }
1655
1656    /// Q-prim-assoc: lower a primitive associated-conversion call
1657    /// (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`) to TS's native
1658    /// conversion. `from` is an infallible value coercion; `try_from` parses a
1659    /// `String` and returns the Bock `Result` tagged-object shape
1660    /// (`{ _tag: "Ok"/"Err" as const, _0: … }`), the `Err` payload a
1661    /// `ConvertError` (in scope via the `Result[T, ConvertError]` return-type
1662    /// import). The parse IIFE annotates its `string` param for strict mode.
1663    /// Returns `true` when handled.
1664    fn try_emit_primitive_conversion(
1665        &mut self,
1666        node: &AIRNode,
1667        callee: &AIRNode,
1668        args: &[bock_air::AirArg],
1669    ) -> Result<bool, CodegenError> {
1670        let Some((target, method, arg)) =
1671            crate::generator::primitive_conversion_call(node, callee, args)
1672        else {
1673            return Ok(false);
1674        };
1675        let arg_str = self.expr_to_string(arg)?;
1676        let code = match (target, method) {
1677            ("Float" | "Int" | "String", "from") => format!("({arg_str})"),
1678            ("Int", "try_from") => format!(
1679                "((__s: string) => /^[+-]?[0-9]+$/.test(__s.trim()) \
1680                 ? {{ _tag: \"Ok\" as const, _0: Number.parseInt(__s.trim(), 10) }} \
1681                 : {{ _tag: \"Err\" as const, _0: new ConvertError({{ message: \
1682                 `cannot parse '${{__s}}' as Int` }}) }})({arg_str})"
1683            ),
1684            ("Float", "try_from") => format!(
1685                "((__s: string) => {{ const __t = __s.trim(); const __n = Number(__t); \
1686                 return (__t.length > 0 && !Number.isNaN(__n)) \
1687                 ? {{ _tag: \"Ok\" as const, _0: __n }} \
1688                 : {{ _tag: \"Err\" as const, _0: new ConvertError({{ message: \
1689                 `cannot parse '${{__s}}' as Float` }}) }}; }})({arg_str})"
1690            ),
1691            _ => return Ok(false),
1692        };
1693        self.buf.push_str(&code);
1694        Ok(true)
1695    }
1696
1697    /// Emit a built-in `Optional`/`Result` method call to its TS form.
1698    ///
1699    /// Recognised via the checker's `recv_kind` annotation
1700    /// ([`crate::generator::desugared_optional_method`] /
1701    /// [`crate::generator::desugared_result_method`]). Both types use the tagged
1702    /// representation (`{ _tag, _0 }`), so the lowering is a ternary on `._tag`,
1703    /// wrapped in a *generic* arrow IIFE — `(<T,>(__c: BockOption<T>) => …)(recv)`
1704    /// / `(<T, E>(__c: BockResult<T, E>) => …)(recv)` — so the payload type is
1705    /// inferred from the receiver (strict-mode clean: no implicit `any`) and the
1706    /// receiver is evaluated exactly once. Returns `true` if handled.
1707    fn try_emit_container_method(
1708        &mut self,
1709        node: &AIRNode,
1710        callee: &AIRNode,
1711        args: &[bock_air::AirArg],
1712    ) -> Result<bool, CodegenError> {
1713        if let Some((recv, method, rest)) =
1714            crate::generator::desugared_optional_method(node, callee, args)
1715        {
1716            self.emit_tagged_container_method(recv, method, rest, "Some", "<T,>", "BockOption<T>")?;
1717            return Ok(true);
1718        }
1719        if let Some((recv, method, rest)) =
1720            crate::generator::desugared_result_method(node, callee, args)
1721        {
1722            self.emit_tagged_container_method(
1723                recv,
1724                method,
1725                rest,
1726                "Ok",
1727                "<T, E>",
1728                "BockResult<T, E>",
1729            )?;
1730            return Ok(true);
1731        }
1732        Ok(false)
1733    }
1734
1735    /// Lower a tagged-container method on `recv`. `present_tag` is the
1736    /// payload-carrying tag (`"Some"`/`"Ok"`); `type_params` / `param_ty` type
1737    /// the generic IIFE param (`<T,>` + `BockOption<T>`, or `<T, E>` +
1738    /// `BockResult<T, E>`).
1739    fn emit_tagged_container_method(
1740        &mut self,
1741        recv: &AIRNode,
1742        method: &str,
1743        rest: &[bock_air::AirArg],
1744        present_tag: &str,
1745        type_params: &str,
1746        param_ty: &str,
1747    ) -> Result<(), CodegenError> {
1748        // Pure tag tests read the receiver once → emit inline.
1749        match method {
1750            "is_some" | "is_ok" => {
1751                self.buf.push('(');
1752                self.emit_expr(recv)?;
1753                let _ = write!(self.buf, "._tag === \"{present_tag}\")");
1754                return Ok(());
1755            }
1756            "is_none" | "is_err" => {
1757                self.buf.push('(');
1758                self.emit_expr(recv)?;
1759                let _ = write!(self.buf, "._tag !== \"{present_tag}\")");
1760                return Ok(());
1761            }
1762            _ => {}
1763        }
1764        let _ = write!(self.buf, "(({type_params}(__c: {param_ty}) => ");
1765        match method {
1766            "unwrap" => {
1767                let _ = write!(
1768                    self.buf,
1769                    "__c._tag === \"{present_tag}\" ? __c._0 : (undefined as never)"
1770                );
1771            }
1772            "unwrap_or" => {
1773                let _ = write!(self.buf, "__c._tag === \"{present_tag}\" ? __c._0 : (");
1774                if let Some(d) = rest.first() {
1775                    self.emit_expr(&d.value)?;
1776                } else {
1777                    self.buf.push_str("undefined");
1778                }
1779                self.buf.push(')');
1780            }
1781            "map" => {
1782                // The callback's parameter type is the concrete payload type the
1783                // checker already validated; the generic IIFE param `T` is wider
1784                // (unconstrained), so feed the payload through `as any` to satisfy
1785                // strict mode without recovering the concrete type here.
1786                let _ = write!(
1787                    self.buf,
1788                    "__c._tag === \"{present_tag}\" ? {{ _tag: \"{present_tag}\" as const, _0: ("
1789                );
1790                if let Some(f) = rest.first() {
1791                    self.emit_expr(&f.value)?;
1792                } else {
1793                    self.buf.push_str("(x) => x");
1794                }
1795                self.buf.push_str(")(__c._0 as any) } : __c");
1796            }
1797            "flat_map" => {
1798                let _ = write!(self.buf, "__c._tag === \"{present_tag}\" ? (");
1799                if let Some(f) = rest.first() {
1800                    self.emit_expr(&f.value)?;
1801                } else {
1802                    self.buf.push_str("(x) => x");
1803                }
1804                self.buf.push_str(")(__c._0 as any) : __c");
1805            }
1806            "map_err" => {
1807                self.buf
1808                    .push_str("__c._tag === \"Ok\" ? __c : { _tag: \"Err\" as const, _0: (");
1809                if let Some(f) = rest.first() {
1810                    self.emit_expr(&f.value)?;
1811                } else {
1812                    self.buf.push_str("(x) => x");
1813                }
1814                self.buf.push_str(")(__c._0 as any) }");
1815            }
1816            _ => self.buf.push_str("(undefined as never)"),
1817        }
1818        self.buf.push_str(")(");
1819        self.emit_expr(recv)?;
1820        self.buf.push_str("))");
1821        Ok(())
1822    }
1823
1824    /// Emit a read-only `List` built-in method call to its TS form.
1825    ///
1826    /// Mirrors the JS lowering but stays strict-mode clean: the
1827    /// `Optional`-returning methods (`get`/`first`/`last`/`index_of`) wrap the
1828    /// receiver in a *generic* arrow IIFE (`<T,>(__r: ReadonlyArray<T>, …):
1829    /// BockOption<T> => …`) so the element type `T` is inferred from the
1830    /// receiver and the result is the typed `BockOption<T>` union the `match`
1831    /// lowering narrows on `._tag`. The receiver is therefore evaluated exactly
1832    /// once and no parameter is implicitly `any`.
1833    fn try_emit_list_method(
1834        &mut self,
1835        node: &AIRNode,
1836        callee: &AIRNode,
1837        args: &[bock_air::AirArg],
1838    ) -> Result<bool, CodegenError> {
1839        let Some((recv, method, rest)) =
1840            crate::generator::desugared_list_method(node, callee, args)
1841        else {
1842            return Ok(false);
1843        };
1844        match method {
1845            "len" | "length" | "count" => {
1846                self.buf.push('(');
1847                self.emit_expr(recv)?;
1848                self.buf.push_str(").length");
1849            }
1850            "is_empty" => {
1851                self.buf.push_str("((");
1852                self.emit_expr(recv)?;
1853                self.buf.push_str(").length === 0)");
1854            }
1855            "get" => {
1856                let Some(idx) = rest.first() else {
1857                    return Ok(false);
1858                };
1859                self.buf.push_str(
1860                    "(<T,>(__r: ReadonlyArray<T>, __i: number): BockOption<T> => \
1861                     (__i >= 0 && __i < __r.length) ? \
1862                     { _tag: \"Some\" as const, _0: __r[__i] } : { _tag: \"None\" as const })(",
1863                );
1864                self.emit_expr(recv)?;
1865                self.buf.push_str(", ");
1866                self.emit_expr(&idx.value)?;
1867                self.buf.push(')');
1868            }
1869            "first" => {
1870                self.buf.push_str(
1871                    "(<T,>(__r: ReadonlyArray<T>): BockOption<T> => __r.length > 0 ? \
1872                     { _tag: \"Some\" as const, _0: __r[0] } : { _tag: \"None\" as const })(",
1873                );
1874                self.emit_expr(recv)?;
1875                self.buf.push(')');
1876            }
1877            "last" => {
1878                self.buf.push_str(
1879                    "(<T,>(__r: ReadonlyArray<T>): BockOption<T> => __r.length > 0 ? \
1880                     { _tag: \"Some\" as const, _0: __r[__r.length - 1] } : \
1881                     { _tag: \"None\" as const })(",
1882                );
1883                self.emit_expr(recv)?;
1884                self.buf.push(')');
1885            }
1886            "contains" => {
1887                let Some(x) = rest.first() else {
1888                    return Ok(false);
1889                };
1890                self.buf.push('(');
1891                self.emit_expr(recv)?;
1892                self.buf.push_str(").includes(");
1893                self.emit_expr(&x.value)?;
1894                self.buf.push(')');
1895            }
1896            "index_of" => {
1897                let Some(x) = rest.first() else {
1898                    return Ok(false);
1899                };
1900                self.buf.push_str(
1901                    "(<T,>(__r: ReadonlyArray<T>, __x: T): BockOption<number> => \
1902                     { const __i = __r.indexOf(__x); return __i >= 0 ? \
1903                     { _tag: \"Some\" as const, _0: __i } : { _tag: \"None\" as const }; })(",
1904                );
1905                self.emit_expr(recv)?;
1906                self.buf.push_str(", ");
1907                self.emit_expr(&x.value)?;
1908                self.buf.push(')');
1909            }
1910            "concat" => {
1911                let Some(o) = rest.first() else {
1912                    return Ok(false);
1913                };
1914                self.buf.push('(');
1915                self.emit_expr(recv)?;
1916                self.buf.push_str(").concat(");
1917                self.emit_expr(&o.value)?;
1918                self.buf.push(')');
1919            }
1920            "join" => {
1921                let Some(sep) = rest.first() else {
1922                    return Ok(false);
1923                };
1924                self.buf.push('(');
1925                self.emit_expr(recv)?;
1926                self.buf.push_str(").join(");
1927                self.emit_expr(&sep.value)?;
1928                self.buf.push(')');
1929            }
1930            _ => return Ok(false),
1931        }
1932        Ok(true)
1933    }
1934
1935    /// Emit an in-place `List` mutator (`push`/`append`, DQ18) to its TS form.
1936    ///
1937    /// Recognised via [`crate::generator::desugared_list_mutating_method`].
1938    /// Bock's `List[T]` lowers to a mutable `Array<T>` (`T[]`), so `recv.push(x)`
1939    /// lowers directly. The checker types these as `Void` (statement position);
1940    /// the receiver is a `mut` lvalue (ownership-enforced), evaluated once.
1941    fn try_emit_list_mutating_method(
1942        &mut self,
1943        node: &AIRNode,
1944        callee: &AIRNode,
1945        args: &[bock_air::AirArg],
1946    ) -> Result<bool, CodegenError> {
1947        let Some((recv, _method, rest)) =
1948            crate::generator::desugared_list_mutating_method(node, callee, args)
1949        else {
1950            return Ok(false);
1951        };
1952        let Some(x) = rest.first() else {
1953            return Ok(false);
1954        };
1955        self.buf.push('(');
1956        self.emit_expr(recv)?;
1957        self.buf.push_str(").push(");
1958        self.emit_expr(&x.value)?;
1959        self.buf.push(')');
1960        Ok(true)
1961    }
1962
1963    /// Emit a DQ30 in-place `List` mutator
1964    /// (`pop`/`remove_at`/`insert`/`reverse`/`set`) to its TS form.
1965    ///
1966    /// Mirrors the JS lowering (arrays are reference values, so the IIFE
1967    /// parameter aliases the receiver and mutations are caller-visible) but
1968    /// stays strict-mode clean: the IIFEs are *generic* arrows over a mutable
1969    /// `T[]` parameter (not the read-only methods' `ReadonlyArray<T>`), `pop`
1970    /// returns the typed `BockOption<T>` union (the `T | undefined` from
1971    /// native `.pop()` is narrowed by the length guard and asserted `as T`),
1972    /// and `remove_at`'s `splice(i, 1)[0]` is asserted `as T` for
1973    /// `noUncheckedIndexedAccess` robustness. The bounds checks throw with the
1974    /// normalized abort message `List.<op>: index <i> out of bounds (len <n>)`
1975    /// (the DQ23 zero-check convention); `set`'s check is load-bearing because
1976    /// native index-assign past the end silently extends the array.
1977    fn try_emit_list_inplace_mutator(
1978        &mut self,
1979        node: &AIRNode,
1980        callee: &AIRNode,
1981        args: &[bock_air::AirArg],
1982    ) -> Result<bool, CodegenError> {
1983        let Some((recv, method, rest)) =
1984            crate::generator::desugared_list_inplace_mutator(node, callee, args)
1985        else {
1986            return Ok(false);
1987        };
1988        match method {
1989            "pop" => {
1990                self.buf.push_str(
1991                    "(<T,>(__r: T[]): BockOption<T> => __r.length > 0 ? \
1992                     { _tag: \"Some\" as const, _0: __r.pop() as T } : \
1993                     { _tag: \"None\" as const })(",
1994                );
1995                self.emit_expr(recv)?;
1996                self.buf.push(')');
1997            }
1998            "remove_at" => {
1999                let Some(idx) = rest.first() else {
2000                    return Ok(false);
2001                };
2002                self.buf.push_str(
2003                    "(<T,>(__r: T[], __i: number): T => { \
2004                     if (__i < 0 || __i >= __r.length) { throw new Error(\
2005                     \"List.remove_at: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
2006                     return __r.splice(__i, 1)[0] as T; })(",
2007                );
2008                self.emit_expr(recv)?;
2009                self.buf.push_str(", ");
2010                self.emit_expr(&idx.value)?;
2011                self.buf.push(')');
2012            }
2013            "insert" => {
2014                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
2015                    return Ok(false);
2016                };
2017                self.buf.push_str(
2018                    "(<T,>(__r: T[], __i: number, __x: T): void => { \
2019                     if (__i < 0 || __i > __r.length) { throw new Error(\
2020                     \"List.insert: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
2021                     __r.splice(__i, 0, __x); })(",
2022                );
2023                self.emit_expr(recv)?;
2024                self.buf.push_str(", ");
2025                self.emit_expr(&idx.value)?;
2026                self.buf.push_str(", ");
2027                self.emit_expr(&x.value)?;
2028                self.buf.push(')');
2029            }
2030            "reverse" => {
2031                self.buf.push('(');
2032                self.emit_expr(recv)?;
2033                self.buf.push_str(").reverse()");
2034            }
2035            "set" => {
2036                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
2037                    return Ok(false);
2038                };
2039                self.buf.push_str(
2040                    "(<T,>(__r: T[], __i: number, __x: T): void => { \
2041                     if (__i < 0 || __i >= __r.length) { throw new Error(\
2042                     \"List.set: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
2043                     __r[__i] = __x; })(",
2044                );
2045                self.emit_expr(recv)?;
2046                self.buf.push_str(", ");
2047                self.emit_expr(&idx.value)?;
2048                self.buf.push_str(", ");
2049                self.emit_expr(&x.value)?;
2050                self.buf.push(')');
2051            }
2052            _ => return Ok(false),
2053        }
2054        Ok(true)
2055    }
2056
2057    /// Emit a functional (closure-taking) `List` built-in method call to its TS
2058    /// form.
2059    ///
2060    /// Recognised via [`crate::generator::desugared_list_functional_method`].
2061    /// Mirrors the JS lowering — native `map`/`filter`/`reduce`/`forEach`/`some`/
2062    /// `every`/`flatMap` with the closure passed *once* on the receiver array, so
2063    /// `tsc --strict --noImplicitAny` contextually types each callback parameter
2064    /// from the array's element type (the broken `recv.map(recv, cb)` form both
2065    /// double-passed the receiver *and* left the params implicitly `any`).
2066    /// `fold(init, cb)` maps to `reduce(cb, init)`; `find` wraps the native
2067    /// result into the tagged `BockOption`.
2068    fn try_emit_list_functional_method(
2069        &mut self,
2070        node: &AIRNode,
2071        callee: &AIRNode,
2072        args: &[bock_air::AirArg],
2073    ) -> Result<bool, CodegenError> {
2074        let Some((recv, method, rest)) =
2075            crate::generator::desugared_list_functional_method(node, callee, args)
2076        else {
2077            return Ok(false);
2078        };
2079        match method {
2080            "map" | "filter" | "for_each" | "any" | "all" | "flat_map" => {
2081                let Some(cb) = rest.first() else {
2082                    return Ok(false);
2083                };
2084                let native = match method {
2085                    "map" => "map",
2086                    "filter" => "filter",
2087                    "for_each" => "forEach",
2088                    "any" => "some",
2089                    "all" => "every",
2090                    "flat_map" => "flatMap",
2091                    _ => unreachable!(),
2092                };
2093                self.buf.push('(');
2094                self.emit_expr(recv)?;
2095                let _ = write!(self.buf, ").{native}(");
2096                self.emit_expr(&cb.value)?;
2097                self.buf.push(')');
2098            }
2099            "reduce" => {
2100                let Some(cb) = rest.first() else {
2101                    return Ok(false);
2102                };
2103                self.buf.push('(');
2104                self.emit_expr(recv)?;
2105                self.buf.push_str(").reduce(");
2106                self.emit_expr(&cb.value)?;
2107                self.buf.push(')');
2108            }
2109            "fold" => {
2110                let (Some(init), Some(cb)) = (rest.first(), rest.get(1)) else {
2111                    return Ok(false);
2112                };
2113                self.buf.push('(');
2114                self.emit_expr(recv)?;
2115                self.buf.push_str(").reduce(");
2116                self.emit_expr(&cb.value)?;
2117                self.buf.push_str(", ");
2118                self.emit_expr(&init.value)?;
2119                self.buf.push(')');
2120            }
2121            "find" => {
2122                self.buf.push_str(
2123                    "(<T,>(__r: ReadonlyArray<T>, __p: (x: T) => boolean): BockOption<T> => \
2124                     { const __m = __r.find(__p); return __m === undefined ? \
2125                     { _tag: \"None\" as const } : { _tag: \"Some\" as const, _0: __m }; })(",
2126                );
2127                self.emit_expr(recv)?;
2128                self.buf.push_str(", ");
2129                let Some(cb) = rest.first() else {
2130                    return Ok(false);
2131                };
2132                self.emit_expr(&cb.value)?;
2133                self.buf.push(')');
2134            }
2135            _ => return Ok(false),
2136        }
2137        Ok(true)
2138    }
2139
2140    /// Emit a built-in `Map[K, V]` method call to its TS form (native `Map`).
2141    ///
2142    /// Recognised via [`crate::generator::desugared_map_method`] (gated on
2143    /// `recv_kind = "Map"`) and wired *before* [`Self::try_emit_list_method`].
2144    /// Mirrors the JS lowering but types the generic IIFEs (`<K, V>` params,
2145    /// `BockOption<V>` return for `get`) so `tsc --strict` narrows correctly.
2146    /// `get` returns the tagged `Optional` rep (`{ _tag: "Some" as const, _0: v
2147    /// }` / `{ _tag: "None" as const }`); mutating methods (`set`/`delete`/
2148    /// `merge`) mutate in place and return the receiver. Returns `true` if
2149    /// handled.
2150    fn try_emit_map_method(
2151        &mut self,
2152        node: &AIRNode,
2153        callee: &AIRNode,
2154        args: &[bock_air::AirArg],
2155    ) -> Result<bool, CodegenError> {
2156        let Some((recv, method, rest)) = crate::generator::desugared_map_method(node, callee, args)
2157        else {
2158            return Ok(false);
2159        };
2160        match method {
2161            "len" | "length" | "count" => {
2162                self.buf.push('(');
2163                self.emit_expr(recv)?;
2164                self.buf.push_str(").size");
2165            }
2166            "is_empty" => {
2167                self.buf.push_str("((");
2168                self.emit_expr(recv)?;
2169                self.buf.push_str(").size === 0)");
2170            }
2171            "contains_key" => {
2172                let Some(k) = rest.first() else {
2173                    return Ok(false);
2174                };
2175                self.buf.push('(');
2176                self.emit_expr(recv)?;
2177                self.buf.push_str(").has(");
2178                self.emit_expr(&k.value)?;
2179                self.buf.push(')');
2180            }
2181            "get" => {
2182                let Some(k) = rest.first() else {
2183                    return Ok(false);
2184                };
2185                self.buf.push_str(
2186                    "(<K, V>(__m: Map<K, V>, __k: K): BockOption<V> => __m.has(__k) ? \
2187                     { _tag: \"Some\" as const, _0: __m.get(__k)! } : { _tag: \"None\" as const })(",
2188                );
2189                self.emit_expr(recv)?;
2190                self.buf.push_str(", ");
2191                self.emit_expr(&k.value)?;
2192                self.buf.push(')');
2193            }
2194            "set" => {
2195                let (Some(k), Some(v)) = (rest.first(), rest.get(1)) else {
2196                    return Ok(false);
2197                };
2198                self.buf.push_str(
2199                    "(<K, V>(__m: Map<K, V>, __k: K, __v: V): Map<K, V> => \
2200                     { __m.set(__k, __v); return __m; })(",
2201                );
2202                self.emit_expr(recv)?;
2203                self.buf.push_str(", ");
2204                self.emit_expr(&k.value)?;
2205                self.buf.push_str(", ");
2206                self.emit_expr(&v.value)?;
2207                self.buf.push(')');
2208            }
2209            "delete" => {
2210                let Some(k) = rest.first() else {
2211                    return Ok(false);
2212                };
2213                self.buf.push_str(
2214                    "(<K, V>(__m: Map<K, V>, __k: K): Map<K, V> => \
2215                     { __m.delete(__k); return __m; })(",
2216                );
2217                self.emit_expr(recv)?;
2218                self.buf.push_str(", ");
2219                self.emit_expr(&k.value)?;
2220                self.buf.push(')');
2221            }
2222            "merge" => {
2223                let Some(o) = rest.first() else {
2224                    return Ok(false);
2225                };
2226                self.buf.push_str(
2227                    "(<K, V>(__m: Map<K, V>, __o: Map<K, V>): Map<K, V> => \
2228                     { for (const [__k, __v] of __o) __m.set(__k, __v); return __m; })(",
2229                );
2230                self.emit_expr(recv)?;
2231                self.buf.push_str(", ");
2232                self.emit_expr(&o.value)?;
2233                self.buf.push(')');
2234            }
2235            "filter" => {
2236                let Some(f) = rest.first() else {
2237                    return Ok(false);
2238                };
2239                self.buf.push_str(
2240                    "(<K, V>(__m: Map<K, V>, __f: (k: K, v: V) => boolean): Map<K, V> => \
2241                     { const __r = new Map<K, V>(); \
2242                     for (const [__k, __v] of __m) if (__f(__k, __v)) __r.set(__k, __v); \
2243                     return __r; })(",
2244                );
2245                self.emit_expr(recv)?;
2246                self.buf.push_str(", ");
2247                self.emit_expr(&f.value)?;
2248                self.buf.push(')');
2249            }
2250            "keys" => {
2251                self.buf.push_str("[...(");
2252                self.emit_expr(recv)?;
2253                self.buf.push_str(").keys()]");
2254            }
2255            "values" => {
2256                self.buf.push_str("[...(");
2257                self.emit_expr(recv)?;
2258                self.buf.push_str(").values()]");
2259            }
2260            "entries" | "to_list" => {
2261                self.buf.push_str("[...(");
2262                self.emit_expr(recv)?;
2263                self.buf.push_str(").entries()]");
2264            }
2265            "for_each" => {
2266                let Some(f) = rest.first() else {
2267                    return Ok(false);
2268                };
2269                self.buf.push_str(
2270                    "(<K, V>(__m: Map<K, V>, __f: (k: K, v: V) => void): void => \
2271                     { for (const [__k, __v] of __m) __f(__k, __v); })(",
2272                );
2273                self.emit_expr(recv)?;
2274                self.buf.push_str(", ");
2275                self.emit_expr(&f.value)?;
2276                self.buf.push(')');
2277            }
2278            _ => return Ok(false),
2279        }
2280        Ok(true)
2281    }
2282
2283    /// Emit a built-in `Set[E]` method call to its TS form (native `Set`).
2284    ///
2285    /// Recognised via [`crate::generator::desugared_set_method`] (gated on
2286    /// `recv_kind = "Set"`) and wired *before* [`Self::try_emit_list_method`].
2287    /// Generic-typed IIFEs (`<E>` params) keep `tsc --strict` happy. Mutating
2288    /// methods (`add`/`remove`) mutate in place and return the receiver.
2289    fn try_emit_set_method(
2290        &mut self,
2291        node: &AIRNode,
2292        callee: &AIRNode,
2293        args: &[bock_air::AirArg],
2294    ) -> Result<bool, CodegenError> {
2295        let Some((recv, method, rest)) = crate::generator::desugared_set_method(node, callee, args)
2296        else {
2297            return Ok(false);
2298        };
2299        match method {
2300            "len" | "length" | "count" => {
2301                self.buf.push('(');
2302                self.emit_expr(recv)?;
2303                self.buf.push_str(").size");
2304            }
2305            "is_empty" => {
2306                self.buf.push_str("((");
2307                self.emit_expr(recv)?;
2308                self.buf.push_str(").size === 0)");
2309            }
2310            "contains" => {
2311                let Some(x) = rest.first() else {
2312                    return Ok(false);
2313                };
2314                self.buf.push('(');
2315                self.emit_expr(recv)?;
2316                self.buf.push_str(").has(");
2317                self.emit_expr(&x.value)?;
2318                self.buf.push(')');
2319            }
2320            "add" => {
2321                let Some(x) = rest.first() else {
2322                    return Ok(false);
2323                };
2324                self.buf.push_str(
2325                    "(<E>(__s: Set<E>, __x: E): Set<E> => { __s.add(__x); return __s; })(",
2326                );
2327                self.emit_expr(recv)?;
2328                self.buf.push_str(", ");
2329                self.emit_expr(&x.value)?;
2330                self.buf.push(')');
2331            }
2332            "remove" => {
2333                let Some(x) = rest.first() else {
2334                    return Ok(false);
2335                };
2336                self.buf.push_str(
2337                    "(<E>(__s: Set<E>, __x: E): Set<E> => { __s.delete(__x); return __s; })(",
2338                );
2339                self.emit_expr(recv)?;
2340                self.buf.push_str(", ");
2341                self.emit_expr(&x.value)?;
2342                self.buf.push(')');
2343            }
2344            "union" => {
2345                let Some(o) = rest.first() else {
2346                    return Ok(false);
2347                };
2348                self.buf.push_str(
2349                    "(<E>(__a: Set<E>, __b: Set<E>): Set<E> => new Set<E>([...__a, ...__b]))(",
2350                );
2351                self.emit_expr(recv)?;
2352                self.buf.push_str(", ");
2353                self.emit_expr(&o.value)?;
2354                self.buf.push(')');
2355            }
2356            "intersection" => {
2357                let Some(o) = rest.first() else {
2358                    return Ok(false);
2359                };
2360                self.buf.push_str(
2361                    "(<E>(__a: Set<E>, __b: Set<E>): Set<E> => \
2362                     new Set<E>([...__a].filter((__x) => __b.has(__x))))(",
2363                );
2364                self.emit_expr(recv)?;
2365                self.buf.push_str(", ");
2366                self.emit_expr(&o.value)?;
2367                self.buf.push(')');
2368            }
2369            "difference" => {
2370                let Some(o) = rest.first() else {
2371                    return Ok(false);
2372                };
2373                self.buf.push_str(
2374                    "(<E>(__a: Set<E>, __b: Set<E>): Set<E> => \
2375                     new Set<E>([...__a].filter((__x) => !__b.has(__x))))(",
2376                );
2377                self.emit_expr(recv)?;
2378                self.buf.push_str(", ");
2379                self.emit_expr(&o.value)?;
2380                self.buf.push(')');
2381            }
2382            "is_subset" => {
2383                let Some(o) = rest.first() else {
2384                    return Ok(false);
2385                };
2386                self.buf.push_str(
2387                    "(<E>(__a: Set<E>, __b: Set<E>): boolean => \
2388                     [...__a].every((__x) => __b.has(__x)))(",
2389                );
2390                self.emit_expr(recv)?;
2391                self.buf.push_str(", ");
2392                self.emit_expr(&o.value)?;
2393                self.buf.push(')');
2394            }
2395            "is_superset" => {
2396                let Some(o) = rest.first() else {
2397                    return Ok(false);
2398                };
2399                self.buf.push_str(
2400                    "(<E>(__a: Set<E>, __b: Set<E>): boolean => \
2401                     [...__b].every((__x) => __a.has(__x)))(",
2402                );
2403                self.emit_expr(recv)?;
2404                self.buf.push_str(", ");
2405                self.emit_expr(&o.value)?;
2406                self.buf.push(')');
2407            }
2408            "filter" => {
2409                let Some(f) = rest.first() else {
2410                    return Ok(false);
2411                };
2412                self.buf.push_str(
2413                    "(<E>(__s: Set<E>, __f: (x: E) => boolean): Set<E> => \
2414                     new Set<E>([...__s].filter(__f)))(",
2415                );
2416                self.emit_expr(recv)?;
2417                self.buf.push_str(", ");
2418                self.emit_expr(&f.value)?;
2419                self.buf.push(')');
2420            }
2421            "map" => {
2422                let Some(f) = rest.first() else {
2423                    return Ok(false);
2424                };
2425                self.buf.push_str(
2426                    "(<E>(__s: Set<E>, __f: (x: E) => E): Set<E> => \
2427                     new Set<E>([...__s].map(__f)))(",
2428                );
2429                self.emit_expr(recv)?;
2430                self.buf.push_str(", ");
2431                self.emit_expr(&f.value)?;
2432                self.buf.push(')');
2433            }
2434            "to_list" => {
2435                self.buf.push_str("[...(");
2436                self.emit_expr(recv)?;
2437                self.buf.push_str(")]");
2438            }
2439            "for_each" => {
2440                let Some(f) = rest.first() else {
2441                    return Ok(false);
2442                };
2443                self.buf.push_str(
2444                    "(<E>(__s: Set<E>, __f: (x: E) => void): void => \
2445                     { for (const __x of __s) __f(__x); })(",
2446                );
2447                self.emit_expr(recv)?;
2448                self.buf.push_str(", ");
2449                self.emit_expr(&f.value)?;
2450                self.buf.push(')');
2451            }
2452            _ => return Ok(false),
2453        }
2454        Ok(true)
2455    }
2456
2457    /// Lower a primitive trait-bridge method call (`compare`/`eq`/`to_string`/
2458    /// `display` on a primitive receiver) to its TS form.
2459    ///
2460    /// Mirrors the JS lowering, but uses `as const` tags so the ternary's type
2461    /// is the discriminated `Ordering` union `tsc` can narrow on `._tag` in the
2462    /// match. `eq` → `===`; `to_string`/`display` → `String(x)`.
2463    /// Lower a desugared `String` built-in method call (`recv_kind =
2464    /// "Primitive:String"`) to its native TypeScript string op. Wired into the
2465    /// `Call` arm *before* `try_emit_list_method` so a String receiver's
2466    /// `len`/`contains`/`is_empty` dispatch here, not through the List path.
2467    ///
2468    /// `len` is the Unicode SCALAR count (`[...s].length`, iterating by code
2469    /// point) per spec §18.3 — not `s.length` (UTF-16 code units). `byte_len` is
2470    /// the UTF-8 byte count via `TextEncoder`. `replace` replaces ALL occurrences
2471    /// (`replaceAll`). `split` returns a TS array, the List runtime rep.
2472    ///
2473    /// Gated on `recv_kind = "Primitive:String"` directly (not the cross-backend
2474    /// [`crate::generator::desugared_string_method`] subset) so TS can lower the
2475    /// wider resolved String surface — `slice`/`substring`/`char_at`/`index_of`/
2476    /// `repeat`/`reverse`/`trim_start`/`trim_end` — to native ops, matching the
2477    /// Rust backend. The emitted forms are kept type-clean under `--strict`: the
2478    /// IIFE params are annotated (`number`/`string`) so no implicit `any` arises,
2479    /// and `char_at`/`index_of` return the typed `BockOption<…>` union (`{ _tag:
2480    /// "Some" as const, _0: v }` / `{ _tag: "None" as const }`).
2481    fn try_emit_string_method(
2482        &mut self,
2483        node: &AIRNode,
2484        callee: &AIRNode,
2485        args: &[bock_air::AirArg],
2486    ) -> Result<bool, CodegenError> {
2487        if crate::generator::primitive_recv_kind(node) != Some("String") {
2488            return Ok(false);
2489        }
2490        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
2491            return Ok(false);
2492        };
2493        let method = field.name.as_str();
2494        let recv_str = self.expr_to_string(recv)?;
2495        let arg0 = |this: &mut Self| -> Result<Option<String>, CodegenError> {
2496            rest.first()
2497                .map(|a| this.expr_to_string(&a.value))
2498                .transpose()
2499        };
2500        let code = match method {
2501            "len" | "length" | "count" => format!("[...({recv_str})].length"),
2502            "byte_len" => format!("new TextEncoder().encode({recv_str}).length"),
2503            "is_empty" => format!("(({recv_str}).length === 0)"),
2504            "to_upper" => format!("({recv_str}).toUpperCase()"),
2505            "to_lower" => format!("({recv_str}).toLowerCase()"),
2506            "trim" => format!("({recv_str}).trim()"),
2507            "trim_start" => format!("({recv_str}).trimStart()"),
2508            "trim_end" => format!("({recv_str}).trimEnd()"),
2509            "reverse" => format!("[...({recv_str})].reverse().join('')"),
2510            "to_string" | "display" => format!("String({recv_str})"),
2511            "repeat" => {
2512                let Some(n) = arg0(self)? else {
2513                    return Ok(false);
2514                };
2515                format!("({recv_str}).repeat({n})")
2516            }
2517            "contains" => {
2518                let Some(p) = arg0(self)? else {
2519                    return Ok(false);
2520                };
2521                format!("({recv_str}).includes({p})")
2522            }
2523            "starts_with" => {
2524                let Some(p) = arg0(self)? else {
2525                    return Ok(false);
2526                };
2527                format!("({recv_str}).startsWith({p})")
2528            }
2529            "ends_with" => {
2530                let Some(p) = arg0(self)? else {
2531                    return Ok(false);
2532                };
2533                format!("({recv_str}).endsWith({p})")
2534            }
2535            "replace" => {
2536                let Some(from) = arg0(self)? else {
2537                    return Ok(false);
2538                };
2539                let Some(to) = rest
2540                    .get(1)
2541                    .map(|a| self.expr_to_string(&a.value))
2542                    .transpose()?
2543                else {
2544                    return Ok(false);
2545                };
2546                format!("({recv_str}).replaceAll({from}, {to})")
2547            }
2548            "split" => {
2549                let Some(sep) = arg0(self)? else {
2550                    return Ok(false);
2551                };
2552                format!("({recv_str}).split({sep})")
2553            }
2554            // `slice`/`substring(start, end)`: scalar-index half-open substring
2555            // (spec §18.3 — indices count Unicode scalars). Iterate by code point.
2556            "slice" | "substring" => {
2557                let Some(start) = arg0(self)? else {
2558                    return Ok(false);
2559                };
2560                let Some(end) = rest
2561                    .get(1)
2562                    .map(|a| self.expr_to_string(&a.value))
2563                    .transpose()?
2564                else {
2565                    return Ok(false);
2566                };
2567                format!("[...({recv_str})].slice({start}, {end}).join('')")
2568            }
2569            // `char_at(i)` returns `Optional[Char]` — `None` when out of range.
2570            "char_at" => {
2571                let Some(i) = arg0(self)? else {
2572                    return Ok(false);
2573                };
2574                self.needs_runtime_optional = true;
2575                format!(
2576                    "((__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})"
2577                )
2578            }
2579            // `index_of(needle)` returns `Optional[Int]` — the scalar index of the
2580            // first match, or `None`. TS `indexOf` is a UTF-16 code-unit offset, so
2581            // convert it to a scalar index via the code-point prefix length.
2582            "index_of" => {
2583                let Some(p) = arg0(self)? else {
2584                    return Ok(false);
2585                };
2586                self.needs_runtime_optional = true;
2587                format!(
2588                    "((__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})"
2589                )
2590            }
2591            _ => return Ok(false),
2592        };
2593        self.buf.push_str(&code);
2594        Ok(true)
2595    }
2596
2597    /// Lower a desugared numeric/`Char`/`Bool` primitive method (`recv_kind =
2598    /// "Primitive:Int" | "Primitive:Float" | "Primitive:Char" | "Primitive:Bool"`)
2599    /// to its native TypeScript form. Covers the conversion and math methods the
2600    /// checker resolves on the scalar primitives — `to_float`/`to_int`/`abs`/`min`/
2601    /// `max`/`clamp`/`floor`/`ceil`/`round`/`sqrt`/… — none of which exist as
2602    /// methods on a TS `number`/`boolean`/string-char. Wired into the `Call` arm
2603    /// alongside [`Self::try_emit_string_method`], before the generic
2604    /// desugared-self-call fall-through (which would emit `n.to_float(n)`).
2605    /// `compare`/`eq`/`to_string`/`display`/`hash_code` stay on the primitive
2606    /// *bridge* path. The emitted forms are type-clean (`number` in, `number` out).
2607    fn try_emit_numeric_method(
2608        &mut self,
2609        node: &AIRNode,
2610        callee: &AIRNode,
2611        args: &[bock_air::AirArg],
2612    ) -> Result<bool, CodegenError> {
2613        let prim = match crate::generator::primitive_recv_kind(node) {
2614            Some(p @ ("Int" | "Float" | "Char" | "Bool")) => p,
2615            _ => return Ok(false),
2616        };
2617        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
2618            return Ok(false);
2619        };
2620        let method = field.name.as_str();
2621        let recv_str = self.expr_to_string(recv)?;
2622        let arg = |this: &mut Self, i: usize| -> Result<Option<String>, CodegenError> {
2623            rest.get(i)
2624                .map(|a| this.expr_to_string(&a.value))
2625                .transpose()
2626        };
2627        let code = match (prim, method) {
2628            // Conversions. Both `Int` and `Float` are a TS `number`; `to_int`
2629            // truncates toward zero (Bock `Float.to_int`).
2630            ("Int", "to_float") => format!("({recv_str})"),
2631            ("Float", "to_int") => format!("Math.trunc({recv_str})"),
2632            ("Char", "to_int") => format!("(({recv_str}).codePointAt(0) ?? 0)"),
2633            ("Bool", "to_int") => format!("(({recv_str}) ? 1 : 0)"),
2634            // Int math.
2635            ("Int", "abs") => format!("Math.abs({recv_str})"),
2636            ("Int" | "Float", "min") => {
2637                let Some(o) = arg(self, 0)? else {
2638                    return Ok(false);
2639                };
2640                format!("Math.min({recv_str}, {o})")
2641            }
2642            ("Int" | "Float", "max") => {
2643                let Some(o) = arg(self, 0)? else {
2644                    return Ok(false);
2645                };
2646                format!("Math.max({recv_str}, {o})")
2647            }
2648            ("Int" | "Float", "clamp") => {
2649                let (Some(lo), Some(hi)) = (arg(self, 0)?, arg(self, 1)?) else {
2650                    return Ok(false);
2651                };
2652                format!("Math.min(Math.max({recv_str}, {lo}), {hi})")
2653            }
2654            ("Int", "shift_left") => {
2655                let Some(o) = arg(self, 0)? else {
2656                    return Ok(false);
2657                };
2658                format!("(({recv_str}) << ({o}))")
2659            }
2660            ("Int", "shift_right") => {
2661                let Some(o) = arg(self, 0)? else {
2662                    return Ok(false);
2663                };
2664                format!("(({recv_str}) >> ({o}))")
2665            }
2666            // Float math.
2667            ("Float", "abs") => format!("Math.abs({recv_str})"),
2668            ("Float", "floor") => format!("Math.floor({recv_str})"),
2669            ("Float", "ceil") => format!("Math.ceil({recv_str})"),
2670            ("Float", "round") => format!("Math.round({recv_str})"),
2671            ("Float", "sqrt") => format!("Math.sqrt({recv_str})"),
2672            ("Float", "is_nan") => format!("Number.isNaN({recv_str})"),
2673            ("Float", "is_infinite") => format!("(!Number.isFinite({recv_str}))"),
2674            // Bool.
2675            ("Bool", "negate") => format!("(!({recv_str}))"),
2676            // Char (a one-code-point TS string).
2677            ("Char", "to_upper") => format!("({recv_str}).toUpperCase()"),
2678            ("Char", "to_lower") => format!("({recv_str}).toLowerCase()"),
2679            ("Char", "is_alpha") => format!("(/\\p{{L}}/u.test({recv_str}))"),
2680            ("Char", "is_digit") => format!("(/[0-9]/.test({recv_str}))"),
2681            ("Char", "is_whitespace") => format!("(/\\s/.test({recv_str}))"),
2682            _ => return Ok(false),
2683        };
2684        self.buf.push_str(&code);
2685        Ok(true)
2686    }
2687
2688    fn try_emit_primitive_bridge(
2689        &mut self,
2690        node: &AIRNode,
2691        callee: &AIRNode,
2692        args: &[bock_air::AirArg],
2693    ) -> Result<bool, CodegenError> {
2694        let Some((recv, method, rest, prim)) =
2695            crate::generator::primitive_bridge_call(node, callee, args)
2696        else {
2697            return Ok(false);
2698        };
2699        self.emit_bridge_method(recv, method, rest, Some(prim))
2700    }
2701
2702    /// Lower a sealed-core-trait bridge method on a *bounded generic type
2703    /// variable* (`a.eq(b)` / `a.compare(b)` inside `eq_check[T: Equatable]`) to
2704    /// its TS form (GAP-C). The method body is identical to the `Primitive:<Ty>`
2705    /// bridge; the `<T extends Equatable>` bound is separately dropped at the
2706    /// signature (see `generic_params_to_ts`). Fires only when the bound trait is
2707    /// sealed-core and NOT a user-declared trait.
2708    fn try_emit_trait_bound_bridge(
2709        &mut self,
2710        node: &AIRNode,
2711        callee: &AIRNode,
2712        args: &[bock_air::AirArg],
2713    ) -> Result<bool, CodegenError> {
2714        let Some((recv, method, rest, _tr)) =
2715            crate::generator::trait_bound_bridge_call(node, callee, args, &self.trait_decls)
2716        else {
2717            return Ok(false);
2718        };
2719        // DQ29: unlike the `Primitive:<Ty>` bridge (whose receiver is a known
2720        // scalar, where `===` is correct), a bounded `T: Equatable` receiver
2721        // may be instantiated with a RECORD — `===` would be reference
2722        // identity — so `a.eq(b)` lowers through the `__bockEq` structural
2723        // helper, which falls back to `===` for primitives.
2724        if method == "eq" {
2725            let Some(other) = rest.first() else {
2726                return Ok(false);
2727            };
2728            let recv_str = self.expr_to_string(recv)?;
2729            let other = self.expr_to_string(&other.value)?;
2730            let _ = write!(self.buf, "__bockEq({recv_str}, {other})");
2731            return Ok(true);
2732        }
2733        // Q-bounded-comparable-codegen: a bounded `T: Comparable` `compare`
2734        // receiver may be instantiated with a RECORD whose ordering lives in its
2735        // own `compare` method — the native `<`/`===` ternary the
2736        // `emit_bridge_method` `compare` arm emits is correct ONLY for a
2737        // primitive instantiation (object `<` coerces to `NaN`; `===` is
2738        // reference identity). Route through `__bockCompare`, which calls the
2739        // value's `compare` method when present and falls back to the native
2740        // ternary for primitives.
2741        if method == "compare" {
2742            let Some(other) = rest.first() else {
2743                return Ok(false);
2744            };
2745            let recv_str = self.expr_to_string(recv)?;
2746            let other = self.expr_to_string(&other.value)?;
2747            self.needs_runtime_compare = true;
2748            let _ = write!(self.buf, "__bockCompare({recv_str}, {other})");
2749            return Ok(true);
2750        }
2751        self.emit_bridge_method(recv, method, rest, None)
2752    }
2753
2754    /// Shared body of the primitive / trait-bound bridges: emit the native TS form
2755    /// of `compare` (the `Ordering` ternary), `eq` (`===`), or `to_string`/
2756    /// `display` (`String(..)`).
2757    ///
2758    /// `widen_prim` is the receiver's primitive type name (`"Int"`, `"Float"`,
2759    /// …) when this is the concrete `Primitive:<Ty>` bridge, or `None` for the
2760    /// trait-bound bridge (whose receiver is a generic `T`). When present, the
2761    /// `eq` arm widens the receiver with a `as <ts-type>` assertion before
2762    /// `===`: two distinct *literal* operands (`(3).eq(4)`) would otherwise be
2763    /// inferred as the literal types `3`/`4`, which `tsc` rejects under
2764    /// `strictNullChecks` as TS2367 ("this comparison appears to be
2765    /// unintentional because the types '3' and '4' have no overlap"). The cast
2766    /// widens the `3` to `number`, so the comparison is between `number` and a
2767    /// literal — overlapping, hence accepted — while the runtime `===` is
2768    /// unchanged. (Q-ts-primitive-eq-literal-overlap.)
2769    fn emit_bridge_method(
2770        &mut self,
2771        recv: &AIRNode,
2772        method: &str,
2773        rest: &[bock_air::AirArg],
2774        widen_prim: Option<&str>,
2775    ) -> Result<bool, CodegenError> {
2776        let recv_str = self.expr_to_string(recv)?;
2777        match method {
2778            "compare" => {
2779                let Some(other) = rest.first() else {
2780                    return Ok(false);
2781                };
2782                let other = self.expr_to_string(&other.value)?;
2783                let _ = write!(
2784                    self.buf,
2785                    "(({recv_str}) < ({other}) ? {{ _tag: \"Less\" as const }} : \
2786                     (({recv_str}) === ({other}) ? {{ _tag: \"Equal\" as const }} : \
2787                     {{ _tag: \"Greater\" as const }}))"
2788                );
2789            }
2790            "eq" => {
2791                let Some(other) = rest.first() else {
2792                    return Ok(false);
2793                };
2794                let other = self.expr_to_string(&other.value)?;
2795                // Widen the receiver to its primitive TS type so two distinct
2796                // literal operands (`(3).eq(4)`) compare as `number === 3`
2797                // rather than the literal-only `3 === 4` (TS2367). See the
2798                // method doc-comment.
2799                if let Some(prim) = widen_prim {
2800                    let ts_ty = self.map_type_name(prim);
2801                    let _ = write!(self.buf, "(({recv_str} as {ts_ty}) === ({other}))");
2802                } else {
2803                    let _ = write!(self.buf, "(({recv_str}) === ({other}))");
2804                }
2805            }
2806            "to_string" | "display" => {
2807                let _ = write!(self.buf, "String({recv_str})");
2808            }
2809            _ => return Ok(false),
2810        }
2811        Ok(true)
2812    }
2813
2814    // ── Type emission ────────────────────────────────────────────────────────
2815
2816    /// Emit a type expression from an AIR type node to a TS type string.
2817    fn type_to_ts(&self, node: &AIRNode) -> String {
2818        match &node.kind {
2819            NodeKind::TypeNamed { path, args } => {
2820                let name = path
2821                    .segments
2822                    .iter()
2823                    .map(|s| s.name.as_str())
2824                    .collect::<Vec<_>>()
2825                    .join(".");
2826                let ts_name = self.map_type_name(&name);
2827                if args.is_empty() {
2828                    ts_name
2829                } else {
2830                    let arg_strs: Vec<String> = args.iter().map(|a| self.type_to_ts(a)).collect();
2831                    format!("{ts_name}<{}>", arg_strs.join(", "))
2832                }
2833            }
2834            NodeKind::TypeTuple { elems } => {
2835                let elem_strs: Vec<String> = elems.iter().map(|e| self.type_to_ts(e)).collect();
2836                format!("[{}]", elem_strs.join(", "))
2837            }
2838            NodeKind::TypeFunction { params, ret, .. } => {
2839                let param_strs: Vec<String> = params
2840                    .iter()
2841                    .enumerate()
2842                    .map(|(i, p)| format!("arg{i}: {}", self.type_to_ts(p)))
2843                    .collect();
2844                format!("({}) => {}", param_strs.join(", "), self.type_to_ts(ret))
2845            }
2846            NodeKind::TypeOptional { inner } => {
2847                // `T?` lowers to the tagged Optional runtime union, not `T |
2848                // null`: the value is `{ _tag: "Some", _0: v }` / `{ _tag:
2849                // "None" }`, so the type must describe that. See
2850                // `OPTIONAL_RUNTIME_TS`.
2851                format!("BockOption<{}>", self.type_to_ts(inner))
2852            }
2853            NodeKind::TypeSelf => self
2854                .trait_self_subst
2855                .clone()
2856                .unwrap_or_else(|| "this".into()),
2857            _ => "unknown".into(),
2858        }
2859    }
2860
2861    /// Map Bock type names to TS equivalents.
2862    /// True when the real `core.compare.Ordering` enum is reachable in this
2863    /// program (its `Less` variant is a registered user enum variant). When
2864    /// `core.compare` is `use`d, the actual `Ordering` union type is emitted and
2865    /// imported; otherwise the prelude form (a bare structural `{ _tag: … }`
2866    /// union) is used. Mirrors the rust/go backends.
2867    fn ordering_enum_reachable(&self) -> bool {
2868        self.enum_variants
2869            .get("Less")
2870            .is_some_and(|info| info.enum_name == "Ordering")
2871    }
2872
2873    fn map_type_name(&self, name: &str) -> String {
2874        match name {
2875            "Int" => "number".into(),
2876            "Float" => "number".into(),
2877            "Bool" => "boolean".into(),
2878            "String" => "string".into(),
2879            // Bock `Char` has no distinct TS runtime type; a Char literal
2880            // (`'A'`) emits as a single-character string and every Char method
2881            // (`to_upper`, `is_alpha`, …) is implemented as a string op, so the
2882            // type maps to `string` for consistency.
2883            "Char" => "string".into(),
2884            "Void" | "Unit" => "void".into(),
2885            "List" => "Array".into(),
2886            "Map" => "Map".into(),
2887            "Set" => "Set".into(),
2888            "Any" => "any".into(),
2889            "Never" => "never".into(),
2890            // `Result[T, E]` lowers to the tagged-union runtime type, mirroring
2891            // `Optional[T]` → `BockOption<T>` (see `RESULT_RUNTIME_TS`).
2892            "Result" => "BockResult".into(),
2893            // The spelled-out `Optional[T]` (a named type application, distinct
2894            // from the `T?` shorthand handled by the `TypeOptional` arm) must
2895            // also lower to the tagged runtime union `BockOption<T>`, matching
2896            // the emitted `{ _tag: "Some", _0: v }` / `{ _tag: "None" }` value
2897            // representation. Without this it emitted a bare `Optional<T>`
2898            // (TS2304, undefined name).
2899            "Optional" => "BockOption".into(),
2900            // §18.3.1 builtin time types: a `Duration` value is stored as a
2901            // signed-nanosecond `number`, and an `Instant` likewise lowers to a
2902            // `number` (`performance.now() * 1e6`). They are NOT user-defined
2903            // types, so as annotations (e.g. on a `Clock` handler's
2904            // `now_monotonic()` / `sleep(duration: Duration)`) they must render
2905            // `number`, not the undefined identifiers `Duration`/`Instant`.
2906            "Duration" | "Instant" => "number".into(),
2907            // The prelude `Ordering` enum: when the real `core.compare.Ordering`
2908            // is NOT reachable (no `use core.compare`), its variants lower to a
2909            // bare structural tagged union (`{ _tag: "Less" }` …), so a
2910            // `-> Ordering` annotation must render that union — the bare
2911            // `Ordering` is an undefined TS name (TS2304). When the enum IS
2912            // reachable the imported `Ordering` union type is in scope and keeps
2913            // its name. (Q-prelude-impl-missing-import.)
2914            "Ordering" if !self.ordering_enum_reachable() => {
2915                "({ _tag: \"Less\" } | { _tag: \"Equal\" } | { _tag: \"Greater\" })".into()
2916            }
2917            other => other.into(),
2918        }
2919    }
2920
2921    /// Emit an AST TypeExpr to a TS type string (for record fields).
2922    fn ast_type_to_ts(&self, ty: &TypeExpr) -> String {
2923        match ty {
2924            TypeExpr::Named { path, args, .. } => {
2925                let name = path
2926                    .segments
2927                    .iter()
2928                    .map(|s| s.name.as_str())
2929                    .collect::<Vec<_>>()
2930                    .join(".");
2931                let ts_name = self.map_type_name(&name);
2932                if args.is_empty() {
2933                    ts_name
2934                } else {
2935                    let arg_strs: Vec<String> =
2936                        args.iter().map(|a| self.ast_type_to_ts(a)).collect();
2937                    format!("{ts_name}<{}>", arg_strs.join(", "))
2938                }
2939            }
2940            TypeExpr::Tuple { elems, .. } => {
2941                let elem_strs: Vec<String> = elems.iter().map(|e| self.ast_type_to_ts(e)).collect();
2942                format!("[{}]", elem_strs.join(", "))
2943            }
2944            TypeExpr::Function { params, ret, .. } => {
2945                let param_strs: Vec<String> = params
2946                    .iter()
2947                    .enumerate()
2948                    .map(|(i, p)| format!("arg{i}: {}", self.ast_type_to_ts(p)))
2949                    .collect();
2950                format!(
2951                    "({}) => {}",
2952                    param_strs.join(", "),
2953                    self.ast_type_to_ts(ret)
2954                )
2955            }
2956            TypeExpr::Optional { inner, .. } => {
2957                // See the `TypeOptional` arm of `type_to_ts`: the tagged
2958                // Optional union must match the emitted tagged-object value.
2959                format!("BockOption<{}>", self.ast_type_to_ts(inner))
2960            }
2961            TypeExpr::SelfType { .. } => "this".into(),
2962        }
2963    }
2964
2965    /// Emit generic parameter list: `<T, U extends Foo>`.
2966    /// Resolve the generic params that apply to an `impl` target: the impl's own
2967    /// params when present (`impl[T] Box[T] { ... }`), else the params declared
2968    /// on the target record/enum (`impl Box { ... }` where `T` lives on
2969    /// `record Box[T]`). Empty for a non-generic target.
2970    fn impl_target_generics(
2971        &self,
2972        impl_params: &[bock_ast::GenericParam],
2973        target_name: &str,
2974    ) -> Vec<bock_ast::GenericParam> {
2975        if !impl_params.is_empty() {
2976            return impl_params.to_vec();
2977        }
2978        self.generic_decls
2979            .get(target_name)
2980            .cloned()
2981            .unwrap_or_default()
2982    }
2983
2984    /// Render a *use-site* generic argument list (`<T>`, `<T, U>`) — the bare
2985    /// param names, no `extends` bounds — for a type reference like `Box<T>`.
2986    /// Empty string for no params.
2987    fn generic_param_args(&self, params: &[bock_ast::GenericParam]) -> String {
2988        if params.is_empty() {
2989            return String::new();
2990        }
2991        let names: Vec<&str> = params.iter().map(|p| p.name.name.as_str()).collect();
2992        format!("<{}>", names.join(", "))
2993    }
2994
2995    /// Render the combined generic-parameter declaration for an impl method's
2996    /// prototype function: the target type's params (with bounds) followed by
2997    /// the method's own params. Used because the prototype assignment lives
2998    /// outside the class, so its function must re-declare the class's `<T>`.
2999    fn merge_generic_params_to_ts(
3000        &self,
3001        target_params: &[bock_ast::GenericParam],
3002        method_params: &[bock_ast::GenericParam],
3003    ) -> String {
3004        let mut merged = target_params.to_vec();
3005        merged.extend(method_params.iter().cloned());
3006        self.generic_params_to_ts(&merged)
3007    }
3008
3009    fn generic_params_to_ts(&self, params: &[bock_ast::GenericParam]) -> String {
3010        if params.is_empty() {
3011            return String::new();
3012        }
3013        let items: Vec<String> = params
3014            .iter()
3015            .map(|p| {
3016                // Drop a compiler-provided sealed-core bound (`Equatable`/…) with no
3017                // user `impl`: TS has no such type, so `<T extends Equatable>` fails
3018                // `tsc` with TS2304 (GAP-C). The method is lowered to a native
3019                // operator (`===`/comparison) by `try_emit_trait_bound_bridge`, so
3020                // an unconstrained `<T>` is correct. A user-declared trait of the
3021                // same name keeps its `extends` bound.
3022                let bounds: Vec<String> = p
3023                    .bounds
3024                    .iter()
3025                    .map(|b| {
3026                        b.segments
3027                            .iter()
3028                            .map(|s| s.name.as_str())
3029                            .collect::<Vec<_>>()
3030                            .join(".")
3031                    })
3032                    .filter(|name| {
3033                        !crate::generator::is_unimplemented_sealed_core_trait(
3034                            name,
3035                            &self.trait_decls,
3036                        )
3037                    })
3038                    .collect();
3039                if bounds.is_empty() {
3040                    p.name.name.clone()
3041                } else {
3042                    format!("{} extends {}", p.name.name, bounds.join(" & "))
3043                }
3044            })
3045            .collect();
3046        format!("<{}>", items.join(", "))
3047    }
3048
3049    /// Use-site type-argument list for a generic declaration: just the
3050    /// parameter names (`<T>`, `<T, U>`), with no `extends` bounds. This is the
3051    /// form a *reference* to the generic type takes (e.g. a variant interface
3052    /// named inside its enum's union alias, or a constructor factory's return
3053    /// type), as distinct from [`Self::generic_params_to_ts`], which is the
3054    /// *declaration* form that carries the bounds.
3055    ///
3056    /// Mismatching the two — declaring `interface Box_Full<T>` but referencing
3057    /// it as a bare `Box_Full` in the union alias `type Box<T> = Box_Full | …`
3058    /// — fails `tsc` with TS2314 ("Generic type requires N type argument(s)"),
3059    /// because the alias body supplies the wrong type-argument arity. Both the
3060    /// union alias and the variant factories must reference each variant
3061    /// interface at its declared arity, which this helper supplies.
3062    fn generic_args_to_ts(&self, params: &[bock_ast::GenericParam]) -> String {
3063        if params.is_empty() {
3064            return String::new();
3065        }
3066        let items: Vec<String> = params.iter().map(|p| p.name.name.clone()).collect();
3067        format!("<{}>", items.join(", "))
3068    }
3069
3070    // ── Top-level dispatch ──────────────────────────────────────────────────
3071
3072    fn emit_node(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
3073        self.mark_span(node.span);
3074        match &node.kind {
3075            NodeKind::Module { items, imports, .. } => {
3076                // Field/method name-collision set (camelCased). Pre-seeded
3077                // program-wide by `generate_project` so a call site in one file
3078                // agrees with the renamed method declared in another; extended
3079                // here so the single-module `generate_module` path (no pre-seed)
3080                // is also covered.
3081                self.field_method_collisions
3082                    .extend(crate::generator::collect_record_field_names(
3083                        node,
3084                        to_camel_case,
3085                    ));
3086                if self.per_module {
3087                    // Per-module native-import path (the real build): each module
3088                    // is emitted to its own `.ts` file and the runtime
3089                    // types/helpers live in the shared `_bock_runtime.ts`. Record
3090                    // which the module references; `generate_project` emits them
3091                    // once into the shared module, and `emit_esm_imports` imports
3092                    // them here.
3093                    if module_uses_optional(items) {
3094                        self.needs_runtime_optional = true;
3095                    }
3096                    if module_uses_result(items) {
3097                        self.needs_runtime_result = true;
3098                    }
3099                    if module_uses_concurrency(items) {
3100                        self.needs_runtime_concurrency = true;
3101                    }
3102                    if module_uses_range(items) {
3103                        self.needs_runtime_range = true;
3104                    }
3105                    if module_uses_eq(items) {
3106                        self.needs_runtime_eq = true;
3107                    }
3108                    if module_uses_compare(items) {
3109                        self.needs_runtime_compare = true;
3110                    }
3111                    if module_uses_str(items) {
3112                        self.needs_runtime_str = true;
3113                    }
3114                    self.emit_esm_imports(imports)?;
3115                } else {
3116                    // Single-module self-contained emit (`generate_module`, used
3117                    // by unit tests): the module's runtime preludes are inlined
3118                    // into this one file and `ImportDecl`s are dropped. Each
3119                    // prelude is inlined at most once, gated on a ctx flag (a
3120                    // duplicate `type Option<T>` would be a TS redeclaration).
3121                    if !self.optional_runtime_emitted && module_uses_optional(items) {
3122                        self.buf.push_str(OPTIONAL_RUNTIME_TS);
3123                        self.buf.push('\n');
3124                        self.optional_runtime_emitted = true;
3125                    }
3126                    if !self.result_runtime_emitted && module_uses_result(items) {
3127                        self.buf.push_str(RESULT_RUNTIME_TS);
3128                        self.buf.push('\n');
3129                        self.result_runtime_emitted = true;
3130                    }
3131                    if !self.concurrency_runtime_emitted && module_uses_concurrency(items) {
3132                        self.buf.push_str(CONCURRENCY_RUNTIME_TS);
3133                        self.buf.push('\n');
3134                        self.concurrency_runtime_emitted = true;
3135                    }
3136                    if !self.range_runtime_emitted && module_uses_range(items) {
3137                        self.buf.push_str(RANGE_RUNTIME_TS);
3138                        self.buf.push('\n');
3139                        self.range_runtime_emitted = true;
3140                    }
3141                    if !self.compare_runtime_emitted && module_uses_compare(items) {
3142                        self.buf.push_str(COMPARE_RUNTIME_TS);
3143                        self.buf.push('\n');
3144                        self.compare_runtime_emitted = true;
3145                    }
3146                    if !self.str_runtime_emitted && module_uses_str(items) {
3147                        self.buf.push_str(STR_RUNTIME_TS);
3148                        self.buf.push('\n');
3149                        self.str_runtime_emitted = true;
3150                    }
3151                    if !self.eq_runtime_emitted && module_uses_eq(items) {
3152                        self.buf.push_str(EQ_RUNTIME_TS);
3153                        self.buf.push('\n');
3154                        self.eq_runtime_emitted = true;
3155                    }
3156                }
3157                // `@test` functions are transpiled separately into Vitest/Jest
3158                // test files (project mode, §20.6.2 — see `generate_tests`), never
3159                // into the runtime module tree: their `expect(...)` assertion DSL
3160                // has no runtime definition in the emitted source.
3161                let mut first = true;
3162                for item in items.iter() {
3163                    if crate::generator::fn_is_test(item) {
3164                        continue;
3165                    }
3166                    if !first {
3167                        self.buf.push('\n');
3168                    }
3169                    first = false;
3170                    self.emit_node(item)?;
3171                }
3172                // Per-module path: re-export enum-variant values (everything else
3173                // exports inline). Emitted once after all items.
3174                if self.per_module {
3175                    self.emit_trailing_exports();
3176                }
3177                Ok(())
3178            }
3179            NodeKind::ImportDecl { .. } => {
3180                // Resolved by the real ESM imports emitted up front by
3181                // `emit_esm_imports` (per-module path), or dropped entirely in
3182                // the single-module self-contained path. Either way a no-op here.
3183                Ok(())
3184            }
3185            NodeKind::FnDecl {
3186                visibility,
3187                is_async,
3188                name,
3189                generic_params,
3190                params,
3191                return_type,
3192                effect_clause,
3193                where_clause,
3194                body,
3195                ..
3196            } => {
3197                // Fold any `where`-clause trait bounds onto the generic params so
3198                // the `<T extends Bound>` constraint is emitted for a
3199                // `where`-bounded fn — local or imported (the imported fn is
3200                // emitted in its own module file with its reconstructed
3201                // `where`-clause, PR #286).
3202                let merged = crate::generator::merge_where_bounds_into_generics(
3203                    generic_params,
3204                    where_clause,
3205                );
3206                self.emit_fn_decl(
3207                    *visibility,
3208                    *is_async,
3209                    &name.name,
3210                    &merged,
3211                    params,
3212                    return_type.as_deref(),
3213                    effect_clause,
3214                    body,
3215                    false,
3216                )
3217            }
3218            NodeKind::RecordDecl {
3219                visibility,
3220                name,
3221                generic_params,
3222                fields,
3223                ..
3224            } => {
3225                let export = if matches!(visibility, Visibility::Public) {
3226                    "export "
3227                } else {
3228                    ""
3229                };
3230                let generics = self.generic_params_to_ts(generic_params);
3231                self.record_names.insert(name.name.clone());
3232                if fields.is_empty() {
3233                    self.writeln(&format!("{export}class {}{generics} {{}}", name.name));
3234                } else {
3235                    self.writeln(&format!("{export}class {}{generics} {{", name.name));
3236                    self.indent += 1;
3237                    for f in fields {
3238                        let ty = self.ast_type_to_ts(&f.ty);
3239                        self.writeln(&format!("{}: {};", f.name.name, ty));
3240                    }
3241                    let init_fields: Vec<String> = fields
3242                        .iter()
3243                        .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
3244                        .collect();
3245                    let destructure: Vec<&str> =
3246                        fields.iter().map(|f| f.name.name.as_str()).collect();
3247                    self.writeln(&format!(
3248                        "constructor({{ {} }}: {{ {} }}) {{",
3249                        destructure.join(", "),
3250                        init_fields.join("; "),
3251                    ));
3252                    self.indent += 1;
3253                    for fname in &destructure {
3254                        self.writeln(&format!("this.{fname} = {fname};"));
3255                    }
3256                    self.indent -= 1;
3257                    self.writeln("}");
3258                    self.indent -= 1;
3259                    self.writeln("}");
3260                }
3261                Ok(())
3262            }
3263            NodeKind::EnumDecl {
3264                visibility,
3265                name,
3266                generic_params,
3267                variants,
3268                ..
3269            } => {
3270                let export = if matches!(visibility, Visibility::Public) {
3271                    "export "
3272                } else {
3273                    ""
3274                };
3275                let generics = self.generic_params_to_ts(generic_params);
3276                // Use-site type-argument list (`<T>`, no bounds) for referencing
3277                // each variant interface inside the union alias. The variant
3278                // interfaces are declared as `Box_Full<T>` / `Box_Empty<T>`
3279                // (carrying `generics`), so the alias body must reference them
3280                // at the same arity or `tsc` rejects with TS2314.
3281                let type_args = self.generic_args_to_ts(generic_params);
3282
3283                // Emit discriminated union type
3284                let variant_names: Vec<String> = variants
3285                    .iter()
3286                    .filter_map(|v| {
3287                        if let NodeKind::EnumVariant { name: vn, .. } = &v.kind {
3288                            Some(format!("{}_{}{type_args}", name.name, vn.name))
3289                        } else {
3290                            None
3291                        }
3292                    })
3293                    .collect();
3294                if !variant_names.is_empty() {
3295                    self.writeln(&format!(
3296                        "{export}type {}{generics} = {};",
3297                        name.name,
3298                        variant_names.join(" | "),
3299                    ));
3300                    self.buf.push('\n');
3301                }
3302
3303                // Emit interface + factory for each variant
3304                for variant in variants {
3305                    self.emit_enum_variant(&name.name, generic_params, variant)?;
3306                }
3307                Ok(())
3308            }
3309            NodeKind::ClassDecl {
3310                visibility,
3311                name,
3312                generic_params,
3313                fields,
3314                methods,
3315                ..
3316            } => {
3317                // Register the class's positional field order so a `class`
3318                // literal lowers to `new Name(...)` (see `class_fields`). A
3319                // pre-pass already seeds this across the reachable set; re-record
3320                // here so the single-module emit path is correct even when the
3321                // pre-pass is not run.
3322                self.class_fields.insert(
3323                    name.name.clone(),
3324                    fields.iter().map(|f| f.name.name.clone()).collect(),
3325                );
3326                let export = if matches!(visibility, Visibility::Public) {
3327                    "export "
3328                } else {
3329                    ""
3330                };
3331                let generics = self.generic_params_to_ts(generic_params);
3332                self.writeln(&format!("{export}class {}{generics} {{", name.name));
3333                self.indent += 1;
3334                // Fields
3335                for f in fields {
3336                    let ty = self.ast_type_to_ts(&f.ty);
3337                    self.writeln(&format!("{}: {};", f.name.name, ty));
3338                }
3339                if !fields.is_empty() {
3340                    self.buf.push('\n');
3341                }
3342                // Constructor
3343                let ctor_params: Vec<String> = fields
3344                    .iter()
3345                    .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
3346                    .collect();
3347                self.writeln(&format!("constructor({}) {{", ctor_params.join(", ")));
3348                self.indent += 1;
3349                for f in fields {
3350                    self.writeln(&format!("this.{} = {};", f.name.name, f.name.name));
3351                }
3352                self.indent -= 1;
3353                self.writeln("}");
3354                // Methods
3355                for method in methods {
3356                    self.buf.push('\n');
3357                    self.emit_class_method(method)?;
3358                }
3359                self.indent -= 1;
3360                self.writeln("}");
3361                Ok(())
3362            }
3363            NodeKind::TraitDecl {
3364                visibility,
3365                name,
3366                generic_params,
3367                methods,
3368                ..
3369            } => {
3370                let export = if matches!(visibility, Visibility::Public) {
3371                    "export "
3372                } else {
3373                    ""
3374                };
3375                let generics = self.generic_params_to_ts(generic_params);
3376                // The trait-self type: the interface name applied to its own
3377                // generic params, e.g. `Comparable<T>` for `trait Comparable[T]`.
3378                // The leading `self` param of every trait method is typed to
3379                // this (it is the receiver), and a bare `other: Self` resolves to
3380                // it too — without a type, `tsc --strict` flags `self` as an
3381                // implicit `any` (Q-ts-codegen). Mirrors how an `ImplBlock` types
3382                // `self` as the impl target.
3383                let trait_self_ty =
3384                    format!("{}{}", name.name, self.generic_param_args(generic_params));
3385                self.writeln(&format!("{export}interface {}{generics} {{", name.name));
3386                self.indent += 1;
3387                for (i, method) in methods.iter().enumerate() {
3388                    if i > 0 {
3389                        self.buf.push('\n');
3390                    }
3391                    if let NodeKind::FnDecl {
3392                        name,
3393                        generic_params: method_generics,
3394                        params,
3395                        return_type,
3396                        ..
3397                    } = &method.kind
3398                    {
3399                        let m_generics = self.generic_params_to_ts(method_generics);
3400                        let param_list = self.collect_trait_typed_params(params, &trait_self_ty);
3401                        let ret = return_type
3402                            .as_ref()
3403                            .map(|r| self.type_to_ts(r))
3404                            .unwrap_or_else(|| "void".into());
3405                        self.writeln(&format!(
3406                            "{}{m_generics}({}): {};",
3407                            self.ts_method_name(&name.name),
3408                            param_list.join(", "),
3409                            ret,
3410                        ));
3411                    }
3412                }
3413                self.indent -= 1;
3414                self.writeln("}");
3415                Ok(())
3416            }
3417            NodeKind::ImplBlock {
3418                generic_params,
3419                trait_path,
3420                trait_args,
3421                target,
3422                methods,
3423                ..
3424            } => {
3425                let target_base = self.type_expr_to_string(target);
3426                // The target's generic params — from the impl's own list when
3427                // present, else from the record/enum decl (`impl Box { ... }`
3428                // where `T` is declared on `record Box[T]`). `target_name` is
3429                // `Box<T>`, used for the merged interface head and the
3430                // `self: Box<T>` receiver param. Each prototype function
3431                // re-declares `<T>` (it is a free function outside the class
3432                // scope) via `merge_generic_params_to_ts`.
3433                let target_params = self.impl_target_generics(generic_params, &target_base);
3434                let target_name =
3435                    format!("{target_base}{}", self.generic_param_args(&target_params));
3436                // Trait default methods (codegen-completeness P2): for an
3437                // `impl Trait for Type` block, the trait's default methods that
3438                // this impl does not override must also be attached to the
3439                // target's prototype — the trait interface declares only their
3440                // signatures. We synthesize them exactly like the impl's own
3441                // methods (same interface sig + prototype function); a default
3442                // body that calls another trait method via `self.other()`
3443                // resolves through the same merged interface.
3444                let default_methods: Vec<AIRNode> = trait_path
3445                    .as_ref()
3446                    .map(|tp| {
3447                        crate::generator::inherited_default_methods(&self.trait_decls, tp, methods)
3448                    })
3449                    .unwrap_or_default();
3450                // Each entry carries whether it is a *synthesized default*
3451                // method (`true`). The `Self` type renders as the concrete
3452                // target (`trait_self_subst`) rather than `this` for ALL impl
3453                // methods — synthesized trait defaults AND the impl's own
3454                // inherent methods alike — since each emits as a free prototype
3455                // function (`Target.prototype.m = function(...): Self`) where
3456                // `this` is not a valid type annotation (TS2526). The boolean is
3457                // still threaded for clarity / future per-kind handling.
3458                let all_methods: Vec<(&AIRNode, bool)> = methods
3459                    .iter()
3460                    .map(|m| (m, false))
3461                    .chain(default_methods.iter().map(|m| (m, true)))
3462                    .collect();
3463                // Methods are attached via `Target.prototype.m = function(...)`.
3464                // For `tsc` to accept `p.m(...)` at call sites, the class type
3465                // must declare those members. We emit a declaration-merging
3466                // `interface Target { ... }` whose signatures mirror the
3467                // prototype functions exactly — crucially including the leading
3468                // `self` parameter (the AIR lowerer prepends the receiver as the
3469                // first argument and keeps `self` as a declared param, so the
3470                // call site is `p.m(p, ...)`). The untyped `self` param is typed
3471                // as the impl target, which also removes the implicit-`any`
3472                // error inside each method body.
3473                let mut iface_sigs: Vec<String> = Vec::new();
3474                for (method, _is_default) in &all_methods {
3475                    // Associated functions (no `self`, e.g. a `From` impl's
3476                    // `from`) are static members, declared via a merged
3477                    // `namespace` below — not instance methods on the interface.
3478                    if crate::generator::is_associated_impl_method(method, &self.effect_ops) {
3479                        continue;
3480                    }
3481                    if let NodeKind::FnDecl {
3482                        is_async,
3483                        name,
3484                        generic_params,
3485                        params,
3486                        return_type,
3487                        effect_clause,
3488                        ..
3489                    } = &method.kind
3490                    {
3491                        // `Self` → the concrete target for every impl method (see
3492                        // `all_methods`). The merged-interface signature MUST
3493                        // match the prototype function's signature exactly, so the
3494                        // same substitution is applied in both loops; a mismatch
3495                        // (e.g. `this` here, `Target` there) is a declaration-merge
3496                        // error.
3497                        let prev_subst = self.trait_self_subst.take();
3498                        self.trait_self_subst = Some(target_name.clone());
3499                        let generics = self.generic_params_to_ts(generic_params);
3500                        let mut all_params = self.collect_impl_typed_params(params, &target_name);
3501                        if let Some(ep) = self.effects_param(effect_clause) {
3502                            all_params.push(ep);
3503                        }
3504                        let ret_str = build_ts_return_type(
3505                            *is_async,
3506                            return_type.as_deref().map(|r| self.type_to_ts(r)),
3507                        );
3508                        self.trait_self_subst = prev_subst;
3509                        iface_sigs.push(format!(
3510                            "{}{generics}({}){ret_str};",
3511                            self.ts_method_name(&name.name),
3512                            all_params.join(", "),
3513                        ));
3514                    }
3515                }
3516                // The declaration-merging `interface` must be `export`ed exactly
3517                // when the target `class` is — TS rejects a merged declaration
3518                // whose members disagree on export-ness (TS2395).
3519                let iface_export = if self.exported_types.contains(&target_base) {
3520                    "export "
3521                } else {
3522                    ""
3523                };
3524                // A §18.2 prelude (compiler-sealed) trait with no user `trait`
3525                // decl — `Comparable`/`Equatable`/`Displayable`/`Hashable` used
3526                // without a `use core.compare` — emits NO TS interface, so an
3527                // `interface Foo extends Comparable` references an undefined name
3528                // (TS2304). Treat such an impl like a trait-less one: emit the
3529                // concrete method signatures on the merged interface, but with no
3530                // `extends` clause (TS dispatches the method by structural typing,
3531                // and `Ordering` maps to its runtime form via `type_to_ts`).
3532                // (Q-prelude-impl-missing-import.)
3533                let prelude_trait = trait_path.as_ref().is_some_and(|tp| {
3534                    tp.segments.last().is_some_and(|seg| {
3535                        crate::generator::is_unimplemented_sealed_core_trait(
3536                            &seg.name,
3537                            &self.trait_decls,
3538                        )
3539                    })
3540                });
3541                if let Some(tp) = trait_path.as_ref().filter(|_| !prelude_trait) {
3542                    let trait_base = tp
3543                        .segments
3544                        .iter()
3545                        .map(|s| s.name.as_str())
3546                        .collect::<Vec<_>>()
3547                        .join(".");
3548                    // The trait may itself be generic (`trait P[T]`), emitted as
3549                    // `interface P<T>`. The `extends` clause must carry the
3550                    // impl's trait type arguments (`impl P[T] for R[T]` →
3551                    // `extends P<T>`); without them `tsc` rejects with TS2314
3552                    // ("Generic type 'P<T>' requires 1 type argument(s)"). The
3553                    // args are type-expression AIR nodes; render each to its TS
3554                    // form. Empty `trait_args` ⇒ a non-generic trait, no `<...>`.
3555                    let trait_name = if trait_args.is_empty() {
3556                        trait_base
3557                    } else {
3558                        let arg_strs: Vec<String> =
3559                            trait_args.iter().map(|a| self.type_to_ts(a)).collect();
3560                        format!("{trait_base}<{}>", arg_strs.join(", "))
3561                    };
3562                    // An impl whose methods are *all associated* (no instance
3563                    // methods — e.g. `From`, whose only method `from(value)` takes
3564                    // no `self`) contributes no merged-interface members, and its
3565                    // trait carries no instance contract for `new Target()` to
3566                    // satisfy. Skip the `interface … extends Trait` entirely: an
3567                    // empty `extends Trait` body would still reference the trait
3568                    // interface, which may not be in scope (a prelude trait like
3569                    // `From` is not emitted into the consuming module).
3570                    if iface_sigs.is_empty() {
3571                        self.writeln(&format!("// impl {trait_name} for {target_name}"));
3572                    } else {
3573                        // Declaration merging: `extends Trait` keeps
3574                        // `new Target()` assignable to the trait's interface
3575                        // type, while the concrete signatures (with `self`) make
3576                        // `p.m(p)` resolve.
3577                        self.writeln(&format!(
3578                            "{iface_export}interface {target_name} extends {trait_name} {{"
3579                        ));
3580                        self.indent += 1;
3581                        for sig in &iface_sigs {
3582                            self.writeln(sig);
3583                        }
3584                        self.indent -= 1;
3585                        self.writeln("}");
3586                        self.writeln(&format!("// impl {trait_name} for {target_name}"));
3587                    }
3588                } else if iface_sigs.is_empty() {
3589                    self.writeln(&format!("// impl {target_name}"));
3590                } else {
3591                    self.writeln(&format!("{iface_export}interface {target_name} {{"));
3592                    self.indent += 1;
3593                    for sig in &iface_sigs {
3594                        self.writeln(sig);
3595                    }
3596                    self.indent -= 1;
3597                    self.writeln("}");
3598                    self.writeln(&format!("// impl {target_name}"));
3599                }
3600                for (method, _is_default) in &all_methods {
3601                    // Associated functions are emitted as merged-`namespace`
3602                    // static members below, not prototype instance methods.
3603                    if crate::generator::is_associated_impl_method(method, &self.effect_ops) {
3604                        continue;
3605                    }
3606                    if let NodeKind::FnDecl {
3607                        is_async,
3608                        name,
3609                        generic_params,
3610                        params,
3611                        return_type,
3612                        effect_clause,
3613                        body,
3614                        ..
3615                    } = &method.kind
3616                    {
3617                        // Every impl method emits as a free prototype function
3618                        // (`Target.prototype.m = function(...)`), where `this` is
3619                        // not a valid type. So a `Self` type — whether in a
3620                        // synthesized trait default (`other: Self`) or the impl's
3621                        // own inherent method (`fn combine(self, ...) -> Self`) —
3622                        // must render as the concrete target. This matches the
3623                        // merged-interface signature emitted above.
3624                        let prev_subst = self.trait_self_subst.take();
3625                        self.trait_self_subst = Some(target_name.clone());
3626                        let async_kw = if *is_async { "async " } else { "" };
3627                        // The prototype assignment lives outside the class scope,
3628                        // so the function itself must re-declare the target's
3629                        // generic params (`function<T>(self: Box<T>): T`) — they
3630                        // are NOT in scope from the class. Merge them with the
3631                        // method's own generics. The `.prototype` reference uses
3632                        // the *bare* type name (`Box.prototype`, never
3633                        // `Box<T>.prototype`, which is not valid TS).
3634                        let generics =
3635                            self.merge_generic_params_to_ts(&target_params, generic_params);
3636                        let param_list = self.collect_impl_typed_params(params, &target_name);
3637                        let effects_param = self.effects_param(effect_clause);
3638                        let mut all_params = param_list;
3639                        if let Some(ep) = effects_param {
3640                            all_params.push(ep);
3641                        }
3642                        let ret_str = build_ts_return_type(
3643                            *is_async,
3644                            return_type.as_deref().map(|r| self.type_to_ts(r)),
3645                        );
3646                        self.writeln(&format!(
3647                            "{target_base}.prototype.{} = {async_kw}function{generics}({}){ret_str} {{",
3648                            self.ts_method_name(&name.name),
3649                            all_params.join(", "),
3650                        ));
3651                        self.indent += 1;
3652                        let old_handler_vars = self.current_handler_vars.clone();
3653                        let expanded = self.expand_effect_names(effect_clause);
3654                        for ename in &expanded {
3655                            self.current_handler_vars
3656                                .insert(ename.clone(), to_camel_case(ename));
3657                        }
3658                        self.emit_block_body(body)?;
3659                        self.current_handler_vars = old_handler_vars;
3660                        self.indent -= 1;
3661                        self.writeln("};");
3662                        // Restore after the whole method (signature + body) is
3663                        // emitted, so any `Self` annotation in the body also
3664                        // resolves to the concrete target.
3665                        self.trait_self_subst = prev_subst;
3666                    }
3667                }
3668                // Associated functions (`Type.method(...)`, no `self`) are static
3669                // members. A merged `namespace Target { export function m(...) }`
3670                // both declares the static on `typeof Target` (so `tsc` accepts
3671                // the `Target.m(...)` call) and provides the implementation —
3672                // unlike a bare `Target.m = function(...)` assignment, which tsc
3673                // rejects (TS2339, the property is undeclared on the class type).
3674                let assoc_methods: Vec<&AIRNode> = all_methods
3675                    .iter()
3676                    .filter(|(m, _)| {
3677                        crate::generator::is_associated_impl_method(m, &self.effect_ops)
3678                    })
3679                    .map(|(m, _)| *m)
3680                    .collect();
3681                if !assoc_methods.is_empty() {
3682                    let ns_export = if self.exported_types.contains(&target_base) {
3683                        "export "
3684                    } else {
3685                        ""
3686                    };
3687                    self.writeln(&format!("{ns_export}namespace {target_base} {{"));
3688                    self.indent += 1;
3689                    for method in assoc_methods {
3690                        if let NodeKind::FnDecl {
3691                            is_async,
3692                            name,
3693                            generic_params,
3694                            params,
3695                            return_type,
3696                            effect_clause,
3697                            body,
3698                            ..
3699                        } = &method.kind
3700                        {
3701                            let prev_subst = self.trait_self_subst.take();
3702                            self.trait_self_subst = Some(target_name.clone());
3703                            let async_kw = if *is_async { "async " } else { "" };
3704                            let generics =
3705                                self.merge_generic_params_to_ts(&target_params, generic_params);
3706                            let param_list = self.collect_impl_typed_params(params, &target_name);
3707                            let effects_param = self.effects_param(effect_clause);
3708                            let mut all_params = param_list;
3709                            if let Some(ep) = effects_param {
3710                                all_params.push(ep);
3711                            }
3712                            let ret_str = build_ts_return_type(
3713                                *is_async,
3714                                return_type.as_deref().map(|r| self.type_to_ts(r)),
3715                            );
3716                            self.writeln(&format!(
3717                                "export {async_kw}function {}{generics}({}){ret_str} {{",
3718                                self.ts_method_name(&name.name),
3719                                all_params.join(", "),
3720                            ));
3721                            self.indent += 1;
3722                            let old_handler_vars = self.current_handler_vars.clone();
3723                            let expanded = self.expand_effect_names(effect_clause);
3724                            for ename in &expanded {
3725                                self.current_handler_vars
3726                                    .insert(ename.clone(), to_camel_case(ename));
3727                            }
3728                            self.emit_block_body(body)?;
3729                            self.current_handler_vars = old_handler_vars;
3730                            self.indent -= 1;
3731                            self.writeln("}");
3732                            self.trait_self_subst = prev_subst;
3733                        }
3734                    }
3735                    self.indent -= 1;
3736                    self.writeln("}");
3737                }
3738                Ok(())
3739            }
3740            NodeKind::EffectDecl {
3741                visibility,
3742                name,
3743                generic_params,
3744                components,
3745                operations,
3746                ..
3747            } => {
3748                if !components.is_empty() {
3749                    let comp_names: Vec<String> = components
3750                        .iter()
3751                        .map(|tp| {
3752                            tp.segments
3753                                .last()
3754                                .map_or("effect".to_string(), |s| s.name.clone())
3755                        })
3756                        .collect();
3757                    self.writeln(&format!(
3758                        "// composite effect {} = {}",
3759                        name.name,
3760                        comp_names.join(" + ")
3761                    ));
3762                    self.composite_effects.insert(name.name.clone(), comp_names);
3763                    return Ok(());
3764                }
3765                // Record effect operations for Call → handler.op rewriting.
3766                for op in operations {
3767                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
3768                        self.effect_ops
3769                            .insert(op_name.name.clone(), name.name.clone());
3770                    }
3771                }
3772                self.effect_names.insert(name.name.clone());
3773                // Effects → TS interface
3774                let export = if matches!(visibility, Visibility::Public) {
3775                    "export "
3776                } else {
3777                    ""
3778                };
3779                let generics = self.generic_params_to_ts(generic_params);
3780                self.writeln(&format!("{export}interface {}{generics} {{", name.name));
3781                self.indent += 1;
3782                for op in operations {
3783                    if let NodeKind::FnDecl {
3784                        name,
3785                        params,
3786                        return_type,
3787                        ..
3788                    } = &op.kind
3789                    {
3790                        let param_list = self.collect_typed_params(params);
3791                        let ret = return_type
3792                            .as_ref()
3793                            .map(|r| self.type_to_ts(r))
3794                            .unwrap_or_else(|| "void".into());
3795                        self.writeln(&format!(
3796                            "{}({}): {};",
3797                            name.name,
3798                            param_list.join(", "),
3799                            ret,
3800                        ));
3801                    }
3802                }
3803                self.indent -= 1;
3804                self.writeln("}");
3805                Ok(())
3806            }
3807            NodeKind::TypeAlias {
3808                visibility,
3809                name,
3810                generic_params,
3811                ty,
3812                ..
3813            } => {
3814                let export = if matches!(visibility, Visibility::Public) {
3815                    "export "
3816                } else {
3817                    ""
3818                };
3819                let generics = self.generic_params_to_ts(generic_params);
3820                let ty_str = self.type_to_ts(ty);
3821                self.writeln(&format!("{export}type {}{generics} = {ty_str};", name.name));
3822                Ok(())
3823            }
3824            NodeKind::ConstDecl {
3825                visibility,
3826                name,
3827                ty,
3828                value,
3829                ..
3830            } => {
3831                let export = if matches!(visibility, Visibility::Public) {
3832                    "export "
3833                } else {
3834                    ""
3835                };
3836                let ty_str = self.type_to_ts(ty);
3837                let ind = self.indent_str();
3838                let _ = write!(self.buf, "{ind}{export}const {}: {ty_str} = ", name.name);
3839                self.emit_expr(value)?;
3840                self.buf.push_str(";\n");
3841                Ok(())
3842            }
3843            NodeKind::ModuleHandle { effect, handler } => {
3844                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
3845                let var_name = format!("__{}", to_camel_case(effect_name));
3846                let type_name = effect_name;
3847                let ind = self.indent_str();
3848                let _ = write!(self.buf, "{ind}const {var_name}: {type_name} = ");
3849                self.emit_expr(handler)?;
3850                self.buf.push_str(";\n");
3851                // Register as ambient handler so same-module calls pick it up.
3852                self.current_handler_vars
3853                    .insert(effect_name.to_string(), var_name);
3854                Ok(())
3855            }
3856            NodeKind::PropertyTest { name, body, .. } => {
3857                self.writeln(&format!("// property test: {name}"));
3858                self.writeln("// (property tests are not emitted in TS output)");
3859                let _ = body;
3860                Ok(())
3861            }
3862            // Statement / expression nodes at top level:
3863            NodeKind::LetBinding { .. }
3864            | NodeKind::If { .. }
3865            | NodeKind::For { .. }
3866            | NodeKind::While { .. }
3867            | NodeKind::Loop { .. }
3868            | NodeKind::Return { .. }
3869            | NodeKind::Break { .. }
3870            | NodeKind::Continue
3871            | NodeKind::Guard { .. }
3872            | NodeKind::Match { .. }
3873            | NodeKind::Block { .. }
3874            | NodeKind::HandlingBlock { .. }
3875            | NodeKind::Assign { .. } => self.emit_stmt(node),
3876            // A bare `expr?` statement (`save_task(task)?`): the Ok/Some payload
3877            // is discarded, but the `?` still early-returns the Err/None from the
3878            // enclosing fn. Lower the early-return guard and drop the payload.
3879            NodeKind::Propagate { expr } => {
3880                self.emit_propagate(expr)?;
3881                Ok(())
3882            }
3883            // Expression nodes that appear as statements:
3884            _ => {
3885                self.write_indent();
3886                self.emit_expr(node)?;
3887                self.buf.push_str(";\n");
3888                Ok(())
3889            }
3890        }
3891    }
3892
3893    // ── Function declarations ───────────────────────────────────────────────
3894
3895    #[allow(clippy::too_many_arguments)]
3896    fn emit_fn_decl(
3897        &mut self,
3898        visibility: Visibility,
3899        is_async: bool,
3900        name: &str,
3901        generic_params: &[bock_ast::GenericParam],
3902        params: &[AIRNode],
3903        return_type: Option<&AIRNode>,
3904        effect_clause: &[bock_ast::TypePath],
3905        body: &AIRNode,
3906        _is_method: bool,
3907    ) -> Result<(), CodegenError> {
3908        let export = if matches!(visibility, Visibility::Public) {
3909            "export "
3910        } else {
3911            ""
3912        };
3913        let async_kw = if is_async { "async " } else { "" };
3914        let generics = self.generic_params_to_ts(generic_params);
3915        let param_list = self.collect_typed_params(params);
3916        let effects_param = self.effects_param(effect_clause);
3917        let mut all_params = param_list;
3918        if let Some(ep) = effects_param {
3919            all_params.push(ep);
3920        }
3921        let ret_str = build_ts_return_type(is_async, return_type.map(|r| self.type_to_ts(r)));
3922        if !effect_clause.is_empty() {
3923            let effect_names = self.expand_effect_names(effect_clause);
3924            self.fn_effects.insert(name.to_string(), effect_names);
3925        }
3926        let ts_name = ts_value_ident(name);
3927        self.writeln(&format!(
3928            "{export}{async_kw}function {ts_name}{generics}({}){ret_str} {{",
3929            all_params.join(", "),
3930        ));
3931        self.indent += 1;
3932        let old_handler_vars = self.current_handler_vars.clone();
3933        let expanded = self.expand_effect_names(effect_clause);
3934        for ename in &expanded {
3935            self.current_handler_vars
3936                .insert(ename.clone(), to_camel_case(ename));
3937        }
3938        let prev_prop_tag = self.current_fn_propagate_tag;
3939        self.current_fn_propagate_tag = Self::propagate_tag_for_return(return_type);
3940        self.emit_fn_body_seeded(params, body)?;
3941        self.current_fn_propagate_tag = prev_prop_tag;
3942        self.current_handler_vars = old_handler_vars;
3943        self.indent -= 1;
3944        self.writeln("}");
3945        Ok(())
3946    }
3947
3948    fn emit_class_method(&mut self, method: &AIRNode) -> Result<(), CodegenError> {
3949        if let NodeKind::FnDecl {
3950            is_async,
3951            name,
3952            generic_params,
3953            params,
3954            return_type,
3955            effect_clause,
3956            body,
3957            ..
3958        } = &method.kind
3959        {
3960            let async_kw = if *is_async { "async " } else { "" };
3961            let generics = self.generic_params_to_ts(generic_params);
3962            let param_list = self.collect_typed_params(params);
3963            let effects_param = self.effects_param(effect_clause);
3964            let mut all_params = param_list;
3965            if let Some(ep) = effects_param {
3966                all_params.push(ep);
3967            }
3968            let ret_str = build_ts_return_type(
3969                *is_async,
3970                return_type.as_deref().map(|r| self.type_to_ts(r)),
3971            );
3972            let method_name = self.ts_method_name(&to_camel_case(&name.name));
3973            self.writeln(&format!(
3974                "{async_kw}{method_name}{generics}({}){ret_str} {{",
3975                all_params.join(", "),
3976            ));
3977            self.indent += 1;
3978            let old_handler_vars = self.current_handler_vars.clone();
3979            let expanded = self.expand_effect_names(effect_clause);
3980            for ename in &expanded {
3981                self.current_handler_vars
3982                    .insert(ename.clone(), to_camel_case(ename));
3983            }
3984            let prev_prop_tag = self.current_fn_propagate_tag;
3985            self.current_fn_propagate_tag = Self::propagate_tag_for_return(return_type.as_deref());
3986            self.emit_fn_body_seeded(params, body)?;
3987            self.current_fn_propagate_tag = prev_prop_tag;
3988            self.current_handler_vars = old_handler_vars;
3989            self.indent -= 1;
3990            self.writeln("}");
3991        }
3992        Ok(())
3993    }
3994
3995    /// Collect a lambda's parameters, typing any *un-annotated* param as `any`.
3996    ///
3997    /// Bock lambdas usually omit param types (`(x) => …`); the AIR carries no
3998    /// inferred type, so an un-annotated emit (`(x) =>`) is an implicit `any`
3999    /// that `tsc --strict` (`noImplicitAny`) rejects (TS7006). An explicit `any`
4000    /// is always accepted and never loses correctness — at a contextually-typed
4001    /// call site (`.filter((m) => …)`) TS would have inferred `m` anyway, and the
4002    /// explicit annotation is compatible; at an un-contextual site (`const f =
4003    /// (x) => …`) it is the only thing that type-checks. Params that *do* carry a
4004    /// declared type keep it.
4005    fn collect_lambda_params(&self, params: &[AIRNode]) -> Vec<String> {
4006        params
4007            .iter()
4008            .filter_map(|p| {
4009                let NodeKind::Param { pattern, ty, .. } = &p.kind else {
4010                    return None;
4011                };
4012                let name = self.pattern_to_binding_name(pattern);
4013                let ty_str = match ty {
4014                    Some(t) => format!(": {}", self.type_to_ts(t)),
4015                    None => ": any".to_string(),
4016                };
4017                Some(format!("{name}{ty_str}"))
4018            })
4019            .collect()
4020    }
4021
4022    /// Collect typed parameter names: `name: Type`.
4023    fn collect_typed_params(&self, params: &[AIRNode]) -> Vec<String> {
4024        params
4025            .iter()
4026            .filter_map(|p| {
4027                if let NodeKind::Param {
4028                    pattern,
4029                    ty,
4030                    default,
4031                } = &p.kind
4032                {
4033                    let name = self.pattern_to_binding_name(pattern);
4034                    let ty_str = ty
4035                        .as_ref()
4036                        .map(|t| format!(": {}", self.type_to_ts(t)))
4037                        .unwrap_or_default();
4038                    if let Some(def) = default {
4039                        let mut ctx = TsEmitCtx::new();
4040                        ctx.indent = self.indent;
4041                        ctx.enum_variants = self.enum_variants.clone();
4042                        if ctx.emit_expr_to_string(def).is_ok() {
4043                            let (def_str, _) = ctx.finish();
4044                            return Some(format!("{name}{ty_str} = {def_str}"));
4045                        }
4046                    }
4047                    Some(format!("{name}{ty_str}"))
4048                } else {
4049                    None
4050                }
4051            })
4052            .collect()
4053    }
4054
4055    /// Collect typed parameters for an `impl` method, typing an untyped
4056    /// receiver (`self`) parameter as the impl target.
4057    ///
4058    /// Bock impl methods declare `self` with no type annotation
4059    /// (`fn sum(self)`), and the AIR lowerer keeps it as a real parameter while
4060    /// prepending the receiver at call sites (`p.sum(p)`). Without a type, `tsc`
4061    /// flags `self` as implicit `any`; we substitute the target type so the
4062    /// method body (`self.x`) and the declaration-merged interface both
4063    /// type-check. Non-`self` params, and a `self` that already carries an
4064    /// explicit type, are handled exactly as [`Self::collect_typed_params`].
4065    fn collect_impl_typed_params(&self, params: &[AIRNode], target_name: &str) -> Vec<String> {
4066        params
4067            .iter()
4068            .filter_map(|p| {
4069                let NodeKind::Param {
4070                    pattern,
4071                    ty,
4072                    default,
4073                } = &p.kind
4074                else {
4075                    return None;
4076                };
4077                let name = self.pattern_to_binding_name(pattern);
4078                let ty_str = match ty {
4079                    Some(t) => format!(": {}", self.type_to_ts(t)),
4080                    None if name == "self" => format!(": {target_name}"),
4081                    None => String::new(),
4082                };
4083                if let Some(def) = default {
4084                    let mut ctx = TsEmitCtx::new();
4085                    ctx.indent = self.indent;
4086                    ctx.enum_variants = self.enum_variants.clone();
4087                    if ctx.emit_expr_to_string(def).is_ok() {
4088                        let (def_str, _) = ctx.finish();
4089                        return Some(format!("{name}{ty_str} = {def_str}"));
4090                    }
4091                }
4092                Some(format!("{name}{ty_str}"))
4093            })
4094            .collect()
4095    }
4096
4097    /// Collect typed parameters for a **trait declaration** method, typing an
4098    /// untyped receiver (`self`) as the trait's own interface type
4099    /// (`trait_self_ty`, e.g. `Comparable<T>`).
4100    ///
4101    /// A trait method declares `self` with no annotation (`fn compare(self,
4102    /// other: Self)`); the AIR lowerer keeps it as a real leading parameter. In
4103    /// the emitted interface the untyped `self` would otherwise be `tsc
4104    /// --strict`'s implicit `any`. Typing it to the trait-self type makes the
4105    /// interface method signature well-typed and keeps it compatible (via
4106    /// declaration-merging method bivariance) with the concrete `self: Target`
4107    /// the `ImplBlock` arm emits. A `self` that already carries an explicit type,
4108    /// and all non-`self` params, are handled exactly as
4109    /// [`Self::collect_typed_params`] (where a `Self` annotation maps to `this`,
4110    /// the implementing type — correct for `other: Self`).
4111    fn collect_trait_typed_params(&self, params: &[AIRNode], trait_self_ty: &str) -> Vec<String> {
4112        params
4113            .iter()
4114            .filter_map(|p| {
4115                let NodeKind::Param {
4116                    pattern,
4117                    ty,
4118                    default,
4119                } = &p.kind
4120                else {
4121                    return None;
4122                };
4123                let name = self.pattern_to_binding_name(pattern);
4124                let ty_str = match ty {
4125                    Some(t) => format!(": {}", self.type_to_ts(t)),
4126                    None if name == "self" => format!(": {trait_self_ty}"),
4127                    None => String::new(),
4128                };
4129                if let Some(def) = default {
4130                    let mut ctx = TsEmitCtx::new();
4131                    ctx.indent = self.indent;
4132                    ctx.enum_variants = self.enum_variants.clone();
4133                    if ctx.emit_expr_to_string(def).is_ok() {
4134                        let (def_str, _) = ctx.finish();
4135                        return Some(format!("{name}{ty_str} = {def_str}"));
4136                    }
4137                }
4138                Some(format!("{name}{ty_str}"))
4139            })
4140            .collect()
4141    }
4142
4143    fn emit_expr_to_string(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
4144        self.emit_expr(node)
4145    }
4146
4147    /// Expand effect names, replacing composite effects with their components.
4148    fn expand_effect_names(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
4149        let mut result = Vec::new();
4150        for tp in effects {
4151            let name = tp
4152                .segments
4153                .last()
4154                .map_or("effect".to_string(), |s| s.name.clone());
4155            if let Some(components) = self.composite_effects.get(&name) {
4156                result.extend(components.iter().cloned());
4157            } else {
4158                result.push(name);
4159            }
4160        }
4161        result
4162    }
4163
4164    /// The in-scope `Clock` effect handler variable, if one is installed.
4165    ///
4166    /// When `Some`, the `Clock` time operations (`Instant.now`, `sleep`,
4167    /// `elapsed`) are routed through the handler instead of inlining the host
4168    /// primitive (Q-clock-handler-routing, §18.3.1/§18.4); when `None`, no
4169    /// handler is in scope and the default host primitive is emitted.
4170    fn clock_handler_var(&self) -> Option<&str> {
4171        self.current_handler_vars.get("Clock").map(String::as_str)
4172    }
4173
4174    /// Effects → typed destructured parameter object: `{ log, clock }: { log: Log, clock: Clock }`.
4175    fn effects_param(&self, effects: &[bock_ast::TypePath]) -> Option<String> {
4176        if effects.is_empty() {
4177            return None;
4178        }
4179        let expanded = self.expand_effect_names(effects);
4180        if expanded.is_empty() {
4181            return None;
4182        }
4183        let names: Vec<String> = expanded.iter().map(|n| to_camel_case(n)).collect();
4184        let type_entries: Vec<String> = expanded
4185            .iter()
4186            .zip(names.iter())
4187            .map(|(orig, camel)| format!("{camel}: {orig}"))
4188            .collect();
4189        Some(format!(
4190            "{{ {} }}: {{ {} }}",
4191            names.join(", "),
4192            type_entries.join(", ")
4193        ))
4194    }
4195
4196    /// Build a `{ effect: handler_var, ... }` argument for calling an effectful function.
4197    fn build_effects_call_arg_ts(&self, fn_name: &str) -> Option<String> {
4198        let effects = self.fn_effects.get(fn_name)?;
4199        let entries: Vec<String> = effects
4200            .iter()
4201            .filter_map(|e| {
4202                let handler_var = self.current_handler_vars.get(e)?;
4203                let param_name = to_camel_case(e);
4204                Some(format!("{param_name}: {handler_var}"))
4205            })
4206            .collect();
4207        if entries.is_empty() {
4208            return None;
4209        }
4210        Some(format!("{{ {} }}", entries.join(", ")))
4211    }
4212
4213    // ── Enum variant interfaces + factories ──────────────────────────────────
4214
4215    fn emit_enum_variant(
4216        &mut self,
4217        enum_name: &str,
4218        generic_params: &[bock_ast::GenericParam],
4219        variant: &AIRNode,
4220    ) -> Result<(), CodegenError> {
4221        if let NodeKind::EnumVariant { name, payload } = &variant.kind {
4222            let vname = &name.name;
4223            let generics = self.generic_params_to_ts(generic_params);
4224            // Use-site type-argument list (`<T>`, no bounds) for the variant's
4225            // *references* — its constructor-factory return type, and (for a
4226            // unit variant) its `const` annotation. Declaring the interface
4227            // `Box_Full<T>` but returning a bare `Box_Full` from the factory
4228            // fails `tsc` with TS2314; the return type must carry the same args.
4229            let type_args = self.generic_args_to_ts(generic_params);
4230            // Default-parameter form of the generic params, so a generic *unit*
4231            // variant — whose type param is phantom (unused in the variant
4232            // body) — can be both referenced with explicit args inside the
4233            // union alias (`Box_Empty<T>`) AND named with zero args on its
4234            // frozen `const` (`const Box_Empty: Box_Empty`). Without the
4235            // `= unknown` default the zero-arg const annotation fails TS2314.
4236            let unit_generics = if generic_params.is_empty() {
4237                String::new()
4238            } else {
4239                let items: Vec<String> = generic_params
4240                    .iter()
4241                    .map(|p| format!("{} = unknown", p.name.name))
4242                    .collect();
4243                format!("<{}>", items.join(", "))
4244            };
4245            let qualified = format!("{enum_name}_{vname}");
4246
4247            match payload {
4248                EnumVariantPayload::Unit => {
4249                    // Interface for unit variant
4250                    self.writeln(&format!(
4251                        "interface {qualified}{unit_generics} {{ readonly _tag: \"{vname}\"; }}"
4252                    ));
4253                    self.writeln(&format!(
4254                        "const {qualified}: {qualified} = Object.freeze({{ _tag: \"{vname}\" as const }});"
4255                    ));
4256                }
4257                EnumVariantPayload::Struct(fields) => {
4258                    // Interface for struct variant
4259                    self.writeln(&format!("interface {qualified}{generics} {{"));
4260                    self.indent += 1;
4261                    self.writeln(&format!("readonly _tag: \"{vname}\";"));
4262                    for f in fields {
4263                        let ty = self.ast_type_to_ts(&f.ty);
4264                        self.writeln(&format!("readonly {}: {};", f.name.name, ty));
4265                    }
4266                    self.indent -= 1;
4267                    self.writeln("}");
4268                    let field_params: Vec<String> = fields
4269                        .iter()
4270                        .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
4271                        .collect();
4272                    let field_names: Vec<&str> =
4273                        fields.iter().map(|f| f.name.name.as_str()).collect();
4274                    self.writeln(&format!(
4275                        "function {qualified}{generics}({}): {qualified}{type_args} {{",
4276                        field_params.join(", "),
4277                    ));
4278                    self.indent += 1;
4279                    self.writeln(&format!(
4280                        "return {{ _tag: \"{vname}\" as const, {} }};",
4281                        field_names.join(", "),
4282                    ));
4283                    self.indent -= 1;
4284                    self.writeln("}");
4285                }
4286                EnumVariantPayload::Tuple(elems) => {
4287                    // Interface for tuple variant
4288                    self.writeln(&format!("interface {qualified}{generics} {{"));
4289                    self.indent += 1;
4290                    self.writeln(&format!("readonly _tag: \"{vname}\";"));
4291                    for (i, elem) in elems.iter().enumerate() {
4292                        let ty = self.type_to_ts(elem);
4293                        self.writeln(&format!("readonly _{i}: {ty};"));
4294                    }
4295                    self.indent -= 1;
4296                    self.writeln("}");
4297                    let param_decls: Vec<String> = elems
4298                        .iter()
4299                        .enumerate()
4300                        .map(|(i, e)| format!("_{i}: {}", self.type_to_ts(e)))
4301                        .collect();
4302                    let param_names: Vec<String> =
4303                        (0..elems.len()).map(|i| format!("_{i}")).collect();
4304                    self.writeln(&format!(
4305                        "function {qualified}{generics}({}): {qualified}{type_args} {{",
4306                        param_decls.join(", "),
4307                    ));
4308                    self.indent += 1;
4309                    self.writeln(&format!(
4310                        "return {{ _tag: \"{vname}\" as const, {} }};",
4311                        param_names
4312                            .iter()
4313                            .enumerate()
4314                            .map(|(i, p)| format!("_{i}: {p}"))
4315                            .collect::<Vec<_>>()
4316                            .join(", ")
4317                    ));
4318                    self.indent -= 1;
4319                    self.writeln("}");
4320                }
4321            }
4322        }
4323        Ok(())
4324    }
4325
4326    // ── Statements ──────────────────────────────────────────────────────────
4327
4328    fn emit_stmt(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
4329        self.mark_span(node.span);
4330        match &node.kind {
4331            NodeKind::LetBinding {
4332                is_mut,
4333                pattern,
4334                ty,
4335                value,
4336                ..
4337            } => {
4338                let kw = if *is_mut { "let" } else { "const" };
4339                let binding = self.pattern_to_ts_destructure(pattern);
4340                let ty_ts = ty.as_ref().map(|t| self.type_to_ts(t));
4341                let ty_str = ty_ts.as_ref().map(|t| format!(": {t}")).unwrap_or_default();
4342                // Declare-only temp from the shared value-CF hoist: emit a bare
4343                // `let name;` (no initialiser); the relocated control flow assigns
4344                // it on every non-diverging path.
4345                if node.metadata.contains_key(crate::generator::DECL_ONLY_META) {
4346                    let ind = self.indent_str();
4347                    let _ = writeln!(self.buf, "{ind}let {binding}{ty_str};");
4348                    return Ok(());
4349                }
4350                // `let name = expr?` — the `?` unwraps the Ok/Some payload and
4351                // early-returns the Err/None from the enclosing fn. Lower the
4352                // unwrap in statement position (a temp + early-return guard),
4353                // then bind `name` to the payload access. See `emit_propagate`.
4354                if let NodeKind::Propagate { expr } = &value.kind {
4355                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
4356                        let ts_name = ts_value_ident(&name.name);
4357                        let access = self.emit_propagate(expr)?;
4358                        let kw = if *is_mut || self.simple_let_needs_let(&ts_name) {
4359                            "let"
4360                        } else {
4361                            "const"
4362                        };
4363                        if self.simple_let_redeclared(&ts_name) {
4364                            let ind = self.indent_str();
4365                            let _ = writeln!(self.buf, "{ind}{ts_name} = {access};");
4366                        } else {
4367                            self.mark_simple_let_declared(&ts_name);
4368                            let ind = self.indent_str();
4369                            let _ = writeln!(self.buf, "{ind}{kw} {ts_name}{ty_str} = {access};");
4370                        }
4371                        return Ok(());
4372                    }
4373                    // A non-simple destructuring binding over a `?` value: unwrap
4374                    // into a temp, then destructure from it.
4375                    let access = self.emit_propagate(expr)?;
4376                    let ind = self.indent_str();
4377                    let _ = writeln!(self.buf, "{ind}{kw} {binding}{ty_str} = {access};");
4378                    return Ok(());
4379                }
4380                // A simple `let name = …` is subject to TS redeclaration rules
4381                // (TS2451). Bock allows re-binding the same name in one scope
4382                // (shadowing); TS does not, so the second-and-later binding of a
4383                // simple name becomes a plain assignment, and the first
4384                // declaration uses `let` (not `const`) when the name is later
4385                // re-bound/assigned. Mirrors the js backend (#217). The `?`
4386                // unwrap (`Propagate`) value takes a separate statement-form path
4387                // above, so it is excluded here.
4388                if let NodeKind::BindPat { name, .. } = &pattern.kind {
4389                    if !matches!(value.kind, NodeKind::Propagate { .. }) {
4390                        let ts_name = ts_value_ident(&name.name);
4391                        if self.simple_let_redeclared(&ts_name) {
4392                            let ind = self.indent_str();
4393                            let _ = write!(self.buf, "{ind}{ts_name} = ");
4394                            self.emit_let_value(value, ty_ts)?;
4395                            self.buf.push_str(";\n");
4396                            return Ok(());
4397                        }
4398                        if !self.value_needs_stmt_form(value) {
4399                            let needs_let = *is_mut || self.simple_let_needs_let(&ts_name);
4400                            let kw = if needs_let { "let" } else { "const" };
4401                            self.mark_simple_let_declared(&ts_name);
4402                            let ind = self.indent_str();
4403                            let _ = write!(self.buf, "{ind}{kw} {ts_name}{ty_str} = ");
4404                            self.emit_let_value(value, ty_ts)?;
4405                            self.buf.push_str(";\n");
4406                            return Ok(());
4407                        }
4408                    }
4409                }
4410                // A control-flow initialiser with no TS expression form (a `loop`,
4411                // or a value `if`/`match` with a `return`/`break` arm) is lowered
4412                // in statement position: declare the binding `let`, then emit the
4413                // control flow assigning into it. The original binding must be
4414                // `let` (not `const`) so the arms can assign it (cluster-1 fix).
4415                if self.value_needs_stmt_form(value) {
4416                    let ind = self.indent_str();
4417                    // A plain identifier binding can be reassigned; a destructuring
4418                    // pattern cannot be split, so this path only applies to simple
4419                    // bindings (the only shape these examples produce).
4420                    let _ = writeln!(self.buf, "{ind}let {binding}{ty_str};");
4421                    return self.emit_value_in_stmt_pos(value, &ValueSink::Assign(binding));
4422                }
4423                let ind = self.indent_str();
4424                let _ = write!(self.buf, "{ind}{kw} {binding}{ty_str} = ");
4425                // `emit_let_value` records the binding's declared type as the
4426                // expected type for the value (so a value-position `match`/`if`
4427                // IIFE annotates its arrow return and hoists a bare-identifier
4428                // scrutinee — see `current_expected_type`) and, for a
4429                // single-variant enum construction, widens the initialiser to the
4430                // declared union so the binding does not narrow to the
4431                // construction variant (TS2678 on a later sibling-variant match).
4432                self.emit_let_value(value, ty_ts)?;
4433                self.buf.push_str(";\n");
4434                Ok(())
4435            }
4436            NodeKind::If {
4437                let_pattern,
4438                condition,
4439                then_block,
4440                else_block,
4441            } => {
4442                if let Some(pat) = let_pattern {
4443                    let ind = self.indent_str();
4444                    let _ = write!(self.buf, "{ind}if (");
4445                    self.emit_expr(condition)?;
4446                    self.buf.push_str(" != null) {\n");
4447                    self.indent += 1;
4448                    let binding = self.pattern_to_ts_destructure(pat);
4449                    self.writeln(&format!("const {binding} = "));
4450                    self.emit_block_body(then_block)?;
4451                    self.indent -= 1;
4452                } else {
4453                    let ind = self.indent_str();
4454                    let _ = write!(self.buf, "{ind}if (");
4455                    self.emit_expr(condition)?;
4456                    self.buf.push_str(") {\n");
4457                    self.indent += 1;
4458                    self.emit_block_body(then_block)?;
4459                    self.indent -= 1;
4460                }
4461                if let Some(else_b) = else_block {
4462                    if matches!(else_b.kind, NodeKind::If { .. }) {
4463                        let ind = self.indent_str();
4464                        let _ = write!(self.buf, "{ind}}} else ");
4465                        self.emit_stmt(else_b)?;
4466                        return Ok(());
4467                    }
4468                    self.writeln("} else {");
4469                    self.indent += 1;
4470                    self.emit_block_body(else_b)?;
4471                    self.indent -= 1;
4472                }
4473                self.writeln("}");
4474                Ok(())
4475            }
4476            NodeKind::For {
4477                pattern,
4478                iterable,
4479                body,
4480            } => {
4481                let binding = self.pattern_to_ts_destructure(pattern);
4482                self.emit_loop_label_prefix(body);
4483                let ind = self.indent_str();
4484                let _ = write!(self.buf, "{ind}for (const {binding} of ");
4485                self.emit_expr(iterable)?;
4486                self.buf.push_str(") {\n");
4487                self.indent += 1;
4488                self.emit_loop_body(body)?;
4489                self.indent -= 1;
4490                self.writeln("}");
4491                self.pop_loop_frame();
4492                Ok(())
4493            }
4494            NodeKind::While { condition, body } => {
4495                self.emit_loop_label_prefix(body);
4496                let ind = self.indent_str();
4497                let _ = write!(self.buf, "{ind}while (");
4498                self.emit_expr(condition)?;
4499                self.buf.push_str(") {\n");
4500                self.indent += 1;
4501                self.emit_loop_body(body)?;
4502                self.indent -= 1;
4503                self.writeln("}");
4504                self.pop_loop_frame();
4505                Ok(())
4506            }
4507            NodeKind::Loop { body } => {
4508                self.emit_loop_label_prefix(body);
4509                self.writeln("while (true) {");
4510                self.indent += 1;
4511                self.emit_loop_body(body)?;
4512                self.indent -= 1;
4513                self.writeln("}");
4514                self.pop_loop_frame();
4515                Ok(())
4516            }
4517            NodeKind::Return { value } => {
4518                if let Some(val) = value {
4519                    let ind = self.indent_str();
4520                    let _ = write!(self.buf, "{ind}return ");
4521                    self.emit_expr(val)?;
4522                    self.buf.push_str(";\n");
4523                } else {
4524                    self.writeln("return;");
4525                }
4526                Ok(())
4527            }
4528            NodeKind::Break { value } => {
4529                if let Some(val) = value {
4530                    // In a value-position loop (`let r = loop { … break v … }`),
4531                    // `break v` delivers the loop's value through the loop sink
4532                    // (`r = v;`) before breaking. Without a sink (an ordinary
4533                    // statement loop) the value is dropped — emit it as a comment
4534                    // so it remains visible but inert.
4535                    if let Some(sink) = self.innermost_loop_value_sink() {
4536                        self.emit_sink_value(val, &sink)?;
4537                    } else {
4538                        let ind = self.indent_str();
4539                        let _ = write!(self.buf, "{ind}/* break value: ");
4540                        self.emit_expr(val)?;
4541                        self.buf.push_str(" */\n");
4542                    }
4543                }
4544                if self.switch_label_depth > 0 {
4545                    if let Some(label) = self.innermost_loop_label() {
4546                        self.writeln(&format!("break {label};"));
4547                        return Ok(());
4548                    }
4549                }
4550                self.writeln("break;");
4551                Ok(())
4552            }
4553            NodeKind::Continue => {
4554                if self.switch_label_depth > 0 {
4555                    if let Some(label) = self.innermost_loop_label() {
4556                        self.writeln(&format!("continue {label};"));
4557                        return Ok(());
4558                    }
4559                }
4560                self.writeln("continue;");
4561                Ok(())
4562            }
4563            NodeKind::Guard {
4564                let_pattern,
4565                condition,
4566                else_block,
4567            } => {
4568                if let Some(pat) = let_pattern {
4569                    // `guard (let pat = expr) else { … }`: evaluate `expr` once,
4570                    // run the else (which must diverge) when `pat` does not
4571                    // match, then bind `pat`'s names into the *enclosing* scope
4572                    // so they are in scope for the statements after the guard.
4573                    // Mirrors the js lowering (#217).
4574                    self.match_temp_counter += 1;
4575                    let tmp = format!("__guard{}", self.match_temp_counter);
4576                    let ind = self.indent_str();
4577                    let _ = write!(self.buf, "{ind}const {tmp} = ");
4578                    self.emit_expr(condition)?;
4579                    self.buf.push_str(";\n");
4580                    let test = self.pattern_test_ts(pat, &tmp);
4581                    // A bare bind / wildcard pattern always matches → no `if`.
4582                    if !test.is_empty() {
4583                        let ind = self.indent_str();
4584                        let _ = writeln!(self.buf, "{ind}if (!({test})) {{");
4585                        self.indent += 1;
4586                        self.emit_block_body(else_block)?;
4587                        self.indent -= 1;
4588                        self.writeln("}");
4589                    }
4590                    // Bindings land in the enclosing scope (no nested block), so
4591                    // they are visible to the statements following the guard.
4592                    self.pattern_binds_ts(pat, &tmp)?;
4593                } else {
4594                    let ind = self.indent_str();
4595                    let _ = write!(self.buf, "{ind}if (!(");
4596                    self.emit_expr(condition)?;
4597                    self.buf.push_str(")) {\n");
4598                    self.indent += 1;
4599                    self.emit_block_body(else_block)?;
4600                    self.indent -= 1;
4601                    self.writeln("}");
4602                }
4603                Ok(())
4604            }
4605            NodeKind::Match { scrutinee, arms } => self.emit_match(scrutinee, arms, false),
4606            NodeKind::Block { stmts, tail } => {
4607                // A statement-position block is its own TS `{}` lexical scope, so
4608                // it gets its own `let` scope frame (a name re-bound inside is
4609                // independent of the enclosing block's bindings).
4610                self.writeln("{");
4611                self.indent += 1;
4612                self.enter_let_scope(node);
4613                for s in stmts {
4614                    self.emit_node(s)?;
4615                }
4616                if let Some(t) = tail {
4617                    self.write_indent();
4618                    self.emit_expr(t)?;
4619                    self.buf.push_str(";\n");
4620                }
4621                self.leave_let_scope();
4622                self.indent -= 1;
4623                self.writeln("}");
4624                Ok(())
4625            }
4626            NodeKind::HandlingBlock { handlers, body } => {
4627                // handling block → scoped handler instantiation. The emitted
4628                // `{ … }` is its own TS lexical block, so it gets a fresh `let`
4629                // scope frame: a name first bound in one `handling` block and
4630                // re-bound in a *sibling* `handling` block is two independent
4631                // declarations (each block-scoped), not a redeclaration. Without
4632                // a fresh frame the redeclaration tracker would carry the prior
4633                // block's `declared` set into this one and rewrite the second
4634                // `let x = …` into a bare `x = …` — a name that went out of
4635                // scope when the first block closed (TS2304 / strict-mode
4636                // ReferenceError).
4637                self.writeln("{");
4638                self.indent += 1;
4639                self.enter_let_scope(body);
4640                let old_handler_vars = self.current_handler_vars.clone();
4641                for h in handlers {
4642                    let effect_name = h
4643                        .effect
4644                        .segments
4645                        .last()
4646                        .map_or("effect", |s| s.name.as_str());
4647                    let var_name = format!("__{}", to_camel_case(effect_name));
4648                    let type_name = effect_name;
4649                    let ind = self.indent_str();
4650                    let _ = write!(self.buf, "{ind}const {var_name}: {type_name} = ");
4651                    self.emit_expr(&h.handler)?;
4652                    self.buf.push_str(";\n");
4653                    self.current_handler_vars
4654                        .insert(effect_name.to_string(), var_name);
4655                }
4656                if let NodeKind::Block { stmts, tail } = &body.kind {
4657                    for s in stmts {
4658                        self.emit_node(s)?;
4659                    }
4660                    if let Some(t) = tail {
4661                        self.write_indent();
4662                        self.emit_expr(t)?;
4663                        self.buf.push_str(";\n");
4664                    }
4665                } else {
4666                    self.emit_stmt(body)?;
4667                }
4668                self.current_handler_vars = old_handler_vars;
4669                self.leave_let_scope();
4670                self.indent -= 1;
4671                self.writeln("}");
4672                Ok(())
4673            }
4674            NodeKind::Assign { op, target, value } => {
4675                let ind = self.indent_str();
4676                let _ = write!(self.buf, "{ind}");
4677                self.emit_expr(target)?;
4678                let op_str = match op {
4679                    AssignOp::Assign => "=",
4680                    AssignOp::AddAssign => "+=",
4681                    AssignOp::SubAssign => "-=",
4682                    AssignOp::MulAssign => "*=",
4683                    AssignOp::DivAssign => "/=",
4684                    AssignOp::RemAssign => "%=",
4685                };
4686                let _ = write!(self.buf, " {op_str} ");
4687                self.emit_expr(value)?;
4688                self.buf.push_str(";\n");
4689                Ok(())
4690            }
4691            _ => {
4692                self.write_indent();
4693                self.emit_expr(node)?;
4694                self.buf.push_str(";\n");
4695                Ok(())
4696            }
4697        }
4698    }
4699
4700    /// Lower the `?` propagation operator (`inner?`) in statement position.
4701    ///
4702    /// Emits a temp holding `inner` once, then an early-return guard that
4703    /// returns the temp unchanged from the enclosing fn when the value is the
4704    /// failure variant (`Err` for `Result`, `None` for `Optional`), and returns
4705    /// the *access expression* for the success payload (`__propN._0`) so the
4706    /// caller can bind/use the unwrapped value. The runtime representation is a
4707    /// tagged object (`{ _tag, _0 }`) shared with `match`/method lowering, so the
4708    /// same guard covers both container kinds: the failure value *is* the temp
4709    /// (an `Err`/`None`), so `return __propN` propagates it as-is, and the
4710    /// payload is always `._0`. Implements the standard Rust-like `?` semantics
4711    /// (early-return on failure, unwrap on success).
4712    fn emit_propagate(&mut self, inner: &AIRNode) -> Result<String, CodegenError> {
4713        self.match_temp_counter += 1;
4714        let tmp = format!("__prop{}", self.match_temp_counter);
4715        let ind = self.indent_str();
4716        let _ = write!(self.buf, "{ind}const {tmp} = ");
4717        self.emit_expr(inner)?;
4718        self.buf.push_str(";\n");
4719        // Failure variant → propagate the container unchanged. When the enclosing
4720        // fn's return type tells us the failure tag (`Err` for `Result`, `None`
4721        // for `Optional`), test that single discriminant: it is a real member of
4722        // the value's tag union, so TS both accepts the comparison *and* narrows
4723        // the success access (`{tmp}._0`) to the payload type after the guard.
4724        let test = match self.current_fn_propagate_tag {
4725            Some(tag) => format!("{tmp}._tag === \"{tag}\""),
4726            // Unknown container kind (no typed return) → fall back to testing
4727            // both failure tags with the `._tag` widened to `string`, so the
4728            // (always-false but legal) cross-container arm does not trip a TS2367
4729            // "no overlap" error. This path forgoes narrowing; the typed-return
4730            // path above (which every example hits) keeps it.
4731            None => {
4732                format!("({tmp}._tag as string) === \"Err\" || ({tmp}._tag as string) === \"None\"")
4733            }
4734        };
4735        let ind = self.indent_str();
4736        let _ = writeln!(self.buf, "{ind}if ({test}) {{");
4737        self.indent += 1;
4738        let ind = self.indent_str();
4739        // `as never` lets the `Err`/`None` container satisfy the fn's declared
4740        // return type without re-narrowing it here (the value is already the
4741        // correct container shape; the cast only quiets the structural check).
4742        let _ = writeln!(self.buf, "{ind}return {tmp} as never;");
4743        self.indent -= 1;
4744        self.writeln("}");
4745        Ok(format!("{tmp}._0"))
4746    }
4747
4748    // ── Expressions ─────────────────────────────────────────────────────────
4749
4750    fn emit_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
4751        self.mark_span(node.span);
4752        match &node.kind {
4753            NodeKind::Literal { lit } => {
4754                match lit {
4755                    Literal::Int(s) => self.buf.push_str(s),
4756                    Literal::Float(s) => self.buf.push_str(s),
4757                    Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
4758                    Literal::Char(s) => {
4759                        self.buf.push('\'');
4760                        self.buf.push_str(s);
4761                        self.buf.push('\'');
4762                    }
4763                    Literal::String(s) => {
4764                        self.buf.push('"');
4765                        self.buf.push_str(&escape_js_string(s));
4766                        self.buf.push('"');
4767                    }
4768                    Literal::Unit => self.buf.push_str("undefined"),
4769                }
4770                Ok(())
4771            }
4772            NodeKind::Identifier { name } => {
4773                if name.name == "None" {
4774                    self.buf.push_str("{ _tag: \"None\" as const }");
4775                } else if let Some(variant) = crate::generator::ordering_variant(&name.name) {
4776                    // Prelude `Ordering` variant → an inline tagged object (the
4777                    // self-contained representation the primitive-bridge
4778                    // `compare` and the `_tag`-switch match also use).
4779                    let _ = write!(self.buf, "{{ _tag: \"{variant}\" as const }}");
4780                } else if let Some(enum_name) = self
4781                    .user_variant_for_name(&name.name)
4782                    .map(|i| i.enum_name.clone())
4783                {
4784                    // A bare unit-variant reference (`Red`) → the frozen
4785                    // `{enum}_{variant}` const.
4786                    let _ = write!(self.buf, "{enum_name}_{}", name.name);
4787                } else if self.const_names.contains(&name.name) {
4788                    // A module-scope `const` is emitted verbatim at its
4789                    // declaration; spell its use site identically (the
4790                    // `to_camel_case` transform would mangle `FIZZ_NUM` → `fizzNUM`).
4791                    self.buf.push_str(&name.name);
4792                } else {
4793                    self.buf.push_str(&ts_value_ident(&name.name));
4794                }
4795                Ok(())
4796            }
4797            NodeKind::BinaryOp { op, left, right } => {
4798                // `+` on two `List[T]` operands is concatenation: spread both into
4799                // a fresh array (`[...a, ...b]`). TS rejects `+` on `T[]`
4800                // (`Operator '+' cannot be applied to T[]`); the spread preserves
4801                // the element type.
4802                if matches!(op, BinOp::Add) && crate::generator::is_list_concat(node, left, right) {
4803                    self.buf.push_str("[...");
4804                    self.emit_expr(left)?;
4805                    self.buf.push_str(", ...");
4806                    self.emit_expr(right)?;
4807                    self.buf.push(']');
4808                    return Ok(());
4809                }
4810                // Integer `/` and `%` (DQ23, §3.6): TS `/` is float division and
4811                // `Math.trunc(a / 0)` yields `Infinity` rather than aborting, so
4812                // lower to a self-contained IIFE that aborts on a zero divisor and
4813                // truncates toward zero. TS `%` already takes the dividend's sign,
4814                // so the remainder needs only the zero-abort. Arrow parameters are
4815                // `number`-annotated for strict mode; both operands are passed as
4816                // arguments so each is evaluated exactly once.
4817                if matches!(op, BinOp::Div | BinOp::Rem) && crate::generator::is_int_arith(node) {
4818                    let body = if matches!(op, BinOp::Div) {
4819                        "Math.trunc(__a / __b)"
4820                    } else {
4821                        "__a % __b"
4822                    };
4823                    self.buf.push_str("((__a: number, __b: number): number => { if (__b === 0) { throw new Error(\"integer division or modulo by zero\"); } return ");
4824                    self.buf.push_str(body);
4825                    self.buf.push_str("; })(");
4826                    self.emit_expr(left)?;
4827                    self.buf.push_str(", ");
4828                    self.emit_expr(right)?;
4829                    self.buf.push(')');
4830                    return Ok(());
4831                }
4832                // Ordering operators on a user `Comparable` type lower through
4833                // `compare` (TS rejects `<` on two objects at compile time, and the
4834                // runtime would coerce to `NaN`). Reads the tagged `Ordering` off
4835                // `._tag`, mirroring how a hand-written `a.compare(b)` lowers (the
4836                // receiver is passed as both the method receiver and the explicit
4837                // `self` argument).
4838                if crate::generator::is_user_compare(node) {
4839                    if let Some((tag, is_eq)) = crate::generator::user_compare_variant(*op) {
4840                        let recv = self.expr_to_string(left)?;
4841                        let other = self.expr_to_string(right)?;
4842                        let eq = if is_eq { "===" } else { "!==" };
4843                        let _ = write!(
4844                            self.buf,
4845                            "(({recv}).compare({recv}, {other})._tag {eq} \"{tag}\")"
4846                        );
4847                        return Ok(());
4848                    }
4849                }
4850                // DQ29 (§18.5 structural Equatable): a stamped `==`/`!=`
4851                // cannot use native `===` (reference identity on objects).
4852                // The `"impl"` lane dispatches through the explicit
4853                // `impl Equatable`'s `eq` (receiver doubled as the explicit
4854                // `self` argument, matching the method-call lowering —
4855                // Q-js-user-equality-reference, #339); the structural lanes
4856                // lower through the `__bockEq` runtime helper.
4857                if matches!(op, BinOp::Eq | BinOp::Ne) {
4858                    if let Some(kind) = crate::generator::user_eq_kind(node) {
4859                        let recv = self.expr_to_string(left)?;
4860                        let other = self.expr_to_string(right)?;
4861                        let neg = if *op == BinOp::Ne { "!" } else { "" };
4862                        if kind == "impl" {
4863                            let _ = write!(self.buf, "{neg}(({recv}).eq({recv}, {other}))");
4864                        } else {
4865                            let _ = write!(self.buf, "{neg}__bockEq({recv}, {other})");
4866                        }
4867                        return Ok(());
4868                    }
4869                }
4870                self.buf.push('(');
4871                self.emit_expr(left)?;
4872                let op_str = match op {
4873                    BinOp::Add => " + ",
4874                    BinOp::Sub => " - ",
4875                    BinOp::Mul => " * ",
4876                    BinOp::Div => " / ",
4877                    BinOp::Rem => " % ",
4878                    BinOp::Pow => " ** ",
4879                    BinOp::Eq => " === ",
4880                    BinOp::Ne => " !== ",
4881                    BinOp::Lt => " < ",
4882                    BinOp::Le => " <= ",
4883                    BinOp::Gt => " > ",
4884                    BinOp::Ge => " >= ",
4885                    BinOp::And => " && ",
4886                    BinOp::Or => " || ",
4887                    BinOp::BitAnd => " & ",
4888                    BinOp::BitOr => " | ",
4889                    BinOp::BitXor => " ^ ",
4890                    BinOp::Compose => " /* >> */ ",
4891                    BinOp::Is => " instanceof ",
4892                };
4893                self.buf.push_str(op_str);
4894                self.emit_expr(right)?;
4895                self.buf.push(')');
4896                Ok(())
4897            }
4898            NodeKind::UnaryOp { op, operand } => {
4899                let op_str = match op {
4900                    UnaryOp::Neg => "-",
4901                    UnaryOp::Not => "!",
4902                    UnaryOp::BitNot => "~",
4903                };
4904                self.buf.push_str(op_str);
4905                self.emit_expr(operand)?;
4906                Ok(())
4907            }
4908            NodeKind::Call { callee, args, .. } => {
4909                if let Some(code) = self.map_prelude_call(callee, args)? {
4910                    self.buf.push_str(&code);
4911                    return Ok(());
4912                }
4913                if self.try_emit_prelude_ctor(callee, args)? {
4914                    return Ok(());
4915                }
4916                if self.try_emit_time_assoc_call(callee, args)? {
4917                    return Ok(());
4918                }
4919                if self.try_emit_time_desugared_method(node, callee, args)? {
4920                    return Ok(());
4921                }
4922                if self.try_emit_concurrency_call(callee, args)? {
4923                    return Ok(());
4924                }
4925                // Map/Set dispatch precedes the List recogniser so the
4926                // overlapping method names route by `recv_kind`, not by name.
4927                if self.try_emit_map_method(node, callee, args)? {
4928                    return Ok(());
4929                }
4930                if self.try_emit_set_method(node, callee, args)? {
4931                    return Ok(());
4932                }
4933                // String method dispatch runs *before* the List recogniser so the
4934                // overlapping `len`/`contains`/`is_empty` names route by the
4935                // checker's `recv_kind = "Primitive:String"`, not by name alone.
4936                if self.try_emit_string_method(node, callee, args)? {
4937                    return Ok(());
4938                }
4939                // Numeric/Char/Bool primitive methods (`to_float`/`abs`/`sqrt`/…)
4940                // likewise route by the checker's `recv_kind = "Primitive:Int|…"`
4941                // before the generic fall-through, which would emit `n.to_float(n)`.
4942                if self.try_emit_numeric_method(node, callee, args)? {
4943                    return Ok(());
4944                }
4945                if self.try_emit_list_mutating_method(node, callee, args)? {
4946                    return Ok(());
4947                }
4948                if self.try_emit_list_inplace_mutator(node, callee, args)? {
4949                    return Ok(());
4950                }
4951                if self.try_emit_list_method(node, callee, args)? {
4952                    return Ok(());
4953                }
4954                if self.try_emit_list_functional_method(node, callee, args)? {
4955                    return Ok(());
4956                }
4957                if self.try_emit_primitive_bridge(node, callee, args)? {
4958                    return Ok(());
4959                }
4960                if self.try_emit_trait_bound_bridge(node, callee, args)? {
4961                    return Ok(());
4962                }
4963                if self.try_emit_container_method(node, callee, args)? {
4964                    return Ok(());
4965                }
4966                // Rewrite bare effect operation calls: log(...) → handler.log(...)
4967                if let NodeKind::Identifier { name } = &callee.kind {
4968                    if let Some(effect_name) = self.effect_ops.get(&name.name).cloned() {
4969                        if let Some(handler_var) =
4970                            self.current_handler_vars.get(&effect_name).cloned()
4971                        {
4972                            let _ = write!(self.buf, "{}.{}", handler_var, name.name);
4973                            self.buf.push('(');
4974                            for (i, arg) in args.iter().enumerate() {
4975                                if i > 0 {
4976                                    self.buf.push_str(", ");
4977                                }
4978                                self.emit_expr(&arg.value)?;
4979                            }
4980                            self.buf.push(')');
4981                            return Ok(());
4982                        }
4983                    }
4984                }
4985                // Q-prim-assoc: a primitive associated-conversion call
4986                // (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`)
4987                // lowers to TS's native conversion, NOT the static-member form
4988                // below (`Float.from` is undefined on the host `number`).
4989                if self.try_emit_primitive_conversion(node, callee, args)? {
4990                    return Ok(());
4991                }
4992                // An associated-function call (`Type.method(args)` — stamped by
4993                // the lowerer, no `self` prepended) resolves to the merged
4994                // `namespace Type { export function method(...) }` static member.
4995                // Emit `Type.method(args)` with the type name preserved (it names
4996                // the namespace/class, not a value); the generic fall-through
4997                // would camel-case it into a non-existent value.
4998                if crate::generator::is_associated_call(node) {
4999                    if let NodeKind::FieldAccess { object, field } = &callee.kind {
5000                        if let NodeKind::Identifier { name: type_name } = &object.kind {
5001                            let _ = write!(
5002                                self.buf,
5003                                "{}.{}",
5004                                type_name.name,
5005                                self.ts_method_name(&field.name)
5006                            );
5007                            self.buf.push('(');
5008                            for (i, arg) in args.iter().enumerate() {
5009                                if i > 0 {
5010                                    self.buf.push_str(", ");
5011                                }
5012                                self.emit_expr(&arg.value)?;
5013                            }
5014                            self.buf.push(')');
5015                            return Ok(());
5016                        }
5017                    }
5018                }
5019                // A trait/record method call lowers to `Call(FieldAccess(recv,
5020                // method), [recv, ...])` (the receiver is re-passed as `self`,
5021                // sharing the receiver's NodeId — see `desugared_self_call`).
5022                // When the method name collides with a field name, the *method*
5023                // was renamed at its declaration (`<name>Method`); rename the
5024                // call's member access to match so it resolves. A genuine field
5025                // *read* (bare `FieldAccess`, not in call position) and a
5026                // field-closure call `(p.f)(x)` (distinct receiver nodes) keep
5027                // the field name. Shared policy with go/js/py. The prototype
5028                // function takes an explicit `self`, so all `args` are kept.
5029                if let NodeKind::FieldAccess { object, field } = &callee.kind {
5030                    if crate::generator::desugared_self_call(callee, args).is_some() {
5031                        // The prototype/merged-interface declarations spell the
5032                        // method with the raw Bock name, so disambiguate the call
5033                        // against the raw name to match (and the generic
5034                        // fall-through emits the field raw too).
5035                        let renamed = self.ts_method_name(&field.name);
5036                        if renamed != field.name {
5037                            self.emit_expr(object)?;
5038                            let _ = write!(self.buf, ".{renamed}");
5039                            self.buf.push('(');
5040                            for (i, arg) in args.iter().enumerate() {
5041                                if i > 0 {
5042                                    self.buf.push_str(", ");
5043                                }
5044                                self.emit_expr(&arg.value)?;
5045                            }
5046                            self.buf.push(')');
5047                            return Ok(());
5048                        }
5049                    }
5050                }
5051                // Pass handler args to effectful function calls.
5052                let effects_arg = if let NodeKind::Identifier { name } = &callee.kind {
5053                    self.build_effects_call_arg_ts(&name.name)
5054                } else {
5055                    None
5056                };
5057                self.emit_callee(callee)?;
5058                self.buf.push('(');
5059                for (i, arg) in args.iter().enumerate() {
5060                    if i > 0 {
5061                        self.buf.push_str(", ");
5062                    }
5063                    self.emit_expr(&arg.value)?;
5064                }
5065                if let Some(ea) = effects_arg {
5066                    if !args.is_empty() {
5067                        self.buf.push_str(", ");
5068                    }
5069                    self.buf.push_str(&ea);
5070                }
5071                self.buf.push(')');
5072                Ok(())
5073            }
5074            NodeKind::MethodCall {
5075                receiver,
5076                method,
5077                args,
5078                ..
5079            } => {
5080                if self.try_emit_time_method(receiver, &method.name, args)? {
5081                    return Ok(());
5082                }
5083                self.emit_expr(receiver)?;
5084                let _ = write!(
5085                    self.buf,
5086                    ".{}",
5087                    self.ts_method_name(&to_camel_case(&method.name))
5088                );
5089                self.buf.push('(');
5090                for (i, arg) in args.iter().enumerate() {
5091                    if i > 0 {
5092                        self.buf.push_str(", ");
5093                    }
5094                    self.emit_expr(&arg.value)?;
5095                }
5096                self.buf.push(')');
5097                Ok(())
5098            }
5099            NodeKind::FieldAccess { object, field } => {
5100                self.emit_expr(object)?;
5101                let _ = write!(self.buf, ".{}", field.name);
5102                Ok(())
5103            }
5104            NodeKind::Index { object, index } => {
5105                self.emit_expr(object)?;
5106                self.buf.push('[');
5107                self.emit_expr(index)?;
5108                self.buf.push(']');
5109                Ok(())
5110            }
5111            NodeKind::Lambda { params, body } => {
5112                let param_list = self.collect_lambda_params(params);
5113                let _ = write!(self.buf, "({}) => ", param_list.join(", "));
5114                if matches!(body.kind, NodeKind::Block { .. }) {
5115                    self.buf.push_str("{\n");
5116                    self.indent += 1;
5117                    // A lambda body is a fresh function-body tail context: its
5118                    // tail is the lambda's return value, so clear any active
5119                    // statement-position sink (e.g. a `Discard` from an enclosing
5120                    // loop body) for the duration of the body.
5121                    let prev_sink = self.value_sink.take();
5122                    let r = self.emit_block_body(body);
5123                    self.value_sink = prev_sink;
5124                    r?;
5125                    self.indent -= 1;
5126                    self.write_indent();
5127                    self.buf.push('}');
5128                } else {
5129                    self.emit_expr(body)?;
5130                }
5131                Ok(())
5132            }
5133            NodeKind::Pipe { left, right } => self.emit_pipe(left, right),
5134            NodeKind::Compose { left, right } => {
5135                // `f >> g` → `((x: any) => g(f(x)))`. A composed callee
5136                // (`left`/`right`) that is itself a `Compose`/`Lambda` must be
5137                // parenthesized: a bare arrow `(x) => …` followed by `(x)` parses
5138                // as `(x) => (…(x))`, binding the call to the arrow's body rather
5139                // than invoking the arrow. `emit_callee` wraps those forms. In
5140                // practice the AIR lowers `>>` to a `Lambda` before codegen (so
5141                // chained `>>` reaches the `Call` arm, not here), making this a
5142                // defensive fall-through.
5143                let _ = write!(self.buf, "((x: any) => ");
5144                self.emit_callee(right)?;
5145                self.buf.push('(');
5146                self.emit_callee(left)?;
5147                self.buf.push_str("(x)))");
5148                Ok(())
5149            }
5150            NodeKind::Await { expr } => {
5151                self.buf.push_str("(await ");
5152                self.emit_expr(expr)?;
5153                self.buf.push(')');
5154                Ok(())
5155            }
5156            NodeKind::Propagate { expr } => {
5157                // `expr?` in *expression* position (nested inside a larger
5158                // expression). Early-return has no expression form in TS, so the
5159                // statement-position lowerings (LetBinding value, bare statement,
5160                // block tail — see `emit_propagate`) handle the common cases. Here
5161                // we can only unwrap the payload (`._0`); the Err/None branch is
5162                // *not* propagated, so this is a partial lowering. The examples in
5163                // scope never hit it (their `?` is always statement-positioned).
5164                // Tracked as OPEN: nested expression-position `?`.
5165                self.buf.push('(');
5166                self.emit_expr(expr)?;
5167                self.buf.push_str(")._0");
5168                Ok(())
5169            }
5170            NodeKind::Range { lo, hi, inclusive } => {
5171                if *inclusive {
5172                    self.buf.push_str("rangeInclusive(");
5173                } else {
5174                    self.buf.push_str("range(");
5175                }
5176                self.emit_expr(lo)?;
5177                self.buf.push_str(", ");
5178                self.emit_expr(hi)?;
5179                self.buf.push(')');
5180                Ok(())
5181            }
5182            NodeKind::RecordConstruct {
5183                path,
5184                fields,
5185                spread,
5186            } => {
5187                // A struct-variant construction (`Circle { radius: 2.0 }`) →
5188                // the `{enum}_{variant}(field, ..)` factory, in field decl
5189                // order. Plain records keep their object/class form.
5190                let struct_variant = if spread.is_none() {
5191                    self.user_variant_for_path(path).and_then(|info| {
5192                        if let crate::generator::VariantPayloadKind::Struct(field_order) =
5193                            &info.payload
5194                        {
5195                            Some((info.enum_name.clone(), field_order.clone()))
5196                        } else {
5197                            None
5198                        }
5199                    })
5200                } else {
5201                    None
5202                };
5203                if let Some((enum_name, field_order)) = struct_variant {
5204                    let variant = path.segments.last().map_or("", |s| s.name.as_str());
5205                    let _ = write!(self.buf, "{enum_name}_{variant}(");
5206                    for (i, fname) in field_order.iter().enumerate() {
5207                        if i > 0 {
5208                            self.buf.push_str(", ");
5209                        }
5210                        let supplied = fields.iter().find(|f| &f.name.name == fname);
5211                        match supplied.and_then(|f| f.value.as_ref()) {
5212                            Some(val) => self.emit_expr(val)?,
5213                            None => self.buf.push_str(&ts_value_ident(fname)),
5214                        }
5215                    }
5216                    self.buf.push(')');
5217                    return Ok(());
5218                }
5219                let type_name = path.segments.last().map(|s| s.name.as_str()).unwrap_or("");
5220                // A Bock `class` lowers to a *positional* `constructor(a, b)`
5221                // (unlike a record's destructured `constructor({ a, b })`), so a
5222                // class literal must construct as `new T(a_value, b_value)` with
5223                // values ordered by the *declared* field order — not the literal's
5224                // field order, and not a bare object literal (whose inherent/trait
5225                // methods are not on the object-literal type, so `tsc` rejects the
5226                // method call). Falls through to the record/object path only when
5227                // this is not a known class.
5228                if let Some(field_order) = self.class_fields.get(type_name).cloned() {
5229                    let _ = write!(self.buf, "new {type_name}(");
5230                    for (i, fname) in field_order.iter().enumerate() {
5231                        if i > 0 {
5232                            self.buf.push_str(", ");
5233                        }
5234                        let supplied = fields.iter().find(|f| &f.name.name == fname);
5235                        match supplied.and_then(|f| f.value.as_ref()) {
5236                            Some(val) => self.emit_expr(val)?,
5237                            // A field present in the literal as shorthand
5238                            // (`T { label }` ≡ `T { label: label }`) — the RHS is
5239                            // a value reference; otherwise (field omitted, only
5240                            // possible with a `..base` spread) read it off `base`.
5241                            None if supplied.is_some() => {
5242                                self.buf.push_str(&ts_value_ident(fname));
5243                            }
5244                            None => match spread {
5245                                Some(sp) => {
5246                                    self.emit_expr(sp)?;
5247                                    let _ = write!(self.buf, ".{}", ts_value_ident(fname));
5248                                }
5249                                None => self.buf.push_str("undefined"),
5250                            },
5251                        }
5252                    }
5253                    self.buf.push(')');
5254                    return Ok(());
5255                }
5256                let is_class = self.record_names.contains(type_name);
5257                if is_class {
5258                    let _ = write!(self.buf, "new {type_name}(");
5259                    if fields.is_empty() && spread.is_none() {
5260                        self.buf.push(')');
5261                        return Ok(());
5262                    }
5263                }
5264                if let Some(sp) = spread {
5265                    self.buf.push_str("{ ...");
5266                    self.emit_expr(sp)?;
5267                    if !fields.is_empty() {
5268                        self.buf.push_str(", ");
5269                    }
5270                } else {
5271                    self.buf.push_str("{ ");
5272                }
5273                for (i, f) in fields.iter().enumerate() {
5274                    if i > 0 {
5275                        self.buf.push_str(", ");
5276                    }
5277                    if let Some(val) = &f.value {
5278                        let _ = write!(self.buf, "{}: ", f.name.name);
5279                        self.emit_expr(val)?;
5280                    } else {
5281                        self.buf.push_str(&f.name.name);
5282                    }
5283                }
5284                self.buf.push_str(" }");
5285                if is_class {
5286                    self.buf.push(')');
5287                }
5288                Ok(())
5289            }
5290            NodeKind::ListLiteral { elems } => {
5291                self.buf.push('[');
5292                for (i, e) in elems.iter().enumerate() {
5293                    if i > 0 {
5294                        self.buf.push_str(", ");
5295                    }
5296                    self.emit_expr(e)?;
5297                }
5298                self.buf.push(']');
5299                Ok(())
5300            }
5301            NodeKind::MapLiteral { entries } => {
5302                self.buf.push_str("new Map([");
5303                for (i, entry) in entries.iter().enumerate() {
5304                    if i > 0 {
5305                        self.buf.push_str(", ");
5306                    }
5307                    self.buf.push('[');
5308                    self.emit_expr(&entry.key)?;
5309                    self.buf.push_str(", ");
5310                    self.emit_expr(&entry.value)?;
5311                    self.buf.push(']');
5312                }
5313                self.buf.push_str("])");
5314                Ok(())
5315            }
5316            NodeKind::SetLiteral { elems } => {
5317                self.buf.push_str("new Set([");
5318                for (i, e) in elems.iter().enumerate() {
5319                    if i > 0 {
5320                        self.buf.push_str(", ");
5321                    }
5322                    self.emit_expr(e)?;
5323                }
5324                self.buf.push_str("])");
5325                Ok(())
5326            }
5327            NodeKind::TupleLiteral { elems } => {
5328                // TS tuples are arrays with typed positions.
5329                self.buf.push('[');
5330                for (i, e) in elems.iter().enumerate() {
5331                    if i > 0 {
5332                        self.buf.push_str(", ");
5333                    }
5334                    self.emit_expr(e)?;
5335                }
5336                self.buf.push(']');
5337                Ok(())
5338            }
5339            NodeKind::Interpolation { parts } => {
5340                self.buf.push('`');
5341                for part in parts {
5342                    match part {
5343                        AirInterpolationPart::Literal(s) => {
5344                            self.buf.push_str(&escape_template_literal(s));
5345                        }
5346                        AirInterpolationPart::Expr(expr) => {
5347                            // Q-displayable-interpolation-dispatch: render through
5348                            // `__bockStr` so a user value with a `Displayable`
5349                            // impl (its `to_string` method) shows via that method,
5350                            // not `[object Object]`. Primitives fall back to
5351                            // native `String(x)`.
5352                            self.needs_runtime_str = true;
5353                            self.buf.push_str("${__bockStr(");
5354                            self.emit_expr(expr)?;
5355                            self.buf.push_str(")}");
5356                        }
5357                    }
5358                }
5359                self.buf.push('`');
5360                Ok(())
5361            }
5362            NodeKind::Placeholder => {
5363                self.buf.push('_');
5364                Ok(())
5365            }
5366            NodeKind::Unreachable => {
5367                self.buf
5368                    .push_str("(() => { throw new Error(\"unreachable\"); })()");
5369                Ok(())
5370            }
5371            NodeKind::ResultConstruct { variant, value } => {
5372                // Use the `_0` payload key — the same shape the surface
5373                // `Ok(..)`/`Err(..)` construction (`try_emit_prelude_ctor`) emits
5374                // and the `Result` match reads — so construction and match agree
5375                // (the old `value`/`error` keys were never read by the match).
5376                let tag = match variant {
5377                    ResultVariant::Ok => "Ok",
5378                    ResultVariant::Err => "Err",
5379                };
5380                let _ = write!(self.buf, "{{ _tag: \"{tag}\" as const, _0: ");
5381                if let Some(v) = value {
5382                    self.emit_expr(v)?;
5383                } else {
5384                    self.buf.push_str("undefined");
5385                }
5386                self.buf.push_str(" }");
5387                Ok(())
5388            }
5389            NodeKind::Assign { op, target, value } => {
5390                self.emit_expr(target)?;
5391                let op_str = match op {
5392                    AssignOp::Assign => " = ",
5393                    AssignOp::AddAssign => " += ",
5394                    AssignOp::SubAssign => " -= ",
5395                    AssignOp::MulAssign => " *= ",
5396                    AssignOp::DivAssign => " /= ",
5397                    AssignOp::RemAssign => " %= ",
5398                };
5399                self.buf.push_str(op_str);
5400                self.emit_expr(value)?;
5401                Ok(())
5402            }
5403            NodeKind::If {
5404                condition,
5405                then_block,
5406                else_block,
5407                ..
5408            } => {
5409                // Ternary for expression-position if.
5410                self.buf.push('(');
5411                self.emit_expr(condition)?;
5412                self.buf.push_str(" ? ");
5413                self.emit_block_as_expr(then_block)?;
5414                self.buf.push_str(" : ");
5415                if let Some(eb) = else_block {
5416                    self.emit_block_as_expr(eb)?;
5417                } else {
5418                    self.buf.push_str("undefined");
5419                }
5420                self.buf.push(')');
5421                Ok(())
5422            }
5423            NodeKind::Block { stmts, tail } => {
5424                // IIFE. The IIFE body is its own TS lexical scope, so it gets its
5425                // own `let` scope frame — a name re-bound across two *sibling*
5426                // IIFEs (e.g. two arms of an expression-position `match`) is two
5427                // independent declarations, not a redeclaration.
5428                self.buf.push_str("(() => {\n");
5429                self.indent += 1;
5430                self.enter_let_scope(node);
5431                for s in stmts {
5432                    self.emit_node(s)?;
5433                }
5434                if let Some(t) = tail {
5435                    let ind = self.indent_str();
5436                    let _ = write!(self.buf, "{ind}return ");
5437                    self.emit_expr(t)?;
5438                    self.buf.push_str(";\n");
5439                }
5440                self.leave_let_scope();
5441                self.indent -= 1;
5442                self.write_indent();
5443                self.buf.push_str("})()");
5444                Ok(())
5445            }
5446            NodeKind::Match { scrutinee, arms } => {
5447                // Expression-position match → IIFE. When the value is consumed
5448                // into a typed binding (`let x: T = match …`), annotate the
5449                // arrow's return type (`(() : T => {…})()`) so a `T` distinct
5450                // from the enclosing function's inferred return is respected, and
5451                // force-hoist a bare-identifier scrutinee so `switch (s)` does not
5452                // narrow `s` to the case literal inside arm bodies (TS2367). The
5453                // expected type is taken here so it scopes to this IIFE only and
5454                // does not leak into the (separately typed) arm-body expressions.
5455                let expected = self.current_expected_type.take();
5456                let arrow_ret = expected
5457                    .as_deref()
5458                    .map(|t| format!(" : {t}"))
5459                    .unwrap_or_default();
5460                let force_hoist = expected.is_some();
5461                let _ = write!(self.buf, "((){arrow_ret} => {{");
5462                self.buf.push('\n');
5463                self.indent += 1;
5464                // The IIFE arrow returns the matched arm's value, so its arm
5465                // bodies deliver through a `Return` sink — *not* whatever
5466                // statement-position sink is active outside (a `Discard` from an
5467                // enclosing loop/statement context, or an `Assign` from an outer
5468                // statement-form lowering). Reset for the IIFE body, restore after
5469                // so the outer sink is untouched.
5470                let prev_sink = self.value_sink.replace(ValueSink::Return);
5471                let r = self.emit_match(scrutinee, arms, force_hoist);
5472                self.value_sink = prev_sink;
5473                r?;
5474                self.indent -= 1;
5475                self.write_indent();
5476                self.buf.push_str("})()");
5477                self.current_expected_type = expected;
5478                Ok(())
5479            }
5480            // Ownership: erase
5481            NodeKind::Move { expr }
5482            | NodeKind::Borrow { expr }
5483            | NodeKind::MutableBorrow { expr } => self.emit_expr(expr),
5484            // Effect operation invocation
5485            NodeKind::EffectOp {
5486                effect,
5487                operation,
5488                args,
5489            } => {
5490                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
5491                let _ = write!(
5492                    self.buf,
5493                    "{}.{}",
5494                    to_camel_case(effect_name),
5495                    operation.name
5496                );
5497                self.buf.push('(');
5498                for (i, arg) in args.iter().enumerate() {
5499                    if i > 0 {
5500                        self.buf.push_str(", ");
5501                    }
5502                    self.emit_expr(&arg.value)?;
5503                }
5504                self.buf.push(')');
5505                Ok(())
5506            }
5507            // Type expressions in expression position: emit the TS type
5508            NodeKind::TypeNamed { .. }
5509            | NodeKind::TypeTuple { .. }
5510            | NodeKind::TypeFunction { .. }
5511            | NodeKind::TypeOptional { .. }
5512            | NodeKind::TypeSelf => {
5513                let ty_str = self.type_to_ts(node);
5514                let _ = write!(self.buf, "/* {ty_str} */");
5515                Ok(())
5516            }
5517            NodeKind::EffectRef { path } => {
5518                let name = path
5519                    .segments
5520                    .iter()
5521                    .map(|s| s.name.as_str())
5522                    .collect::<Vec<_>>()
5523                    .join(".");
5524                self.buf.push_str(&name);
5525                Ok(())
5526            }
5527            NodeKind::Error => {
5528                self.buf.push_str("/* error */");
5529                Ok(())
5530            }
5531            _ => {
5532                self.buf.push_str("/* unsupported */");
5533                Ok(())
5534            }
5535        }
5536    }
5537
5538    // ── Match → switch ──────────────────────────────────────────────────────
5539
5540    /// Lower a `match`. `force_hoist` requests that even a bare-identifier
5541    /// scrutinee be hoisted into a single `const __matchN = …;` temp before the
5542    /// `switch` — set for an expression-position match consumed into a typed
5543    /// binding, so the `switch` narrows the temp (not the original binding) and
5544    /// arm bodies re-referencing the scrutinee do not trip TS2367. Statement-
5545    /// position calls pass `false`, preserving the inline `switch (s)` fast-path.
5546    fn emit_match(
5547        &mut self,
5548        scrutinee: &AIRNode,
5549        arms: &[AIRNode],
5550        force_hoist: bool,
5551    ) -> Result<(), CodegenError> {
5552        // Guards, or-patterns, tuple patterns, and nested constructor/record
5553        // patterns cannot be expressed by the flat `switch` below. Lower those
5554        // to an if/else-if chain. Additive: the proven Optional / Result /
5555        // user-enum / value `switch` fast-path is kept for everything else (see
5556        // `match_needs_ifchain`). The if-chain already casts the scrutinee root
5557        // to `as any`, which itself defeats the TS2367 narrowing, so `force_hoist`
5558        // is irrelevant there.
5559        if crate::generator::match_needs_ifchain(arms) {
5560            return self.emit_match_ifchain(scrutinee, arms);
5561        }
5562
5563        // ADT (dispatch on `._tag`) when any arm is a constructor pattern or a
5564        // record pattern naming a registered enum variant. The record-pattern
5565        // case is the struct-payload variant the prior `ConstructorPat`-only
5566        // check missed (DV14).
5567        let is_adt = arms.iter().any(|arm| {
5568            let NodeKind::MatchArm { pattern, .. } = &arm.kind else {
5569                return false;
5570            };
5571            match &pattern.kind {
5572                NodeKind::ConstructorPat { .. } => true,
5573                NodeKind::RecordPat { path, .. } => self.user_variant_for_path(path).is_some(),
5574                _ => false,
5575            }
5576        });
5577
5578        // Hoist a non-trivial scrutinee into a single `const __matchN = …;` so it
5579        // is evaluated once and TS narrowing on `__matchN._tag` reaches the
5580        // payload access `__matchN._0` in the arm bodies. A bare identifier is
5581        // already a stable reference — normally left inline. But `force_hoist`
5582        // (an expression-position value match) hoists it too, so `switch
5583        // (__matchN)` narrows the temp rather than the original binding, keeping
5584        // arm bodies that re-reference the scrutinee free of TS2367.
5585        let temp = if matches!(scrutinee.kind, NodeKind::Identifier { .. }) && !force_hoist {
5586            None
5587        } else {
5588            self.match_temp_counter += 1;
5589            let name = format!("__match{}", self.match_temp_counter);
5590            let ind = self.indent_str();
5591            let _ = write!(self.buf, "{ind}const {name} = ");
5592            self.emit_expr(scrutinee)?;
5593            self.buf.push_str(";\n");
5594            Some(name)
5595        };
5596
5597        let ind = self.indent_str();
5598        let _ = write!(self.buf, "{ind}switch (");
5599        self.emit_scrutinee_ref(scrutinee, temp.as_deref())?;
5600        if is_adt {
5601            self.buf.push_str("._tag) {\n");
5602        } else {
5603            self.buf.push_str(") {\n");
5604        }
5605        self.indent += 1;
5606        self.switch_label_depth += 1;
5607        for arm in arms {
5608            self.emit_match_arm(arm, is_adt, scrutinee, temp.as_deref())?;
5609        }
5610        self.switch_label_depth -= 1;
5611        self.indent -= 1;
5612        self.writeln("}");
5613        Ok(())
5614    }
5615
5616    /// Emit a reference to the match scrutinee: the hoisted temp name when one
5617    /// was introduced, else the scrutinee expression inline (a bare identifier).
5618    fn emit_scrutinee_ref(
5619        &mut self,
5620        scrutinee: &AIRNode,
5621        temp: Option<&str>,
5622    ) -> Result<(), CodegenError> {
5623        match temp {
5624            Some(name) => {
5625                self.buf.push_str(name);
5626                Ok(())
5627            }
5628            None => self.emit_expr(scrutinee),
5629        }
5630    }
5631
5632    /// Render the scrutinee reference (hoisted temp name, else the inline
5633    /// scrutinee expression) to a `String` instead of straight to `self.buf`,
5634    /// by swapping in a scratch buffer for the duration. Used by the ADT-arm
5635    /// payload bindings to build the narrowing cast around the reference.
5636    fn scrutinee_ref_string(
5637        &mut self,
5638        scrutinee: &AIRNode,
5639        temp: Option<&str>,
5640    ) -> Result<String, CodegenError> {
5641        let saved = std::mem::take(&mut self.buf);
5642        let result = self.emit_scrutinee_ref(scrutinee, temp);
5643        let rendered = std::mem::replace(&mut self.buf, saved);
5644        result.map(|()| rendered)
5645    }
5646
5647    /// Wrap a scrutinee reference in a discriminated-union narrowing cast so a
5648    /// constructor/record arm's payload binding is pinned to the matched
5649    /// variant's member type — `(<ref> as Extract<typeof <ref>, { _tag:
5650    /// "<variant>" }>)`.
5651    ///
5652    /// TS narrows `s` to the matched member inside a reachable `case "<variant>":`
5653    /// arm, so `s._0` is the payload type. But narrowing is control-flow-scoped:
5654    /// when an earlier statement-position `match` has every arm `return`, TS
5655    /// marks the rest of the function unreachable and stops narrowing, so a later
5656    /// arm's bare `s._0` widens back to the full union payload and tripping
5657    /// TS2345 (e.g. `T | E` passed to a `T` parameter — the task-api blocker).
5658    /// `Extract<typeof s, { _tag: "<variant>" }>` selects the matched member
5659    /// structurally, independent of reachability, so the binding never widens.
5660    /// `Extract` on a non-union or `any` scrutinee is a no-op / `any`, and on a
5661    /// non-matching member yields `never` (whose `._N` assigns anywhere), so the
5662    /// cast is sound for every ADT scrutinee shape.
5663    fn narrowing_cast(scrutinee_ref: &str, variant: &str) -> String {
5664        format!("({scrutinee_ref} as Extract<typeof {scrutinee_ref}, {{ _tag: \"{variant}\" }}>)")
5665    }
5666
5667    /// Emit a TS label before a loop iff a contained statement-arm `match`
5668    /// needs to `break`/`continue` the loop. Pair with [`Self::pop_loop_frame`].
5669    /// Also pushes a `None` loop result-sink frame; a value-position loop (see
5670    /// [`Self::emit_value_in_stmt_pos`]) overwrites it with [`Self::set_loop_value_sink`].
5671    fn emit_loop_label_prefix(&mut self, body: &AIRNode) {
5672        if crate::generator::loop_needs_break_label(body) {
5673            self.loop_label_counter += 1;
5674            let label = format!("__bockLoop{}", self.loop_label_counter);
5675            self.writeln(&format!("{label}:"));
5676            self.loop_labels.push(Some(label));
5677        } else {
5678            self.loop_labels.push(None);
5679        }
5680        self.loop_value_sinks.push(None);
5681    }
5682
5683    /// Pop the loop frame pushed by [`Self::emit_loop_label_prefix`] (both the
5684    /// label and the result-sink entry).
5685    fn pop_loop_frame(&mut self) {
5686        self.loop_labels.pop();
5687        self.loop_value_sinks.pop();
5688    }
5689
5690    /// Set the innermost loop's result sink — where `break v` writes the loop's
5691    /// value (`<binding> = v;` / `return v;`). Called right after
5692    /// [`Self::emit_loop_label_prefix`] for a value-position loop.
5693    fn set_loop_value_sink(&mut self, sink: ValueSink) {
5694        if let Some(top) = self.loop_value_sinks.last_mut() {
5695            *top = Some(sink);
5696        }
5697    }
5698
5699    /// The innermost loop's result sink, if it is a value-position loop.
5700    fn innermost_loop_value_sink(&self) -> Option<ValueSink> {
5701        self.loop_value_sinks.last().and_then(Clone::clone)
5702    }
5703
5704    /// Label of the innermost loop, if one was allocated.
5705    fn innermost_loop_label(&self) -> Option<&str> {
5706        self.loop_labels.last().and_then(|l| l.as_deref())
5707    }
5708
5709    fn emit_match_arm(
5710        &mut self,
5711        arm: &AIRNode,
5712        is_adt: bool,
5713        scrutinee: &AIRNode,
5714        temp: Option<&str>,
5715    ) -> Result<(), CodegenError> {
5716        if let NodeKind::MatchArm {
5717            pattern,
5718            guard,
5719            body,
5720        } = &arm.kind
5721        {
5722            match &pattern.kind {
5723                NodeKind::WildcardPat => {
5724                    self.writeln("default: {");
5725                }
5726                NodeKind::BindPat { name, is_mut } if !is_adt => {
5727                    self.writeln("default: {");
5728                    self.indent += 1;
5729                    // A `mut x =>` arm reassigns the binding in its body, so it
5730                    // must be `let` — emitting `const` trips TS2588 ("cannot
5731                    // assign to a constant").
5732                    let kw = if *is_mut { "let" } else { "const" };
5733                    let ind = self.indent_str();
5734                    let _ = write!(self.buf, "{ind}{kw} {} = ", name.name);
5735                    self.emit_scrutinee_ref(scrutinee, temp)?;
5736                    self.buf.push_str(";\n");
5737                    self.indent -= 1;
5738                }
5739                NodeKind::LiteralPat { lit } => {
5740                    let ind = self.indent_str();
5741                    let _ = write!(self.buf, "{ind}case ");
5742                    match lit {
5743                        Literal::Int(s) => self.buf.push_str(s),
5744                        Literal::Float(s) => self.buf.push_str(s),
5745                        Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
5746                        Literal::Char(s) => {
5747                            self.buf.push('\'');
5748                            self.buf.push_str(s);
5749                            self.buf.push('\'');
5750                        }
5751                        Literal::String(s) => {
5752                            self.buf.push('"');
5753                            self.buf.push_str(&escape_js_string(s));
5754                            self.buf.push('"');
5755                        }
5756                        Literal::Unit => self.buf.push_str("undefined"),
5757                    }
5758                    self.buf.push_str(": {\n");
5759                }
5760                NodeKind::ConstructorPat { path, fields } => {
5761                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
5762                    self.writeln(&format!("case \"{variant_name}\": {{"));
5763                    if !fields.is_empty() {
5764                        self.indent += 1;
5765                        // Cast the scrutinee to the matched variant member so each
5766                        // payload binding stays the variant's payload type even in
5767                        // control-flow-unreachable arms (see `narrowing_cast`).
5768                        let scrut_ref = self.scrutinee_ref_string(scrutinee, temp)?;
5769                        let cast = Self::narrowing_cast(&scrut_ref, variant_name);
5770                        for (i, field) in fields.iter().enumerate() {
5771                            let binding = self.pattern_to_binding_name(field);
5772                            let ind = self.indent_str();
5773                            let _ = writeln!(self.buf, "{ind}const {binding} = {cast}._{i};");
5774                        }
5775                        self.indent -= 1;
5776                    }
5777                }
5778                NodeKind::RecordPat { path, fields, .. } => {
5779                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
5780                    if is_adt {
5781                        self.writeln(&format!("case \"{variant_name}\": {{"));
5782                    } else {
5783                        self.writeln("default: {");
5784                    }
5785                    if !fields.is_empty() {
5786                        self.indent += 1;
5787                        // For a struct-payload enum variant (`is_adt`), pin each
5788                        // field binding to the matched member via the narrowing
5789                        // cast so it survives control-flow-unreachable arms (see
5790                        // `narrowing_cast`). A non-ADT record destructure binds off
5791                        // the un-narrowed scrutinee as before.
5792                        let scrut_ref = self.scrutinee_ref_string(scrutinee, temp)?;
5793                        let access = if is_adt {
5794                            Self::narrowing_cast(&scrut_ref, variant_name)
5795                        } else {
5796                            scrut_ref
5797                        };
5798                        for f in fields {
5799                            let field_name = &f.name.name;
5800                            let binding = match &f.pattern {
5801                                Some(pat) => self.pattern_to_binding_name(pat),
5802                                None => field_name.clone(),
5803                            };
5804                            let ind = self.indent_str();
5805                            let _ =
5806                                writeln!(self.buf, "{ind}const {binding} = {access}.{field_name};");
5807                        }
5808                        self.indent -= 1;
5809                    }
5810                }
5811                _ => {
5812                    self.writeln("default: {");
5813                }
5814            }
5815
5816            self.indent += 1;
5817            if let Some(g) = guard {
5818                let ind = self.indent_str();
5819                let _ = write!(self.buf, "{ind}if (!(");
5820                self.emit_expr(g)?;
5821                self.buf.push_str(")) break;\n");
5822            }
5823            self.emit_block_body(body)?;
5824            self.writeln("break;");
5825            self.indent -= 1;
5826            self.writeln("}");
5827        }
5828        Ok(())
5829    }
5830
5831    // ── Match → if/else-if chain (guards, or-/tuple/nested patterns) ──────────
5832
5833    /// Lower a `match` whose arms cannot be expressed by a flat `switch` (see
5834    /// [`crate::generator::match_needs_ifchain`]) to an `if (<test>) { <binds>;
5835    /// <body> } else if …` chain. Mirrors the JS lowering; the only difference
5836    /// is that TS access paths into the scrutinee are cast through `as any` so a
5837    /// nested access (`__matchN._0._tag`) typechecks without relying on
5838    /// discriminated-union narrowing flowing through `&&`.
5839    fn emit_match_ifchain(
5840        &mut self,
5841        scrutinee: &AIRNode,
5842        arms: &[AIRNode],
5843    ) -> Result<(), CodegenError> {
5844        // Single-evaluation root. A bare identifier is stable; anything else is
5845        // hoisted into `__matchN`. The access root the tests/binds descend from
5846        // is cast to `any` so nested field access typechecks under `tsc`.
5847        let root: String = if let NodeKind::Identifier { name } = &scrutinee.kind {
5848            format!("({} as any)", name.name)
5849        } else {
5850            self.match_temp_counter += 1;
5851            let name = format!("__match{}", self.match_temp_counter);
5852            let ind = self.indent_str();
5853            let _ = write!(self.buf, "{ind}const {name} = ");
5854            self.emit_expr(scrutinee)?;
5855            self.buf.push_str(";\n");
5856            format!("({name} as any)")
5857        };
5858
5859        let mut first = true;
5860        let mut closed = false;
5861        let arm_count = arms.len();
5862        for (idx, arm) in arms.iter().enumerate() {
5863            let NodeKind::MatchArm {
5864                pattern,
5865                guard,
5866                body,
5867            } = &arm.kind
5868            else {
5869                continue;
5870            };
5871            let test = self.pattern_test_ts(pattern, &root);
5872            let is_catch_all = matches!(
5873                pattern.kind,
5874                NodeKind::WildcardPat | NodeKind::BindPat { .. }
5875            );
5876            let is_last = idx + 1 == arm_count;
5877            // See the JS lowering: an unguarded catch-all *or* the final
5878            // unguarded arm becomes the unconditional `else`, closing the chain
5879            // so a value-returning function typechecks under `tsc`.
5880            let unconditional = guard.is_none() && (is_catch_all || is_last);
5881            let ind = self.indent_str();
5882            if unconditional {
5883                if first {
5884                    let _ = writeln!(self.buf, "{ind}{{");
5885                } else {
5886                    let _ = writeln!(self.buf, "{ind}else {{");
5887                }
5888                closed = true;
5889            } else {
5890                let mut cond = if test.is_empty() {
5891                    "true".to_string()
5892                } else {
5893                    test
5894                };
5895                if let Some(g) = guard {
5896                    let g_str = self.expr_to_string(g)?;
5897                    let binds = self.pattern_binds_to_string_ts(pattern, &root);
5898                    let guard_test = if binds.is_empty() {
5899                        format!("({g_str})")
5900                    } else {
5901                        format!("(() => {{ {binds}return ({g_str}); }})()")
5902                    };
5903                    if cond == "true" {
5904                        cond = guard_test;
5905                    } else {
5906                        cond = format!("{cond} && {guard_test}");
5907                    }
5908                }
5909                if first {
5910                    let _ = writeln!(self.buf, "{ind}if ({cond}) {{");
5911                } else {
5912                    let _ = writeln!(self.buf, "{ind}else if ({cond}) {{");
5913                }
5914            }
5915            first = false;
5916            self.indent += 1;
5917            self.pattern_binds_ts(pattern, &root)?;
5918            self.emit_block_body(body)?;
5919            self.indent -= 1;
5920            self.writeln("}");
5921        }
5922        if !closed && !first {
5923            self.writeln("else { throw new Error(\"non-exhaustive match\"); }");
5924        }
5925        Ok(())
5926    }
5927
5928    /// Build the boolean test that selects `pat` against the TS expression
5929    /// `access` (already `any`-typed by the caller). Mirrors `pattern_test_js`.
5930    fn pattern_test_ts(&self, pat: &AIRNode, access: &str) -> String {
5931        match &pat.kind {
5932            NodeKind::WildcardPat | NodeKind::BindPat { .. } => String::new(),
5933            NodeKind::LiteralPat { lit } => {
5934                format!("{access} === {}", ts_literal(lit))
5935            }
5936            NodeKind::ConstructorPat { path, fields } => {
5937                let variant = path.segments.last().map_or("_", |s| s.name.as_str());
5938                let mut tests = vec![format!("{access}._tag === \"{variant}\"")];
5939                for (i, field) in fields.iter().enumerate() {
5940                    let sub = self.pattern_test_ts(field, &format!("{access}._{i}"));
5941                    if !sub.is_empty() {
5942                        tests.push(sub);
5943                    }
5944                }
5945                tests.join(" && ")
5946            }
5947            NodeKind::RecordPat { path, fields, .. } => {
5948                let variant = path.segments.last().map_or("_", |s| s.name.as_str());
5949                let mut tests = Vec::new();
5950                if self.user_variant_for_path(path).is_some() {
5951                    tests.push(format!("{access}._tag === \"{variant}\""));
5952                }
5953                for f in fields {
5954                    if let Some(p) = &f.pattern {
5955                        let sub = self.pattern_test_ts(p, &format!("{access}.{}", f.name.name));
5956                        if !sub.is_empty() {
5957                            tests.push(sub);
5958                        }
5959                    }
5960                }
5961                if tests.is_empty() {
5962                    String::new()
5963                } else {
5964                    tests.join(" && ")
5965                }
5966            }
5967            NodeKind::TuplePat { elems } => {
5968                let mut tests = vec![format!("Array.isArray({access})")];
5969                for (i, e) in elems.iter().enumerate() {
5970                    let sub = self.pattern_test_ts(e, &format!("{access}[{i}]"));
5971                    if !sub.is_empty() {
5972                        tests.push(sub);
5973                    }
5974                }
5975                tests.join(" && ")
5976            }
5977            NodeKind::ListPat { elems, rest } => {
5978                // `[a, b]` requires an array of exactly len(elems); `[a, ..rest]`
5979                // requires at least len(elems). Element sub-patterns are tested
5980                // positionally; the rest binds the slice and adds no test.
5981                // Mirrors `pattern_test_js`.
5982                let n = elems.len();
5983                let len_test = if rest.is_some() {
5984                    format!("{access}.length >= {n}")
5985                } else {
5986                    format!("{access}.length === {n}")
5987                };
5988                let mut tests = vec![format!("Array.isArray({access})"), len_test];
5989                for (i, e) in elems.iter().enumerate() {
5990                    let sub = self.pattern_test_ts(e, &format!("{access}[{i}]"));
5991                    if !sub.is_empty() {
5992                        tests.push(sub);
5993                    }
5994                }
5995                tests.join(" && ")
5996            }
5997            NodeKind::RangePat { lo, hi, inclusive } => {
5998                // `lo..hi` → `access >= lo && access < hi`; `lo..=hi` uses `<=`.
5999                // Mirrors `pattern_test_js`.
6000                let lo_s = range_bound_to_ts(lo);
6001                let hi_s = range_bound_to_ts(hi);
6002                let upper = if *inclusive { "<=" } else { "<" };
6003                format!("{access} >= {lo_s} && {access} {upper} {hi_s}")
6004            }
6005            NodeKind::OrPat { alternatives } => {
6006                let alts: Vec<String> = alternatives
6007                    .iter()
6008                    .map(|a| {
6009                        let t = self.pattern_test_ts(a, access);
6010                        if t.is_empty() {
6011                            "true".to_string()
6012                        } else {
6013                            format!("({t})")
6014                        }
6015                    })
6016                    .collect();
6017                alts.join(" || ")
6018            }
6019            _ => String::new(),
6020        }
6021    }
6022
6023    /// Emit the `const <name> = <access…>;` bindings introduced by `pat`.
6024    /// Mirrors `pattern_binds_js`.
6025    fn pattern_binds_ts(&mut self, pat: &AIRNode, access: &str) -> Result<(), CodegenError> {
6026        match &pat.kind {
6027            // Skip a self-binding (`const n = n` / `const n = (n as any)`): when
6028            // an arm's bind name equals the scrutinee access — e.g.
6029            // `match n { n if … }`, whose if-chain root is the `(n as any)` cast —
6030            // the name already refers to the value. Emitting `const n = (n as any)`
6031            // shadows the parameter and the RHS reads the just-declared `const`
6032            // (TS2448, used before declaration). The guard lets such a binding
6033            // fall through to the no-op `_` arm.
6034            NodeKind::BindPat { name, .. } if !ts_bind_is_self_reference(&name.name, access) => {
6035                let ind = self.indent_str();
6036                let _ = writeln!(
6037                    self.buf,
6038                    "{ind}const {} = {access};",
6039                    ts_value_ident(&name.name)
6040                );
6041            }
6042            NodeKind::ConstructorPat { fields, .. } => {
6043                for (i, field) in fields.iter().enumerate() {
6044                    self.pattern_binds_ts(field, &format!("{access}._{i}"))?;
6045                }
6046            }
6047            NodeKind::RecordPat { fields, .. } => {
6048                for f in fields {
6049                    let field_access = format!("{access}.{}", f.name.name);
6050                    match &f.pattern {
6051                        Some(p) => self.pattern_binds_ts(p, &field_access)?,
6052                        None => {
6053                            let ind = self.indent_str();
6054                            let _ =
6055                                writeln!(self.buf, "{ind}const {} = {field_access};", f.name.name);
6056                        }
6057                    }
6058                }
6059            }
6060            NodeKind::TuplePat { elems } => {
6061                for (i, e) in elems.iter().enumerate() {
6062                    self.pattern_binds_ts(e, &format!("{access}[{i}]"))?;
6063                }
6064            }
6065            NodeKind::ListPat { elems, rest } => {
6066                for (i, e) in elems.iter().enumerate() {
6067                    self.pattern_binds_ts(e, &format!("{access}[{i}]"))?;
6068                }
6069                // `..rest` binds the remaining elements as a slice; a bare `..`
6070                // (RestPat) or absent rest binds nothing. Mirrors `pattern_binds_js`.
6071                if let Some(r) = rest {
6072                    if let NodeKind::BindPat { name, .. } = &r.kind {
6073                        let ind = self.indent_str();
6074                        let _ = writeln!(
6075                            self.buf,
6076                            "{ind}const {} = {access}.slice({});",
6077                            ts_value_ident(&name.name),
6078                            elems.len()
6079                        );
6080                    }
6081                }
6082            }
6083            NodeKind::OrPat { alternatives } => {
6084                if let Some(first) = alternatives.first() {
6085                    self.pattern_binds_ts(first, access)?;
6086                }
6087            }
6088            _ => {}
6089        }
6090        Ok(())
6091    }
6092
6093    /// Collect `pat`'s bindings as a single-line `const … = …; ` string for the
6094    /// guard-evaluating IIFE. Mirrors `pattern_binds_to_string_js`.
6095    fn pattern_binds_to_string_ts(&self, pat: &AIRNode, access: &str) -> String {
6096        let mut out = String::new();
6097        self.collect_binds_ts(pat, access, &mut out);
6098        out
6099    }
6100
6101    fn collect_binds_ts(&self, pat: &AIRNode, access: &str, out: &mut String) {
6102        match &pat.kind {
6103            NodeKind::BindPat { name, .. } => {
6104                // Skip a self-binding (`const n = (n as any)`) — redundant and a
6105                // TS2448 TDZ error inside the guard-evaluating IIFE. See
6106                // `pattern_binds_ts`.
6107                if ts_bind_is_self_reference(&name.name, access) {
6108                    return;
6109                }
6110                let _ = write!(out, "const {} = {access}; ", ts_value_ident(&name.name));
6111            }
6112            NodeKind::ConstructorPat { fields, .. } => {
6113                for (i, field) in fields.iter().enumerate() {
6114                    self.collect_binds_ts(field, &format!("{access}._{i}"), out);
6115                }
6116            }
6117            NodeKind::RecordPat { fields, .. } => {
6118                for f in fields {
6119                    let field_access = format!("{access}.{}", f.name.name);
6120                    match &f.pattern {
6121                        Some(p) => self.collect_binds_ts(p, &field_access, out),
6122                        None => {
6123                            let _ = write!(out, "const {} = {field_access}; ", f.name.name);
6124                        }
6125                    }
6126                }
6127            }
6128            NodeKind::TuplePat { elems } => {
6129                for (i, e) in elems.iter().enumerate() {
6130                    self.collect_binds_ts(e, &format!("{access}[{i}]"), out);
6131                }
6132            }
6133            NodeKind::ListPat { elems, rest } => {
6134                for (i, e) in elems.iter().enumerate() {
6135                    self.collect_binds_ts(e, &format!("{access}[{i}]"), out);
6136                }
6137                if let Some(r) = rest {
6138                    if let NodeKind::BindPat { name, .. } = &r.kind {
6139                        let _ = write!(
6140                            out,
6141                            "const {} = {access}.slice({}); ",
6142                            ts_value_ident(&name.name),
6143                            elems.len()
6144                        );
6145                    }
6146                }
6147            }
6148            NodeKind::OrPat { alternatives } => {
6149                if let Some(first) = alternatives.first() {
6150                    self.collect_binds_ts(first, access, out);
6151                }
6152            }
6153            _ => {}
6154        }
6155    }
6156
6157    // ── Pipe operator ───────────────────────────────────────────────────────
6158
6159    fn emit_pipe(&mut self, left: &AIRNode, right: &AIRNode) -> Result<(), CodegenError> {
6160        if let NodeKind::Call { callee, args, .. } = &right.kind {
6161            let has_placeholder = args
6162                .iter()
6163                .any(|a| matches!(a.value.kind, NodeKind::Placeholder));
6164            if has_placeholder {
6165                self.emit_callee(callee)?;
6166                self.buf.push('(');
6167                for (i, arg) in args.iter().enumerate() {
6168                    if i > 0 {
6169                        self.buf.push_str(", ");
6170                    }
6171                    if matches!(arg.value.kind, NodeKind::Placeholder) {
6172                        self.emit_expr(left)?;
6173                    } else {
6174                        self.emit_expr(&arg.value)?;
6175                    }
6176                }
6177                self.buf.push(')');
6178                return Ok(());
6179            }
6180        }
6181        // `right` is a callee, so parenthesize it when it is a bare arrow
6182        // (`Lambda`/`Compose`) — otherwise the trailing `(left)` binds to the
6183        // arrow body instead of invoking it.
6184        self.emit_callee(right)?;
6185        self.buf.push('(');
6186        self.emit_expr(left)?;
6187        self.buf.push(')');
6188        Ok(())
6189    }
6190
6191    /// Emit an expression in **callee** position, parenthesizing it when its
6192    /// surface syntax would otherwise swallow the trailing argument list.
6193    ///
6194    /// The case that matters is a bare arrow callee: `(x) => body` followed by
6195    /// `(arg)` parses in TS as `(x) => (body(arg))` — the call binds to the body,
6196    /// never invoking the arrow. Wrapping it as `((x) => body)(arg)` makes the
6197    /// call apply to the arrow itself. This arises when the AIR compose desugar
6198    /// (`f >> g` → `(__compose_x) => g(f(__compose_x))`) **nests**: a chained
6199    /// `>>` lowers the inner compose to a `Lambda` (or a `Compose` still awaiting
6200    /// lowering), which then appears as the callee `f`/`g` inside the call.
6201    /// Mirrors the python (`emit_callee`) and rust (`emit_callee_rs`) backends.
6202    fn emit_callee(&mut self, callee: &AIRNode) -> Result<(), CodegenError> {
6203        if matches!(
6204            callee.kind,
6205            NodeKind::Lambda { .. } | NodeKind::Compose { .. }
6206        ) {
6207            self.buf.push('(');
6208            self.emit_expr(callee)?;
6209            self.buf.push(')');
6210            Ok(())
6211        } else {
6212            self.emit_expr(callee)
6213        }
6214    }
6215
6216    // ── Helpers ─────────────────────────────────────────────────────────────
6217
6218    /// Returns true if `ts_name` has already been declared in the innermost
6219    /// `let` scope, so a further binding of it must be a plain assignment rather
6220    /// than a `const`/`let` re-declaration (which TS rejects with TS2451).
6221    fn simple_let_redeclared(&self, ts_name: &str) -> bool {
6222        self.let_scopes
6223            .last()
6224            .is_some_and(|s| s.declared.contains(ts_name))
6225    }
6226
6227    /// Returns true if `ts_name` is re-bound or assigned later in its block, so
6228    /// its first declaration must use `let` (not `const`) to allow reassignment.
6229    fn simple_let_needs_let(&self, ts_name: &str) -> bool {
6230        self.let_scopes
6231            .last()
6232            .is_some_and(|s| s.needs_let.contains(ts_name))
6233    }
6234
6235    /// Record that `ts_name` has now been declared in the innermost `let` scope.
6236    fn mark_simple_let_declared(&mut self, ts_name: &str) {
6237        if let Some(s) = self.let_scopes.last_mut() {
6238            s.declared.insert(ts_name.to_string());
6239        }
6240    }
6241
6242    /// Push a fresh `let` scope for a TS block, pre-scanning `block`'s direct
6243    /// statements to find which simple `let`-bound names are re-bound or
6244    /// assigned within the block (so their first declaration emits `let`). Only
6245    /// the block's own statements are scanned — nested blocks open their own
6246    /// scopes, so a name re-bound only in a nested block does not force `let`
6247    /// here. Mirrors the js backend (#217).
6248    fn enter_let_scope(&mut self, block: &AIRNode) {
6249        let mut needs_let = HashSet::new();
6250        if let NodeKind::Block { stmts, tail } = &block.kind {
6251            let mut seen: HashSet<String> = HashSet::new();
6252            let mut visit = |n: &AIRNode, needs_let: &mut HashSet<String>| match &n.kind {
6253                NodeKind::LetBinding { pattern, .. } => {
6254                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
6255                        let ts = ts_value_ident(&name.name);
6256                        // A re-binding of an already-seen name needs `let`.
6257                        if !seen.insert(ts.clone()) {
6258                            needs_let.insert(ts);
6259                        }
6260                    }
6261                }
6262                NodeKind::Assign { target, .. } => {
6263                    if let NodeKind::Identifier { name } = &target.kind {
6264                        needs_let.insert(ts_value_ident(&name.name));
6265                    }
6266                }
6267                _ => {}
6268            };
6269            for s in stmts {
6270                visit(s, &mut needs_let);
6271            }
6272            if let Some(t) = tail {
6273                visit(t, &mut needs_let);
6274            }
6275        }
6276        self.let_scopes.push(LetScope {
6277            declared: HashSet::new(),
6278            needs_let,
6279        });
6280    }
6281
6282    /// Pop the innermost `let` scope pushed by [`Self::enter_let_scope`].
6283    fn leave_let_scope(&mut self) {
6284        self.let_scopes.pop();
6285    }
6286
6287    fn emit_block_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
6288        self.enter_let_scope(node);
6289        let r = self.emit_block_body_inner(node);
6290        self.leave_let_scope();
6291        r
6292    }
6293
6294    /// Emit a **loop body** (`for`/`while`/`loop`). A loop body is statement
6295    /// position: its tail expression is discarded (Bock loops evaluate to Unit;
6296    /// the body's value is not the function's value). The default
6297    /// [`Self::emit_block_body`] treats a tail as a function-body return, which
6298    /// for a loop body would `return console.log(i);` — aborting the function on
6299    /// the first iteration. Activating a [`ValueSink::Discard`] for the body's
6300    /// duration routes the tail to a bare expression statement instead. The sink
6301    /// is saved/restored so it never leaks past the loop, and any nested lambda
6302    /// clears it (a lambda body's tail is genuinely returned — see the
6303    /// `NodeKind::Lambda` arm). A `break v` value still flows through the
6304    /// separate per-loop `loop_value_sinks` stack, not this discard sink.
6305    fn emit_loop_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
6306        let prev_sink = self.value_sink.replace(ValueSink::Discard);
6307        let r = self.emit_block_body(node);
6308        self.value_sink = prev_sink;
6309        r
6310    }
6311
6312    /// Emit a function/method body whose top-level `let` scope is pre-seeded with
6313    /// the function's `params` as already-declared names. A Bock `let x = …` that
6314    /// shadows a parameter `x` is the same block scope as the TS parameter, so it
6315    /// must lower to a plain assignment (`x = …`) rather than a `let`/`const`
6316    /// redeclaration (which TS rejects). Mirrors the js backend (#217).
6317    fn emit_fn_body_seeded(
6318        &mut self,
6319        params: &[AIRNode],
6320        body: &AIRNode,
6321    ) -> Result<(), CodegenError> {
6322        self.enter_let_scope(body);
6323        if let Some(scope) = self.let_scopes.last_mut() {
6324            for p in params {
6325                if let NodeKind::Param { pattern, .. } = &p.kind {
6326                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
6327                        let ts = ts_value_ident(&name.name);
6328                        scope.needs_let.insert(ts.clone());
6329                        scope.declared.insert(ts);
6330                    }
6331                }
6332            }
6333        }
6334        let r = self.emit_block_body_inner(body);
6335        self.leave_let_scope();
6336        r
6337    }
6338
6339    fn emit_block_body_inner(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
6340        if let NodeKind::Block { stmts, tail } = &node.kind {
6341            // Every non-tail statement is statement position: its value is
6342            // discarded. A statement-position `if`/`match` whose branch/arm
6343            // bodies end in an expression (e.g. `println(...)`) must NOT `return`
6344            // that value — doing so aborts the function before the statements
6345            // after the `if`/`match` run. Activate a `Discard` sink for the
6346            // non-tail statements (restored before the tail, which keeps the
6347            // function-body-return semantics). A nested loop/lambda overrides
6348            // this sink within its own body, so the discard applies only to the
6349            // immediate statement-position control flow.
6350            let prev_sink = self.value_sink.replace(ValueSink::Discard);
6351            let mut stmt_res = Ok(());
6352            for s in stmts {
6353                stmt_res = self.emit_node(s);
6354                if stmt_res.is_err() {
6355                    break;
6356                }
6357            }
6358            self.value_sink = prev_sink;
6359            stmt_res?;
6360            if let Some(t) = tail {
6361                if crate::generator::node_is_statement(t) {
6362                    self.emit_node(t)?;
6363                    return Ok(());
6364                }
6365                // A diverging-intrinsic tail (`todo()`/`unreachable()`) lowers to
6366                // a bare `throw` statement — emitting `return throw …` is invalid
6367                // TS (TS1109). Emit it as a statement instead.
6368                if self.call_is_diverging(t) {
6369                    self.write_indent();
6370                    self.emit_expr(t)?;
6371                    self.buf.push_str(";\n");
6372                    return Ok(());
6373                }
6374                // A tail-position `expr?`: emit the early-return guard, then
6375                // `return` the unwrapped payload (the fn's value). Through the
6376                // sink when one is active (statement-position control flow).
6377                if let NodeKind::Propagate { expr } = &t.kind {
6378                    let access = self.emit_propagate(expr)?;
6379                    if let Some(sink) = self.value_sink.clone() {
6380                        return self.emit_sink_value_str(&access, &sink);
6381                    }
6382                    let ind = self.indent_str();
6383                    let _ = writeln!(self.buf, "{ind}return {access};");
6384                    return Ok(());
6385                }
6386                // Expression-position control flow whose branches carry
6387                // statements (a value `if`/`match` with a `return` arm, or a
6388                // `loop` producing its value via `break v`) has no TS expression
6389                // form. Lower it in statement position, delivering each value
6390                // tail through the active sink (default `return`; cluster-1 fix;
6391                // see `emit_value_in_stmt_pos`).
6392                if self.value_needs_stmt_form(t) {
6393                    let sink = self.value_sink.clone().unwrap_or(ValueSink::Return);
6394                    return self.emit_value_in_stmt_pos(t, &sink);
6395                }
6396                if let NodeKind::Match { scrutinee, arms } = &t.kind {
6397                    if crate::generator::match_has_statement_arm(arms) {
6398                        self.emit_match(scrutinee, arms, false)?;
6399                        return Ok(());
6400                    }
6401                }
6402                self.emit_block_tail_value(t)?;
6403            }
6404        } else if crate::generator::node_is_statement(node) {
6405            self.emit_node(node)?;
6406        } else if self.call_is_diverging(node) {
6407            self.write_indent();
6408            self.emit_expr(node)?;
6409            self.buf.push_str(";\n");
6410        } else if self.value_needs_stmt_form(node) {
6411            let sink = self.value_sink.clone().unwrap_or(ValueSink::Return);
6412            self.emit_value_in_stmt_pos(node, &sink)?;
6413        } else if let NodeKind::Match { scrutinee, arms } = &node.kind {
6414            if crate::generator::match_has_statement_arm(arms) {
6415                self.emit_match(scrutinee, arms, false)?;
6416            } else {
6417                self.emit_block_tail_value(node)?;
6418            }
6419        } else {
6420            self.emit_block_tail_value(node)?;
6421        }
6422        Ok(())
6423    }
6424
6425    /// Deliver a block's value tail. When a [`ValueSink`] is active (the block is
6426    /// a `match`-arm body inside a statement-position control-flow lowering), the
6427    /// value flows through it (`return v` / `binding = v`); otherwise the default
6428    /// function-body behavior — `return v` — applies.
6429    fn emit_block_tail_value(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
6430        if let Some(sink) = self.value_sink.clone() {
6431            return self.emit_sink_value(node, &sink);
6432        }
6433        let ind = self.indent_str();
6434        let _ = write!(self.buf, "{ind}return ");
6435        self.emit_expr(node)?;
6436        self.buf.push_str(";\n");
6437        Ok(())
6438    }
6439
6440    /// True when a `Call` is to a diverging intrinsic (`todo()` / `unreachable()`),
6441    /// which lowers to a bare `throw new Error(...)` *statement* — never produces
6442    /// a value. Emitting it after `return ` (`return throw …`) is invalid TS, so
6443    /// callers in value-tail position must emit it as a statement instead.
6444    fn call_is_diverging(&self, node: &AIRNode) -> bool {
6445        if let NodeKind::Call { callee, .. } = &node.kind {
6446            if let NodeKind::Identifier { name } = &callee.kind {
6447                return matches!(name.name.as_str(), "todo" | "unreachable");
6448            }
6449        }
6450        false
6451    }
6452
6453    /// True when `node` (in *value* position) contains control flow that has no
6454    /// TypeScript expression form — a `Loop` (its value arrives via `break v`), or
6455    /// an `if`/`match`/`block` any of whose value-tails is a `return`/`break`/
6456    /// `continue`, a diverging intrinsic call, or (recursively) a nested
6457    /// control-flow value that itself needs statement form. Such a value must be
6458    /// emitted in **statement** position (assigning the result to a binding, or
6459    /// `return`ing it) via [`Self::emit_value_in_stmt_pos`] — the ternary /
6460    /// value-IIFE lowering would emit `/* unsupported */` for the control-flow
6461    /// tail. The recursion is what makes a *nested* `if … else if … else { return
6462    /// … }` chain (chat-protocol) trigger the statement lowering, not just a
6463    /// single-level `if`.
6464    fn value_needs_stmt_form(&self, node: &AIRNode) -> bool {
6465        match &node.kind {
6466            // A loop never has an expression form; its value comes from `break v`.
6467            NodeKind::Loop { .. } => true,
6468            NodeKind::If {
6469                then_block,
6470                else_block,
6471                let_pattern: None,
6472                ..
6473            } => {
6474                self.branch_needs_stmt_form(then_block)
6475                    || else_block
6476                        .as_deref()
6477                        .is_some_and(|e| self.branch_needs_stmt_form(e))
6478            }
6479            NodeKind::Match { arms, .. } => arms.iter().any(|arm| {
6480                matches!(&arm.kind, NodeKind::MatchArm { body, .. } if self.branch_needs_stmt_form(body))
6481            }),
6482            NodeKind::Block { tail, .. } => {
6483                tail.as_deref().is_some_and(|t| self.value_needs_stmt_form(t))
6484            }
6485            _ => false,
6486        }
6487    }
6488
6489    /// True when a branch / arm body (an `if`/`match`/`loop`/`block` arm, or a
6490    /// bare expression) used in value position requires statement-form lowering:
6491    /// its value tail is a diverging statement, or it is itself control flow that
6492    /// needs statement form. Drives the recursion in [`Self::value_needs_stmt_form`].
6493    fn branch_needs_stmt_form(&self, node: &AIRNode) -> bool {
6494        self.value_tail_diverges(node) || self.value_needs_stmt_form(node)
6495    }
6496
6497    /// True when the *value tail* of `node` is a diverging statement — a
6498    /// `return`/`break`/`continue` node, a diverging intrinsic call, or (for a
6499    /// block) a tail that itself diverges. Drives [`Self::branch_needs_stmt_form`]:
6500    /// a value-position branch whose tail is one of these cannot be a ternary/IIFE
6501    /// arm.
6502    fn value_tail_diverges(&self, node: &AIRNode) -> bool {
6503        match &node.kind {
6504            NodeKind::Return { .. } | NodeKind::Break { .. } | NodeKind::Continue => true,
6505            NodeKind::Call { .. } => self.call_is_diverging(node),
6506            NodeKind::Block { stmts, tail } => match tail {
6507                Some(t) => self.value_tail_diverges(t),
6508                // A block with no tail (ends in a statement) yields no value.
6509                None => stmts.last().is_some_and(|s| self.value_tail_diverges(s)),
6510            },
6511            _ => false,
6512        }
6513    }
6514
6515    /// Emit a value-position expression that [`Self::value_needs_stmt_form`] has
6516    /// flagged, in **statement** position, delivering its result via `sink`
6517    /// (`return <v>;` or `<name> = <v>;`). Diverging tails (`return`/`break`/
6518    /// `continue`/`todo()`) emit their own statement and ignore the sink. This is
6519    /// the TS-side desugar of expression-position control flow whose branches
6520    /// carry statements; it keeps `return`/`break` in the enclosing function/loop
6521    /// scope (an IIFE would capture them) — see the cluster-1 OPEN in the PR body
6522    /// proposing a shared AIR desugar.
6523    fn emit_value_in_stmt_pos(
6524        &mut self,
6525        node: &AIRNode,
6526        sink: &ValueSink,
6527    ) -> Result<(), CodegenError> {
6528        match &node.kind {
6529            // A diverging statement: emit it directly, ignore the sink.
6530            NodeKind::Return { .. } | NodeKind::Break { .. } | NodeKind::Continue => {
6531                self.emit_node(node)
6532            }
6533            NodeKind::Call { .. } if self.call_is_diverging(node) => {
6534                self.write_indent();
6535                self.emit_expr(node)?;
6536                self.buf.push_str(";\n");
6537                Ok(())
6538            }
6539            NodeKind::Loop { body } => {
6540                self.emit_loop_label_prefix(body);
6541                // The loop's value arrives through `break v` (recorded in the
6542                // separate `loop_value_sinks` stack), NOT the body's tail — the
6543                // body is statement position, so its tail is discarded via
6544                // `emit_loop_body`.
6545                self.set_loop_value_sink(sink.clone());
6546                self.writeln("while (true) {");
6547                self.indent += 1;
6548                self.emit_loop_body(body)?;
6549                self.indent -= 1;
6550                self.writeln("}");
6551                self.pop_loop_frame();
6552                Ok(())
6553            }
6554            NodeKind::If {
6555                let_pattern: None,
6556                condition,
6557                then_block,
6558                else_block,
6559            } => {
6560                let ind = self.indent_str();
6561                let _ = write!(self.buf, "{ind}if (");
6562                self.emit_expr(condition)?;
6563                self.buf.push_str(") {\n");
6564                self.indent += 1;
6565                self.emit_value_in_stmt_pos(then_block, sink)?;
6566                self.indent -= 1;
6567                if let Some(else_b) = else_block {
6568                    if matches!(
6569                        else_b.kind,
6570                        NodeKind::If {
6571                            let_pattern: None,
6572                            ..
6573                        }
6574                    ) {
6575                        let ind = self.indent_str();
6576                        let _ = write!(self.buf, "{ind}}} else ");
6577                        // Re-dispatch the nested `if` without re-opening a brace.
6578                        self.emit_value_in_stmt_pos_else_if(else_b, sink)?;
6579                        return Ok(());
6580                    }
6581                    self.writeln("} else {");
6582                    self.indent += 1;
6583                    self.emit_value_in_stmt_pos(else_b, sink)?;
6584                    self.indent -= 1;
6585                }
6586                self.writeln("}");
6587                Ok(())
6588            }
6589            NodeKind::Match { scrutinee, arms } => {
6590                let prev = self.value_sink.replace(sink.clone());
6591                let res = self.emit_match(scrutinee, arms, false);
6592                self.value_sink = prev;
6593                res
6594            }
6595            NodeKind::Block { stmts, tail } => {
6596                for s in stmts {
6597                    self.emit_node(s)?;
6598                }
6599                if let Some(t) = tail {
6600                    self.emit_value_in_stmt_pos(t, sink)?;
6601                }
6602                Ok(())
6603            }
6604            // A plain value: deliver it through the sink.
6605            _ => self.emit_sink_value(node, sink),
6606        }
6607    }
6608
6609    /// Helper for the `else if` chain in [`Self::emit_value_in_stmt_pos`]: emits an
6610    /// `if (...) { … } else …` after the caller has already written `} else `.
6611    fn emit_value_in_stmt_pos_else_if(
6612        &mut self,
6613        node: &AIRNode,
6614        sink: &ValueSink,
6615    ) -> Result<(), CodegenError> {
6616        let NodeKind::If {
6617            condition,
6618            then_block,
6619            else_block,
6620            ..
6621        } = &node.kind
6622        else {
6623            return self.emit_value_in_stmt_pos(node, sink);
6624        };
6625        self.buf.push_str("if (");
6626        self.emit_expr(condition)?;
6627        self.buf.push_str(") {\n");
6628        self.indent += 1;
6629        self.emit_value_in_stmt_pos(then_block, sink)?;
6630        self.indent -= 1;
6631        if let Some(else_b) = else_block {
6632            if matches!(
6633                else_b.kind,
6634                NodeKind::If {
6635                    let_pattern: None,
6636                    ..
6637                }
6638            ) {
6639                let ind = self.indent_str();
6640                let _ = write!(self.buf, "{ind}}} else ");
6641                self.emit_value_in_stmt_pos_else_if(else_b, sink)?;
6642                return Ok(());
6643            }
6644            self.writeln("} else {");
6645            self.indent += 1;
6646            self.emit_value_in_stmt_pos(else_b, sink)?;
6647            self.indent -= 1;
6648        }
6649        self.writeln("}");
6650        Ok(())
6651    }
6652
6653    /// Deliver a finished value expression `node` through `sink`.
6654    fn emit_sink_value(&mut self, node: &AIRNode, sink: &ValueSink) -> Result<(), CodegenError> {
6655        let ind = self.indent_str();
6656        match sink {
6657            ValueSink::Return => {
6658                let _ = write!(self.buf, "{ind}return ");
6659                self.emit_expr(node)?;
6660                self.buf.push_str(";\n");
6661            }
6662            ValueSink::Assign(name) => {
6663                let _ = write!(self.buf, "{ind}{name} = ");
6664                self.emit_expr(node)?;
6665                self.buf.push_str(";\n");
6666            }
6667            ValueSink::Discard => {
6668                let _ = write!(self.buf, "{ind}");
6669                self.emit_expr(node)?;
6670                self.buf.push_str(";\n");
6671            }
6672        }
6673        Ok(())
6674    }
6675
6676    /// Deliver an already-rendered value expression `value` through `sink`
6677    /// (`return value;` / `name = value;`). Used by the tail-position `?`
6678    /// lowering, whose unwrapped payload is a literal access string rather than
6679    /// an [`AIRNode`].
6680    fn emit_sink_value_str(&mut self, value: &str, sink: &ValueSink) -> Result<(), CodegenError> {
6681        let ind = self.indent_str();
6682        match sink {
6683            ValueSink::Return => {
6684                let _ = writeln!(self.buf, "{ind}return {value};");
6685            }
6686            ValueSink::Assign(name) => {
6687                let _ = writeln!(self.buf, "{ind}{name} = {value};");
6688            }
6689            ValueSink::Discard => {
6690                let _ = writeln!(self.buf, "{ind}{value};");
6691            }
6692        }
6693        Ok(())
6694    }
6695
6696    fn emit_block_as_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
6697        if let NodeKind::Block { stmts, tail } = &node.kind {
6698            if stmts.is_empty() {
6699                if let Some(t) = tail {
6700                    return self.emit_expr(t);
6701                }
6702            }
6703        }
6704        self.emit_expr(node)
6705    }
6706
6707    fn pattern_to_binding_name(&self, pat: &AIRNode) -> String {
6708        match &pat.kind {
6709            NodeKind::BindPat { name, .. } => ts_value_ident(&name.name),
6710            NodeKind::WildcardPat => "_".into(),
6711            NodeKind::TuplePat { elems } => {
6712                format!(
6713                    "[{}]",
6714                    elems
6715                        .iter()
6716                        .map(|e| self.pattern_to_binding_name(e))
6717                        .collect::<Vec<_>>()
6718                        .join(", ")
6719                )
6720            }
6721            NodeKind::RecordPat { fields, .. } => {
6722                format!(
6723                    "{{ {} }}",
6724                    fields
6725                        .iter()
6726                        .map(|f| to_camel_case(&f.name.name).to_string())
6727                        .collect::<Vec<_>>()
6728                        .join(", ")
6729                )
6730            }
6731            _ => "_".into(),
6732        }
6733    }
6734
6735    fn pattern_to_ts_destructure(&self, pat: &AIRNode) -> String {
6736        self.pattern_to_binding_name(pat)
6737    }
6738
6739    fn type_expr_to_string(&self, node: &AIRNode) -> String {
6740        match &node.kind {
6741            NodeKind::TypeNamed { path, .. } => path
6742                .segments
6743                .iter()
6744                .map(|s| s.name.as_str())
6745                .collect::<Vec<_>>()
6746                .join("."),
6747            NodeKind::Identifier { name } => name.name.clone(),
6748            _ => "Unknown".into(),
6749        }
6750    }
6751}
6752
6753// ─── Utility functions ───────────────────────────────────────────────────────
6754
6755/// Build the `: T` return-type clause for a TS function signature, wrapping
6756/// the inner type in `Promise<...>` when the function is async. An async
6757/// function with no declared return type is typed `Promise<void>`.
6758fn build_ts_return_type(is_async: bool, inner: Option<String>) -> String {
6759    match (is_async, inner) {
6760        (true, Some(t)) => format!(": Promise<{t}>"),
6761        (true, None) => ": Promise<void>".to_string(),
6762        (false, Some(t)) => format!(": {t}"),
6763        (false, None) => String::new(),
6764    }
6765}
6766
6767/// Returns true if `name` is the identifier of a Duration or Instant instance
6768/// method. Used to recognise `d.as_millis()` / `i.elapsed()` calls during codegen.
6769fn is_time_method_name(name: &str) -> bool {
6770    matches!(
6771        name,
6772        "as_nanos"
6773            | "as_millis"
6774            | "as_seconds"
6775            | "is_zero"
6776            | "is_negative"
6777            | "abs"
6778            | "elapsed"
6779            | "duration_since"
6780    )
6781}
6782
6783/// Convert a name to `camelCase` (handles `snake_case`, `PascalCase`, and already `camelCase`).
6784/// Convert a Bock *value* identifier (a param, local binding, or free-function
6785/// name) to its TS form: `camelCase`, then escaped against the TS reserved-word
6786/// set so a binding named e.g. `default`/`type` emits `default_`/`type_` rather
6787/// than the illegal bare keyword. Apply at every value declaration and reference
6788/// site so the escaped name is used uniformly; member/method names use bare
6789/// [`to_camel_case`]. See [`crate::generator::escape_target_keyword`].
6790///
6791/// On top of the shared reserved-word escape, TS modules are emitted in *strict
6792/// mode* (ES modules always are), which makes `eval` and `arguments` illegal as
6793/// binding names — a `function eval(...)` declaration is rejected with `TS1215:
6794/// Invalid use of 'eval'` even though `eval` is not a keyword. The shared
6795/// keyword set is target-agnostic and JS (non-module/sloppy-mode) accepts these
6796/// names, so they are escaped here, in the TS-only funnel, with the same
6797/// trailing-`_` mangle the keyword escape uses (`eval` → `eval_`). Applied at
6798/// the single value-ident funnel so declarations and references stay in sync.
6799fn ts_value_ident(name: &str) -> String {
6800    let escaped = crate::generator::escape_target_keyword(
6801        &to_camel_case(name),
6802        crate::generator::KeywordTarget::Ts,
6803    );
6804    if matches!(escaped.as_str(), "eval" | "arguments") {
6805        format!("{escaped}_")
6806    } else {
6807        escaped
6808    }
6809}
6810
6811/// True when binding `bind_name` against `access` would be a self-reference:
6812/// the access is either the bare TS name (`n`) or its if-chain cast form
6813/// (`(n as any)`). Emitting `const n = (n as any)` shadows the parameter and
6814/// reads the freshly-declared `const` in its own initialiser (TS2448), so such
6815/// a binding must be skipped. Mirrors the JS `js != access` self-bind guard.
6816fn ts_bind_is_self_reference(bind_name: &str, access: &str) -> bool {
6817    let ts = ts_value_ident(bind_name);
6818    access == ts || access == format!("({ts} as any)")
6819}
6820
6821/// Spell `name` the way the TS backend emits the symbol's declaration / call
6822/// sites for an `import`/`export` specifier in the per-module path: a function
6823/// is camelCased and keyword-escaped via [`ts_value_ident`]; any other kind
6824/// (records, enum variants, classes, traits, effects, consts, type aliases)
6825/// keeps its raw name.
6826fn ts_esm_emit_name(name: &str, is_fn: bool) -> String {
6827    if is_fn {
6828        ts_value_ident(name)
6829    } else {
6830        name.to_string()
6831    }
6832}
6833
6834fn to_camel_case(s: &str) -> String {
6835    if s.is_empty() || s == "_" {
6836        return s.to_string();
6837    }
6838    // If already camelCase (starts lowercase, no underscores), return as-is.
6839    if !s.contains('_') && s.starts_with(|c: char| c.is_lowercase()) {
6840        return s.to_string();
6841    }
6842    // If it's snake_case, convert to camelCase.
6843    if s.contains('_') {
6844        let parts: Vec<&str> = s.split('_').filter(|p| !p.is_empty()).collect();
6845        if parts.is_empty() {
6846            return s.to_string();
6847        }
6848        let mut result = parts[0].to_lowercase();
6849        for part in &parts[1..] {
6850            let mut chars = part.chars();
6851            if let Some(first) = chars.next() {
6852                result.push(
6853                    first
6854                        .to_uppercase()
6855                        .next()
6856                        .expect("uppercase yields at least one char"),
6857                );
6858                result.extend(chars);
6859            }
6860        }
6861        return result;
6862    }
6863    // If PascalCase, lowercase first letter.
6864    let mut chars = s.chars();
6865    let first = chars.next().expect("non-empty string guaranteed by caller");
6866    let mut result = first.to_lowercase().to_string();
6867    result.extend(chars);
6868    result
6869}
6870
6871/// Escape special characters in a JS/TS string literal.
6872fn escape_js_string(s: &str) -> String {
6873    let mut out = String::with_capacity(s.len());
6874    for ch in s.chars() {
6875        match ch {
6876            '"' => out.push_str("\\\""),
6877            '\\' => out.push_str("\\\\"),
6878            '\n' => out.push_str("\\n"),
6879            '\r' => out.push_str("\\r"),
6880            '\t' => out.push_str("\\t"),
6881            _ => out.push(ch),
6882        }
6883    }
6884    out
6885}
6886
6887/// Render a literal as a TS value expression — used by the if-chain match
6888/// lowering to compare a scrutinee against a literal pattern (`<access> === …`).
6889/// Render a `RangePat` bound (`lo`/`hi`) as a TS expression. Range bounds are
6890/// literals (`1..10`) or a const identifier (`MIN..MAX`); anything else falls
6891/// back to the wrapped literal/identifier text, or `0` for an unrecognised node.
6892/// Mirrors `range_bound_to_js`.
6893fn range_bound_to_ts(node: &AIRNode) -> String {
6894    match &node.kind {
6895        NodeKind::LiteralPat { lit } => ts_literal(lit),
6896        NodeKind::Literal { lit } => ts_literal(lit),
6897        NodeKind::Identifier { name } => ts_value_ident(&name.name),
6898        _ => "0".to_string(),
6899    }
6900}
6901
6902fn ts_literal(lit: &Literal) -> String {
6903    match lit {
6904        Literal::Int(s) | Literal::Float(s) => s.clone(),
6905        Literal::Bool(b) => {
6906            if *b {
6907                "true".to_string()
6908            } else {
6909                "false".to_string()
6910            }
6911        }
6912        Literal::Char(s) => format!("'{s}'"),
6913        Literal::String(s) => format!("\"{}\"", escape_js_string(s)),
6914        Literal::Unit => "undefined".to_string(),
6915    }
6916}
6917
6918/// Escape special characters in a JS/TS template literal.
6919fn escape_template_literal(s: &str) -> String {
6920    let mut out = String::with_capacity(s.len());
6921    for ch in s.chars() {
6922        match ch {
6923            '`' => out.push_str("\\`"),
6924            '\\' => out.push_str("\\\\"),
6925            '$' => out.push_str("\\$"),
6926            _ => out.push(ch),
6927        }
6928    }
6929    out
6930}
6931
6932// ─── Tests ───────────────────────────────────────────────────────────────────
6933
6934#[cfg(test)]
6935mod tests {
6936    use super::*;
6937    use bock_air::{AirArg, AirRecordField};
6938    use bock_ast::{GenericParam, Ident, TypeExpr, TypePath};
6939    use bock_errors::{FileId, Span};
6940
6941    fn span() -> Span {
6942        Span {
6943            file: FileId(0),
6944            start: 0,
6945            end: 0,
6946        }
6947    }
6948
6949    fn ident(name: &str) -> Ident {
6950        Ident {
6951            name: name.to_string(),
6952            span: span(),
6953        }
6954    }
6955
6956    fn type_path(segments: &[&str]) -> TypePath {
6957        TypePath {
6958            segments: segments.iter().map(|s| ident(s)).collect(),
6959            span: span(),
6960        }
6961    }
6962
6963    fn node(id: u32, kind: NodeKind) -> AIRNode {
6964        AIRNode::new(id, span(), kind)
6965    }
6966
6967    fn int_lit(id: u32, val: &str) -> AIRNode {
6968        node(
6969            id,
6970            NodeKind::Literal {
6971                lit: Literal::Int(val.into()),
6972            },
6973        )
6974    }
6975
6976    fn str_lit(id: u32, val: &str) -> AIRNode {
6977        node(
6978            id,
6979            NodeKind::Literal {
6980                lit: Literal::String(val.into()),
6981            },
6982        )
6983    }
6984
6985    fn id_node(id: u32, name: &str) -> AIRNode {
6986        node(id, NodeKind::Identifier { name: ident(name) })
6987    }
6988
6989    fn bind_pat(id: u32, name: &str) -> AIRNode {
6990        node(
6991            id,
6992            NodeKind::BindPat {
6993                name: ident(name),
6994                is_mut: false,
6995            },
6996        )
6997    }
6998
6999    fn typed_param_node(id: u32, name: &str, ty_name: &str) -> AIRNode {
7000        node(
7001            id,
7002            NodeKind::Param {
7003                pattern: Box::new(bind_pat(id + 100, name)),
7004                ty: Some(Box::new(node(
7005                    id + 200,
7006                    NodeKind::TypeNamed {
7007                        path: type_path(&[ty_name]),
7008                        args: vec![],
7009                    },
7010                ))),
7011                default: None,
7012            },
7013        )
7014    }
7015
7016    fn type_node(id: u32, name: &str) -> AIRNode {
7017        node(
7018            id,
7019            NodeKind::TypeNamed {
7020                path: type_path(&[name]),
7021                args: vec![],
7022            },
7023        )
7024    }
7025
7026    /// A parameter with no type annotation (e.g. the `self` receiver of an
7027    /// impl method, which Bock declares as bare `self`).
7028    fn untyped_param_node(id: u32, name: &str) -> AIRNode {
7029        node(
7030            id,
7031            NodeKind::Param {
7032                pattern: Box::new(bind_pat(id + 100, name)),
7033                ty: None,
7034                default: None,
7035            },
7036        )
7037    }
7038
7039    fn block(id: u32, stmts: Vec<AIRNode>, tail: Option<AIRNode>) -> AIRNode {
7040        node(
7041            id,
7042            NodeKind::Block {
7043                stmts,
7044                tail: tail.map(Box::new),
7045            },
7046        )
7047    }
7048
7049    fn module(imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
7050        node(
7051            0,
7052            NodeKind::Module {
7053                path: None,
7054                annotations: vec![],
7055                imports,
7056                items,
7057            },
7058        )
7059    }
7060
7061    fn gen(module: &AIRNode) -> String {
7062        let gen = TsGenerator::new();
7063        let result = gen.generate_module(module).unwrap();
7064        result.files[0].content.clone()
7065    }
7066
7067    fn make_generic_param(name: &str) -> GenericParam {
7068        GenericParam {
7069            id: 0,
7070            span: span(),
7071            name: ident(name),
7072            bounds: vec![],
7073        }
7074    }
7075
7076    fn make_bounded_generic_param(name: &str, bounds: &[&str]) -> GenericParam {
7077        GenericParam {
7078            id: 0,
7079            span: span(),
7080            name: ident(name),
7081            bounds: bounds.iter().map(|b| type_path(&[b])).collect(),
7082        }
7083    }
7084
7085    fn make_type_expr(name: &str) -> TypeExpr {
7086        TypeExpr::Named {
7087            id: 0,
7088            span: span(),
7089            path: type_path(&[name]),
7090            args: vec![],
7091        }
7092    }
7093
7094    fn make_record_field(name: &str, ty_name: &str) -> bock_ast::RecordDeclField {
7095        bock_ast::RecordDeclField {
7096            id: 0,
7097            span: span(),
7098            name: ident(name),
7099            ty: make_type_expr(ty_name),
7100            default: None,
7101        }
7102    }
7103
7104    // ── Basic tests ─────────────────────────────────────────────────────────
7105
7106    #[test]
7107    fn implements_code_generator_trait() {
7108        let gen = TsGenerator::new();
7109        assert_eq!(gen.target().id, "ts");
7110    }
7111
7112    #[test]
7113    fn empty_module() {
7114        let m = module(vec![], vec![]);
7115        let out = gen(&m);
7116        assert_eq!(out, "");
7117    }
7118
7119    #[test]
7120    fn generate_project_uses_source_mirrored_path_for_ts() {
7121        let gen = TsGenerator::new();
7122        let m = module(vec![], vec![]);
7123        let src_path = std::path::Path::new("src/lib.bock");
7124        let result = gen.generate_project(&[(&m, src_path)]).unwrap();
7125        assert_eq!(result.files[0].path, std::path::PathBuf::from("lib.ts"));
7126    }
7127
7128    /// A module node with a declared dotted `path`, for the per-module tests.
7129    fn module_with_path(path: &[&str], imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
7130        node(
7131            0,
7132            NodeKind::Module {
7133                path: Some(bock_ast::ModulePath {
7134                    segments: path.iter().map(|s| ident(s)).collect(),
7135                    span: span(),
7136                }),
7137                annotations: vec![],
7138                imports,
7139                items,
7140            },
7141        )
7142    }
7143
7144    /// An `import <path>.{ name }` AIR node (single-item `Named` import).
7145    fn import_named(id: u32, path: &[&str], name: &str) -> AIRNode {
7146        node(
7147            id,
7148            NodeKind::ImportDecl {
7149                path: bock_ast::ModulePath {
7150                    segments: path.iter().map(|s| ident(s)).collect(),
7151                    span: span(),
7152                },
7153                items: bock_ast::ImportItems::Named(vec![bock_ast::ImportedName {
7154                    span: span(),
7155                    name: ident(name),
7156                    alias: None,
7157                }]),
7158            },
7159        )
7160    }
7161
7162    /// A bare `fn <name>() -> <tail>` declaration with the given visibility.
7163    fn fn_decl_tail(id: u32, vis: Visibility, name: &str, tail: AIRNode) -> AIRNode {
7164        node(
7165            id,
7166            NodeKind::FnDecl {
7167                annotations: vec![],
7168                visibility: vis,
7169                is_async: false,
7170                name: ident(name),
7171                generic_params: vec![],
7172                params: vec![],
7173                return_type: None,
7174                effect_clause: vec![],
7175                where_clause: vec![],
7176                body: Box::new(block(id + 1, vec![], Some(tail))),
7177            },
7178        )
7179    }
7180
7181    #[test]
7182    fn per_module_emits_native_esm_import_tree_ts() {
7183        // entry `module main` uses `mathutil.add_one`; emission must produce
7184        // `main.ts` (with `import { addOne } from "./mathutil.ts"` — the relative
7185        // specifier references the *emitted* `.ts` source directly) and
7186        // `mathutil.ts`. The `package.json` run affordance is emitted by the
7187        // scaffolder (project mode), NOT codegen (S6a / DV18).
7188        let call = node(
7189            10,
7190            NodeKind::Call {
7191                callee: Box::new(id_node(11, "add_one")),
7192                args: vec![bock_air::AirArg {
7193                    label: None,
7194                    value: int_lit(12, "6"),
7195                }],
7196                type_args: vec![],
7197            },
7198        );
7199        let main_mod = module_with_path(
7200            &["main"],
7201            vec![import_named(5, &["mathutil"], "add_one")],
7202            vec![fn_decl_tail(1, Visibility::Private, "main", call)],
7203        );
7204        let util_mod = module_with_path(
7205            &["mathutil"],
7206            vec![],
7207            vec![fn_decl_tail(
7208                20,
7209                Visibility::Public,
7210                "add_one",
7211                int_lit(22, "7"),
7212            )],
7213        );
7214
7215        let gen = TsGenerator::new();
7216        let out = gen
7217            .generate_project(&[
7218                (&main_mod, std::path::Path::new("src/main.bock")),
7219                (&util_mod, std::path::Path::new("src/mathutil.bock")),
7220            ])
7221            .unwrap();
7222        let by_name = |p: &str| out.files.iter().find(|f| f.path == std::path::Path::new(p));
7223        let main_file = by_name("main.ts").expect("main.ts emitted");
7224        let util_file = by_name("mathutil.ts").expect("mathutil.ts emitted");
7225        // Codegen no longer emits the run affordance (S6a / DV18) — the
7226        // scaffolder owns the `package.json` in project mode.
7227        assert!(
7228            by_name("package.json").is_none(),
7229            "codegen must NOT emit package.json — the scaffolder owns it (S6a)"
7230        );
7231        assert!(
7232            main_file
7233                .content
7234                .contains("import { addOne } from \"./mathutil.ts\";"),
7235            "main.ts must import the camelCased fn via the `.ts` specifier; got:\n{}",
7236            main_file.content
7237        );
7238        assert!(
7239            util_file.content.contains("export function addOne("),
7240            "mathutil.ts must export the fn inline; got:\n{}",
7241            util_file.content
7242        );
7243    }
7244
7245    #[test]
7246    fn per_module_imports_enum_type_via_import_type_ts() {
7247        // entry `module main` references a `public enum Color` declared in
7248        // `module palette` *as a type* (a function param annotation). The enum's
7249        // type name has no JS binding, so TS must `import type { Color }` — not a
7250        // value import. The variants are re-exported by `palette.ts` and lower
7251        // inline as tagged objects.
7252        let color_enum = node(
7253            40,
7254            NodeKind::EnumDecl {
7255                annotations: vec![],
7256                visibility: Visibility::Public,
7257                name: ident("Color"),
7258                generic_params: vec![],
7259                variants: vec![
7260                    node(
7261                        41,
7262                        NodeKind::EnumVariant {
7263                            name: ident("Red"),
7264                            payload: EnumVariantPayload::Unit,
7265                        },
7266                    ),
7267                    node(
7268                        42,
7269                        NodeKind::EnumVariant {
7270                            name: ident("Blue"),
7271                            payload: EnumVariantPayload::Unit,
7272                        },
7273                    ),
7274                ],
7275            },
7276        );
7277        let palette_mod = module_with_path(&["palette"], vec![], vec![color_enum]);
7278        // `fn paint(c: Color) -> Int { 0 }` — `Color` appears as a type only.
7279        let paint = node(
7280            1,
7281            NodeKind::FnDecl {
7282                annotations: vec![],
7283                visibility: Visibility::Private,
7284                is_async: false,
7285                name: ident("main"),
7286                generic_params: vec![],
7287                params: vec![typed_param_node(2, "c", "Color")],
7288                return_type: None,
7289                effect_clause: vec![],
7290                where_clause: vec![],
7291                body: Box::new(block(4, vec![], Some(int_lit(5, "0")))),
7292            },
7293        );
7294        let main_mod = module_with_path(
7295            &["main"],
7296            vec![import_named(6, &["palette"], "Color")],
7297            vec![paint],
7298        );
7299
7300        let gen = TsGenerator::new();
7301        let out = gen
7302            .generate_project(&[
7303                (&main_mod, std::path::Path::new("src/main.bock")),
7304                (&palette_mod, std::path::Path::new("src/palette.bock")),
7305            ])
7306            .unwrap();
7307        let by_name = |p: &str| out.files.iter().find(|f| f.path == std::path::Path::new(p));
7308        let main_file = by_name("main.ts").expect("main.ts emitted");
7309        let palette_file = by_name("palette.ts").expect("palette.ts emitted");
7310        assert!(
7311            main_file
7312                .content
7313                .contains("import type { Color } from \"./palette.ts\";"),
7314            "main.ts must `import type` the enum type; got:\n{}",
7315            main_file.content
7316        );
7317        assert!(
7318            palette_file
7319                .content
7320                .contains("export { Color_Blue, Color_Red };"),
7321            "palette.ts must re-export the enum variant values; got:\n{}",
7322            palette_file.content
7323        );
7324    }
7325
7326    // ── Type annotations ────────────────────────────────────────────────────
7327
7328    #[test]
7329    fn function_with_type_annotations() {
7330        let body = block(10, vec![], Some(id_node(11, "x")));
7331        let f = node(
7332            1,
7333            NodeKind::FnDecl {
7334                annotations: vec![],
7335                visibility: Visibility::Public,
7336                is_async: false,
7337                name: ident("add"),
7338                generic_params: vec![],
7339                params: vec![
7340                    typed_param_node(2, "x", "Int"),
7341                    typed_param_node(3, "y", "Int"),
7342                ],
7343                return_type: Some(Box::new(type_node(4, "Int"))),
7344                effect_clause: vec![],
7345                where_clause: vec![],
7346                body: Box::new(body),
7347            },
7348        );
7349        let out = gen(&module(vec![], vec![f]));
7350        assert!(out.contains("x: number"), "got: {out}");
7351        assert!(out.contains("y: number"), "got: {out}");
7352        assert!(out.contains("): number"), "got: {out}");
7353        assert!(out.contains("export function add"));
7354    }
7355
7356    #[test]
7357    fn function_without_type_annotations() {
7358        let body = block(10, vec![], Some(int_lit(11, "42")));
7359        let f = node(
7360            1,
7361            NodeKind::FnDecl {
7362                annotations: vec![],
7363                visibility: Visibility::Private,
7364                is_async: false,
7365                name: ident("answer"),
7366                generic_params: vec![],
7367                params: vec![],
7368                return_type: None,
7369                effect_clause: vec![],
7370                where_clause: vec![],
7371                body: Box::new(body),
7372            },
7373        );
7374        let out = gen(&module(vec![], vec![f]));
7375        assert!(out.contains("function answer()"), "got: {out}");
7376        assert!(!out.contains("export"), "got: {out}");
7377    }
7378
7379    // ── Generics ────────────────────────────────────────────────────────────
7380
7381    #[test]
7382    fn function_with_generics() {
7383        let body = block(10, vec![], Some(id_node(11, "x")));
7384        let f = node(
7385            1,
7386            NodeKind::FnDecl {
7387                annotations: vec![],
7388                visibility: Visibility::Private,
7389                is_async: false,
7390                name: ident("identity"),
7391                generic_params: vec![make_generic_param("T")],
7392                params: vec![typed_param_node(2, "x", "T")],
7393                return_type: Some(Box::new(type_node(3, "T"))),
7394                effect_clause: vec![],
7395                where_clause: vec![],
7396                body: Box::new(body),
7397            },
7398        );
7399        let out = gen(&module(vec![], vec![f]));
7400        assert!(out.contains("function identity<T>"), "got: {out}");
7401        assert!(out.contains("x: T"), "got: {out}");
7402        assert!(out.contains("): T"), "got: {out}");
7403    }
7404
7405    #[test]
7406    fn generics_with_bounds() {
7407        let body = block(10, vec![], Some(id_node(11, "x")));
7408        let f = node(
7409            1,
7410            NodeKind::FnDecl {
7411                annotations: vec![],
7412                visibility: Visibility::Private,
7413                is_async: false,
7414                name: ident("sorted"),
7415                generic_params: vec![make_bounded_generic_param("T", &["Comparable"])],
7416                params: vec![typed_param_node(2, "x", "T")],
7417                return_type: Some(Box::new(type_node(3, "T"))),
7418                effect_clause: vec![],
7419                where_clause: vec![],
7420                body: Box::new(body),
7421            },
7422        );
7423        let out = gen(&module(vec![], vec![f]));
7424        // GAP-C: `Comparable` is a sealed-core trait with no user `impl` in this
7425        // module, so the `extends Comparable` bound (no such TS type) is dropped to
7426        // a bare `<T>`; the `.compare` call is lowered to a native operator. A
7427        // user-declared `Comparable` would keep its `extends` (see use_core_compare).
7428        assert!(
7429            out.contains("function sorted<T>(x: T): T {") && !out.contains("extends"),
7430            "got: {out}"
7431        );
7432    }
7433
7434    // ── Traits → Interfaces ─────────────────────────────────────────────────
7435
7436    #[test]
7437    fn trait_becomes_interface() {
7438        let method = node(
7439            2,
7440            NodeKind::FnDecl {
7441                annotations: vec![],
7442                visibility: Visibility::Public,
7443                is_async: false,
7444                name: ident("area"),
7445                generic_params: vec![],
7446                params: vec![],
7447                return_type: Some(Box::new(type_node(3, "Float"))),
7448                effect_clause: vec![],
7449                where_clause: vec![],
7450                body: Box::new(block(4, vec![], None)),
7451            },
7452        );
7453        let trait_decl = node(
7454            1,
7455            NodeKind::TraitDecl {
7456                annotations: vec![],
7457                visibility: Visibility::Public,
7458                is_platform: false,
7459                name: ident("Shape"),
7460                generic_params: vec![],
7461                associated_types: vec![],
7462                methods: vec![method],
7463            },
7464        );
7465        let out = gen(&module(vec![], vec![trait_decl]));
7466        assert!(out.contains("export interface Shape"), "got: {out}");
7467        assert!(out.contains("area(): number"), "got: {out}");
7468    }
7469
7470    #[test]
7471    fn trait_with_generics() {
7472        let method = node(
7473            2,
7474            NodeKind::FnDecl {
7475                annotations: vec![],
7476                visibility: Visibility::Public,
7477                is_async: false,
7478                name: ident("compare"),
7479                generic_params: vec![],
7480                params: vec![typed_param_node(3, "other", "T")],
7481                return_type: Some(Box::new(type_node(4, "Int"))),
7482                effect_clause: vec![],
7483                where_clause: vec![],
7484                body: Box::new(block(5, vec![], None)),
7485            },
7486        );
7487        let trait_decl = node(
7488            1,
7489            NodeKind::TraitDecl {
7490                annotations: vec![],
7491                visibility: Visibility::Public,
7492                is_platform: false,
7493                name: ident("Comparable"),
7494                generic_params: vec![make_generic_param("T")],
7495                associated_types: vec![],
7496                methods: vec![method],
7497            },
7498        );
7499        let out = gen(&module(vec![], vec![trait_decl]));
7500        assert!(out.contains("interface Comparable<T>"), "got: {out}");
7501        assert!(out.contains("compare(other: T): number"), "got: {out}");
7502    }
7503
7504    #[test]
7505    fn trait_decl_self_param_typed_as_trait_interface() {
7506        // P2 item 2: a trait method whose leading `self` is untyped must be
7507        // typed as the trait's own interface type (here `Eq`) — otherwise `tsc
7508        // --strict` flags `self` as an implicit `any`.
7509        let method = node(
7510            2,
7511            NodeKind::FnDecl {
7512                annotations: vec![],
7513                visibility: Visibility::Public,
7514                is_async: false,
7515                name: ident("equals"),
7516                generic_params: vec![],
7517                params: vec![untyped_param_node(3, "self")],
7518                return_type: Some(Box::new(type_node(4, "Bool"))),
7519                effect_clause: vec![],
7520                where_clause: vec![],
7521                body: Box::new(block(5, vec![], None)),
7522            },
7523        );
7524        let trait_decl = node(
7525            1,
7526            NodeKind::TraitDecl {
7527                annotations: vec![],
7528                visibility: Visibility::Public,
7529                is_platform: false,
7530                name: ident("Eq"),
7531                generic_params: vec![],
7532                associated_types: vec![],
7533                methods: vec![method],
7534            },
7535        );
7536        let out = gen(&module(vec![], vec![trait_decl]));
7537        assert!(
7538            out.contains("equals(self: Eq): boolean"),
7539            "trait `self` should be typed as the trait interface, got: {out}"
7540        );
7541    }
7542
7543    // ── Records → Interfaces ────────────────────────────────────────────────
7544
7545    #[test]
7546    fn record_becomes_interface_and_factory() {
7547        let record = node(
7548            1,
7549            NodeKind::RecordDecl {
7550                annotations: vec![],
7551                visibility: Visibility::Public,
7552                name: ident("Point"),
7553                generic_params: vec![],
7554                fields: vec![
7555                    make_record_field("x", "Float"),
7556                    make_record_field("y", "Float"),
7557                ],
7558            },
7559        );
7560        let out = gen(&module(vec![], vec![record]));
7561        assert!(out.contains("export class Point"), "got: {out}");
7562        assert!(out.contains("x: number"), "got: {out}");
7563        assert!(out.contains("y: number"), "got: {out}");
7564        assert!(
7565            out.contains("constructor({ x, y }: { x: number; y: number })"),
7566            "got: {out}"
7567        );
7568        assert!(out.contains("this.x = x;"), "got: {out}");
7569        assert!(out.contains("this.y = y;"), "got: {out}");
7570    }
7571
7572    // ── Enums → Discriminated unions ────────────────────────────────────────
7573
7574    #[test]
7575    fn enum_becomes_discriminated_union() {
7576        let variants = vec![
7577            node(
7578                2,
7579                NodeKind::EnumVariant {
7580                    name: ident("None"),
7581                    payload: EnumVariantPayload::Unit,
7582                },
7583            ),
7584            node(
7585                3,
7586                NodeKind::EnumVariant {
7587                    name: ident("Some"),
7588                    payload: EnumVariantPayload::Struct(vec![make_record_field("value", "T")]),
7589                },
7590            ),
7591        ];
7592        let enum_decl = node(
7593            1,
7594            NodeKind::EnumDecl {
7595                annotations: vec![],
7596                visibility: Visibility::Public,
7597                name: ident("Option"),
7598                generic_params: vec![make_generic_param("T")],
7599                variants,
7600            },
7601        );
7602        let out = gen(&module(vec![], vec![enum_decl]));
7603        // Union type: each variant reference carries the enum's type args
7604        // (`Option_None<T> | Option_Some<T>`), at the same arity the variant
7605        // interfaces declare — referencing them bare fails `tsc` with TS2314
7606        // (Q-ts-generic-enum-codegen).
7607        assert!(
7608            out.contains("export type Option<T> = Option_None<T> | Option_Some<T>;"),
7609            "got: {out}"
7610        );
7611        // Unit variant: a phantom (unused) type param gets an `= unknown`
7612        // default so the zero-arg `const Option_None: Option_None` annotation is
7613        // valid while the union still references it as `Option_None<T>`.
7614        assert!(
7615            out.contains("interface Option_None<T = unknown>"),
7616            "got: {out}"
7617        );
7618        assert!(out.contains("readonly _tag: \"None\""), "got: {out}");
7619        assert!(
7620            out.contains("const Option_None: Option_None = "),
7621            "got: {out}"
7622        );
7623        // Struct variant: interface and factory return type carry consistent
7624        // arity (`Option_Some<T>` … `: Option_Some<T>`).
7625        assert!(out.contains("interface Option_Some<T>"), "got: {out}");
7626        assert!(out.contains("readonly value: T"), "got: {out}");
7627        assert!(
7628            out.contains("function Option_Some<T>(value: T): Option_Some<T>"),
7629            "got: {out}"
7630        );
7631    }
7632
7633    /// Q-ts-variant-constructed-let-typing: a type-annotated binding initialised
7634    /// by a single variant construction must widen the initialiser to the
7635    /// declared enum *union* (`Gate_Open(7) as Gate`), not narrow `g` to the
7636    /// construction variant — otherwise a later `match g` on a sibling variant
7637    /// fails `tsc --noEmit` (TS2678). Covers all three payload shapes: struct
7638    /// (`RecordConstruct`), tuple (`Call` on a variant name), and unit
7639    /// (bare `Identifier`).
7640    #[test]
7641    fn typed_let_binding_widens_variant_construct_to_declared_union() {
7642        let gate = node(
7643            1,
7644            NodeKind::EnumDecl {
7645                annotations: vec![],
7646                visibility: Visibility::Public,
7647                name: ident("Gate"),
7648                generic_params: vec![],
7649                variants: vec![
7650                    node(
7651                        2,
7652                        NodeKind::EnumVariant {
7653                            name: ident("Open"),
7654                            payload: EnumVariantPayload::Struct(vec![make_record_field(
7655                                "level", "Int",
7656                            )]),
7657                        },
7658                    ),
7659                    node(
7660                        3,
7661                        NodeKind::EnumVariant {
7662                            name: ident("Closed"),
7663                            payload: EnumVariantPayload::Tuple(vec![type_node(4, "Int")]),
7664                        },
7665                    ),
7666                    node(
7667                        5,
7668                        NodeKind::EnumVariant {
7669                            name: ident("Locked"),
7670                            payload: EnumVariantPayload::Unit,
7671                        },
7672                    ),
7673                ],
7674            },
7675        );
7676        // let g: Gate = Open { level: 7 }   (struct variant)
7677        let open = node(
7678            10,
7679            NodeKind::RecordConstruct {
7680                path: type_path(&["Open"]),
7681                fields: vec![bock_air::AirRecordField {
7682                    name: ident("level"),
7683                    value: Some(Box::new(int_lit(11, "7"))),
7684                }],
7685                spread: None,
7686            },
7687        );
7688        let let_g = node(
7689            12,
7690            NodeKind::LetBinding {
7691                is_mut: false,
7692                pattern: Box::new(bind_pat(13, "g")),
7693                ty: Some(Box::new(type_node(14, "Gate"))),
7694                value: Box::new(open),
7695            },
7696        );
7697        // let c: Gate = Closed(3)   (tuple variant)
7698        let closed = node(
7699            20,
7700            NodeKind::Call {
7701                callee: Box::new(id_node(21, "Closed")),
7702                args: vec![AirArg {
7703                    label: None,
7704                    value: int_lit(22, "3"),
7705                }],
7706                type_args: vec![],
7707            },
7708        );
7709        let let_c = node(
7710            23,
7711            NodeKind::LetBinding {
7712                is_mut: false,
7713                pattern: Box::new(bind_pat(24, "c")),
7714                ty: Some(Box::new(type_node(25, "Gate"))),
7715                value: Box::new(closed),
7716            },
7717        );
7718        // let k: Gate = Locked   (unit variant)
7719        let let_k = node(
7720            30,
7721            NodeKind::LetBinding {
7722                is_mut: false,
7723                pattern: Box::new(bind_pat(31, "k")),
7724                ty: Some(Box::new(type_node(32, "Gate"))),
7725                value: Box::new(id_node(33, "Locked")),
7726            },
7727        );
7728        let f = ts_fn_decl(
7729            40,
7730            "main",
7731            vec![],
7732            None,
7733            block(41, vec![let_g, let_c, let_k], None),
7734        );
7735        let out = gen(&module(vec![], vec![gate, f]));
7736        assert!(
7737            out.contains("const g: Gate = Gate_Open(7) as Gate;"),
7738            "struct-variant binding must widen to the declared union, got:\n{out}"
7739        );
7740        assert!(
7741            out.contains("const c: Gate = Gate_Closed(3) as Gate;"),
7742            "tuple-variant binding must widen to the declared union, got:\n{out}"
7743        );
7744        assert!(
7745            out.contains("const k: Gate = Gate_Locked as Gate;"),
7746            "unit-variant binding must widen to the declared union, got:\n{out}"
7747        );
7748    }
7749
7750    /// The widening cast is *scoped to the matching declared type*: a binding
7751    /// annotated with a type that is not the constructed variant's enum union
7752    /// (here a record `Point`, not an enum) emits unchanged — no spurious cast.
7753    #[test]
7754    fn typed_let_binding_no_widening_for_non_enum_construct() {
7755        let point = node(
7756            1,
7757            NodeKind::RecordDecl {
7758                annotations: vec![],
7759                visibility: Visibility::Public,
7760                name: ident("Point"),
7761                generic_params: vec![],
7762                fields: vec![make_record_field("x", "Int")],
7763            },
7764        );
7765        let ctor = node(
7766            10,
7767            NodeKind::RecordConstruct {
7768                path: type_path(&["Point"]),
7769                fields: vec![bock_air::AirRecordField {
7770                    name: ident("x"),
7771                    value: Some(Box::new(int_lit(11, "5"))),
7772                }],
7773                spread: None,
7774            },
7775        );
7776        let let_p = node(
7777            12,
7778            NodeKind::LetBinding {
7779                is_mut: false,
7780                pattern: Box::new(bind_pat(13, "p")),
7781                ty: Some(Box::new(type_node(14, "Point"))),
7782                value: Box::new(ctor),
7783            },
7784        );
7785        let f = ts_fn_decl(20, "main", vec![], None, block(21, vec![let_p], None));
7786        let out = gen(&module(vec![], vec![point, f]));
7787        assert!(
7788            !out.contains(" as Point"),
7789            "a record (non-enum) binding must not get a widening cast, got:\n{out}"
7790        );
7791    }
7792
7793    // ── Type aliases ────────────────────────────────────────────────────────
7794
7795    #[test]
7796    fn type_alias_emitted() {
7797        let alias = node(
7798            1,
7799            NodeKind::TypeAlias {
7800                annotations: vec![],
7801                visibility: Visibility::Public,
7802                name: ident("UserId"),
7803                generic_params: vec![],
7804                ty: Box::new(type_node(2, "String")),
7805                where_clause: vec![],
7806            },
7807        );
7808        let out = gen(&module(vec![], vec![alias]));
7809        assert!(out.contains("export type UserId = string;"), "got: {out}");
7810    }
7811
7812    #[test]
7813    fn generic_type_alias() {
7814        let alias = node(
7815            1,
7816            NodeKind::TypeAlias {
7817                annotations: vec![],
7818                visibility: Visibility::Private,
7819                name: ident("Pair"),
7820                generic_params: vec![make_generic_param("A"), make_generic_param("B")],
7821                ty: Box::new(node(
7822                    2,
7823                    NodeKind::TypeTuple {
7824                        elems: vec![type_node(3, "A"), type_node(4, "B")],
7825                    },
7826                )),
7827                where_clause: vec![],
7828            },
7829        );
7830        let out = gen(&module(vec![], vec![alias]));
7831        assert!(out.contains("type Pair<A, B> = [A, B];"), "got: {out}");
7832    }
7833
7834    // ── Effects → typed parameters ──────────────────────────────────────────
7835
7836    #[test]
7837    fn effects_as_typed_params() {
7838        let body = block(10, vec![], None);
7839        let f = node(
7840            1,
7841            NodeKind::FnDecl {
7842                annotations: vec![],
7843                visibility: Visibility::Private,
7844                is_async: false,
7845                name: ident("process"),
7846                generic_params: vec![],
7847                params: vec![typed_param_node(2, "data", "String")],
7848                return_type: None,
7849                effect_clause: vec![type_path(&["Log"]), type_path(&["Clock"])],
7850                where_clause: vec![],
7851                body: Box::new(body),
7852            },
7853        );
7854        let out = gen(&module(vec![], vec![f]));
7855        assert!(
7856            out.contains("{ log, clock }: { log: Log, clock: Clock }"),
7857            "got: {out}"
7858        );
7859    }
7860
7861    /// Q-clock-handler-routing: inside a `with Clock` function the §18.3.1 time
7862    /// builtins route through the in-scope `clock` handler — `Instant.now()` →
7863    /// `clock.now_monotonic()`, `sleep(d)` → `clock.sleep(d)`,
7864    /// `start.elapsed()` → `clock.now_monotonic() - start` — NOT the inlined
7865    /// host primitives (`performance.now()` / `setTimeout`). The `Duration` /
7866    /// `Instant` annotations also render `number` (not the undefined identifiers)
7867    /// so the handler impl type-checks under `tsc --noEmit`.
7868    #[test]
7869    fn clock_time_ops_route_through_handler() {
7870        let out = gen(&module(vec![], vec![clock_timed_fn()]));
7871        assert!(out.contains("clock.now_monotonic()"), "got: {out}");
7872        assert!(out.contains("clock.sleep("), "got: {out}");
7873        assert!(
7874            !out.contains("performance.now()"),
7875            "host clock primitive leaked past the handler: {out}"
7876        );
7877        assert!(
7878            !out.contains("setTimeout"),
7879            "host sleep primitive leaked past the handler: {out}"
7880        );
7881    }
7882
7883    /// `Duration` / `Instant` used as type annotations must render `number`
7884    /// (their value representation), not the undefined identifiers, so a
7885    /// `Clock` handler impl type-checks (Q-clock-handler-routing supporting fix).
7886    #[test]
7887    fn builtin_time_types_map_to_number() {
7888        let f = node(
7889            1,
7890            NodeKind::FnDecl {
7891                annotations: vec![],
7892                visibility: Visibility::Private,
7893                is_async: false,
7894                name: ident("span"),
7895                generic_params: vec![],
7896                params: vec![typed_param_node(2, "d", "Duration")],
7897                return_type: Some(Box::new(type_node(3, "Instant"))),
7898                effect_clause: vec![],
7899                where_clause: vec![],
7900                body: Box::new(block(10, vec![], None)),
7901            },
7902        );
7903        let out = gen(&module(vec![], vec![f]));
7904        assert!(out.contains("d: number"), "Duration annotation: {out}");
7905        assert!(out.contains("): number"), "Instant annotation: {out}");
7906        assert!(!out.contains(": Duration"), "leaked Duration type: {out}");
7907        assert!(!out.contains(": Instant"), "leaked Instant type: {out}");
7908    }
7909
7910    /// Builds `fn timed() with Clock { let start = Instant.now(); sleep(
7911    /// Duration.millis(1)); let d = start.elapsed() }` — the `with Clock` clause
7912    /// puts the `clock` handler in scope so the time builtins route through it.
7913    fn clock_timed_fn() -> AIRNode {
7914        let instant_now = node(
7915            40,
7916            NodeKind::Call {
7917                callee: Box::new(node(
7918                    41,
7919                    NodeKind::FieldAccess {
7920                        object: Box::new(id_node(42, "Instant")),
7921                        field: ident("now"),
7922                    },
7923                )),
7924                args: vec![],
7925                type_args: vec![],
7926            },
7927        );
7928        let duration_millis = node(
7929            50,
7930            NodeKind::Call {
7931                callee: Box::new(node(
7932                    51,
7933                    NodeKind::FieldAccess {
7934                        object: Box::new(id_node(52, "Duration")),
7935                        field: ident("millis"),
7936                    },
7937                )),
7938                args: vec![AirArg {
7939                    label: None,
7940                    value: int_lit(53, "1"),
7941                }],
7942                type_args: vec![],
7943            },
7944        );
7945        let sleep_call = node(
7946            60,
7947            NodeKind::Call {
7948                callee: Box::new(id_node(61, "sleep")),
7949                args: vec![AirArg {
7950                    label: None,
7951                    value: duration_millis,
7952                }],
7953                type_args: vec![],
7954            },
7955        );
7956        let elapsed_call = node(
7957            70,
7958            NodeKind::MethodCall {
7959                receiver: Box::new(id_node(71, "start")),
7960                method: ident("elapsed"),
7961                type_args: vec![],
7962                args: vec![],
7963            },
7964        );
7965        let body = block(
7966            30,
7967            vec![
7968                node(
7969                    31,
7970                    NodeKind::LetBinding {
7971                        is_mut: false,
7972                        pattern: Box::new(bind_pat(32, "start")),
7973                        ty: None,
7974                        value: Box::new(instant_now),
7975                    },
7976                ),
7977                sleep_call,
7978                node(
7979                    33,
7980                    NodeKind::LetBinding {
7981                        is_mut: false,
7982                        pattern: Box::new(bind_pat(34, "d")),
7983                        ty: None,
7984                        value: Box::new(elapsed_call),
7985                    },
7986                ),
7987            ],
7988            None,
7989        );
7990        node(
7991            1,
7992            NodeKind::FnDecl {
7993                annotations: vec![],
7994                visibility: Visibility::Private,
7995                is_async: false,
7996                name: ident("timed"),
7997                generic_params: vec![],
7998                params: vec![],
7999                return_type: None,
8000                effect_clause: vec![type_path(&["Clock"])],
8001                where_clause: vec![],
8002                body: Box::new(body),
8003            },
8004        )
8005    }
8006
8007    // ── Async functions ─────────────────────────────────────────────────────
8008
8009    #[test]
8010    fn async_function_with_types() {
8011        let body = block(10, vec![], Some(str_lit(11, "done")));
8012        let f = node(
8013            1,
8014            NodeKind::FnDecl {
8015                annotations: vec![],
8016                visibility: Visibility::Public,
8017                is_async: true,
8018                name: ident("fetch"),
8019                generic_params: vec![],
8020                params: vec![typed_param_node(2, "url", "String")],
8021                return_type: Some(Box::new(type_node(3, "String"))),
8022                effect_clause: vec![],
8023                where_clause: vec![],
8024                body: Box::new(body),
8025            },
8026        );
8027        let out = gen(&module(vec![], vec![f]));
8028        assert!(out.contains("export async function fetch"), "got: {out}");
8029        assert!(out.contains("url: string"), "got: {out}");
8030        // Async declared return type is wrapped in Promise<T>.
8031        assert!(out.contains("): Promise<string>"), "got: {out}");
8032    }
8033
8034    #[test]
8035    fn async_function_without_return_type_is_promise_void() {
8036        let body = block(10, vec![], None);
8037        let f = node(
8038            1,
8039            NodeKind::FnDecl {
8040                annotations: vec![],
8041                visibility: Visibility::Private,
8042                is_async: true,
8043                name: ident("tick"),
8044                generic_params: vec![],
8045                params: vec![],
8046                return_type: None,
8047                effect_clause: vec![],
8048                where_clause: vec![],
8049                body: Box::new(body),
8050            },
8051        );
8052        let out = gen(&module(vec![], vec![f]));
8053        assert!(out.contains("async function tick()"), "got: {out}");
8054        assert!(out.contains("): Promise<void>"), "got: {out}");
8055    }
8056
8057    #[test]
8058    fn sync_function_return_type_unchanged() {
8059        let body = block(10, vec![], Some(str_lit(11, "done")));
8060        let f = node(
8061            1,
8062            NodeKind::FnDecl {
8063                annotations: vec![],
8064                visibility: Visibility::Private,
8065                is_async: false,
8066                name: ident("hello"),
8067                generic_params: vec![],
8068                params: vec![],
8069                return_type: Some(Box::new(type_node(2, "String"))),
8070                effect_clause: vec![],
8071                where_clause: vec![],
8072                body: Box::new(body),
8073            },
8074        );
8075        let out = gen(&module(vec![], vec![f]));
8076        assert!(out.contains("function hello(): string"), "got: {out}");
8077        assert!(!out.contains("Promise"), "got: {out}");
8078    }
8079
8080    #[test]
8081    fn entry_invocation_async_main_ts() {
8082        let inv = TsGenerator::new().entry_invocation(true).unwrap();
8083        assert!(inv.contains("async () =>"));
8084        assert!(inv.contains("await main()"));
8085    }
8086
8087    #[test]
8088    fn generate_project_async_main_wraps_entry_ts() {
8089        let main_fn = node(
8090            1,
8091            NodeKind::FnDecl {
8092                annotations: vec![],
8093                visibility: Visibility::Private,
8094                is_async: true,
8095                name: ident("main"),
8096                generic_params: vec![],
8097                params: vec![],
8098                return_type: None,
8099                effect_clause: vec![],
8100                where_clause: vec![],
8101                body: Box::new(block(2, vec![], None)),
8102            },
8103        );
8104        let m = module(vec![], vec![main_fn]);
8105        let gen = TsGenerator::new();
8106        let src_path = std::path::Path::new("src/main.bock");
8107        let out = gen.generate_project(&[(&m, src_path)]).unwrap();
8108        let src = &out.files[0].content;
8109        assert_eq!(out.files[0].path, std::path::PathBuf::from("main.ts"));
8110        assert!(src.contains("async function main()"), "got: {src}");
8111        assert!(
8112            src.contains("(async () => { await main(); })();"),
8113            "got: {src}"
8114        );
8115    }
8116
8117    // ── Let bindings with type annotations ──────────────────────────────────
8118
8119    #[test]
8120    fn let_binding_with_type() {
8121        let stmt = node(
8122            1,
8123            NodeKind::LetBinding {
8124                is_mut: false,
8125                pattern: Box::new(bind_pat(2, "x")),
8126                ty: Some(Box::new(type_node(3, "Int"))),
8127                value: Box::new(int_lit(4, "42")),
8128            },
8129        );
8130        let f = node(
8131            5,
8132            NodeKind::FnDecl {
8133                annotations: vec![],
8134                visibility: Visibility::Private,
8135                is_async: false,
8136                name: ident("test"),
8137                generic_params: vec![],
8138                params: vec![],
8139                return_type: None,
8140                effect_clause: vec![],
8141                where_clause: vec![],
8142                body: Box::new(block(6, vec![stmt], None)),
8143            },
8144        );
8145        let out = gen(&module(vec![], vec![f]));
8146        assert!(out.contains("const x: number = 42;"), "got: {out}");
8147    }
8148
8149    #[test]
8150    fn mutable_binding_with_type() {
8151        let stmt = node(
8152            1,
8153            NodeKind::LetBinding {
8154                is_mut: true,
8155                pattern: Box::new(bind_pat(2, "count")),
8156                ty: Some(Box::new(type_node(3, "Int"))),
8157                value: Box::new(int_lit(4, "0")),
8158            },
8159        );
8160        let f = node(
8161            5,
8162            NodeKind::FnDecl {
8163                annotations: vec![],
8164                visibility: Visibility::Private,
8165                is_async: false,
8166                name: ident("test"),
8167                generic_params: vec![],
8168                params: vec![],
8169                return_type: None,
8170                effect_clause: vec![],
8171                where_clause: vec![],
8172                body: Box::new(block(6, vec![stmt], None)),
8173            },
8174        );
8175        let out = gen(&module(vec![], vec![f]));
8176        assert!(out.contains("let count: number = 0;"), "got: {out}");
8177    }
8178
8179    // ── Type mapping ────────────────────────────────────────────────────────
8180
8181    #[test]
8182    fn type_mapping_primitives() {
8183        let ctx = TsEmitCtx::new();
8184        assert_eq!(ctx.map_type_name("Int"), "number");
8185        assert_eq!(ctx.map_type_name("Float"), "number");
8186        assert_eq!(ctx.map_type_name("Bool"), "boolean");
8187        assert_eq!(ctx.map_type_name("String"), "string");
8188        assert_eq!(ctx.map_type_name("Void"), "void");
8189        assert_eq!(ctx.map_type_name("Unit"), "void");
8190        assert_eq!(ctx.map_type_name("List"), "Array");
8191        assert_eq!(ctx.map_type_name("CustomType"), "CustomType");
8192    }
8193
8194    #[test]
8195    fn optional_type_emitted() {
8196        // `T?` must lower to the tagged `BockOption<T>` union (not `T | null`):
8197        // the runtime value is `{ _tag: "Some", _0: v }` / `{ _tag: "None" }`,
8198        // so the type has to describe that for `tsc` to accept it (Q-ts-codegen).
8199        let ctx = TsEmitCtx::new();
8200        let opt = node(
8201            1,
8202            NodeKind::TypeOptional {
8203                inner: Box::new(type_node(2, "String")),
8204            },
8205        );
8206        assert_eq!(ctx.type_to_ts(&opt), "BockOption<string>");
8207    }
8208
8209    #[test]
8210    fn generic_type_args() {
8211        let ctx = TsEmitCtx::new();
8212        let list_of_int = node(
8213            1,
8214            NodeKind::TypeNamed {
8215                path: type_path(&["List"]),
8216                args: vec![type_node(2, "Int")],
8217            },
8218        );
8219        assert_eq!(ctx.type_to_ts(&list_of_int), "Array<number>");
8220    }
8221
8222    #[test]
8223    fn function_type() {
8224        let ctx = TsEmitCtx::new();
8225        let fn_type = node(
8226            1,
8227            NodeKind::TypeFunction {
8228                params: vec![type_node(2, "Int"), type_node(3, "String")],
8229                ret: Box::new(type_node(4, "Bool")),
8230                effects: vec![],
8231            },
8232        );
8233        assert_eq!(
8234            ctx.type_to_ts(&fn_type),
8235            "(arg0: number, arg1: string) => boolean"
8236        );
8237    }
8238
8239    #[test]
8240    fn tuple_type() {
8241        let ctx = TsEmitCtx::new();
8242        let tuple = node(
8243            1,
8244            NodeKind::TypeTuple {
8245                elems: vec![type_node(2, "Int"), type_node(3, "String")],
8246            },
8247        );
8248        assert_eq!(ctx.type_to_ts(&tuple), "[number, string]");
8249    }
8250
8251    // ── Constants with types ────────────────────────────────────────────────
8252
8253    #[test]
8254    fn const_with_type() {
8255        let c = node(
8256            1,
8257            NodeKind::ConstDecl {
8258                annotations: vec![],
8259                visibility: Visibility::Public,
8260                name: ident("PI"),
8261                ty: Box::new(type_node(2, "Float")),
8262                value: Box::new(node(
8263                    3,
8264                    NodeKind::Literal {
8265                        lit: Literal::Float("3.14159".into()),
8266                    },
8267                )),
8268            },
8269        );
8270        let out = gen(&module(vec![], vec![c]));
8271        assert!(
8272            out.contains("export const PI: number = 3.14159;"),
8273            "got: {out}"
8274        );
8275    }
8276
8277    // ── Class with types ────────────────────────────────────────────────────
8278
8279    #[test]
8280    fn class_with_typed_fields() {
8281        let class = node(
8282            1,
8283            NodeKind::ClassDecl {
8284                annotations: vec![],
8285                visibility: Visibility::Public,
8286                name: ident("Point"),
8287                generic_params: vec![],
8288                base: None,
8289                traits: vec![],
8290                fields: vec![
8291                    make_record_field("x", "Float"),
8292                    make_record_field("y", "Float"),
8293                ],
8294                methods: vec![],
8295            },
8296        );
8297        let out = gen(&module(vec![], vec![class]));
8298        assert!(out.contains("export class Point"), "got: {out}");
8299        assert!(out.contains("x: number;"), "got: {out}");
8300        assert!(out.contains("y: number;"), "got: {out}");
8301        assert!(
8302            out.contains("constructor(x: number, y: number)"),
8303            "got: {out}"
8304        );
8305    }
8306
8307    /// A `class T { a, b }` literal must construct via the class's **positional**
8308    /// constructor — `new T(a_value, b_value)` in *field-declaration order* — not
8309    /// the bare object literal the record path emits (which `tsc` rejects with
8310    /// `TS2339: Property '<method>' does not exist`). Q-class-codegen.
8311    #[test]
8312    fn class_literal_constructs_positionally() {
8313        let class = node(
8314            1,
8315            NodeKind::ClassDecl {
8316                annotations: vec![],
8317                visibility: Visibility::Public,
8318                name: ident("Button"),
8319                generic_params: vec![],
8320                base: None,
8321                traits: vec![],
8322                fields: vec![
8323                    make_record_field("label", "String"),
8324                    make_record_field("on_click", "String"),
8325                ],
8326                methods: vec![],
8327            },
8328        );
8329        // Supply fields OUT of declaration order; the emitter must reorder to
8330        // declaration order for the positional constructor.
8331        let construct = node(
8332            10,
8333            NodeKind::RecordConstruct {
8334                path: type_path(&["Button"]),
8335                fields: vec![
8336                    AirRecordField {
8337                        name: ident("on_click"),
8338                        value: Some(Box::new(str_lit(11, "click"))),
8339                    },
8340                    AirRecordField {
8341                        name: ident("label"),
8342                        value: Some(Box::new(str_lit(12, "Submit"))),
8343                    },
8344                ],
8345                spread: None,
8346            },
8347        );
8348        let main_fn = node(
8349            20,
8350            NodeKind::FnDecl {
8351                annotations: vec![],
8352                visibility: Visibility::Private,
8353                is_async: false,
8354                name: ident("main"),
8355                generic_params: vec![],
8356                params: vec![],
8357                return_type: None,
8358                effect_clause: vec![],
8359                where_clause: vec![],
8360                body: Box::new(block(
8361                    21,
8362                    vec![node(
8363                        22,
8364                        NodeKind::LetBinding {
8365                            is_mut: false,
8366                            pattern: Box::new(bind_pat(23, "b")),
8367                            ty: None,
8368                            value: Box::new(construct),
8369                        },
8370                    )],
8371                    None,
8372                )),
8373            },
8374        );
8375        let out = gen(&module(vec![], vec![class, main_fn]));
8376        assert!(
8377            out.contains(r#"new Button("Submit", "click")"#),
8378            "expected positional `new Button(...)` in declaration order, got:\n{out}"
8379        );
8380        assert!(
8381            !out.contains("{ label:"),
8382            "class literal must not emit a bare object literal:\n{out}"
8383        );
8384    }
8385
8386    // ── Effect declarations → interfaces ────────────────────────────────────
8387
8388    #[test]
8389    fn effect_becomes_interface() {
8390        let op = node(
8391            2,
8392            NodeKind::FnDecl {
8393                annotations: vec![],
8394                visibility: Visibility::Public,
8395                is_async: false,
8396                name: ident("log"),
8397                generic_params: vec![],
8398                params: vec![typed_param_node(3, "msg", "String")],
8399                return_type: Some(Box::new(type_node(4, "Void"))),
8400                effect_clause: vec![],
8401                where_clause: vec![],
8402                body: Box::new(block(5, vec![], None)),
8403            },
8404        );
8405        let effect = node(
8406            1,
8407            NodeKind::EffectDecl {
8408                annotations: vec![],
8409                visibility: Visibility::Public,
8410                name: ident("Logger"),
8411                generic_params: vec![],
8412                components: vec![],
8413                operations: vec![op],
8414            },
8415        );
8416        let out = gen(&module(vec![], vec![effect]));
8417        assert!(out.contains("interface Logger"), "got: {out}");
8418        assert!(out.contains("log(msg: string): void"), "got: {out}");
8419    }
8420
8421    // ── Ownership erasure ───────────────────────────────────────────────────
8422
8423    #[test]
8424    fn ownership_erased() {
8425        let move_expr = node(
8426            1,
8427            NodeKind::Move {
8428                expr: Box::new(id_node(2, "x")),
8429            },
8430        );
8431        let borrow_expr = node(
8432            3,
8433            NodeKind::Borrow {
8434                expr: Box::new(id_node(4, "y")),
8435            },
8436        );
8437        let stmts = vec![
8438            node(
8439                5,
8440                NodeKind::LetBinding {
8441                    is_mut: false,
8442                    pattern: Box::new(bind_pat(6, "a")),
8443                    ty: None,
8444                    value: Box::new(move_expr),
8445                },
8446            ),
8447            node(
8448                7,
8449                NodeKind::LetBinding {
8450                    is_mut: false,
8451                    pattern: Box::new(bind_pat(8, "b")),
8452                    ty: None,
8453                    value: Box::new(borrow_expr),
8454                },
8455            ),
8456        ];
8457        let f = node(
8458            9,
8459            NodeKind::FnDecl {
8460                annotations: vec![],
8461                visibility: Visibility::Private,
8462                is_async: false,
8463                name: ident("test"),
8464                generic_params: vec![],
8465                params: vec![],
8466                return_type: None,
8467                effect_clause: vec![],
8468                where_clause: vec![],
8469                body: Box::new(block(10, stmts, None)),
8470            },
8471        );
8472        let out = gen(&module(vec![], vec![f]));
8473        assert!(out.contains("const a = x;"), "got: {out}");
8474        assert!(out.contains("const b = y;"), "got: {out}");
8475    }
8476
8477    // ── String interpolation ────────────────────────────────────────────────
8478
8479    #[test]
8480    fn string_interpolation() {
8481        let interp = node(
8482            1,
8483            NodeKind::Interpolation {
8484                parts: vec![
8485                    AirInterpolationPart::Literal("Hello, ".into()),
8486                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
8487                    AirInterpolationPart::Literal("!".into()),
8488                ],
8489            },
8490        );
8491        let stmt = node(
8492            3,
8493            NodeKind::LetBinding {
8494                is_mut: false,
8495                pattern: Box::new(bind_pat(4, "msg")),
8496                ty: None,
8497                value: Box::new(interp),
8498            },
8499        );
8500        let f = node(
8501            5,
8502            NodeKind::FnDecl {
8503                annotations: vec![],
8504                visibility: Visibility::Private,
8505                is_async: false,
8506                name: ident("test"),
8507                generic_params: vec![],
8508                params: vec![],
8509                return_type: None,
8510                effect_clause: vec![],
8511                where_clause: vec![],
8512                body: Box::new(block(6, vec![stmt], None)),
8513            },
8514        );
8515        let out = gen(&module(vec![], vec![f]));
8516        // Rendered through `__bockStr` so a user `Displayable` value dispatches
8517        // through its `to_string` (Q-displayable-interpolation-dispatch).
8518        assert!(out.contains("`Hello, ${__bockStr(name)}!`"), "got: {out}");
8519    }
8520
8521    // ── Collections ─────────────────────────────────────────────────────────
8522
8523    #[test]
8524    fn collections() {
8525        let list = node(
8526            1,
8527            NodeKind::ListLiteral {
8528                elems: vec![int_lit(2, "1"), int_lit(3, "2"), int_lit(4, "3")],
8529            },
8530        );
8531        let map = node(
8532            5,
8533            NodeKind::MapLiteral {
8534                entries: vec![bock_air::AirMapEntry {
8535                    key: str_lit(6, "a"),
8536                    value: int_lit(7, "1"),
8537                }],
8538            },
8539        );
8540        let set = node(
8541            8,
8542            NodeKind::SetLiteral {
8543                elems: vec![int_lit(9, "1"), int_lit(10, "2")],
8544            },
8545        );
8546        let stmts = vec![
8547            node(
8548                11,
8549                NodeKind::LetBinding {
8550                    is_mut: false,
8551                    pattern: Box::new(bind_pat(12, "xs")),
8552                    ty: None,
8553                    value: Box::new(list),
8554                },
8555            ),
8556            node(
8557                13,
8558                NodeKind::LetBinding {
8559                    is_mut: false,
8560                    pattern: Box::new(bind_pat(14, "m")),
8561                    ty: None,
8562                    value: Box::new(map),
8563                },
8564            ),
8565            node(
8566                15,
8567                NodeKind::LetBinding {
8568                    is_mut: false,
8569                    pattern: Box::new(bind_pat(16, "s")),
8570                    ty: None,
8571                    value: Box::new(set),
8572                },
8573            ),
8574        ];
8575        let f = node(
8576            17,
8577            NodeKind::FnDecl {
8578                annotations: vec![],
8579                visibility: Visibility::Private,
8580                is_async: false,
8581                name: ident("test"),
8582                generic_params: vec![],
8583                params: vec![],
8584                return_type: None,
8585                effect_clause: vec![],
8586                where_clause: vec![],
8587                body: Box::new(block(18, stmts, None)),
8588            },
8589        );
8590        let out = gen(&module(vec![], vec![f]));
8591        assert!(out.contains("[1, 2, 3]"), "got: {out}");
8592        assert!(out.contains("new Map("), "got: {out}");
8593        assert!(out.contains("new Set("), "got: {out}");
8594    }
8595
8596    // ── Result types with as const ──────────────────────────────────────────
8597
8598    #[test]
8599    fn result_construct_has_as_const() {
8600        let ok = node(
8601            1,
8602            NodeKind::ResultConstruct {
8603                variant: ResultVariant::Ok,
8604                value: Some(Box::new(int_lit(2, "42"))),
8605            },
8606        );
8607        let stmt = node(
8608            3,
8609            NodeKind::LetBinding {
8610                is_mut: false,
8611                pattern: Box::new(bind_pat(4, "r")),
8612                ty: None,
8613                value: Box::new(ok),
8614            },
8615        );
8616        let f = node(
8617            5,
8618            NodeKind::FnDecl {
8619                annotations: vec![],
8620                visibility: Visibility::Private,
8621                is_async: false,
8622                name: ident("test"),
8623                generic_params: vec![],
8624                params: vec![],
8625                return_type: None,
8626                effect_clause: vec![],
8627                where_clause: vec![],
8628                body: Box::new(block(6, vec![stmt], None)),
8629            },
8630        );
8631        let out = gen(&module(vec![], vec![f]));
8632        // Reconciled on the `_0` payload key the `Result` match reads.
8633        assert!(
8634            out.contains("{ _tag: \"Ok\" as const, _0: 42 }"),
8635            "got: {out}"
8636        );
8637    }
8638
8639    // ── Record construct ────────────────────────────────────────────────────
8640
8641    #[test]
8642    fn record_construct() {
8643        let rc = node(
8644            1,
8645            NodeKind::RecordConstruct {
8646                path: type_path(&["Point"]),
8647                fields: vec![
8648                    AirRecordField {
8649                        name: ident("x"),
8650                        value: Some(Box::new(int_lit(2, "1"))),
8651                    },
8652                    AirRecordField {
8653                        name: ident("y"),
8654                        value: Some(Box::new(int_lit(3, "2"))),
8655                    },
8656                ],
8657                spread: None,
8658            },
8659        );
8660        let stmt = node(
8661            4,
8662            NodeKind::LetBinding {
8663                is_mut: false,
8664                pattern: Box::new(bind_pat(5, "p")),
8665                ty: None,
8666                value: Box::new(rc),
8667            },
8668        );
8669        let f = node(
8670            6,
8671            NodeKind::FnDecl {
8672                annotations: vec![],
8673                visibility: Visibility::Private,
8674                is_async: false,
8675                name: ident("test"),
8676                generic_params: vec![],
8677                params: vec![],
8678                return_type: None,
8679                effect_clause: vec![],
8680                where_clause: vec![],
8681                body: Box::new(block(7, vec![stmt], None)),
8682            },
8683        );
8684        let out = gen(&module(vec![], vec![f]));
8685        assert!(out.contains("{ x: 1, y: 2 }"), "got: {out}");
8686    }
8687
8688    #[test]
8689    fn to_camel_case_converts_snake_case() {
8690        assert_eq!(to_camel_case("create_user"), "createUser");
8691        assert_eq!(to_camel_case("get_all_items"), "getAllItems");
8692        assert_eq!(to_camel_case("Log"), "log");
8693        assert_eq!(to_camel_case("createUser"), "createUser");
8694        assert_eq!(to_camel_case("_"), "_");
8695        assert_eq!(to_camel_case(""), "");
8696    }
8697
8698    #[test]
8699    fn snake_case_fn_becomes_camel_case_ts() {
8700        let body = block(2, vec![], Some(int_lit(3, "42")));
8701        let f = node(
8702            1,
8703            NodeKind::FnDecl {
8704                annotations: vec![],
8705                visibility: Visibility::Private,
8706                is_async: false,
8707                name: ident("create_user"),
8708                generic_params: vec![],
8709                params: vec![typed_param_node(4, "name", "String")],
8710                return_type: Some(Box::new(type_node(5, "Int"))),
8711                effect_clause: vec![],
8712                where_clause: vec![],
8713                body: Box::new(body),
8714            },
8715        );
8716        let out = gen(&module(vec![], vec![f]));
8717        assert!(
8718            out.contains("function createUser("),
8719            "expected camelCase function name, got: {out}"
8720        );
8721        assert!(
8722            out.contains("name: string"),
8723            "expected type annotations, got: {out}"
8724        );
8725    }
8726
8727    // ── Prelude function mapping tests ──────────────────────────────────────
8728
8729    /// Helper: generate TypeScript for a module with a `main` function containing a single call.
8730    fn gen_prelude_call(func_name: &str, arg: AIRNode) -> String {
8731        let call = node(
8732            10,
8733            NodeKind::Call {
8734                callee: Box::new(id_node(11, func_name)),
8735                args: vec![AirArg {
8736                    label: None,
8737                    value: arg,
8738                }],
8739                type_args: vec![],
8740            },
8741        );
8742        let body = block(2, vec![call], None);
8743        let f = node(
8744            1,
8745            NodeKind::FnDecl {
8746                name: ident("main"),
8747                params: vec![],
8748                return_type: None,
8749                body: Box::new(body),
8750                generic_params: vec![],
8751                visibility: Visibility::Private,
8752                annotations: vec![],
8753                effect_clause: vec![],
8754                where_clause: vec![],
8755                is_async: false,
8756            },
8757        );
8758        gen(&module(vec![], vec![f]))
8759    }
8760
8761    /// Helper: generate TypeScript for a nullary prelude call (no args).
8762    fn gen_prelude_call_no_args(func_name: &str) -> String {
8763        let call = node(
8764            10,
8765            NodeKind::Call {
8766                callee: Box::new(id_node(11, func_name)),
8767                args: vec![],
8768                type_args: vec![],
8769            },
8770        );
8771        let body = block(2, vec![call], None);
8772        let f = node(
8773            1,
8774            NodeKind::FnDecl {
8775                name: ident("main"),
8776                params: vec![],
8777                return_type: None,
8778                body: Box::new(body),
8779                generic_params: vec![],
8780                visibility: Visibility::Private,
8781                annotations: vec![],
8782                effect_clause: vec![],
8783                where_clause: vec![],
8784                is_async: false,
8785            },
8786        );
8787        gen(&module(vec![], vec![f]))
8788    }
8789
8790    #[test]
8791    fn prelude_println_maps_to_console_log() {
8792        let out = gen_prelude_call("println", str_lit(12, "hello"));
8793        assert!(
8794            out.contains("console.log("),
8795            "println should map to console.log, got: {out}"
8796        );
8797        assert!(
8798            !out.contains("println("),
8799            "should not emit bare println(, got: {out}"
8800        );
8801    }
8802
8803    #[test]
8804    fn prelude_print_maps_to_process_stdout() {
8805        let out = gen_prelude_call("print", str_lit(12, "hello"));
8806        assert!(
8807            out.contains("process.stdout.write(String("),
8808            "print should map to process.stdout.write, got: {out}"
8809        );
8810    }
8811
8812    #[test]
8813    fn prelude_debug_maps_to_console_debug() {
8814        let out = gen_prelude_call("debug", str_lit(12, "val"));
8815        assert!(
8816            out.contains("console.debug("),
8817            "debug should map to console.debug, got: {out}"
8818        );
8819    }
8820
8821    #[test]
8822    fn prelude_assert_maps_to_throw() {
8823        let arg = node(
8824            12,
8825            NodeKind::Literal {
8826                lit: Literal::Bool(true),
8827            },
8828        );
8829        let out = gen_prelude_call("assert", arg);
8830        assert!(
8831            out.contains("if (!true) throw new Error(\"assertion failed\")"),
8832            "assert should map to if-throw, got: {out}"
8833        );
8834    }
8835
8836    #[test]
8837    fn prelude_todo_maps_to_throw_not_implemented() {
8838        let out = gen_prelude_call_no_args("todo");
8839        assert!(
8840            out.contains("throw new Error(\"not implemented\")"),
8841            "todo should map to throw, got: {out}"
8842        );
8843    }
8844
8845    #[test]
8846    fn prelude_unreachable_maps_to_throw_unreachable() {
8847        let out = gen_prelude_call_no_args("unreachable");
8848        assert!(
8849            out.contains("throw new Error(\"unreachable\")"),
8850            "unreachable should map to throw, got: {out}"
8851        );
8852    }
8853
8854    #[test]
8855    fn non_prelude_call_passes_through() {
8856        let out = gen_prelude_call("my_custom_func", str_lit(12, "arg"));
8857        assert!(
8858            out.contains("myCustomFunc("),
8859            "non-prelude call should use camelCase, got: {out}"
8860        );
8861    }
8862
8863    #[test]
8864    fn handling_block_passes_handlers_to_effectful_call() {
8865        use bock_air::AirHandlerPair;
8866
8867        let effect_decl = node(
8868            1,
8869            NodeKind::EffectDecl {
8870                annotations: vec![],
8871                visibility: Visibility::Public,
8872                name: ident("Logger"),
8873                generic_params: vec![],
8874                components: vec![],
8875                operations: vec![node(
8876                    2,
8877                    NodeKind::FnDecl {
8878                        annotations: vec![],
8879                        visibility: Visibility::Public,
8880                        is_async: false,
8881                        name: ident("log"),
8882                        generic_params: vec![],
8883                        params: vec![typed_param_node(3, "msg", "String")],
8884                        return_type: None,
8885                        effect_clause: vec![],
8886                        where_clause: vec![],
8887                        body: Box::new(block(4, vec![], None)),
8888                    },
8889                )],
8890            },
8891        );
8892
8893        let inner_fn = node(
8894            10,
8895            NodeKind::FnDecl {
8896                annotations: vec![],
8897                visibility: Visibility::Private,
8898                is_async: false,
8899                name: ident("inner"),
8900                generic_params: vec![],
8901                params: vec![],
8902                return_type: None,
8903                effect_clause: vec![type_path(&["Logger"])],
8904                where_clause: vec![],
8905                body: Box::new(block(12, vec![], Some(str_lit(13, "hello")))),
8906            },
8907        );
8908
8909        let call_inner = node(
8910            20,
8911            NodeKind::Call {
8912                callee: Box::new(id_node(21, "inner")),
8913                args: vec![],
8914                type_args: vec![],
8915            },
8916        );
8917        let handling = node(
8918            30,
8919            NodeKind::HandlingBlock {
8920                handlers: vec![AirHandlerPair {
8921                    effect: type_path(&["Logger"]),
8922                    handler: Box::new(node(
8923                        31,
8924                        NodeKind::Call {
8925                            callee: Box::new(id_node(32, "StdoutLogger")),
8926                            args: vec![],
8927                            type_args: vec![],
8928                        },
8929                    )),
8930                }],
8931                body: Box::new(block(33, vec![], Some(call_inner))),
8932            },
8933        );
8934        let main_fn = node(
8935            40,
8936            NodeKind::FnDecl {
8937                annotations: vec![],
8938                visibility: Visibility::Private,
8939                is_async: false,
8940                name: ident("main"),
8941                generic_params: vec![],
8942                params: vec![],
8943                return_type: None,
8944                effect_clause: vec![],
8945                where_clause: vec![],
8946                body: Box::new(block(41, vec![handling], None)),
8947            },
8948        );
8949
8950        let out = gen(&module(vec![], vec![effect_decl, inner_fn, main_fn]));
8951        // TS: inner({ logger: __logger })
8952        assert!(
8953            out.contains("inner({ logger: __logger })"),
8954            "handling block should pass handler to effectful call, got: {out}"
8955        );
8956        assert!(
8957            out.contains("const __logger: Logger = stdoutLogger()"),
8958            "handling block should instantiate handler with type, got: {out}"
8959        );
8960    }
8961
8962    #[test]
8963    fn sibling_handling_blocks_do_not_share_let_scope() {
8964        use bock_air::AirHandlerPair;
8965
8966        // Two *sibling* `handling` blocks, each `let part = …` under the SAME
8967        // name. Each block lowers to its own `{ … }` TS lexical scope, so both
8968        // must declare a fresh `const part` — neither may be rewritten into a
8969        // bare `part = …` assignment against the other (a name that went out of
8970        // scope when the first block closed; `tsc` would reject it as TS2304).
8971        // Regression for Q-js-handling-let-redeclaration.
8972        let make_handling = |id: u32, val: &str| {
8973            node(
8974                id,
8975                NodeKind::HandlingBlock {
8976                    handlers: vec![AirHandlerPair {
8977                        effect: type_path(&["Logger"]),
8978                        handler: Box::new(node(
8979                            id + 1,
8980                            NodeKind::Call {
8981                                callee: Box::new(id_node(id + 2, "StdoutLogger")),
8982                                args: vec![],
8983                                type_args: vec![],
8984                            },
8985                        )),
8986                    }],
8987                    body: Box::new(block(
8988                        id + 3,
8989                        vec![node(
8990                            id + 4,
8991                            NodeKind::LetBinding {
8992                                is_mut: false,
8993                                pattern: Box::new(bind_pat(id + 5, "part")),
8994                                ty: None,
8995                                value: Box::new(str_lit(id + 6, val)),
8996                            },
8997                        )],
8998                        Some(id_node(id + 7, "part")),
8999                    )),
9000                },
9001            )
9002        };
9003        let main_fn = node(
9004            40,
9005            NodeKind::FnDecl {
9006                annotations: vec![],
9007                visibility: Visibility::Private,
9008                is_async: false,
9009                name: ident("main"),
9010                generic_params: vec![],
9011                params: vec![],
9012                return_type: None,
9013                effect_clause: vec![],
9014                where_clause: vec![],
9015                body: Box::new(block(
9016                    41,
9017                    vec![make_handling(50, "first"), make_handling(70, "second")],
9018                    None,
9019                )),
9020            },
9021        );
9022
9023        let out = gen(&module(vec![], vec![main_fn]));
9024        assert_eq!(
9025            out.matches("const part = ").count(),
9026            2,
9027            "each sibling handling block should declare its own `const part`, got: {out}"
9028        );
9029        assert!(
9030            !out.contains("\n  part = "),
9031            "no sibling handling block may rewrite its `let part` into a bare \
9032             assignment, got: {out}"
9033        );
9034    }
9035
9036    #[test]
9037    fn record_becomes_class() {
9038        let rec = node(
9039            1,
9040            NodeKind::RecordDecl {
9041                annotations: vec![],
9042                visibility: Visibility::Public,
9043                name: ident("ConsoleLogger"),
9044                generic_params: vec![],
9045                fields: vec![],
9046            },
9047        );
9048        let out = gen(&module(vec![], vec![rec]));
9049        assert!(
9050            out.contains("export class ConsoleLogger {}"),
9051            "empty record should be an empty exported class, got: {out}"
9052        );
9053    }
9054
9055    #[test]
9056    fn impl_emits_interface_extension_for_declaration_merging() {
9057        use bock_air::AirHandlerPair;
9058        let _ = AirHandlerPair {
9059            effect: type_path(&["X"]),
9060            handler: Box::new(id_node(0, "x")),
9061        };
9062
9063        let effect_decl = node(
9064            1,
9065            NodeKind::EffectDecl {
9066                annotations: vec![],
9067                visibility: Visibility::Public,
9068                name: ident("Logger"),
9069                generic_params: vec![],
9070                components: vec![],
9071                operations: vec![node(
9072                    2,
9073                    NodeKind::FnDecl {
9074                        annotations: vec![],
9075                        visibility: Visibility::Public,
9076                        is_async: false,
9077                        name: ident("log"),
9078                        generic_params: vec![],
9079                        params: vec![typed_param_node(3, "msg", "String")],
9080                        return_type: None,
9081                        effect_clause: vec![],
9082                        where_clause: vec![],
9083                        body: Box::new(block(4, vec![], None)),
9084                    },
9085                )],
9086            },
9087        );
9088
9089        let rec = node(
9090            5,
9091            NodeKind::RecordDecl {
9092                annotations: vec![],
9093                visibility: Visibility::Public,
9094                name: ident("StdLogger"),
9095                generic_params: vec![],
9096                fields: vec![],
9097            },
9098        );
9099
9100        let impl_block = node(
9101            10,
9102            NodeKind::ImplBlock {
9103                annotations: vec![],
9104                trait_path: Some(type_path(&["Logger"])),
9105                trait_args: vec![],
9106                target: Box::new(type_node(11, "StdLogger")),
9107                generic_params: vec![],
9108                methods: vec![node(
9109                    12,
9110                    NodeKind::FnDecl {
9111                        annotations: vec![],
9112                        visibility: Visibility::Public,
9113                        is_async: false,
9114                        name: ident("log"),
9115                        generic_params: vec![],
9116                        // An instance method leads with `self` (as real lowering
9117                        // produces). A method with no `self` is an associated
9118                        // function, emitted as a `namespace` static, not an
9119                        // instance member on the merged interface.
9120                        params: vec![
9121                            untyped_param_node(15, "self"),
9122                            typed_param_node(13, "msg", "String"),
9123                        ],
9124                        return_type: None,
9125                        effect_clause: vec![],
9126                        where_clause: vec![],
9127                        body: Box::new(block(14, vec![], None)),
9128                    },
9129                )],
9130                where_clause: vec![],
9131            },
9132        );
9133
9134        let out = gen(&module(vec![], vec![effect_decl, rec, impl_block]));
9135        assert!(
9136            out.contains("interface StdLogger extends Logger {"),
9137            "impl should emit interface extension for declaration merging, got: {out}"
9138        );
9139        // The merged interface also declares the concrete method signatures so
9140        // `x.log(...)` call sites resolve against the class type.
9141        assert!(
9142            out.contains("log(msg: string): void;"),
9143            "merged interface should declare the method signature, got: {out}"
9144        );
9145        assert!(
9146            out.contains("StdLogger.prototype.log"),
9147            "impl should attach method to prototype, got: {out}"
9148        );
9149    }
9150
9151    #[test]
9152    fn generic_trait_impl_extends_clause_carries_type_args() {
9153        // GAP-A: `impl P[T] for R[T]` for a generic `trait P[T]` must emit
9154        // `interface R<T> extends P<T>` — the `extends` clause carries the
9155        // impl's trait type-args. Without them `tsc` rejects with TS2314.
9156        let rec = node(
9157            1,
9158            NodeKind::RecordDecl {
9159                annotations: vec![],
9160                visibility: Visibility::Public,
9161                name: ident("R"),
9162                generic_params: vec![make_generic_param("T")],
9163                fields: vec![make_record_field("v", "T")],
9164            },
9165        );
9166        let impl_block = node(
9167            10,
9168            NodeKind::ImplBlock {
9169                annotations: vec![],
9170                trait_path: Some(type_path(&["P"])),
9171                trait_args: vec![node(
9172                    11,
9173                    NodeKind::TypeNamed {
9174                        path: type_path(&["T"]),
9175                        args: vec![],
9176                    },
9177                )],
9178                target: Box::new(node(
9179                    12,
9180                    NodeKind::TypeNamed {
9181                        path: type_path(&["R"]),
9182                        args: vec![node(
9183                            13,
9184                            NodeKind::TypeNamed {
9185                                path: type_path(&["T"]),
9186                                args: vec![],
9187                            },
9188                        )],
9189                    },
9190                )),
9191                generic_params: vec![],
9192                methods: vec![node(
9193                    14,
9194                    NodeKind::FnDecl {
9195                        annotations: vec![],
9196                        visibility: Visibility::Public,
9197                        is_async: false,
9198                        name: ident("f"),
9199                        generic_params: vec![],
9200                        params: vec![untyped_param_node(15, "self")],
9201                        return_type: None,
9202                        effect_clause: vec![],
9203                        where_clause: vec![],
9204                        body: Box::new(block(16, vec![], None)),
9205                    },
9206                )],
9207                where_clause: vec![],
9208            },
9209        );
9210        let out = gen(&module(vec![], vec![rec, impl_block]));
9211        assert!(
9212            out.contains("interface R<T> extends P<T> {"),
9213            "generic trait impl's extends clause must carry `<T>`, got: {out}"
9214        );
9215    }
9216
9217    #[test]
9218    fn optional_named_type_maps_to_bock_option() {
9219        // The spelled-out `Optional[T]` named type must lower to `BockOption<T>`
9220        // (matching the `T?` shorthand and the emitted tagged value), not a
9221        // bare `Optional<T>` (TS2304 undefined name).
9222        let ctx = TsEmitCtx::new();
9223        let opt = node(
9224            1,
9225            NodeKind::TypeNamed {
9226                path: type_path(&["Optional"]),
9227                args: vec![node(
9228                    2,
9229                    NodeKind::TypeNamed {
9230                        path: type_path(&["String"]),
9231                        args: vec![],
9232                    },
9233                )],
9234            },
9235        );
9236        assert_eq!(ctx.type_to_ts(&opt), "BockOption<string>");
9237    }
9238
9239    #[test]
9240    fn impl_self_method_typed_and_declaration_merged() {
9241        // Q-ts-codegen defect 1: an inherent impl method with a bare `self`
9242        // receiver. The AIR keeps `self` as a real param and prepends the
9243        // receiver at call sites. TS must (a) type `self` as the impl target
9244        // (no implicit `any`) and (b) declare the method on the class via a
9245        // merged interface so `p.sum(p)` type-checks.
9246        let rec = node(
9247            1,
9248            NodeKind::RecordDecl {
9249                annotations: vec![],
9250                visibility: Visibility::Public,
9251                name: ident("Point"),
9252                generic_params: vec![],
9253                fields: vec![make_record_field("x", "Int"), make_record_field("y", "Int")],
9254            },
9255        );
9256        let body = block(
9257            20,
9258            vec![],
9259            Some(node(
9260                21,
9261                NodeKind::BinaryOp {
9262                    op: BinOp::Add,
9263                    left: Box::new(node(
9264                        22,
9265                        NodeKind::FieldAccess {
9266                            object: Box::new(id_node(23, "self")),
9267                            field: ident("x"),
9268                        },
9269                    )),
9270                    right: Box::new(node(
9271                        24,
9272                        NodeKind::FieldAccess {
9273                            object: Box::new(id_node(25, "self")),
9274                            field: ident("y"),
9275                        },
9276                    )),
9277                },
9278            )),
9279        );
9280        let impl_block = node(
9281            10,
9282            NodeKind::ImplBlock {
9283                annotations: vec![],
9284                trait_path: None,
9285                trait_args: vec![],
9286                target: Box::new(type_node(11, "Point")),
9287                generic_params: vec![],
9288                methods: vec![node(
9289                    12,
9290                    NodeKind::FnDecl {
9291                        annotations: vec![],
9292                        visibility: Visibility::Public,
9293                        is_async: false,
9294                        name: ident("sum"),
9295                        generic_params: vec![],
9296                        params: vec![untyped_param_node(13, "self")],
9297                        return_type: Some(Box::new(type_node(14, "Int"))),
9298                        effect_clause: vec![],
9299                        where_clause: vec![],
9300                        body: Box::new(body),
9301                    },
9302                )],
9303                where_clause: vec![],
9304            },
9305        );
9306        let out = gen(&module(vec![], vec![rec, impl_block]));
9307        assert!(
9308            out.contains("interface Point {"),
9309            "inherent impl should emit a declaration-merging interface, got: {out}"
9310        );
9311        assert!(
9312            out.contains("sum(self: Point): number;"),
9313            "merged interface should declare the self-typed method, got: {out}"
9314        );
9315        assert!(
9316            out.contains("Point.prototype.sum = function(self: Point): number {"),
9317            "prototype function should type the self param as the target, got: {out}"
9318        );
9319    }
9320
9321    /// A plain inherent `impl` method that names `Self` in its return AND its
9322    /// parameter type must render `Self` as the concrete target (`Counter`), not
9323    /// the `this` type. Each impl method emits as a free prototype function
9324    /// (`Counter.prototype.m = function(...): this`), and `tsc` rejects a `this`
9325    /// type outside a class/interface member (TS2526). Before the P4 fix
9326    /// `trait_self_subst` was set only for synthesized trait *default* methods;
9327    /// an inherent-impl `Self` lowered to `this`. Both the merged-interface
9328    /// signature and the prototype function must agree, or declaration merging
9329    /// breaks.
9330    #[test]
9331    fn self_in_plain_impl_resolves_to_target_not_this() {
9332        let rec = node(
9333            1,
9334            NodeKind::RecordDecl {
9335                annotations: vec![],
9336                visibility: Visibility::Public,
9337                name: ident("Counter"),
9338                generic_params: vec![],
9339                fields: vec![make_record_field("value", "Int")],
9340            },
9341        );
9342        let other_param = node(
9343            30,
9344            NodeKind::Param {
9345                pattern: Box::new(bind_pat(31, "other")),
9346                ty: Some(Box::new(node(32, NodeKind::TypeSelf))),
9347                default: None,
9348            },
9349        );
9350        let impl_block = node(
9351            10,
9352            NodeKind::ImplBlock {
9353                annotations: vec![],
9354                trait_path: None,
9355                trait_args: vec![],
9356                target: Box::new(type_node(11, "Counter")),
9357                generic_params: vec![],
9358                methods: vec![node(
9359                    12,
9360                    NodeKind::FnDecl {
9361                        annotations: vec![],
9362                        visibility: Visibility::Public,
9363                        is_async: false,
9364                        name: ident("combine"),
9365                        generic_params: vec![],
9366                        params: vec![untyped_param_node(13, "self"), other_param],
9367                        return_type: Some(Box::new(node(14, NodeKind::TypeSelf))),
9368                        effect_clause: vec![],
9369                        where_clause: vec![],
9370                        body: Box::new(block(15, vec![], None)),
9371                    },
9372                )],
9373                where_clause: vec![],
9374            },
9375        );
9376        let out = gen(&module(vec![], vec![rec, impl_block]));
9377        assert!(
9378            !out.contains(": this"),
9379            "Self must not lower to the `this` type (TS2526), got: {out}"
9380        );
9381        assert!(
9382            out.contains("combine(self: Counter, other: Counter): Counter;"),
9383            "merged interface should render Self as the target in param & return, got: {out}"
9384        );
9385        assert!(
9386            out.contains(
9387                "Counter.prototype.combine = function(self: Counter, other: Counter): Counter {"
9388            ),
9389            "prototype function should render Self as the target in param & return, got: {out}"
9390        );
9391    }
9392
9393    #[test]
9394    fn optional_runtime_prelude_and_value_type_agree() {
9395        // Q-ts-codegen defect 2: the Optional *type* and *value* must agree.
9396        // A function returning `Int?` gets `BockOption<number>`, the prelude
9397        // type is emitted, and `Some`/`None` lower to the matching tagged
9398        // objects.
9399        let body = block(
9400            20,
9401            vec![],
9402            Some(node(
9403                21,
9404                NodeKind::Call {
9405                    callee: Box::new(id_node(22, "Some")),
9406                    args: vec![AirArg {
9407                        label: None,
9408                        value: int_lit(23, "7"),
9409                    }],
9410                    type_args: vec![],
9411                },
9412            )),
9413        );
9414        let f = node(
9415            1,
9416            NodeKind::FnDecl {
9417                annotations: vec![],
9418                visibility: Visibility::Private,
9419                is_async: false,
9420                name: ident("pick"),
9421                generic_params: vec![],
9422                params: vec![],
9423                return_type: Some(Box::new(node(
9424                    2,
9425                    NodeKind::TypeOptional {
9426                        inner: Box::new(type_node(3, "Int")),
9427                    },
9428                ))),
9429                effect_clause: vec![],
9430                where_clause: vec![],
9431                body: Box::new(body),
9432            },
9433        );
9434        let out = gen(&module(vec![], vec![f]));
9435        assert!(
9436            out.contains("type BockOption<T> ="),
9437            "Optional runtime type prelude should be emitted, got: {out}"
9438        );
9439        assert!(
9440            out.contains("): BockOption<number> {"),
9441            "Optional return type should be BockOption<number>, got: {out}"
9442        );
9443        assert!(
9444            out.contains("{ _tag: \"Some\" as const, _0: 7 }"),
9445            "Some should lower to the matching tagged-object value, got: {out}"
9446        );
9447    }
9448
9449    /// A `match` whose scrutinee is a call (not a bare identifier) must hoist it
9450    /// into a single `const __matchN = …;`. Re-emitting the call inline at the
9451    /// switch head and in each payload binding both double-evaluated it and
9452    /// defeated TS discriminated-union narrowing (TS2339 on `_0`, since
9453    /// `f()._0` is a fresh, un-narrowed expression). The hoisted temp is a
9454    /// stable reference TS narrows correctly.
9455    #[test]
9456    fn match_call_scrutinee_hoisted_to_temp() {
9457        // match f() { Some(x) => x; None => 0 }
9458        let scrutinee = node(
9459            10,
9460            NodeKind::Call {
9461                callee: Box::new(id_node(11, "f")),
9462                args: vec![],
9463                type_args: vec![],
9464            },
9465        );
9466        let some_arm = node(
9467            20,
9468            NodeKind::MatchArm {
9469                pattern: Box::new(node(
9470                    21,
9471                    NodeKind::ConstructorPat {
9472                        path: type_path(&["Some"]),
9473                        fields: vec![bind_pat(22, "x")],
9474                    },
9475                )),
9476                guard: None,
9477                body: Box::new(block(23, vec![], Some(id_node(24, "x")))),
9478            },
9479        );
9480        let none_arm = node(
9481            30,
9482            NodeKind::MatchArm {
9483                pattern: Box::new(node(
9484                    31,
9485                    NodeKind::ConstructorPat {
9486                        path: type_path(&["None"]),
9487                        fields: vec![],
9488                    },
9489                )),
9490                guard: None,
9491                body: Box::new(block(32, vec![], Some(int_lit(33, "0")))),
9492            },
9493        );
9494        let match_stmt = node(
9495            40,
9496            NodeKind::Match {
9497                scrutinee: Box::new(scrutinee),
9498                arms: vec![some_arm, none_arm],
9499            },
9500        );
9501        let f = node(
9502            1,
9503            NodeKind::FnDecl {
9504                annotations: vec![],
9505                visibility: Visibility::Private,
9506                is_async: false,
9507                name: ident("run"),
9508                generic_params: vec![],
9509                params: vec![],
9510                return_type: None,
9511                effect_clause: vec![],
9512                where_clause: vec![],
9513                body: Box::new(block(2, vec![match_stmt], None)),
9514            },
9515        );
9516        let out = gen(&module(vec![], vec![f]));
9517        assert!(
9518            out.contains("const __match1 = f();"),
9519            "call scrutinee should be hoisted to a temp, got: {out}"
9520        );
9521        assert!(
9522            out.contains("switch (__match1._tag)"),
9523            "switch should dispatch on the hoisted temp, got: {out}"
9524        );
9525        assert!(
9526            out.contains(
9527                r#"const x = (__match1 as Extract<typeof __match1, { _tag: "Some" }>)._0;"#
9528            ),
9529            "payload binding should read the hoisted temp through the narrowing cast, got: {out}"
9530        );
9531        // The call must not be re-emitted inline (single evaluation).
9532        assert!(
9533            !out.contains("f()._tag") && !out.contains("f()._0"),
9534            "call scrutinee must not be re-emitted inline, got: {out}"
9535        );
9536    }
9537
9538    /// Q-match-exprpos (P4): an expression-position value `match` over a bare
9539    /// identifier, bound into a typed `let`. The IIFE arrow is annotated with the
9540    /// binding type (`(() : boolean => …)()`), and the bare scrutinee is hoisted
9541    /// into a temp (`const __matchN = n; switch (__matchN) …`) so the `switch`
9542    /// narrows the temp — not the original `n`. Without the hoist, `switch (n)`
9543    /// narrows `n` to the case literal inside each arm, so an arm body
9544    /// re-referencing `n` (`n === <other-literal>`) trips TS2367.
9545    #[test]
9546    fn expr_position_value_match_hoists_scrutinee_and_annotates_iife() {
9547        // let flag: Bool = match n { 0 => n; _ => n }   (in a fn returning Int)
9548        let zero_arm = node(
9549            20,
9550            NodeKind::MatchArm {
9551                pattern: Box::new(node(
9552                    21,
9553                    NodeKind::LiteralPat {
9554                        lit: Literal::Int("0".into()),
9555                    },
9556                )),
9557                guard: None,
9558                body: Box::new(block(22, vec![], Some(id_node(23, "n")))),
9559            },
9560        );
9561        let default_arm = node(
9562            30,
9563            NodeKind::MatchArm {
9564                pattern: Box::new(node(31, NodeKind::WildcardPat)),
9565                guard: None,
9566                body: Box::new(block(32, vec![], Some(id_node(33, "n")))),
9567            },
9568        );
9569        let m = node(
9570            40,
9571            NodeKind::Match {
9572                scrutinee: Box::new(id_node(41, "n")),
9573                arms: vec![zero_arm, default_arm],
9574            },
9575        );
9576        let let_flag = node(
9577            50,
9578            NodeKind::LetBinding {
9579                is_mut: false,
9580                pattern: Box::new(bind_pat(51, "flag")),
9581                ty: Some(Box::new(type_node(52, "Int"))),
9582                value: Box::new(m),
9583            },
9584        );
9585        let f = node(
9586            1,
9587            NodeKind::FnDecl {
9588                annotations: vec![],
9589                visibility: Visibility::Private,
9590                is_async: false,
9591                name: ident("classify"),
9592                generic_params: vec![],
9593                params: vec![{
9594                    node(
9595                        2,
9596                        NodeKind::Param {
9597                            pattern: Box::new(bind_pat(3, "n")),
9598                            ty: Some(Box::new(type_node(4, "Int"))),
9599                            default: None,
9600                        },
9601                    )
9602                }],
9603                return_type: Some(Box::new(type_node(5, "Int"))),
9604                effect_clause: vec![],
9605                where_clause: vec![],
9606                body: Box::new(block(6, vec![let_flag], Some(id_node(7, "flag")))),
9607            },
9608        );
9609        let out = gen(&module(vec![], vec![f]));
9610        assert!(
9611            out.contains("(() : number => {"),
9612            "value-position match IIFE arrow should be annotated with the binding type, got: {out}"
9613        );
9614        assert!(
9615            out.contains("const __match1 = n;"),
9616            "bare-identifier scrutinee must be hoisted in expression position, got: {out}"
9617        );
9618        assert!(
9619            out.contains("switch (__match1)"),
9620            "switch should dispatch on the hoisted temp, not the original binding, got: {out}"
9621        );
9622    }
9623
9624    // ── Generic impl interface-merge (DV12 / P1-b2) ───────────────────────────
9625
9626    fn generic_param(id: u32, name: &str) -> GenericParam {
9627        GenericParam {
9628            id,
9629            span: span(),
9630            name: ident(name),
9631            bounds: vec![],
9632        }
9633    }
9634
9635    fn named_type(id: u32, name: &str) -> AIRNode {
9636        node(
9637            id,
9638            NodeKind::TypeNamed {
9639                path: type_path(&[name]),
9640                args: vec![],
9641            },
9642        )
9643    }
9644
9645    /// `record Box[T] { value: T }`.
9646    fn generic_box_record() -> AIRNode {
9647        node(
9648            10,
9649            NodeKind::RecordDecl {
9650                annotations: vec![],
9651                visibility: Visibility::Private,
9652                name: ident("Box"),
9653                generic_params: vec![generic_param(11, "T")],
9654                fields: vec![bock_ast::RecordDeclField {
9655                    id: 12,
9656                    span: span(),
9657                    name: ident("value"),
9658                    ty: TypeExpr::Named {
9659                        id: 13,
9660                        span: span(),
9661                        path: type_path(&["T"]),
9662                        args: vec![],
9663                    },
9664                    default: None,
9665                }],
9666            },
9667        )
9668    }
9669
9670    /// `impl Box { fn get(self) -> T { return self.value } }`.
9671    fn generic_box_impl() -> AIRNode {
9672        let self_param = node(
9673            20,
9674            NodeKind::Param {
9675                pattern: Box::new(bind_pat(21, "self")),
9676                ty: None,
9677                default: None,
9678            },
9679        );
9680        let body = block(
9681            22,
9682            vec![],
9683            Some(node(
9684                23,
9685                NodeKind::Return {
9686                    value: Some(Box::new(node(
9687                        24,
9688                        NodeKind::FieldAccess {
9689                            object: Box::new(id_node(25, "self")),
9690                            field: ident("value"),
9691                        },
9692                    ))),
9693                },
9694            )),
9695        );
9696        let method = node(
9697            26,
9698            NodeKind::FnDecl {
9699                annotations: vec![],
9700                visibility: Visibility::Private,
9701                is_async: false,
9702                name: ident("get"),
9703                generic_params: vec![],
9704                params: vec![self_param],
9705                return_type: Some(Box::new(named_type(27, "T"))),
9706                effect_clause: vec![],
9707                where_clause: vec![],
9708                body: Box::new(body),
9709            },
9710        );
9711        node(
9712            30,
9713            NodeKind::ImplBlock {
9714                annotations: vec![],
9715                generic_params: vec![],
9716                trait_path: None,
9717                trait_args: vec![],
9718                target: Box::new(named_type(31, "Box")),
9719                where_clause: vec![],
9720                methods: vec![method],
9721            },
9722        )
9723    }
9724
9725    #[test]
9726    fn generic_impl_merges_onto_generic_class() {
9727        // `impl Box { ... }` for `record Box[T]` must declaration-merge onto the
9728        // generic class: `interface Box<T>`, `self: Box<T>`, and a prototype
9729        // function that re-declares `<T>` (it lives outside the class scope).
9730        let out = gen(&module(
9731            vec![],
9732            vec![generic_box_record(), generic_box_impl()],
9733        ));
9734        assert!(
9735            out.contains("interface Box<T> {"),
9736            "merged interface should carry `<T>`, got: {out}"
9737        );
9738        assert!(
9739            out.contains("get(self: Box<T>): T;"),
9740            "interface signature should type `self` as `Box<T>`, got: {out}"
9741        );
9742        assert!(
9743            out.contains("Box.prototype.get = function<T>(self: Box<T>): T {"),
9744            "prototype function should re-declare `<T>` and reference `Box.prototype`, got: {out}"
9745        );
9746    }
9747
9748    #[test]
9749    fn method_colliding_with_field_is_disambiguated() {
9750        // trait Error { fn message(self) -> String }
9751        let trait_decl = node(
9752            1,
9753            NodeKind::TraitDecl {
9754                annotations: vec![],
9755                visibility: Visibility::Public,
9756                is_platform: false,
9757                name: ident("Error"),
9758                generic_params: vec![],
9759                associated_types: vec![],
9760                methods: vec![node(
9761                    2,
9762                    NodeKind::FnDecl {
9763                        annotations: vec![],
9764                        visibility: Visibility::Public,
9765                        is_async: false,
9766                        name: ident("message"),
9767                        generic_params: vec![],
9768                        params: vec![typed_param_node(3, "self", "Error")],
9769                        return_type: Some(Box::new(node(
9770                            4,
9771                            NodeKind::TypeNamed {
9772                                path: type_path(&["String"]),
9773                                args: vec![],
9774                            },
9775                        ))),
9776                        effect_clause: vec![],
9777                        where_clause: vec![],
9778                        body: Box::new(block(5, vec![], None)),
9779                    },
9780                )],
9781            },
9782        );
9783        // record SimpleError { message: String }
9784        let record_decl = node(
9785            10,
9786            NodeKind::RecordDecl {
9787                annotations: vec![],
9788                visibility: Visibility::Public,
9789                name: ident("SimpleError"),
9790                generic_params: vec![],
9791                fields: vec![make_record_field("message", "String")],
9792            },
9793        );
9794        // impl Error for SimpleError { fn message(self) -> String { self.message } }
9795        let method = node(
9796            20,
9797            NodeKind::FnDecl {
9798                annotations: vec![],
9799                visibility: Visibility::Public,
9800                is_async: false,
9801                name: ident("message"),
9802                generic_params: vec![],
9803                params: vec![typed_param_node(21, "self", "SimpleError")],
9804                return_type: Some(Box::new(node(
9805                    22,
9806                    NodeKind::TypeNamed {
9807                        path: type_path(&["String"]),
9808                        args: vec![],
9809                    },
9810                ))),
9811                effect_clause: vec![],
9812                where_clause: vec![],
9813                body: Box::new(block(
9814                    23,
9815                    vec![],
9816                    Some(node(
9817                        24,
9818                        NodeKind::FieldAccess {
9819                            object: Box::new(id_node(25, "self")),
9820                            field: ident("message"),
9821                        },
9822                    )),
9823                )),
9824            },
9825        );
9826        let impl_block = node(
9827            30,
9828            NodeKind::ImplBlock {
9829                annotations: vec![],
9830                target: Box::new(node(
9831                    31,
9832                    NodeKind::TypeNamed {
9833                        path: type_path(&["SimpleError"]),
9834                        args: vec![],
9835                    },
9836                )),
9837                trait_path: Some(type_path(&["Error"])),
9838                trait_args: vec![],
9839                generic_params: vec![],
9840                where_clause: vec![],
9841                methods: vec![method],
9842            },
9843        );
9844        // fn read(e: SimpleError) -> String { e.message() }
9845        let read_fn = node(
9846            40,
9847            NodeKind::FnDecl {
9848                annotations: vec![],
9849                visibility: Visibility::Public,
9850                is_async: false,
9851                name: ident("read"),
9852                generic_params: vec![],
9853                params: vec![typed_param_node(41, "e", "SimpleError")],
9854                return_type: Some(Box::new(node(
9855                    42,
9856                    NodeKind::TypeNamed {
9857                        path: type_path(&["String"]),
9858                        args: vec![],
9859                    },
9860                ))),
9861                effect_clause: vec![],
9862                where_clause: vec![],
9863                body: Box::new(block(
9864                    43,
9865                    vec![],
9866                    Some(node(
9867                        44,
9868                        NodeKind::Call {
9869                            callee: Box::new(node(
9870                                45,
9871                                NodeKind::FieldAccess {
9872                                    // The lowerer reuses the *same* receiver node
9873                                    // in both the field-access object and the self
9874                                    // arg; `desugared_self_call` keys on the shared
9875                                    // NodeId, so the test must too.
9876                                    object: Box::new(id_node(46, "e")),
9877                                    field: ident("message"),
9878                                },
9879                            )),
9880                            type_args: vec![],
9881                            args: vec![AirArg {
9882                                label: None,
9883                                value: id_node(46, "e"),
9884                            }],
9885                        },
9886                    )),
9887                )),
9888            },
9889        );
9890        let out = gen(&module(
9891            vec![],
9892            vec![trait_decl, record_decl, impl_block, read_fn],
9893        ));
9894        // The class field stays `message`.
9895        assert!(
9896            out.contains("message: string;"),
9897            "class field should remain `message: string`, got: {out}"
9898        );
9899        // The trait interface, merged interface, and prototype rename to
9900        // `messageMethod` so the field and method no longer share an identifier.
9901        assert!(
9902            out.contains("messageMethod(self: Error): string;"),
9903            "trait interface should declare `messageMethod`, got: {out}"
9904        );
9905        assert!(
9906            out.contains("messageMethod(self: SimpleError): string;"),
9907            "merged interface should declare `messageMethod`, got: {out}"
9908        );
9909        assert!(
9910            out.contains("SimpleError.prototype.messageMethod = "),
9911            "prototype method should be `messageMethod`, got: {out}"
9912        );
9913        // Call site renamed; field read in the body untouched.
9914        assert!(
9915            out.contains(".messageMethod(e)"),
9916            "call site should be `.messageMethod(e)`, got: {out}"
9917        );
9918        assert!(
9919            out.contains("return self.message;"),
9920            "method body should read the field `self.message`, got: {out}"
9921        );
9922        // No bare duplicate-identifier `message(...)` method declaration remains.
9923        assert!(
9924            !out.contains("message(self: SimpleError)"),
9925            "must NOT emit a `message(...)` method colliding with the field, got: {out}"
9926        );
9927    }
9928
9929    /// `fn f() { let x = if (c) { 1 } else { return 0 }  x }` — value-position
9930    /// `if` with a diverging else. The shared value-CF hoist lowers it to a
9931    /// declare-then-assign temp, never `/* unsupported */` or an IIFE capturing
9932    /// the `return`.
9933    fn diverging_value_if_fn() -> AIRNode {
9934        let then_b = block(2, vec![], Some(int_lit(3, "1")));
9935        let ret = node(
9936            5,
9937            NodeKind::Return {
9938                value: Some(Box::new(int_lit(6, "0"))),
9939            },
9940        );
9941        let else_b = block(4, vec![], Some(ret));
9942        let if_node = node(
9943            1,
9944            NodeKind::If {
9945                let_pattern: None,
9946                condition: Box::new(id_node(7, "c")),
9947                then_block: Box::new(then_b),
9948                else_block: Some(Box::new(else_b)),
9949            },
9950        );
9951        let let_x = node(
9952            10,
9953            NodeKind::LetBinding {
9954                is_mut: false,
9955                pattern: Box::new(bind_pat(11, "x")),
9956                ty: None,
9957                value: Box::new(if_node),
9958            },
9959        );
9960        let body = block(20, vec![let_x], Some(id_node(21, "x")));
9961        let f = node(
9962            30,
9963            NodeKind::FnDecl {
9964                annotations: vec![],
9965                visibility: Visibility::Private,
9966                is_async: false,
9967                name: ident("f"),
9968                generic_params: vec![],
9969                params: vec![],
9970                return_type: None,
9971                effect_clause: vec![],
9972                where_clause: vec![],
9973                body: Box::new(body),
9974            },
9975        );
9976        module(vec![], vec![f])
9977    }
9978
9979    #[test]
9980    fn diverging_value_if_hoists_to_stmt_form_no_iife() {
9981        let out = gen(&diverging_value_if_fn());
9982        assert!(
9983            !out.contains("/* unsupported */"),
9984            "diverging value-if must not emit `/* unsupported */`, got: {out}"
9985        );
9986        assert!(
9987            out.contains("bockCf0 = 1"),
9988            "value arm must assign the temp, got: {out}"
9989        );
9990        assert!(
9991            out.contains("return 0"),
9992            "diverging arm must keep its return (not wrapped in an IIFE), got: {out}"
9993        );
9994    }
9995
9996    // ── ts codegen fixes (examples audit): guard-let, let-shadow, `?` ─────────
9997
9998    /// `let [mut] name = value` binding node.
9999    fn ts_let_binding(id: u32, name: &str, is_mut: bool, value: AIRNode) -> AIRNode {
10000        node(
10001            id,
10002            NodeKind::LetBinding {
10003                is_mut,
10004                pattern: Box::new(node(
10005                    id + 1,
10006                    NodeKind::BindPat {
10007                        name: ident(name),
10008                        is_mut,
10009                    },
10010                )),
10011                ty: None,
10012                value: Box::new(value),
10013            },
10014        )
10015    }
10016
10017    /// A no-arg call `callee()`.
10018    fn ts_call(id: u32, callee: &str) -> AIRNode {
10019        node(
10020            id,
10021            NodeKind::Call {
10022                callee: Box::new(id_node(id + 1, callee)),
10023                args: vec![],
10024                type_args: vec![],
10025            },
10026        )
10027    }
10028
10029    /// A `fn name(params) -> ret { body }` declaration. `ret` is an optional
10030    /// return-type name (e.g. `Result`/`Optional`) for the `?` lowering tests.
10031    fn ts_fn_decl(
10032        id: u32,
10033        name: &str,
10034        params: Vec<AIRNode>,
10035        ret: Option<&str>,
10036        body: AIRNode,
10037    ) -> AIRNode {
10038        node(
10039            id,
10040            NodeKind::FnDecl {
10041                annotations: vec![],
10042                visibility: Visibility::Private,
10043                is_async: false,
10044                name: ident(name),
10045                generic_params: vec![],
10046                params,
10047                return_type: ret.map(|r| Box::new(type_node(id + 500, r))),
10048                effect_clause: vec![],
10049                where_clause: vec![],
10050                body: Box::new(body),
10051            },
10052        )
10053    }
10054
10055    #[test]
10056    fn ts_guard_let_binds_into_enclosing_scope() {
10057        // fn run() { guard (let Ok(guess) = parse()) else { return }; guess }
10058        let guard = node(
10059            10,
10060            NodeKind::Guard {
10061                let_pattern: Some(Box::new(node(
10062                    11,
10063                    NodeKind::ConstructorPat {
10064                        path: type_path(&["Ok"]),
10065                        fields: vec![bind_pat(12, "guess")],
10066                    },
10067                ))),
10068                condition: Box::new(ts_call(13, "parse")),
10069                else_block: Box::new(block(
10070                    15,
10071                    vec![node(16, NodeKind::Return { value: None })],
10072                    None,
10073                )),
10074            },
10075        );
10076        let body = block(2, vec![guard], Some(id_node(20, "guess")));
10077        let out = gen(&module(
10078            vec![],
10079            vec![ts_fn_decl(1, "run", vec![], None, body)],
10080        ));
10081        // The guard evaluates the scrutinee once into a temp, tests the Ok tag,
10082        // diverges in the else, then binds `guess` into the enclosing scope.
10083        assert!(
10084            out.contains("const __guard1 = parse();"),
10085            "guard-let must evaluate the scrutinee once into a temp, got: {out}"
10086        );
10087        assert!(
10088            out.contains("if (!(__guard1._tag === \"Ok\"))"),
10089            "guard-let must test the pattern's tag, got: {out}"
10090        );
10091        assert!(
10092            out.contains("const guess = __guard1._0;"),
10093            "guard-let must bind the payload into the enclosing scope, got: {out}"
10094        );
10095        // The binding must NOT be inside the else block (it follows the guard).
10096        let bind_pos = out.find("const guess = __guard1._0;").unwrap();
10097        let else_pos = out.find("if (!(__guard1._tag").unwrap();
10098        assert!(
10099            bind_pos > else_pos,
10100            "the binding must follow the guard's else, got: {out}"
10101        );
10102    }
10103
10104    #[test]
10105    fn ts_let_shadow_rebinds_to_assignment_not_redeclaration() {
10106        // fn run() { let acc = 1; let acc = 2; let acc = 3 }
10107        let body = block(
10108            2,
10109            vec![
10110                ts_let_binding(10, "acc", false, int_lit(11, "1")),
10111                ts_let_binding(20, "acc", false, int_lit(21, "2")),
10112                ts_let_binding(30, "acc", false, int_lit(31, "3")),
10113            ],
10114            None,
10115        );
10116        let out = gen(&module(
10117            vec![],
10118            vec![ts_fn_decl(1, "run", vec![], None, body)],
10119        ));
10120        // First binding declares `let` (re-bound later), subsequent ones assign.
10121        assert!(
10122            out.contains("let acc = 1;"),
10123            "first re-bound `let` should declare with `let`, got: {out}"
10124        );
10125        assert!(
10126            out.contains("acc = 2;") && out.contains("acc = 3;"),
10127            "later bindings should be plain assignments, got: {out}"
10128        );
10129        assert!(
10130            !out.contains("const acc"),
10131            "a re-bound binding must not emit `const acc` (TS2451), got: {out}"
10132        );
10133        // Exactly one declaration of `acc` (the first `let`); no redeclaration.
10134        assert_eq!(
10135            out.matches("let acc").count(),
10136            1,
10137            "exactly one `let acc` declaration expected, got: {out}"
10138        );
10139    }
10140
10141    #[test]
10142    fn ts_let_shadow_of_param_lowers_to_assignment() {
10143        // fn run(x: Int) { let x = 1; x }  — `let x` shadows the param in the
10144        // same TS block scope, so it must be an assignment, not a redeclaration.
10145        let body = block(
10146            2,
10147            vec![ts_let_binding(10, "x", false, int_lit(11, "1"))],
10148            Some(id_node(20, "x")),
10149        );
10150        let f = ts_fn_decl(1, "run", vec![typed_param_node(3, "x", "Int")], None, body);
10151        let out = gen(&module(vec![], vec![f]));
10152        assert!(
10153            out.contains("x = 1;") && !out.contains("const x = 1;") && !out.contains("let x = 1;"),
10154            "a `let` shadowing a param must lower to assignment, got: {out}"
10155        );
10156    }
10157
10158    #[test]
10159    fn ts_propagate_let_unwraps_and_early_returns_result() {
10160        // fn run() -> Result { let v = f()?; v }
10161        let body = block(
10162            2,
10163            vec![ts_let_binding(
10164                10,
10165                "v",
10166                false,
10167                node(
10168                    12,
10169                    NodeKind::Propagate {
10170                        expr: Box::new(ts_call(13, "f")),
10171                    },
10172                ),
10173            )],
10174            Some(id_node(20, "v")),
10175        );
10176        let out = gen(&module(
10177            vec![],
10178            vec![ts_fn_decl(1, "run", vec![], Some("Result"), body)],
10179        ));
10180        assert!(
10181            out.contains("const __prop1 = f();"),
10182            "`?` must evaluate the inner once into a temp, got: {out}"
10183        );
10184        // Result return → single `Err` discriminant (preserves TS narrowing).
10185        assert!(
10186            out.contains("if (__prop1._tag === \"Err\") {"),
10187            "`?` in a Result fn must guard on the Err tag, got: {out}"
10188        );
10189        assert!(
10190            out.contains("return __prop1 as never;"),
10191            "`?` must early-return the failure container, got: {out}"
10192        );
10193        assert!(
10194            out.contains("const v = __prop1._0;"),
10195            "`?` must bind the unwrapped payload, got: {out}"
10196        );
10197    }
10198
10199    #[test]
10200    fn ts_propagate_optional_guards_on_none_tag() {
10201        // fn run() -> Optional { let v = f()?; v }
10202        let body = block(
10203            2,
10204            vec![ts_let_binding(
10205                10,
10206                "v",
10207                false,
10208                node(
10209                    12,
10210                    NodeKind::Propagate {
10211                        expr: Box::new(ts_call(13, "f")),
10212                    },
10213                ),
10214            )],
10215            Some(id_node(20, "v")),
10216        );
10217        let out = gen(&module(
10218            vec![],
10219            vec![ts_fn_decl(1, "run", vec![], Some("Optional"), body)],
10220        ));
10221        assert!(
10222            out.contains("if (__prop1._tag === \"None\") {"),
10223            "`?` in an Optional fn must guard on the None tag, got: {out}"
10224        );
10225    }
10226
10227    #[test]
10228    fn ts_propagate_bare_statement_early_returns_discards_payload() {
10229        // fn run() -> Result { f()?; g() }  — the `?` value is discarded but the
10230        // early-return guard still fires.
10231        let body = block(
10232            2,
10233            vec![node(
10234                10,
10235                NodeKind::Propagate {
10236                    expr: Box::new(ts_call(11, "f")),
10237                },
10238            )],
10239            Some(ts_call(20, "g")),
10240        );
10241        let out = gen(&module(
10242            vec![],
10243            vec![ts_fn_decl(1, "run", vec![], Some("Result"), body)],
10244        ));
10245        assert!(
10246            out.contains("const __prop1 = f();"),
10247            "a bare `expr?` statement must still hoist + guard, got: {out}"
10248        );
10249        assert!(
10250            out.contains("if (__prop1._tag === \"Err\") {")
10251                && out.contains("return __prop1 as never;"),
10252            "a bare `expr?` must early-return on failure, got: {out}"
10253        );
10254        // The discarded payload is not bound to anything.
10255        assert!(
10256            !out.contains("__prop1._0"),
10257            "a bare `expr?` discards the payload (no `._0` use), got: {out}"
10258        );
10259    }
10260
10261    /// A list-pattern `match` (`[] / [only] / [first, ..rest]`) must route to the
10262    /// if-chain (the shared recogniser now flags `ListPat`), with a length test
10263    /// per arm and positional element / `..rest` slice binds — not the broken
10264    /// `switch` fast-path (duplicate `default:`, unbound `only`/`first`/`rest`).
10265    #[test]
10266    fn ts_list_pattern_match_lowers_to_ifchain_with_binds() {
10267        // match items { [] => "e"; [only] => only?; [first, ..rest] => first? }
10268        let empty_arm = node(
10269            20,
10270            NodeKind::MatchArm {
10271                pattern: Box::new(node(
10272                    21,
10273                    NodeKind::ListPat {
10274                        elems: vec![],
10275                        rest: None,
10276                    },
10277                )),
10278                guard: None,
10279                body: Box::new(block(22, vec![], Some(str_lit(23, "empty")))),
10280            },
10281        );
10282        let single_arm = node(
10283            30,
10284            NodeKind::MatchArm {
10285                pattern: Box::new(node(
10286                    31,
10287                    NodeKind::ListPat {
10288                        elems: vec![bind_pat(32, "only")],
10289                        rest: None,
10290                    },
10291                )),
10292                guard: None,
10293                body: Box::new(block(33, vec![], Some(id_node(34, "only")))),
10294            },
10295        );
10296        let head_rest_arm = node(
10297            40,
10298            NodeKind::MatchArm {
10299                pattern: Box::new(node(
10300                    41,
10301                    NodeKind::ListPat {
10302                        elems: vec![bind_pat(42, "first")],
10303                        rest: Some(Box::new(bind_pat(43, "rest"))),
10304                    },
10305                )),
10306                guard: None,
10307                body: Box::new(block(44, vec![], Some(id_node(45, "first")))),
10308            },
10309        );
10310        // A trailing wildcard keeps `[first, ..rest]` a *conditional* arm so its
10311        // `length >= 1` test is emitted (a final arm becomes the bare `else`).
10312        let else_arm = node(
10313            46,
10314            NodeKind::MatchArm {
10315                pattern: Box::new(node(47, NodeKind::WildcardPat)),
10316                guard: None,
10317                body: Box::new(block(48, vec![], Some(str_lit(49, "other")))),
10318            },
10319        );
10320        let match_stmt = node(
10321            50,
10322            NodeKind::Match {
10323                scrutinee: Box::new(id_node(51, "items")),
10324                arms: vec![empty_arm, single_arm, head_rest_arm, else_arm],
10325            },
10326        );
10327        let f = ts_fn_decl(
10328            1,
10329            "describeList",
10330            vec![typed_param_node(2, "items", "Array")],
10331            Some("String"),
10332            block(3, vec![match_stmt], None),
10333        );
10334        let out = gen(&module(vec![], vec![f]));
10335        // No switch fast-path; arms are an if/else-if chain.
10336        assert!(
10337            !out.contains("switch ("),
10338            "list-pattern match must not use the switch fast-path, got: {out}"
10339        );
10340        // Empty list: exact-length test.
10341        assert!(
10342            out.contains(".length === 0"),
10343            "`[]` arm should test length === 0, got: {out}"
10344        );
10345        // `[only]`: length 1 and binds `only`.
10346        assert!(
10347            out.contains(".length === 1") && out.contains("const only = "),
10348            "`[only]` should test length === 1 and bind `only`, got: {out}"
10349        );
10350        // `[first, ..rest]`: length >= 1, binds `first` and `rest` (a slice).
10351        assert!(
10352            out.contains(".length >= 1"),
10353            "`[first, ..rest]` should test length >= 1, got: {out}"
10354        );
10355        assert!(
10356            out.contains("const first = ")
10357                && out.contains("const rest = ")
10358                && out.contains(".slice(1)"),
10359            "`[first, ..rest]` should bind `first` and a `rest = …slice(1)`, got: {out}"
10360        );
10361    }
10362
10363    /// A range-pattern `match` (`1..10`, `100..=200`) must route to the if-chain
10364    /// and emit a relational bounds test (`>= lo && < hi` exclusive, `<=` for
10365    /// inclusive) — not a `switch` whose every arm collapses to `default:`.
10366    #[test]
10367    fn ts_range_pattern_match_lowers_to_ifchain_with_bounds() {
10368        // match n { 1..10 => "a"; 10..=20 => "b"; _ => "c" }
10369        let lo_arm = node(
10370            20,
10371            NodeKind::MatchArm {
10372                pattern: Box::new(node(
10373                    21,
10374                    NodeKind::RangePat {
10375                        lo: Box::new(int_lit(22, "1")),
10376                        hi: Box::new(int_lit(23, "10")),
10377                        inclusive: false,
10378                    },
10379                )),
10380                guard: None,
10381                body: Box::new(block(24, vec![], Some(str_lit(25, "a")))),
10382            },
10383        );
10384        let hi_arm = node(
10385            30,
10386            NodeKind::MatchArm {
10387                pattern: Box::new(node(
10388                    31,
10389                    NodeKind::RangePat {
10390                        lo: Box::new(int_lit(32, "10")),
10391                        hi: Box::new(int_lit(33, "20")),
10392                        inclusive: true,
10393                    },
10394                )),
10395                guard: None,
10396                body: Box::new(block(34, vec![], Some(str_lit(35, "b")))),
10397            },
10398        );
10399        let else_arm = node(
10400            40,
10401            NodeKind::MatchArm {
10402                pattern: Box::new(node(41, NodeKind::WildcardPat)),
10403                guard: None,
10404                body: Box::new(block(42, vec![], Some(str_lit(43, "c")))),
10405            },
10406        );
10407        let match_stmt = node(
10408            50,
10409            NodeKind::Match {
10410                scrutinee: Box::new(id_node(51, "n")),
10411                arms: vec![lo_arm, hi_arm, else_arm],
10412            },
10413        );
10414        let f = ts_fn_decl(
10415            1,
10416            "classifyRange",
10417            vec![typed_param_node(2, "n", "Int")],
10418            Some("String"),
10419            block(3, vec![match_stmt], None),
10420        );
10421        let out = gen(&module(vec![], vec![f]));
10422        assert!(
10423            !out.contains("switch ("),
10424            "range-pattern match must not use the switch fast-path, got: {out}"
10425        );
10426        // Exclusive `1..10` → `>= 1 && < 10`.
10427        assert!(
10428            out.contains(">= 1") && out.contains("< 10"),
10429            "`1..10` should test `>= 1 && < 10`, got: {out}"
10430        );
10431        // Inclusive `10..=20` → `>= 10 && <= 20`.
10432        assert!(
10433            out.contains(">= 10") && out.contains("<= 20"),
10434            "`10..=20` should test `>= 10 && <= 20`, got: {out}"
10435        );
10436    }
10437
10438    /// Q-ts-match-narrowing: a constructor-pattern arm's payload binding casts
10439    /// the scrutinee through `Extract<typeof s, { _tag: "<variant>" }>` before
10440    /// reading `._N`. TS control-flow narrowing on `s._tag` reaches the arm body
10441    /// *only while the arm is reachable*; when an earlier statement-position
10442    /// `match` has every arm `return`, TS marks everything after it unreachable
10443    /// and stops narrowing, so a later arm's `const x = s._0` widens back to the
10444    /// full union payload (`T | E`) — passing it to a `T`-typed callee is TS2345.
10445    /// The `Extract` cast pins the binding to the matched variant's payload type
10446    /// regardless of reachability, so it never widens.
10447    #[test]
10448    fn ts_match_narrow_payload_binding_casts_through_extract() {
10449        // match r { Ok(x) => x; Err(e) => e }  over a bare identifier `r`.
10450        let ok_arm = node(
10451            20,
10452            NodeKind::MatchArm {
10453                pattern: Box::new(node(
10454                    21,
10455                    NodeKind::ConstructorPat {
10456                        path: type_path(&["Ok"]),
10457                        fields: vec![bind_pat(22, "x")],
10458                    },
10459                )),
10460                guard: None,
10461                body: Box::new(block(23, vec![], Some(id_node(24, "x")))),
10462            },
10463        );
10464        let err_arm = node(
10465            30,
10466            NodeKind::MatchArm {
10467                pattern: Box::new(node(
10468                    31,
10469                    NodeKind::ConstructorPat {
10470                        path: type_path(&["Err"]),
10471                        fields: vec![bind_pat(32, "e")],
10472                    },
10473                )),
10474                guard: None,
10475                body: Box::new(block(33, vec![], Some(id_node(34, "e")))),
10476            },
10477        );
10478        let match_stmt = node(
10479            40,
10480            NodeKind::Match {
10481                scrutinee: Box::new(id_node(41, "r")),
10482                arms: vec![ok_arm, err_arm],
10483            },
10484        );
10485        let f = ts_fn_decl(
10486            1,
10487            "run",
10488            vec![typed_param_node(2, "r", "Result")],
10489            None,
10490            block(3, vec![match_stmt], None),
10491        );
10492        let out = gen(&module(vec![], vec![f]));
10493        assert!(
10494            out.contains(r#"const x = (r as Extract<typeof r, { _tag: "Ok" }>)._0;"#),
10495            "Ok-arm payload binding should cast through Extract for narrowing, got: {out}"
10496        );
10497        assert!(
10498            out.contains(r#"const e = (r as Extract<typeof r, { _tag: "Err" }>)._0;"#),
10499            "Err-arm payload binding should cast through Extract for narrowing, got: {out}"
10500        );
10501    }
10502
10503    // ── Statement-position tails must be discarded, not `return`ed ───────────
10504    //
10505    // A loop / statement-`if` / statement-`match` body's final expression is
10506    // discarded in Bock (these are statements, not the function's value). The
10507    // TS backend's `emit_block_body` had treated the tail as a function-body
10508    // return, so e.g. `for i in … { println(i) }` emitted
10509    // `for (… ) { return console.log(i); }` — the `return` aborts the function
10510    // on the first iteration (the loop runs once, then the fn exits). These
10511    // tests pin the discard behaviour for each statement context.
10512
10513    /// Build a `println(<arg>)` call node.
10514    fn println_call(id: u32, arg: AIRNode) -> AIRNode {
10515        node(
10516            id,
10517            NodeKind::Call {
10518                callee: Box::new(id_node(id + 1, "println")),
10519                args: vec![AirArg {
10520                    label: None,
10521                    value: arg,
10522                }],
10523                type_args: vec![],
10524            },
10525        )
10526    }
10527
10528    #[test]
10529    fn ts_for_loop_body_tail_call_is_discarded_not_returned() {
10530        // fn main() { for i in 1..=3 { println(i) } }
10531        let range = node(
10532            20,
10533            NodeKind::Range {
10534                lo: Box::new(int_lit(21, "1")),
10535                hi: Box::new(int_lit(22, "3")),
10536                inclusive: true,
10537            },
10538        );
10539        let loop_body = block(30, vec![], Some(println_call(31, id_node(33, "i"))));
10540        let for_loop = node(
10541            10,
10542            NodeKind::For {
10543                pattern: Box::new(bind_pat(11, "i")),
10544                iterable: Box::new(range),
10545                body: Box::new(loop_body),
10546            },
10547        );
10548        let f = ts_fn_decl(1, "main", vec![], None, block(2, vec![for_loop], None));
10549        let out = gen(&module(vec![], vec![f]));
10550        assert!(
10551            !out.contains("return console.log"),
10552            "a for-loop body's tail call must be a discarded statement, not a \
10553             `return` (which aborts the loop after one iteration); got:\n{out}"
10554        );
10555        assert!(
10556            out.contains("console.log(i);"),
10557            "the loop body should still emit the call as a statement; got:\n{out}"
10558        );
10559    }
10560
10561    #[test]
10562    fn ts_while_loop_body_tail_call_is_discarded_not_returned() {
10563        // fn main() { while (true) { println("x") } }
10564        let cond = node(
10565            20,
10566            NodeKind::Literal {
10567                lit: Literal::Bool(true),
10568            },
10569        );
10570        let loop_body = block(30, vec![], Some(println_call(31, str_lit(33, "x"))));
10571        let while_loop = node(
10572            10,
10573            NodeKind::While {
10574                condition: Box::new(cond),
10575                body: Box::new(loop_body),
10576            },
10577        );
10578        let f = ts_fn_decl(1, "main", vec![], None, block(2, vec![while_loop], None));
10579        let out = gen(&module(vec![], vec![f]));
10580        assert!(
10581            !out.contains("return console.log"),
10582            "a while-loop body's tail call must be a discarded statement, not a \
10583             `return`; got:\n{out}"
10584        );
10585    }
10586
10587    #[test]
10588    fn ts_statement_match_arm_tail_call_is_discarded_not_returned() {
10589        // fn run(r: Result) { match r { Ok(v) => println(v); Err(e) => println(e) } }
10590        // — the `match` is a non-tail statement (a sibling block follows), so its
10591        // arm tails are discarded, not returned.
10592        let ok_arm = node(
10593            20,
10594            NodeKind::MatchArm {
10595                pattern: Box::new(node(
10596                    21,
10597                    NodeKind::ConstructorPat {
10598                        path: type_path(&["Ok"]),
10599                        fields: vec![bind_pat(22, "v")],
10600                    },
10601                )),
10602                guard: None,
10603                body: Box::new(block(23, vec![], Some(println_call(24, id_node(26, "v"))))),
10604            },
10605        );
10606        let err_arm = node(
10607            30,
10608            NodeKind::MatchArm {
10609                pattern: Box::new(node(
10610                    31,
10611                    NodeKind::ConstructorPat {
10612                        path: type_path(&["Err"]),
10613                        fields: vec![bind_pat(32, "e")],
10614                    },
10615                )),
10616                guard: None,
10617                body: Box::new(block(33, vec![], Some(println_call(34, id_node(36, "e"))))),
10618            },
10619        );
10620        let match_stmt = node(
10621            40,
10622            NodeKind::Match {
10623                scrutinee: Box::new(id_node(41, "r")),
10624                arms: vec![ok_arm, err_arm],
10625            },
10626        );
10627        // A trailing statement makes the match non-tail (statement position).
10628        let trailer = println_call(50, str_lit(52, "done"));
10629        let f = ts_fn_decl(
10630            1,
10631            "run",
10632            vec![typed_param_node(2, "r", "Result")],
10633            None,
10634            block(3, vec![match_stmt], Some(trailer)),
10635        );
10636        let out = gen(&module(vec![], vec![f]));
10637        // The arm bodies must emit the call as a bare statement (`console.log(v);`),
10638        // never `return console.log(v);` — a `return` inside the `switch` would
10639        // skip the code that follows the match.
10640        assert!(
10641            out.contains("console.log(v);") && out.contains("console.log(e);"),
10642            "statement-position match arms should emit their tail call as a \
10643             discarded statement; got:\n{out}"
10644        );
10645        assert!(
10646            !out.contains("return console.log(v);") && !out.contains("return console.log(e);"),
10647            "a statement-position match arm's tail call must be a discarded \
10648             statement, not a `return` (which would skip the code after the \
10649             match); got:\n{out}"
10650        );
10651    }
10652
10653    // ── Cross-module enum-variant glob import (inventory-system) ─────────────
10654    //
10655    // `module main` does `use models.*` and references a bare enum variant
10656    // (`Electronics`) declared in `module models`. The variant emits as the
10657    // value-const `Category_Electronics`, which must be imported from
10658    // `./models.ts`. The shared import scan keys on the value-name but the AIR
10659    // references the bare source name; the shared collector
10660    // (`generator::implicit_esm_imports_for`) now probes the variant's bare
10661    // source name too, so the import is produced for js and ts alike.
10662
10663    /// A glob `use <path>.*` import AIR node.
10664    fn import_glob(id: u32, path: &[&str]) -> AIRNode {
10665        node(
10666            id,
10667            NodeKind::ImportDecl {
10668                path: bock_ast::ModulePath {
10669                    segments: path.iter().map(|s| ident(s)).collect(),
10670                    span: span(),
10671                },
10672                items: bock_ast::ImportItems::Glob,
10673            },
10674        )
10675    }
10676
10677    #[test]
10678    fn ts_glob_imported_enum_variant_is_imported() {
10679        // module models { public enum Category { Electronics Clothing } }
10680        let category_enum = node(
10681            40,
10682            NodeKind::EnumDecl {
10683                annotations: vec![],
10684                visibility: Visibility::Public,
10685                name: ident("Category"),
10686                generic_params: vec![],
10687                variants: vec![
10688                    node(
10689                        41,
10690                        NodeKind::EnumVariant {
10691                            name: ident("Electronics"),
10692                            payload: EnumVariantPayload::Unit,
10693                        },
10694                    ),
10695                    node(
10696                        42,
10697                        NodeKind::EnumVariant {
10698                            name: ident("Clothing"),
10699                            payload: EnumVariantPayload::Unit,
10700                        },
10701                    ),
10702                ],
10703            },
10704        );
10705        let models_mod = module_with_path(&["models"], vec![], vec![category_enum]);
10706
10707        // module main { use models.*  fn main() { let c = Electronics } }
10708        let let_c = node(
10709            10,
10710            NodeKind::LetBinding {
10711                is_mut: false,
10712                pattern: Box::new(bind_pat(11, "c")),
10713                ty: None,
10714                value: Box::new(id_node(12, "Electronics")),
10715            },
10716        );
10717        let main_fn = ts_fn_decl(1, "main", vec![], None, block(2, vec![let_c], None));
10718        let main_mod =
10719            module_with_path(&["main"], vec![import_glob(5, &["models"])], vec![main_fn]);
10720
10721        let gen = TsGenerator::new();
10722        let out = gen
10723            .generate_project(&[
10724                (&main_mod, std::path::Path::new("src/main.bock")),
10725                (&models_mod, std::path::Path::new("src/models.bock")),
10726            ])
10727            .unwrap();
10728        let main_file = out
10729            .files
10730            .iter()
10731            .find(|f| f.path == std::path::Path::new("main.ts"))
10732            .expect("main.ts emitted");
10733        assert!(
10734            main_file.content.contains("Category_Electronics")
10735                && main_file.content.contains(r#"from "./models.ts""#),
10736            "main.ts must import the glob-referenced enum variant \
10737             `Category_Electronics` from ./models.ts; got:\n{}",
10738            main_file.content
10739        );
10740        // It must be a value import (not `import type`) — the variant is a const.
10741        assert!(
10742            !main_file
10743                .content
10744                .contains("import type { Category_Electronics"),
10745            "the variant const must be a value import, not `import type`; got:\n{}",
10746            main_file.content
10747        );
10748    }
10749}