Skip to main content

bynk_emit/
emitter.rs

1//! TypeScript emission (spec §7, v0.1 §6, v0.2 §6).
2//!
3//! Walks the typed AST and writes a single TypeScript module.
4//!
5//! v0.2 lowering rules:
6//! - Refined-base types: branded type alias + constructor object with
7//!   `of`/`unsafe` (+ any user-declared methods).
8//! - Record types: TypeScript `interface` + namespace object with methods.
9//! - Sum types: discriminated-union type alias + namespace object with
10//!   variant constructors and methods.
11//! - Field access lowers to property access.
12//! - Method calls lower to `Type.method(receiver, args)` (UFCS).
13//! - `match` lowers to a switch on `.tag`; in tail position it inlines,
14//!   otherwise it becomes an IIFE.
15//! - `is` lowers to a tag check; bindings become `const` declarations
16//!   on the truthy side of `if`/`&&`.
17
18use std::cell::RefCell;
19use std::collections::{HashMap, HashSet};
20use std::fmt::Write as _;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24// P7.5 (#1307): relocated from `emitter/source_map.rs` to `bynk-ts`,
25// unchanged — `bynk-ts/src/source_map.rs`'s own module doc has the full
26// grounding for why this API stays as-is while `bynk-ts`'s own printer
27// gets a second, simpler way to use the same type.
28use bynk_ts::{SourceMapBuilder, TsType};
29
30use crate::project::{BuildTarget, EmitProjectCtx, ImportExt, UnitKind, UnitTable};
31use bynk_check::builtin_names::map_query;
32use bynk_check::builtin_names::methods::{
33    FOLD_EFF, FOR_EACH, PAR_TRAVERSE, PAR_TRAVERSE_ALL, PAR_TRAVERSE_TRY, RAW, TRAVERSE_ALL,
34    TRAVERSE_TRY,
35};
36use bynk_check::builtin_names::types::*;
37use bynk_check::checker::{CheckedProgram, ExprId, NamedKind, Ty, TyId, TypedCommons, Types};
38use bynk_ir::{IrItem, TypeShape};
39use bynk_ir::{block_uses_emit, walk_block_exprs};
40use bynk_lower::{
41    lower_capability_item_ir, lower_protocol_ir, lower_service_handler_signature_ir,
42    lower_store_field_shape_ir, lower_type_item_ir,
43};
44use bynk_syntax::ast::{
45    AgentDecl, BaseType, BinOp, Block, CommonsItem, Expr, ExprKind, FnDecl, FnName, Ident,
46    InterpPart, MatchBody, MessagesDecl, ObservationMatcher, Param, Pattern, PredKind, ServiceDecl,
47    Statement, TypeBody, TypeDecl, TypeRef, expr_children,
48};
49
50pub mod contracts;
51pub(crate) mod events_fanout;
52pub mod secrets;
53pub(crate) mod serialisation;
54pub mod toml_doc;
55pub(crate) mod workers;
56pub(crate) mod workers_entry;
57pub mod wrangler;
58
59pub(crate) use events_fanout::emit_events_fanout_do;
60pub(crate) use secrets::emit_secrets_manifest;
61pub use toml_doc::print_toml_document;
62pub(crate) use workers::emit_worker_compose;
63pub(crate) use workers_entry::emit_worker_entry;
64pub(crate) use wrangler::emit_wrangler_toml;
65
66mod lower;
67pub(crate) mod runtime_use;
68pub(crate) use lower::*;
69pub(crate) use runtime_use::RuntimeUse;
70pub(crate) mod emit;
71pub(crate) use bynk_check::icu::{self, *};
72pub(crate) use bynk_check::websocket;
73pub(crate) use emit::*;
74
75const INDENT_STEP: usize = 2;
76
77/// Emit the contents of `out/runtime.ts`. This module ships with every
78/// project so the per-context / per-test emissions can `import { Ok, Err,
79/// Some, None, ... }` from a single source. It includes:
80///
81/// - `Result`/`Option` discriminated unions (using `tag` for the
82///   discriminant — same shape user sum types lower to).
83/// - `ValidationError` (the record shape refined-value constructors return).
84/// - The `DurableObjectState`/`DurableObjectStorage` interfaces that agent
85///   classes consume, plus an `InMemoryStorage` implementation and a
86///   `makeTestState(name)` factory for use in test execution.
87///
88/// The content is identical across projects — there is no per-project
89/// tailoring. Dead code is harmless; tsc handles it.
90pub fn emit_runtime_module() -> String {
91    RUNTIME_TS.to_string()
92}
93
94/// The embedded runtime. This is a BUILD OUTPUT, not a hand-edited file: it is
95/// bundled from the focused TypeScript modules in `bynk-emit/runtime/src` by
96/// that package's `scripts/bundle.mjs`. Edit the modules there and run
97/// `npm run bundle` (CI's `runtime` job guards against drift); never edit this
98/// file by hand. Keeping it a committed artifact means `cargo build` stays
99/// Node-free and the emitter stays lockstep with the runtime it embeds.
100const RUNTIME_TS: &str = include_str!("emitter/runtime.ts");
101
102/// Events track, slice 2 (spine #936): the TS type of one buffered/fanned-out
103/// event — a handler's `__events` local, the `__eventsDispatch` deps field
104/// (service, agent, provider, and the Bundle/Workers dispatch closures that
105/// implement it), and the fan-out DO's `FanoutEvent` (`events_fanout.rs`) all
106/// declare this same shape independently, with no shared type today. Routing
107/// every Rust-side site through one constant means the envelope field can
108/// never drift by being added at 8 of 9 of them. The two TypeScript runtime
109/// sources that also declare it (`runtime/src/agent.ts`'s
110/// `dispatchToEventsFanout`, `runtime/src/boundary.ts`'s `deliverEvent`) are
111/// hand-edited files this constant cannot reach — keep them textually
112/// identical to this shape by hand; `cargo test -p bynkc --test
113/// events_workers_wiring` and `events_envelope_behaviour` both exercise every
114/// hop and would fail on a real mismatch.
115///
116/// #973: `runtime/src/boundary.ts`'s `deserialiseEventEnvelope` is a related
117/// but distinct hand-written piece — it validates the *inner* `envelope`
118/// object's shape at the receiving `/_bynk/event/` route, not this outer
119/// wire wrapper. Keep its field list in sync with the `envelope: { ... }`
120/// portion of this shape by hand; nothing generates either from the other.
121pub(crate) const EVENTS_WIRE_EVENT_TS_TYPE: &str = "{ type: string; payload: unknown; envelope: { eventId: string; publisherId: string; emittedAt: number; schemaVersion: number } }";
122
123/// Emit the contents of `out/tsconfig.json`. The CLI uses `tsc -p` against
124/// this when running `bynkc test`; users can also drive `tsc` against it
125/// directly to produce JS for deployment.
126pub fn emit_tsconfig() -> String {
127    TSCONFIG_JSON.to_string()
128}
129
130/// The `bynkc test --coverage` variant (#854): the same config with `sourceMap`
131/// enabled, so `tsc` emits the `.js.map`s the coverage remap consumes (hop 1,
132/// `.js` → emitted `.ts`). Kept coverage-only rather than folded into the
133/// default so a normal `bynkc test` / deployment `tsc` run ships no `.js.map`s.
134/// The runner overwrites the default `out/tsconfig.json` with this before `tsc`.
135pub fn emit_tsconfig_with_source_maps() -> String {
136    TSCONFIG_JSON.replace(
137        "\"outDir\": \"../out-js\",",
138        "\"sourceMap\": true,\n    \"outDir\": \"../out-js\",",
139    )
140}
141
142const TSCONFIG_JSON: &str = r#"{
143  "compilerOptions": {
144    "target": "ES2022",
145    "module": "NodeNext",
146    "moduleResolution": "NodeNext",
147    "strict": true,
148    "noImplicitAny": true,
149    "esModuleInterop": true,
150    "skipLibCheck": true,
151    "resolveJsonModule": true,
152    "isolatedModules": true,
153    "noEmit": false,
154    "outDir": "../out-js",
155    "rootDir": "."
156  },
157  "include": ["**/*.ts"]
158}
159"#;
160
161/// Compute the runtime import specifier for a module at `from_source`. For a
162/// file at `commerce/payment.ts` the runtime sits two levels up, so this
163/// returns `../runtime.js`; for a top-level file it returns `./runtime.js`.
164pub(crate) fn runtime_import_for(from_source: &Path, ext: ImportExt) -> String {
165    let depth = from_source
166        .parent()
167        .map(|p| {
168            p.components()
169                .filter(|c| matches!(c, std::path::Component::Normal(_)))
170                .count()
171        })
172        .unwrap_or(0);
173    let ext = ext.as_str();
174    if depth == 0 {
175        format!("./runtime.{ext}")
176    } else {
177        let prefix: String = "../".repeat(depth);
178        format!("{prefix}runtime.{ext}")
179    }
180}
181
182/// #1478: appends `stmts` to `out`, printing each at `depth` — the shared
183/// "consume a real-node-returning callee's own output" step. #1479 adds the
184/// `depth` parameter (was hardcoded to 0): `emit`/`emit_project`'s own
185/// converted callees are all module-level (depth 0), but `project/
186/// tests_emit.rs`'s own scaffold-body callees (e.g. `emit_ns_destructure`)
187/// print nested inside a generated function body, at that body's own depth.
188pub(crate) fn extend_printed_at(out: &mut String, stmts: Vec<bynk_ts::TsStmt>, depth: usize) {
189    for stmt in stmts {
190        out.push_str(&bynk_ts::print_stmt(&stmt, depth));
191    }
192}
193
194/// As [`extend_printed_at`], at depth 0 — every `emit`/`emit_project` call
195/// site's own shape.
196fn extend_printed(out: &mut String, stmts: Vec<bynk_ts::TsStmt>) {
197    extend_printed_at(out, stmts, 0);
198}
199
200/// #1480: [`extend_printed`]'s merge-aware sibling — appends `stmts` to
201/// `out` at depth 0 through `bynk_ts::print_stmt_and_merge` instead of a
202/// plain `print_stmt`, so any `nested_map` a statement carries (e.g.
203/// `emit_free_fn`'s own `Raw` function body) merges into `map` at its real
204/// print-time offset. A stmt with no `nested_map` (a doc comment, a blank
205/// separator) just prints normally — the merge check inside `render_stmt`
206/// is a no-op for it, so mixing merge-needing and merge-free statements in
207/// one `stmts` list is always safe.
208fn extend_printed_and_merged(
209    out: &mut String,
210    stmts: Vec<bynk_ts::TsStmt>,
211    map: &mut SourceMapBuilder,
212    source_id: usize,
213) {
214    for stmt in stmts {
215        bynk_ts::print_stmt_and_merge(out, &stmt, 0, map, source_id);
216    }
217}
218
219/// Emit TypeScript source for the typed commons (single-file mode).
220///
221/// Takes a [`CheckedProgram`] rather than a bare `TypedCommons` (T3.7, R3.10):
222/// the only way to obtain one is [`bynk_check::checker::certify`], so this
223/// function can no longer be called with an unchecked or partially-checked
224/// program by construction.
225pub(crate) fn emit(program: &CheckedProgram) -> String {
226    let commons = program.program();
227    // Emit the body first so the header can decide which runtime helpers to
228    // import from what the body actually referenced (v0.110: the `__bynkBytes*`
229    // helpers are imported only when a `Bytes` value is constructed/compared).
230    // "What it referenced" comes from `dummy_ctx.runtime_use`, which the `Bytes`
231    // lowerings write as they emit — not from scanning `body` for the helper's
232    // name, which a user string literal or doc comment could also contain.
233    let mut body = String::new();
234    extend_printed(&mut body, write_commons_doc(commons));
235    let dummy_ctx = single_file_ctx();
236    // Types come first (they define interfaces and namespaces).
237    for item in &commons.commons.items {
238        if let CommonsItem::Type(t) = item {
239            let shape = type_shape_for(t, program);
240            extend_printed(&mut body, emit_type(t, &shape, commons, &dummy_ctx));
241        }
242    }
243    // Free functions afterward.
244    for item in &commons.commons.items {
245        if let CommonsItem::Fn(f) = item
246            && let FnName::Free(_) = &f.name
247        {
248            extend_printed(
249                &mut body,
250                emit_free_fn(f, commons, false, &dummy_ctx.runtime_use),
251            );
252        }
253    }
254    // v0.22b: module-local codec helpers for Json.encode/decode targets.
255    extend_printed(
256        &mut body,
257        emit_json_codec_helpers(commons, &dummy_ctx, &HashSet::new(), &HashSet::new()),
258    );
259    let mut out = String::new();
260    // v0.153 (ADR 0177): a commons that names `HttpResult` in any signature —
261    // e.g. a free `fn -> HttpResult[T]` using the `?`-Option lift — imports it.
262    // Structural (over the AST), not a body-string scan, so a comment or string
263    // literal mentioning `HttpResult` never triggers a spurious import.
264    let uses_http = file_mentions_http_result(commons);
265    // #1319: same structural-scan pattern as `uses_http` — a commons naming
266    // `QueueResult` in a field/type declaration imports it, independent of
267    // any queue-consumer handler (which this single-file path has no
268    // concept of anyway).
269    let uses_queue = file_mentions_queue_result(commons);
270    write_header_single(
271        &mut out,
272        commons,
273        dummy_ctx.runtime_use.bytes(),
274        uses_http,
275        uses_queue,
276    );
277    out.push_str(&body);
278    out
279}
280
281/// `t`'s already-lowered `TypeShape` (`bynk-emit::ir`, P6.6/#1188) — reuses the
282/// canonical `Arc<TypeDecl>` `TypedCommons::types` already holds for `t` rather
283/// than a fresh `Arc::new(t.clone())` per call (Decision B, #1188). `types`
284/// holds an entry for every `CommonsItem::Type` *and* every `CommonsItem::Event`
285/// (`resolver.rs`'s own resolve pass inserts both under the same table, the
286/// event's own synthetic `TypeDecl` — `EventDecl::as_type_decl` — keyed
287/// identically), so this one helper serves both emission loops below with no
288/// special-casing for the event mirror.
289///
290/// Derives `commons` from `program` itself rather than taking it as a
291/// separate parameter (review on #1190): the `TyId`s this returns are minted
292/// from `program.program().ty_intern`, and every caller renders them straight
293/// back through that same `commons.ty_intern` (`emit_record_type`/
294/// `emit_sum_type`'s `ts_ty` calls) — a caller free to pass a `TypedCommons`
295/// from a *different* check run would hit `Types::get`'s cross-table panic
296/// instead of a diagnostic. One parameter makes that invariant
297/// unrepresentable instead of merely true today.
298fn type_shape_for(t: &TypeDecl, program: &CheckedProgram) -> TypeShape {
299    let commons = program.program();
300    let def = commons.types.get(&t.name.name).unwrap_or_else(|| {
301        panic!(
302            "bynk internal error (ADR 0334): type `{}` is not in TypedCommons::types, but the \
303             checker already accepted this declaration",
304            t.name.name
305        )
306    });
307    let IrItem::Type { shape, .. } = lower_type_item_ir(def, program) else {
308        unreachable!("lower_type_item_ir always returns IrItem::Type")
309    };
310    shape
311}
312
313/// A no-op project context for single-file emission. Single-file mode never
314/// involves contexts or cross-unit imports, so most fields default to empty.
315fn single_file_ctx() -> EmitProjectCtx {
316    EmitProjectCtx {
317        import_ext: crate::project::ImportExt::Js,
318        contracts: false,
319        source_path: PathBuf::new(),
320        commons_name: String::new(),
321        file_decl_index: crate::project::FileDeclIndex {
322            types: HashMap::new(),
323            fns: HashMap::new(),
324            methods: HashMap::new(),
325        },
326        imported_from: HashMap::new(),
327        imported_from_kind: HashMap::new(),
328        imported_decl_paths: HashMap::new(),
329        unit_kind: UnitKind::Commons,
330        owning_context: None,
331        exports_for_consumed: HashMap::new(),
332        cross_context: bynk_check::resolver::CrossContextInfo::default(),
333        target: BuildTarget::Bundle,
334        local_agents: HashSet::new(),
335        agent_given_deps: HashMap::new(),
336        extra_import_lines: Vec::new(),
337        agent_method_givens: HashMap::new(),
338        actors: HashMap::new(),
339        event_schema_versions: HashMap::new(),
340        consumed_adapters: HashSet::new(),
341        history_target_agents: HashSet::new(),
342        imported_methods: HashMap::new(),
343        runtime_use: Default::default(),
344    }
345}
346
347/// Emit TypeScript source for a single file inside a multi-file project,
348/// including cross-file and cross-commons imports computed from
349/// [`EmitProjectCtx`].
350/// Emit one unit's TypeScript, plus its source map (slice 1, ADR 0103).
351///
352/// `source_text` is the originating `.bynk` file's text and `source_name` its
353/// project-root-relative path; together they let the source-map builder resolve
354/// each recorded span to a `(line, col)` and embed `sourcesContent`. Returns the
355/// generated TS and the serialised source-map v3 JSON (`None` when nothing
356/// mapped — e.g. a unit whose items all came from sibling files).
357pub(crate) fn emit_project(
358    program: &CheckedProgram,
359    ctx: &EmitProjectCtx,
360    source_text: &str,
361    source_name: &str,
362) -> (String, Option<String>) {
363    let commons = program.program();
364    let mut out = String::new();
365    // The file's source-map builder. The free-function bodies record statement /
366    // match-arm checkpoints through their `LowerCtx`; the declaration loops below
367    // record one checkpoint per top-level item so signatures (and the bodies of
368    // services/agents, which lower via spliced local buffers) anchor to their
369    // declaration (ADR 0103 D2, nearest-enclosing).
370    let smb = RefCell::new(SourceMapBuilder::new());
371    // The file's `.bynk` source is the primary map source (id 0); `record` targets
372    // it and spliced handler bodies in the same file merge against it (v0.70).
373    smb.borrow_mut().add_source(source_name, source_text);
374    // #1476: `write_header`'s own runtime-import line needs `ctx.runtime_use`'s
375    // `bytes()`/`icu()` flags, which the item-emission loop below sets as a side
376    // effect while it runs — every `record`/`merge` call below targets `out`
377    // starting from *this* point (item declarations only), not from the header's
378    // own eventual text. The header itself is built once every item has emitted
379    // (below, once `ctx.runtime_use` is fully known) and prepended — the
380    // checkpoints already recorded against this un-prefixed `out` are shifted by
381    // the header's own final length at that point (`SourceMapBuilder::shift_
382    // checkpoints`, the same mechanism `emit_service`'s own prologue insertion
383    // already uses), so this replaces the old post-print text-surgery pass
384    // (`inject_runtime_imports`) with ordinary out-of-order construction: the
385    // header's own bytes are unchanged, only *when* they're built moves.
386    // Compute which names this file actually references that live elsewhere
387    // (sibling file in the same commons/context, or a used commons / consumed
388    // context).
389    let references = collect_external_references(commons, ctx);
390    extend_printed(&mut out, emit_project_imports(commons, ctx, &references));
391    if !references.is_empty() {
392        writeln!(out).unwrap();
393    }
394    // v0.6: namespace imports for each consumed context that exposes services.
395    // v0.15: also for consumed contexts whose capabilities this context uses.
396    extend_printed(&mut out, emit_cross_context_namespace_imports(commons, ctx));
397    // For contexts: emit per-context nominal rebrand aliases for each type
398    // imported via `uses` that this file references. The structural shape is
399    // inherited from the original commons type; the brand makes the
400    // rebranded type nominally distinct (v0.4 §6.2).
401    if ctx.unit_kind == UnitKind::Context {
402        extend_printed(&mut out, emit_context_rebrands(&references, commons, ctx));
403    }
404    extend_printed(&mut out, write_commons_doc(commons));
405    for item in &commons.commons.items {
406        if let CommonsItem::Type(t) = item {
407            smb.borrow_mut().record(out.len(), t.span);
408            let shape = type_shape_for(t, program);
409            extend_printed(&mut out, emit_type(t, &shape, commons, ctx));
410        }
411    }
412    // Events track, slice 0 (spine #936): an `event` is checker-visible as
413    // a type (via `EventDecl::as_type_decl`, so exports/consumes/
414    // construction all worked from day one), but nothing emitted its actual
415    // TS declaration — this loop only ever matched `CommonsItem::Type`, so
416    // a subscriber importing an event type across contexts (`from
417    // Events(E)`, or `E` named in a cross-context signature) got a real
418    // `tsc` "has no exported member" error. Reuses the identical synthetic
419    // `TypeDecl` the checker already builds.
420    for item in &commons.commons.items {
421        if let CommonsItem::Event(e) = item {
422            let t = e.as_type_decl();
423            smb.borrow_mut().record(out.len(), t.span);
424            let shape = type_shape_for(&t, program);
425            extend_printed(&mut out, emit_type(&t, &shape, commons, ctx));
426        }
427    }
428    for item in &commons.commons.items {
429        if let CommonsItem::Fn(f) = item
430            && let FnName::Free(_) = &f.name
431        {
432            smb.borrow_mut().record(out.len(), f.span);
433            extend_printed_and_merged(
434                &mut out,
435                emit_free_fn(f, commons, ctx.contracts, &ctx.runtime_use),
436                &mut smb.borrow_mut(),
437                0,
438            );
439        }
440    }
441    // message-bundles slice 2 (#874): every `messages` block in the commons
442    // is emitted together, once, as a single multi-locale bundle — not
443    // per-item like the other behavioural kinds below — so the generated
444    // `render` can dispatch across every declared locale's own table rather
445    // than reading only the `@reference` one (slice 1's scope). Recorded at
446    // the `@reference` block's own span, matching how a single-item emission
447    // records at that item's span elsewhere in this loop.
448    let messages_blocks: Vec<&MessagesDecl> = commons
449        .commons
450        .items
451        .iter()
452        .filter_map(|item| match item {
453            CommonsItem::Messages(m) => Some(m),
454            _ => None,
455        })
456        .collect();
457    if let Some(reference) = messages_blocks
458        .iter()
459        .find(|m| m.annotations.iter().any(|a| a.name.name == "reference"))
460    {
461        smb.borrow_mut().record(out.len(), reference.span);
462        extend_printed(
463            &mut out,
464            emit_messages_bundle(&messages_blocks, reference, &ctx.runtime_use),
465        );
466    }
467    // v0.5: behavioural items follow the type/fn declarations.
468    for item in &commons.commons.items {
469        match item {
470            CommonsItem::Capability(c) => {
471                smb.borrow_mut().record(out.len(), c.span);
472                // P6.x (#1193, slice 3 of #1187): `emit_capability` reads
473                // each op's resolved types off `ops`, not `c`'s own raw
474                // `TypeRef`s (Decision B, #1193) — no separate helper, this
475                // is `emit_capability`'s one and only call site.
476                let IrItem::Capability { ops, .. } = lower_capability_item_ir(c, program) else {
477                    unreachable!("lower_capability_item_ir always returns IrItem::Capability")
478                };
479                extend_printed(&mut out, emit_capability(c, &ops, commons));
480            }
481            CommonsItem::Provider(p) => {
482                smb.borrow_mut().record(out.len(), p.span);
483                if let Some(stmt) = emit_provider(p, commons, ctx) {
484                    bynk_ts::print_stmt_and_merge(&mut out, &stmt, 0, &mut smb.borrow_mut(), 0);
485                }
486            }
487            CommonsItem::Service(s) => {
488                smb.borrow_mut().record(out.len(), s.span);
489                // #1187's slice 5: `emit_service` reads the protocol's own
490                // resolved data (`ProtocolIr`) and each handler's resolved
491                // signature (params/ret/effectful) instead of `s`'s own raw
492                // `ServiceProtocol`/`TypeRef`s — not a full `IrItem::Service`
493                // (see `lower_service_handler_signature_ir`'s own doc
494                // comment for why: a real `IrHandler` would unconditionally
495                // lower every handler's body, panicking on an ordinary
496                // `Ok`/`Err`-returning Http handler). No separate helper,
497                // this is `emit_service`'s one and only call site.
498                let protocol = lower_protocol_ir(&s.protocol, program);
499                let signatures: Vec<_> = s
500                    .handlers
501                    .iter()
502                    .map(|h| lower_service_handler_signature_ir(h, program))
503                    .collect();
504                let stmt = emit_service(s, &protocol, &signatures, commons, ctx);
505                bynk_ts::print_stmt_and_merge(&mut out, &stmt, 0, &mut smb.borrow_mut(), 0);
506            }
507            CommonsItem::Agent(a) => {
508                smb.borrow_mut().record(out.len(), a.span);
509                let state: Vec<_> = a
510                    .store_fields
511                    .iter()
512                    .map(|f| lower_store_field_shape_ir(f, program))
513                    .collect();
514                extend_printed_and_merged(
515                    &mut out,
516                    emit_agent(a, &state, commons, ctx),
517                    &mut smb.borrow_mut(),
518                    0,
519                );
520            }
521            _ => {}
522        }
523    }
524    // v0.9.2: per-test registry reset. The test runner calls this before each
525    // test so a fresh test sees clean agent state (finding #10's "fresh per
526    // test" half).
527    let agent_names: Vec<&str> = commons
528        .commons
529        .items
530        .iter()
531        .filter_map(|i| match i {
532            CommonsItem::Agent(a) => Some(a.name.name.as_str()),
533            _ => None,
534        })
535        .collect();
536    if !agent_names.is_empty() {
537        // Arc C, slice 30 (#1392): a real `TsDecl::Function`.
538        let reset_fn = bynk_ts::TsStmt::decl(
539            bynk_ts::TsDecl::Export(Box::new(bynk_ts::TsDecl::Function {
540                name: "__resetAgents".to_string(),
541                generics: Vec::new(),
542                params: Vec::new(),
543                return_type: Some(bynk_ts::TsType::named("void")),
544                body: agent_names
545                    .iter()
546                    .map(|name| {
547                        bynk_ts::TsStmt::expr_stmt(
548                            bynk_ts::TsExpr::Call {
549                                callee: Box::new(bynk_ts::TsExpr::Member {
550                                    object: Box::new(bynk_ts::TsExpr::Ident(agent_registry_name(
551                                        name,
552                                    ))),
553                                    property: "reset".to_string(),
554                                }),
555                                args: Vec::new(),
556                            },
557                            None,
558                        )
559                    })
560                    .collect(),
561                is_async: false,
562                inline: false,
563            })),
564            None,
565        );
566        out.push_str(&bynk_ts::print_stmt(&reset_fn, 0));
567        writeln!(out).unwrap();
568    }
569    // v0.6: cross-context surface assembly. Emit `makeSurface` for any
570    // context that declares services — the composition root references it
571    // for every such context, not just those consumed by others. Skipped
572    // in workers mode where each Worker has its own `compose(env)` root.
573    if ctx.unit_kind == UnitKind::Context && matches!(ctx.target, BuildTarget::Bundle) {
574        let has_services = commons
575            .commons
576            .items
577            .iter()
578            .any(|i| matches!(i, CommonsItem::Service(_)));
579        if has_services {
580            extend_printed(&mut out, emit_make_surface(commons, ctx));
581        }
582    }
583    // v0.8: in workers mode, the context module also exports per-type
584    // serialise/deserialise helpers for every type that crosses a
585    // boundary. The commons modules likewise carry helpers for their
586    // own commons-declared boundary types.
587    // v0.96 (ADR 0124): runs on both targets — workers emits service-call +
588    // agent-rehydration boundary helpers; bundle emits only the agent-rehydration
589    // ones (the gate's deserialisers), since in-process calls need no wire codec.
590    let (boundary_stmts, boundary_names, boundary_insts) = emit_boundary_helpers(program, ctx);
591    extend_printed(&mut out, boundary_stmts);
592    // v0.22b: module-local codec helpers for this file's Json.encode/decode
593    // targets, deduped against the workers boundary helpers above.
594    extend_printed(
595        &mut out,
596        emit_json_codec_helpers(commons, ctx, &boundary_names, &boundary_insts),
597    );
598    // #1476: `ctx.runtime_use` is fully populated now — every producer above has
599    // had its chance to note `bytes()`/`icu()` (`emitter::runtime_use`'s own doc:
600    // this used to key on `out.contains("<helper name>")`, wrong in both
601    // directions — a user string literal/doc comment false-positive, or an
602    // unrelated formatting change silently dropping a *required* import). Build
603    // the header — including its own runtime-import line, `bytes()`/`icu()`
604    // folded in directly rather than spliced in afterward — then prepend it,
605    // shifting every checkpoint already recorded above by its own final length
606    // (the same `shift_checkpoints` mechanism `emit_service`'s own prologue
607    // insertion already uses for the identical "content prepended after
608    // checkpoints were recorded" shape).
609    let mut header = String::new();
610    extend_printed(&mut header, write_header(commons, ctx));
611    smb.borrow_mut().shift_checkpoints(header.len());
612    header.push_str(&out);
613    let out = header;
614    // The generated `file` name: the source basename with `.bynk` → `.ts`.
615    let generated_file = Path::new(source_name)
616        .file_stem()
617        .map(|s| format!("{}.ts", s.to_string_lossy()))
618        .unwrap_or_else(|| "module.ts".to_string());
619    let source_map = smb.borrow().to_v3(&out, &generated_file);
620    (out, source_map)
621}
622
623/// v0.110 (ADR 0142): append a set of runtime helpers to a module's existing
624/// runtime import. Done as a post-pass so the decision keys on what the body
625/// references, without a second emission or a source-map-shifting reorder.
626/// Generalised in message-bundles slice 3 (#878) from a `Bytes`-only helper
627/// to take `extra` as a parameter, shared with the ICU-formatting helpers.
628///
629/// v0.176 (#642): anchored on the runtime import's **exact specifier** rather
630/// than on the `type ValidationError` binding it happens to carry. With `Bytes`
631/// now able to cross a workers boundary (ADR 0142 D8's guard retired), the
632/// *Worker entry* references `__bynkBytesFromBase64` too — and its import line
633/// names no `ValidationError`, so the old anchor silently failed to inject and
634/// `tsc` reported an unresolved name.
635///
636/// The specifier is matched exactly (`from "<specifier>"`), not by substring: a
637/// `contains("runtime.js")` would also match a *user* module that happens to be
638/// named `runtime` — or anything like `"./my-runtime.js"` — and appending
639/// `extra`'s bindings to that import would produce an unresolved export. The
640/// caller already knows the exact path it emitted, so there is no reason to
641/// guess.
642pub(crate) fn inject_runtime_imports(out: String, runtime_specifier: &str, extra: &str) -> String {
643    let mut result = String::with_capacity(out.len() + extra.len());
644    let mut injected = false;
645    let from_runtime = format!(" }} from \"{runtime_specifier}\"");
646    for line in out.split_inclusive('\n') {
647        if !injected
648            && line.starts_with("import {")
649            && line.contains(&from_runtime)
650            && let Some(pos) = line.rfind(&from_runtime)
651        {
652            result.push_str(&line[..pos]);
653            result.push_str(&missing_bindings(&line[..pos], extra));
654            result.push_str(&line[pos..]);
655            injected = true;
656            continue;
657        }
658        result.push_str(line);
659    }
660    result
661}
662
663/// The subset of `extra` not already bound on `existing` — the head of an import
664/// line, e.g. `import { Ok, Err, type Result`.
665///
666/// #914: an injection target may already import some of what a group carries. The
667/// test-scaffold module lists `Ok`/`Err` in its fixed set but not `BoundaryError`,
668/// so injecting the boundary group wholesale would emit `import { Ok, …, Ok, … }` —
669/// a duplicate-identifier error, i.e. trading one uncompilable module for another.
670/// Comparing on the bare name lets `type BoundaryError` match an existing
671/// `BoundaryError` and vice versa.
672///
673/// Invariant: a group is a list of plain bindings, optionally `type`-prefixed —
674/// **never an alias**. `bare("Foo as Ok")` is the whole phrase, so an aliased
675/// binding on either side would compare unequal and inject a duplicate. No group
676/// carries one today; keep it that way rather than teaching this to split on
677/// `as`.
678fn missing_bindings(existing: &str, extra: &str) -> String {
679    fn bare(binding: &str) -> &str {
680        binding
681            .trim()
682            .strip_prefix("type ")
683            .unwrap_or(binding.trim())
684    }
685    let present: HashSet<&str> = existing
686        .strip_prefix("import {")
687        .unwrap_or(existing)
688        .split(',')
689        .map(bare)
690        .collect();
691    let wanted: Vec<&str> = extra
692        .split(',')
693        .map(str::trim)
694        .filter(|b| !b.is_empty() && !present.contains(bare(b)))
695        .collect();
696    if wanted.is_empty() {
697        String::new()
698    } else {
699        format!(", {}", wanted.join(", "))
700    }
701}
702
703/// v0.79: does this block contain a `~>` send anywhere — including nested
704/// branches, match arms, and lambdas? Gates execution-context threading
705/// (`deps.__exec`) so a context that never sends keeps byte-identical output.
706///
707/// A `~>` send is a [`Statement`] variant, not an [`ExprKind`] one, and a bare
708/// `{ … }` block is only parseable in a handful of positions (an `if`/`else`
709/// body, a `match` arm, a lambda body) — never as an arbitrary sub-expression
710/// — so `Block`/`If`/`Match`/`Lambda` were already the complete reachable set
711/// and the old `_ => false` tail never actually dropped a send. It is
712/// rewritten to recurse over `expr_children`, the total child iterator,
713/// anyway: a `Statement`-only construct like this is exactly the shape that
714/// silently drifts if a later `ExprKind` variant *does* start admitting a
715/// nested block and this list isn't updated to match — see
716/// `block_writes_state`, whose traversal was converted alongside this one for
717/// the same reason. Both now also enumerate `ExprKind` explicitly instead of
718/// ending in a `_` arm, so that drift is a build failure rather than a silent
719/// miss.
720pub(crate) fn block_uses_send(b: &Block) -> bool {
721    fn stmt(s: &Statement) -> bool {
722        match s {
723            Statement::Send(_) => true,
724            Statement::Let(l) | Statement::EffectLet(l) => expr(&l.value),
725            Statement::Expect(a) => expr(&a.value),
726            Statement::Do(d) => expr(&d.value),
727            Statement::Assign(a) => expr(&a.value),
728        }
729    }
730    fn expr(e: &Expr) -> bool {
731        match &e.kind {
732            ExprKind::Block(b) => block_uses_send(b),
733            ExprKind::If {
734                cond,
735                then_block,
736                else_block,
737            } => expr(cond) || block_uses_send(then_block) || block_uses_send(else_block),
738            ExprKind::Match { discriminant, arms } => {
739                expr(discriminant)
740                    || arms.iter().any(|a| match &a.body {
741                        MatchBody::Expr(e) => expr(e),
742                        MatchBody::Block(b) => block_uses_send(b),
743                    })
744            }
745            // No variant below carries a `Block` *field*, so `expr_children`'s
746            // total descent is complete for it — a block reached through a
747            // child (a braced lambda body, say) comes back as an `Expr` and
748            // re-enters this match at the `Block` arm above. A *new* variant
749            // that holds a `Block` directly must be hand-matched up there
750            // alongside `Block`/`If`/`Match`: appending it here loses the
751            // `Statement::Send` tag (`expr_children` flattens a block to its
752            // statements' values), and with it `deps.__exec` threading for a
753            // context that does send.
754            ExprKind::IntLit { .. }
755            | ExprKind::FloatLit { .. }
756            | ExprKind::DurationLit { .. }
757            | ExprKind::StrLit(_)
758            | ExprKind::InterpStr(_)
759            | ExprKind::BoolLit(_)
760            | ExprKind::Ident(_)
761            | ExprKind::Call { .. }
762            | ExprKind::Lambda(_)
763            | ExprKind::BinOp(..)
764            | ExprKind::UnaryOp(..)
765            | ExprKind::Paren(_)
766            | ExprKind::Ok(_)
767            | ExprKind::Err(_)
768            | ExprKind::Question(_)
769            | ExprKind::ConstructorCall { .. }
770            | ExprKind::RecordConstruction { .. }
771            | ExprKind::FieldAccess { .. }
772            | ExprKind::MethodCall { .. }
773            | ExprKind::Is { .. }
774            | ExprKind::Some(_)
775            | ExprKind::None
776            | ExprKind::UnitLit
777            | ExprKind::RecordSpread { .. }
778            | ExprKind::EffectPure(_)
779            | ExprKind::Expect(_)
780            | ExprKind::Val { .. }
781            | ExprKind::Wire(_)
782            | ExprKind::ListLit(_)
783            | ExprKind::Observation(_)
784            | ExprKind::Trace { .. } => expr_children(e).into_iter().any(expr),
785        }
786    }
787    b.statements.iter().any(stmt) || expr(&b.tail)
788}
789
790/// P6.48 (design/tracks/the-ir.md §6b): every handler/op body in `table` —
791/// every service handler, every agent handler, every provider op — visited
792/// via [`walk_block_exprs`]. The shared walk `project::unit_table_uses_emit`'s
793/// own `body_uses_emit` and `project::called_cross_context_services` each
794/// hand-rolled a separate copy of, over exactly the same three `UnitTable`
795/// collections in the same order. Lives here (already counted regardless by
796/// [`ast_importers`]) so those two `project.rs` callers need only a `&Expr`
797/// closure parameter, never a `&Block` or `&bynk_syntax::ast::Expr` of their
798/// own.
799pub(crate) fn walk_unit_table_bodies(table: &UnitTable, f: &mut impl FnMut(&Expr)) {
800    for service in table.services.values() {
801        for h in &service.handlers {
802            walk_block_exprs(&h.body, f);
803        }
804    }
805    for agent in table.agents.values() {
806        for h in &agent.handlers {
807            walk_block_exprs(&h.body, f);
808        }
809    }
810    for provider in table.providers.values() {
811        for op in &provider.ops {
812            walk_block_exprs(&op.body, f);
813        }
814    }
815}
816
817/// P6.32 (design/tracks/the-ir.md §6a): the one built-in wrapper type
818/// [`type_ref_mentions`] is looking for — `file_mentions_json_error`/
819/// `_http_result`/`_connection` used to carry three separately hand-written
820/// copies of the identical recursive walk below, differing in exactly one
821/// line each (which wrapper variant stops the recursion and reports `true`).
822#[derive(Clone, Copy, PartialEq, Eq)]
823enum TypeRefMarker {
824    JsonError,
825    HttpResult,
826    Connection,
827    /// Closes the `ts_any` residual (#1319): `has_queue` (`write_header`'s
828    /// own `QueueResult` import gate) only detected a real `from queue on
829    /// message` consumer handler, never a bare field/type-declaration
830    /// mention — the one gap among the four runtime-owned error types that
831    /// `JsonError`/`HttpResult` already closed via this same marker
832    /// mechanism (`ValidationError` needs no marker at all; it's imported
833    /// unconditionally).
834    QueueResult,
835}
836
837/// The shared recursive walk `file_mentions_json_error`/`_http_result`/
838/// `_connection` each used to hand-roll their own copy of. `marker`'s own
839/// wrapper variant reports `true` immediately without recursing into its
840/// payload (matching each original function's own `=> true` arm exactly —
841/// `matches!(marker, ..) || type_ref_mentions(a, marker)` short-circuits on
842/// the left, so the right side never evaluates when `t` itself is a match);
843/// every other wrapper variant recurses into its inner type(s) exactly as
844/// each original's own "recurse" bucket did.
845fn type_ref_mentions(t: &TypeRef, marker: TypeRefMarker) -> bool {
846    match t {
847        TypeRef::JsonError(_) => marker == TypeRefMarker::JsonError,
848        TypeRef::HttpResult(a, _) => {
849            marker == TypeRefMarker::HttpResult || type_ref_mentions(a, marker)
850        }
851        TypeRef::Connection(a, _) => {
852            marker == TypeRefMarker::Connection || type_ref_mentions(a, marker)
853        }
854        TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
855            type_ref_mentions(a, marker) || type_ref_mentions(b, marker)
856        }
857        TypeRef::Option(a, _)
858        | TypeRef::Effect(a, _)
859        | TypeRef::Query(a, _)
860        | TypeRef::Stream(a, _)
861        | TypeRef::History(a, _)
862        | TypeRef::List(a, _) => type_ref_mentions(a, marker),
863        TypeRef::Fn(params, ret, _) => {
864            params.iter().any(|p| type_ref_mentions(p, marker)) || type_ref_mentions(ret, marker)
865        }
866        // v0.157 (ADR 0183): recurse into a generic application's arguments.
867        TypeRef::App { args, .. } => args.iter().any(|a| type_ref_mentions(a, marker)),
868        TypeRef::QueueResult(_) => marker == TypeRefMarker::QueueResult,
869        TypeRef::Base(..) | TypeRef::Named(_) | TypeRef::ValidationError(_) | TypeRef::Unit(_) => {
870            false
871        }
872    }
873}
874
875/// The shared outer walk `file_mentions_json_error`/`_http_result` used to
876/// hand-roll two byte-identical copies of (P6.32) — every signature or type
877/// declaration in the file, checked via [`type_ref_mentions`].
878/// `file_mentions_connection` keeps its own distinct outer walk (a
879/// `Connection` can additionally live in a `store` field, which neither
880/// marker below can) rather than being folded in here.
881fn commons_mentions_type(commons: &TypedCommons, marker: TypeRefMarker) -> bool {
882    let in_type_ref = |t: &TypeRef| type_ref_mentions(t, marker);
883    let sig = |params: &[Param], ret: &TypeRef| {
884        params.iter().any(|p| in_type_ref(&p.type_ref)) || in_type_ref(ret)
885    };
886    commons.commons.items.iter().any(|item| match item {
887        CommonsItem::Fn(f) => sig(&f.params, &f.return_type),
888        CommonsItem::Service(s) => s.handlers.iter().any(|h| sig(&h.params, &h.return_type)),
889        CommonsItem::Agent(a) => a.handlers.iter().any(|h| sig(&h.params, &h.return_type)),
890        CommonsItem::Capability(c) => c.ops.iter().any(|op| sig(&op.params, &op.return_type)),
891        CommonsItem::Provider(p) => p.ops.iter().any(|op| sig(&op.params, &op.return_type)),
892        CommonsItem::Type(t) => match &t.body {
893            TypeBody::Record(r) => r.fields.iter().any(|f| in_type_ref(&f.type_ref)),
894            TypeBody::Sum(s) => s
895                .variants
896                .iter()
897                .any(|v| v.payload.iter().any(|p| in_type_ref(&p.type_ref))),
898            TypeBody::Refined { .. } | TypeBody::Opaque { .. } => false,
899        },
900        // An `event` registers into the `types` table and is checked over
901        // the same record-field path as `CommonsItem::Type`'s `Record` arm.
902        CommonsItem::Event(e) => e.body.fields.iter().any(|f| in_type_ref(&f.type_ref)),
903        CommonsItem::Actor(_) | CommonsItem::Messages(_) => false,
904    })
905}
906
907/// v0.22b: whether any signature or type declaration in this file names
908/// `JsonError` — drives the conditional `type JsonError` runtime import.
909fn file_mentions_json_error(commons: &TypedCommons) -> bool {
910    commons_mentions_type(commons, TypeRefMarker::JsonError)
911}
912
913/// Closes the `ts_any` residual (#1319): true if any signature or type
914/// declaration in this file names `QueueResult` — a field/sum-payload
915/// mention, not just a `from queue on message` consumer handler (which
916/// `has_queue`, `write_header`'s own other `QueueResult` gate, already
917/// covers). Drives the conditional `QueueResult` runtime import the same
918/// way `file_mentions_json_error`/`file_mentions_http_result` already do
919/// for their own types.
920fn file_mentions_queue_result(commons: &TypedCommons) -> bool {
921    commons_mentions_type(commons, TypeRefMarker::QueueResult)
922}
923
924/// v0.153 (ADR 0177): true if any signature or type declaration in this file
925/// names `HttpResult` — a service HTTP handler, or a free `fn` / provider /
926/// capability whose parameter or return type mentions it (the `?`-Option lift
927/// makes a bare `fn -> HttpResult[T]` emit `HttpResult.NotFound`). Drives the
928/// conditional `HttpResult` runtime import in both single-file and project
929/// headers, so the import can never be missing nor spuriously added.
930fn file_mentions_http_result(commons: &TypedCommons) -> bool {
931    commons_mentions_type(commons, TypeRefMarker::HttpResult)
932}
933
934/// v0.102: true if a file's signatures or store fields mention `Connection[F]`,
935/// so the header imports the runtime `Connection` interface. Covers the held
936/// sites: capability-operation returns, service/agent handler parameters, and
937/// `store` field value types (`Map[K, Connection]` / `Cell[Option[Connection]]`).
938fn file_mentions_connection(commons: &TypedCommons) -> bool {
939    let in_type_ref = |t: &TypeRef| type_ref_mentions(t, TypeRefMarker::Connection);
940    let sig = |params: &[Param], ret: &TypeRef| {
941        params.iter().any(|p| in_type_ref(&p.type_ref)) || in_type_ref(ret)
942    };
943    commons.commons.items.iter().any(|item| match item {
944        CommonsItem::Fn(f) => sig(&f.params, &f.return_type),
945        CommonsItem::Service(s) => s.handlers.iter().any(|h| sig(&h.params, &h.return_type)),
946        CommonsItem::Agent(a) => {
947            a.handlers.iter().any(|h| sig(&h.params, &h.return_type))
948                || a.store_fields
949                    .iter()
950                    .any(|f| f.kind.args.iter().any(in_type_ref))
951        }
952        CommonsItem::Capability(c) => c.ops.iter().any(|op| sig(&op.params, &op.return_type)),
953        CommonsItem::Provider(p) => p.ops.iter().any(|op| sig(&op.params, &op.return_type)),
954        // A `Connection` is a held resource storable only in a `store` field
955        // (handled above) — never in a plain record field, so `Type`/`Event`
956        // need no case here; `Actor`/`Messages` carry no `TypeRef` at all.
957        CommonsItem::Type(_)
958        | CommonsItem::Actor(_)
959        | CommonsItem::Messages(_)
960        | CommonsItem::Event(_) => false,
961    })
962}
963
964/// v0.22b: a checker `Ty` rendered back to a `TypeRef` for the codec
965/// machinery (which is `TypeRef`-driven). `None` for types the codec
966/// rejects anyway (functions, effects, type variables). `pub(crate)` since
967/// P6.28 (design/tracks/the-ir.md §6a): `project/tests_emit.rs`'s own drain
968/// of `RuntimeUse::json_codec_roots` (a sibling module tree, not a
969/// descendant of `emitter`) needs this same conversion, once, right before
970/// `collect_codec_closure` — the one remaining consumer still genuinely
971/// `TypeRef`-driven.
972pub(crate) fn ty_to_type_ref(t: TyId, tys: &Arc<Types>) -> Option<TypeRef> {
973    let sp = bynk_syntax::span::Span::new(0, 0);
974    Some(match &*tys.get(t) {
975        Ty::Base(b) => TypeRef::Base(*b, sp),
976        // v0.174 (#592): a generic-record instantiation (`Paginated[User]`,
977        // `args` non-empty) round-trips as a `TypeRef::App` so the codec closure
978        // reaches its monomorphised helper; a non-generic named type stays a
979        // bare `Named`.
980        Ty::Named { name, args, .. } if !args.is_empty() => TypeRef::App {
981            name: Ident {
982                name: name.clone(),
983                span: sp,
984            },
985            args: args
986                .iter()
987                .map(|a| ty_to_type_ref(*a, tys))
988                .collect::<Option<Vec<_>>>()?,
989            span: sp,
990        },
991        Ty::Named { name, .. } => TypeRef::Named(Ident {
992            name: name.clone(),
993            span: sp,
994        }),
995        Ty::Result(a, b) => TypeRef::Result(
996            Box::new(ty_to_type_ref(*a, tys)?),
997            Box::new(ty_to_type_ref(*b, tys)?),
998            sp,
999        ),
1000        Ty::Option(a) => TypeRef::Option(Box::new(ty_to_type_ref(*a, tys)?), sp),
1001        Ty::List(a) => TypeRef::List(Box::new(ty_to_type_ref(*a, tys)?), sp),
1002        Ty::Map(k, v) => TypeRef::Map(
1003            Box::new(ty_to_type_ref(*k, tys)?),
1004            Box::new(ty_to_type_ref(*v, tys)?),
1005            sp,
1006        ),
1007        Ty::Unit => TypeRef::Unit(sp),
1008        Ty::ValidationError => TypeRef::ValidationError(sp),
1009        Ty::JsonError => TypeRef::JsonError(sp),
1010        // R4.3: `Ty::Error` has no codec — same as the other non-boundary
1011        // types below, but for the additional reason that a checked program
1012        // should never contain one at a codec-generation site.
1013        Ty::Error
1014        | Ty::Effect(_)
1015        | Ty::Query(_)
1016        | Ty::Stream(_)
1017        | Ty::Connection(_)
1018        | Ty::HttpResult(_)
1019        | Ty::QueueResult
1020        | Ty::Fn { .. }
1021        | Ty::Var(_)
1022        | Ty::Actor(_)
1023        | Ty::ActorSum(_) => {
1024            return None;
1025        }
1026    })
1027}
1028
1029/// v0.22b: collect the `Json.encode`/`Json.decode[T]` target type-refs in
1030/// this file's bodies — the roots of the module-local codec-helper closure.
1031///
1032/// R8.14 (Arc D, P7.d3), revisiting P6.56's own declined attempt: P6.56 found
1033/// no `Callee`-classification for `Json.encode`/`decode` to read at the time
1034/// ("`commons.callees` carries no entry for this call site at all"). That is
1035/// no longer true — `checker::calls`'s own JSON-static dispatch now inserts
1036/// `Callee::Intrinsic { ns: JSON, op }` for exactly this call shape (the same
1037/// `ctx.lookup(JSON).is_none() && !ctx.input.types.contains_key(JSON)` guard
1038/// against a local shadow that this function's own bare `id.name == JSON`
1039/// match had no way to apply). Reading it here closes two things at once:
1040/// R8.14's own "AST-shaped, not checker-resolved" framing, and a real
1041/// (if narrow) correctness gap the old syntactic match carried — a local
1042/// variable or type named `Json` shadowing the builtin would have been
1043/// misread as a real `Json.encode`/`decode` call; `Callee::Intrinsic`'s own
1044/// presence is exactly the checker's already-verified "no, this really is
1045/// the builtin" answer.
1046fn collect_json_codec_roots(commons: &TypedCommons) -> Vec<TypeRef> {
1047    let tys = commons.tys();
1048    let mut roots: Vec<TypeRef> = Vec::new();
1049    {
1050        let mut visit = |e: &Expr| {
1051            let ExprKind::MethodCall { args, .. } = &e.kind else {
1052                return;
1053            };
1054            let Some(bynk_check::checker::Callee::Intrinsic { ns, op }) =
1055                commons.callees.get(&e.id)
1056            else {
1057                return;
1058            };
1059            if *ns != JSON {
1060                return;
1061            }
1062            match op.as_str() {
1063                "decode" => {
1064                    if let Some(Ty::Result(t, _)) = commons.expr_ty(e.id).as_deref()
1065                        && let Some(tr) = ty_to_type_ref(*t, tys)
1066                    {
1067                        roots.push(tr);
1068                    }
1069                }
1070                "encode" => {
1071                    if let Some(a) = args.first()
1072                        && let Some(t) = commons.expr_types.get(&a.id).map(|te| te.ty)
1073                        && let Some(tr) = ty_to_type_ref(t, tys)
1074                    {
1075                        roots.push(tr);
1076                    }
1077                }
1078                _ => {}
1079            }
1080        };
1081        for item in &commons.commons.items {
1082            match item {
1083                CommonsItem::Fn(f) => walk_block_exprs(&f.body, &mut visit),
1084                CommonsItem::Service(s) => {
1085                    for h in &s.handlers {
1086                        walk_block_exprs(&h.body, &mut visit);
1087                    }
1088                }
1089                CommonsItem::Agent(a) => {
1090                    for h in &a.handlers {
1091                        walk_block_exprs(&h.body, &mut visit);
1092                    }
1093                }
1094                CommonsItem::Provider(p) => {
1095                    for op in &p.ops {
1096                        walk_block_exprs(&op.body, &mut visit);
1097                    }
1098                }
1099                _ => {}
1100            }
1101        }
1102    }
1103    roots
1104}
1105
1106/// v0.22b: module-local serialise/deserialise helpers for the types this
1107/// file's `Json.encode`/`Json.decode[T]` calls reference (ADR 0045). The
1108/// closure machinery is shared with the workers boundary path; `skip_names`
1109/// / `skip_insts` dedupe against helpers that path already emitted into
1110/// this module.
1111/// #1478: returns real [`bynk_ts::TsStmt`]s (was `out: &mut String`) — the
1112/// same `decls_as_stmts[_block]` conversion `emit_boundary_helpers`/
1113/// `emit_consumed_context_helpers` just used.
1114fn emit_json_codec_helpers(
1115    commons: &TypedCommons,
1116    ctx: &EmitProjectCtx,
1117    skip_names: &HashSet<String>,
1118    skip_insts: &HashSet<String>,
1119) -> Vec<bynk_ts::TsStmt> {
1120    use serialisation::{collect_codec_closure, emit_generic_helpers, emit_helpers_for_owner};
1121    let roots = collect_json_codec_roots(commons);
1122    if roots.is_empty() {
1123        return Vec::new();
1124    }
1125    let (names, insts) = collect_codec_closure(&roots, &commons.types);
1126    let names: Vec<String> = names
1127        .into_iter()
1128        .filter(|n| !skip_names.contains(n))
1129        .collect();
1130    let mut stmts = serialisation::decls_as_stmts_block(emit_helpers_for_owner(
1131        &names,
1132        &commons.types,
1133        &ctx.commons_name,
1134        &ctx.runtime_use,
1135    ));
1136    let insts: Vec<serialisation::GenericInst> = insts
1137        .into_iter()
1138        .filter(|i| !skip_insts.contains(&i.ts_name()))
1139        .collect();
1140    if !insts.is_empty() {
1141        stmts.extend(serialisation::decls_as_stmts(emit_generic_helpers(
1142            &insts,
1143            &commons.types,
1144            &ctx.runtime_use,
1145        )));
1146    }
1147    stmts
1148}
1149
1150/// Emit boundary serialise/deserialise helpers (v0.8 §3.4 / §5.2) for
1151/// every named type declared in this file that flows through a
1152/// cross-context call, plus the specialised generic helpers for any
1153/// Result/Option instantiation used at the boundary. Returns the emitted
1154/// (or locally-bound) helper type names and generic-instantiation names so
1155/// the v0.22b codec emission can dedupe against them.
1156/// #1478: returns real [`bynk_ts::TsStmt`]s (was `out: &mut String`) as a
1157/// new first element of the tuple — every `out`-write here is already
1158/// exclusively through `serialisation::decls_as_stmts[_block]` or a real
1159/// `bynk_ts::print_stmt` call, so each becomes a `stmts.extend`/`stmts.push`
1160/// into the same local `stmts`, threaded through `emit_consumed_context_
1161/// helpers`'s own identical conversion.
1162fn emit_boundary_helpers(
1163    program: &CheckedProgram,
1164    ctx: &EmitProjectCtx,
1165) -> (Vec<bynk_ts::TsStmt>, HashSet<String>, HashSet<String>) {
1166    use serialisation::{
1167        collect_boundary_types, collect_generic_instantiations, emit_generic_helpers,
1168        emit_helpers_for_owner,
1169    };
1170    // Review of #1211: `commons` is always `program.program()` — derived
1171    // rather than taken as a second parameter, so the two can never alias
1172    // to different tables. `lower_protocol_ir`/`ty_to_type_ref` below mint
1173    // and resolve `TyId`s against the same `program`, and `Types::get`'s
1174    // own doc comment (`bynk-check/src/checker.rs`) names exactly what a
1175    // mismatched pair would silently do in a release build: resolve an
1176    // in-range foreign id to an unrelated `Ty`, not panic.
1177    let commons = program.program();
1178
1179    // For contexts: walk the local services to discover boundary types.
1180    // For commons: walk every consumer's services that reference us
1181    // (approximated as: emit for every type declared in this file).
1182    //
1183    // Service handler types cross the *cross-Worker call* boundary, which only
1184    // exists on the `workers` target; on `bundle` calls are in-process, so their
1185    // serialise/deserialise helpers are not emitted. The agent **rehydration**
1186    // boundary (ADR 0124), in contrast, exists on both targets, so agent
1187    // store-field types are always collected (below).
1188    let workers = matches!(ctx.target, BuildTarget::Workers);
1189    let services: HashMap<String, ServiceDecl> = if workers {
1190        commons
1191            .commons
1192            .items
1193            .iter()
1194            .filter_map(|i| match i {
1195                CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
1196                _ => None,
1197            })
1198            .collect()
1199    } else {
1200        HashMap::new()
1201    };
1202
1203    // v0.96 (ADR 0124): an agent's `store`-field types are rehydration-boundary
1204    // types — their deserialisers drive the load-time validation gate.
1205    let agents: HashMap<String, AgentDecl> = commons
1206        .commons
1207        .items
1208        .iter()
1209        .filter_map(|i| match i {
1210            CommonsItem::Agent(a) => Some((a.name.name.clone(), a.clone())),
1211            _ => None,
1212        })
1213        .collect();
1214
1215    let locally_declared: HashSet<String> = ctx.file_decl_index.types.keys().cloned().collect();
1216    if ctx.unit_kind == UnitKind::Context {
1217        let mut stmts: Vec<bynk_ts::TsStmt> = Vec::new();
1218        let boundary_types_all = collect_boundary_types(&commons.types, &services, &agents);
1219        // Locally-declared boundary types get full helpers in this module. On
1220        // `bundle` (v0.96, ADR 0124) the commons modules emit no boundary helpers,
1221        // so a cross-commons *agent-state* type's deserialiser — needed by the
1222        // rehydration gate — is emitted here in the context instead of re-exported.
1223        let local_boundary: Vec<String> = boundary_types_all
1224            .iter()
1225            .filter(|n| !workers || locally_declared.contains(*n))
1226            .cloned()
1227            .collect();
1228        stmts.extend(serialisation::decls_as_stmts_block(emit_helpers_for_owner(
1229            &local_boundary,
1230            &commons.types,
1231            ctx.commons_name.as_str(),
1232            &ctx.runtime_use,
1233        )));
1234
1235        // Re-export helpers for commons-owned boundary types so consumers
1236        // can address them through this context's handlers.ts namespace
1237        // (matching the namespace import they already use for cross-
1238        // context types). Grouped by source commons. Workers only — on `bundle`
1239        // the commons emit no helpers, so cross-commons types are emitted
1240        // locally above (v0.96) rather than imported.
1241        let mut by_commons: HashMap<String, Vec<String>> = HashMap::new();
1242        for n in &boundary_types_all {
1243            if !workers || locally_declared.contains(n) {
1244                continue;
1245            }
1246            if matches!(ctx.imported_from_kind.get(n), Some(UnitKind::Commons))
1247                && let Some(commons_name) = ctx.imported_from.get(n)
1248            {
1249                by_commons
1250                    .entry(commons_name.clone())
1251                    .or_default()
1252                    .push(n.clone());
1253            }
1254        }
1255        let mut commons_keys: Vec<&String> = by_commons.keys().collect();
1256        commons_keys.sort();
1257        for commons_name in commons_keys {
1258            let names = by_commons.get(commons_name).unwrap();
1259            let mut sorted_names: Vec<String> = names.clone();
1260            sorted_names.sort();
1261            sorted_names.dedup();
1262            let target_path = ctx
1263                .imported_decl_paths
1264                .get(commons_name)
1265                .and_then(|m| sorted_names.iter().find_map(|n| m.get(n).cloned()))
1266                .unwrap_or_else(|| EmitProjectCtx::commons_path(commons_name));
1267            let import_spec = cross_commons_import_specifier_for_path(
1268                &ctx.source_path,
1269                &target_path,
1270                ctx.import_ext,
1271            );
1272            let mut parts: Vec<String> = Vec::new();
1273            for n in &sorted_names {
1274                parts.push(format!("serialise_{n}"));
1275                parts.push(format!("deserialise_{n}"));
1276            }
1277            // v0.9.1: emit both a regular import (so the names are bound
1278            // locally for use inside this file's serialisation helpers) and a
1279            // re-export (so downstream consumers can still reach them
1280            // through this module). A bare `export { ... } from "..."`
1281            // re-export does not create a local binding, which `tsc --strict`
1282            // catches when the body calls one of the helpers directly.
1283            //
1284            // Arc C, slice 30 (#1392): the import is a real `TsDecl::Import`.
1285            // The re-export is a BARE `export { ... };` — already-bound
1286            // local names, no `from` clause — which `TsDecl::ReExport`
1287            // cannot represent (it always carries one); this one real site
1288            // stays opaque `TsStmt::raw` rather than a new variant for a
1289            // single call site, the established "odd, one-off shape stays
1290            // opaque text" posture.
1291            stmts.push(bynk_ts::TsStmt::decl(
1292                bynk_ts::TsDecl::Import {
1293                    type_only: false,
1294                    names: parts.clone(),
1295                    from: import_spec,
1296                },
1297                None,
1298            ));
1299            stmts.push(bynk_ts::TsStmt::raw(
1300                format!("export {{ {} }};\n", parts.join(", ")),
1301                None,
1302            ));
1303        }
1304        if !by_commons.is_empty() {
1305            stmts.push(bynk_ts::TsStmt::blank(None));
1306        }
1307
1308        // Specialised Result_/Option_ helpers for the instantiations used —
1309        // in handler signatures or in boundary-type fields (v0.18).
1310        //
1311        // #977: the field walk follows `local_boundary`, not `boundary_types_all`
1312        // — the same narrowing `emit_helpers_for_owner` applies just above, and
1313        // for the same reason. A boundary type this context does not *declare* is
1314        // either commons-owned (its codec, and its own instantiations, come from
1315        // the commons module) or consumed (its codec is regenerated below by
1316        // `emit_consumed_context_helpers`, whose `Qual` map reaches the owner's
1317        // `import type * as <ns>` alias). Walking a foreign type's fields here
1318        // emitted its instantiations *unqualified* — `Option<Region>` for a
1319        // consumed `Region` — and then seeded `emitted_insts` so the qualified
1320        // pass below skipped it, leaving `tsc --strict` with `TS2304: Cannot find
1321        // name 'Region'`. Handler signatures still walk in full: a *local*
1322        // handler naming `Option[ConsumedRegion]` directly is this module's own
1323        // boundary either way.
1324        let insts =
1325            collect_generic_instantiations(&services, &agents, &local_boundary, &commons.types);
1326        stmts.extend(serialisation::decls_as_stmts(emit_generic_helpers(
1327            &insts,
1328            &commons.types,
1329            &ctx.runtime_use,
1330        )));
1331
1332        // #661 (ADR 0199 Decision G discharged): the caller's own view of each
1333        // consumed context's boundary codecs, so a cross-context call reaches
1334        // `deserialise_Result_AuthId_PaymentError` **locally** instead of through
1335        // the callee's module. Workers only — on `bundle` the call is in-process
1336        // and needs no wire codec. Everything the caller already emits (its own
1337        // boundary types, the commons re-exports above, its own generic
1338        // instantiations) is skipped, so only the callee-*owned* types the caller
1339        // lacks a local view of are generated.
1340        let (consumed_names, consumed_insts) = if workers {
1341            let mut emitted_names: HashSet<String> = local_boundary.iter().cloned().collect();
1342            for names in by_commons.values() {
1343                emitted_names.extend(names.iter().cloned());
1344            }
1345            let mut emitted_insts: HashSet<String> = insts.iter().map(|i| i.ts_name()).collect();
1346            let (consumed_stmts, consumed_names, consumed_insts) =
1347                emit_consumed_context_helpers(program, ctx, &mut emitted_names, &mut emitted_insts);
1348            stmts.extend(consumed_stmts);
1349            (consumed_names, consumed_insts)
1350        } else {
1351            (Vec::new(), Vec::new())
1352        };
1353
1354        let mut ret_names: HashSet<String> = boundary_types_all.into_iter().collect();
1355        ret_names.extend(consumed_names);
1356        let mut ret_insts: HashSet<String> = insts.iter().map(|i| i.ts_name()).collect();
1357        ret_insts.extend(consumed_insts);
1358        (stmts, ret_names, ret_insts)
1359    } else if !workers {
1360        // Commons/adapters have no agents (no rehydration boundary), and on
1361        // `bundle` there is no cross-Worker call boundary either — so emit no
1362        // boundary helpers, matching pre-v0.96 bundle output (the rehydration
1363        // pass that now always runs is for context-declared agents only).
1364        (Vec::new(), HashSet::new(), HashSet::new())
1365    } else {
1366        // Commons/adapters (workers): emit helpers for every type declared in
1367        // this file, plus (v0.18) the generic instantiations their fields use —
1368        // a record like the bynk surface's `Request` carries
1369        // `Option[String]` fields whose serialisers delegate to the
1370        // specialised helpers.
1371        //
1372        // v0.132 (#479): scope to types declared in *this* file, not the whole
1373        // unit. `file_decl_index` is unit-wide (name -> declaring-file path), so a
1374        // multi-file commons must filter by the current file — otherwise a
1375        // non-declaring sibling (e.g. the file holding `fn T.make`, not `type T`)
1376        // emits an orphan `serialise_T`/`deserialise_T` with `T` out of scope, and
1377        // the codec is duplicated across files. Unlike a workers *context* (which
1378        // collapses to one `handlers.ts` under a synthetic source_path, so its
1379        // unit-wide `locally_declared` above is correct), a commons emits per file.
1380        let mut locally: Vec<String> = ctx
1381            .file_decl_index
1382            .types
1383            .iter()
1384            .filter(|(_, path)| path.as_path() == ctx.source_path.as_path())
1385            .map(|(name, _)| name.clone())
1386            .collect();
1387        locally.sort();
1388        let mut stmts: Vec<bynk_ts::TsStmt> = Vec::new();
1389        stmts.extend(serialisation::decls_as_stmts_block(emit_helpers_for_owner(
1390            &locally,
1391            &commons.types,
1392            ctx.commons_name.as_str(),
1393            &ctx.runtime_use,
1394        )));
1395        let insts = collect_generic_instantiations(
1396            &HashMap::new(),
1397            &HashMap::new(),
1398            &locally,
1399            &commons.types,
1400        );
1401        stmts.extend(serialisation::decls_as_stmts(emit_generic_helpers(
1402            &insts,
1403            &commons.types,
1404            &ctx.runtime_use,
1405        )));
1406        (
1407            stmts,
1408            locally.into_iter().collect(),
1409            insts.iter().map(|i| i.ts_name()).collect(),
1410        )
1411    }
1412}
1413
1414/// #661: emit the caller's own `serialise_*`/`deserialise_*` for every
1415/// callee-owned boundary type reachable from the services this context
1416/// **calls**, so a `workers` cross-context call resolves its codecs locally
1417/// rather than importing the callee's module as a value.
1418///
1419/// The codec function names stay bare and local; only the TS *type* positions
1420/// reach through the callee's `import type * as <ns>` alias (via the `Qual`
1421/// map built here). Refinement validation follows the export visibility: an
1422/// opaque type casts structurally (Decision C), a transparent refined type
1423/// inlines its predicates (Decision D) — both decided inside the codec emitter
1424/// from the type's own body.
1425///
1426/// Only the callee-*owned* types (its `exports`) are generated. Commons types
1427/// reachable through the boundary (`Money`) are already emitted or re-exported
1428/// by the caller's own path, so they are left out here and deduped against
1429/// `emitted_names` / `emitted_insts`, which the caller seeds with everything it
1430/// has already emitted. Returns the names and generic-instantiation names newly
1431/// emitted, so the Json-codec pass dedupes against them too.
1432/// #1478: returns real [`bynk_ts::TsStmt`]s (was `out: &mut String`) as a
1433/// new first element of the tuple — every `out`-write here is already
1434/// exclusively through `serialisation::decls_as_stmts[_block]`, called
1435/// directly since that slice, appended into `stmts`.
1436fn emit_consumed_context_helpers(
1437    program: &CheckedProgram,
1438    ctx: &EmitProjectCtx,
1439    emitted_names: &mut HashSet<String>,
1440    emitted_insts: &mut HashSet<String>,
1441) -> (Vec<bynk_ts::TsStmt>, Vec<String>, Vec<String>) {
1442    use serialisation::{
1443        collect_codec_closure, emit_generic_helpers_qualified, emit_helpers_for_owner_qualified,
1444    };
1445    // Review of #1211: derived, not taken as a second parameter — see
1446    // `emit_boundary_helpers`'s own identical note.
1447    let commons = program.program();
1448    let info = &ctx.cross_context;
1449    let mut stmts: Vec<bynk_ts::TsStmt> = Vec::new();
1450    let mut consumed_names_out: Vec<String> = Vec::new();
1451    let mut consumed_insts_out: Vec<String> = Vec::new();
1452
1453    // Only the services this context actually **calls** — not the callee's whole
1454    // provided surface. `consumed_services` carries every service the dependency
1455    // provides; generating a codec for one this context never reaches would bloat
1456    // the bundle with a contract it does not participate in (and pull in the
1457    // uncalled service's own boundary types). Mirrors the `called` narrowing the
1458    // contract manifest applies to `expects` (ADR 0200 Decision E, one layer up).
1459    let called = called_consumed_services(commons, info);
1460
1461    // #973: this context's own `from Events(E)` subscriptions, keyed by the
1462    // consumed context that declares each `E` — a subscriber calls no method
1463    // on the publisher, so `called_consumed_services` alone would never see
1464    // it, and the `continue` below on an empty `called_here` would skip the
1465    // event's payload type entirely (the root cause of #973: a subscriber's
1466    // generated module had no `deserialise_<Payload>` at all).
1467    // #1187's own closing scoping pass: the event's own identity now reads
1468    // `ProtocolIr::Events`'s already-resolved `event: TyId` (via
1469    // `lower_protocol_ir`) instead of a raw match on `svc.protocol`/
1470    // `event_type`. `collect_codec_closure` below is still `TypeRef`-driven
1471    // (confirmed: no resolved-field-type table exists anywhere in
1472    // `bynk-check` to substitute), so `ty_to_type_ref` converts back at the
1473    // end — the same TyId-in, TypeRef-out shape `lower_json_codec_call`
1474    // already uses for the identical reason. `event`'s own type is always
1475    // non-generic in practice: `context_checks.rs`'s own event/handler
1476    // param-type-agreement check (`type_ref_named`) only ever admits a bare
1477    // `Named` event type, so `ty_to_type_ref`'s generic-instantiation arm
1478    // never actually fires here — not relied on silently, just not the
1479    // shape a certified program can produce.
1480    let mut consumed_event_roots: HashMap<String, Vec<bynk_syntax::ast::TypeRef>> = HashMap::new();
1481    for item in &commons.commons.items {
1482        let CommonsItem::Service(svc) = item else {
1483            continue;
1484        };
1485        let bynk_ir::ProtocolIr::Events { event, .. } =
1486            bynk_lower::lower_protocol_ir(&svc.protocol, program)
1487        else {
1488            continue;
1489        };
1490        // Review of #1211: unlike the raw-AST match this replaced, a
1491        // resolve miss here (`lower_protocol_ir` degrades to `Ty::Unit`
1492        // rather than propagating a failure, `ir/lower.rs`'s own
1493        // `resolve_type_ref`/`unit_ty()` fallback) is indistinguishable
1494        // from a legitimately non-`Named` header — a silent `continue`
1495        // would reproduce #973's own regression shape (a subscriber module
1496        // missing its `deserialise_<Payload>`) with no diagnostic. The
1497        // event/handler param-type-agreement check (`context_checks.rs`'s
1498        // `bynk.event.handler_param_type_mismatch`) makes this
1499        // unreachable on a certified program, but that argument is longer
1500        // than the old syntactic match needed — enforced here, not just
1501        // narrated, so a day this stops holding fails loud in the bless
1502        // suite (which runs debug) instead of quietly shipping a
1503        // subscriber with no deserialiser.
1504        let resolved = commons.tys().get(event);
1505        debug_assert!(
1506            matches!(&*resolved, Ty::Named { .. }),
1507            "bynk internal error: a `from Events(...)` header resolved to a non-named \
1508             type — a certified program cannot produce this (see #973)"
1509        );
1510        let Ty::Named { name, .. } = &*resolved else {
1511            continue;
1512        };
1513        let Some(event_type) = ty_to_type_ref(event, commons.tys()) else {
1514            continue;
1515        };
1516        for (c, names) in &info.consumed_event_names {
1517            if names.contains(name) {
1518                consumed_event_roots
1519                    .entry(c.clone())
1520                    .or_default()
1521                    .push(event_type.clone());
1522            }
1523        }
1524    }
1525
1526    let empty_svcs: HashMap<String, bynk_check::resolver::CrossContextService> = HashMap::new();
1527    let empty_called: HashSet<String> = HashSet::new();
1528    let empty_event_roots: Vec<bynk_syntax::ast::TypeRef> = Vec::new();
1529
1530    let mut consumed_keys: HashSet<&String> = info.consumed_services.keys().collect();
1531    consumed_keys.extend(consumed_event_roots.keys());
1532    let mut consumed_keys: Vec<&String> = consumed_keys.into_iter().collect();
1533    consumed_keys.sort();
1534    for c in consumed_keys {
1535        let svcs = info.consumed_services.get(c).unwrap_or(&empty_svcs);
1536        let event_roots = consumed_event_roots.get(c).unwrap_or(&empty_event_roots);
1537        if svcs.is_empty() && event_roots.is_empty() {
1538            continue;
1539        }
1540        let called_here = called.get(c).unwrap_or(&empty_called);
1541        if called_here.is_empty() && event_roots.is_empty() {
1542            continue;
1543        }
1544        let Some(types_table) = info.consumed_types.get(c) else {
1545            continue;
1546        };
1547        // The callee's exports — the set of types it *owns* and a consumer may
1548        // name. A closure type outside this set (a commons type the callee only
1549        // `uses`, e.g. `Money`) is the caller's own already, not the callee's to
1550        // hand out, so the caller never regenerates it under the callee's ns.
1551        let exports = ctx.exports_for_consumed.get(c);
1552        let owned = |n: &str| exports.is_some_and(|e| e.contains_key(n));
1553
1554        // Roots: every called service's parameter and return types, plus (#973)
1555        // any event type this context subscribes to from `c` — a subscriber
1556        // participates in the event's contract as its receiving half, so its
1557        // payload is not an uncalled surface the way an unreached method is
1558        // (the narrowing this loop otherwise applies, mirroring ADR 0200
1559        // Decision E one layer up, at `called_consumed_services` above).
1560        let mut svc_names: Vec<&String> =
1561            svcs.keys().filter(|s| called_here.contains(*s)).collect();
1562        svc_names.sort();
1563        let mut roots: Vec<bynk_syntax::ast::TypeRef> = event_roots.clone();
1564        for sn in svc_names {
1565            let svc = &svcs[sn];
1566            for (_, t) in &svc.params {
1567                roots.push(t.clone());
1568            }
1569            roots.push(svc.return_type.clone());
1570        }
1571        let (names, cinsts) = collect_codec_closure(&roots, types_table);
1572
1573        let ns = format!("{}.", qualified_to_ns(c));
1574        let mut qual: HashMap<String, String> = HashMap::new();
1575        for n in &names {
1576            if owned(n) {
1577                qual.insert(n.clone(), ns.clone());
1578            }
1579        }
1580
1581        let mut to_emit: Vec<String> = names
1582            .iter()
1583            .filter(|n| owned(n) && emitted_names.insert((*n).clone()))
1584            .cloned()
1585            .collect();
1586        to_emit.sort();
1587        stmts.extend(serialisation::decls_as_stmts_block(
1588            emit_helpers_for_owner_qualified(
1589                &to_emit,
1590                types_table,
1591                ctx.commons_name.as_str(),
1592                &qual,
1593                &ctx.runtime_use,
1594            ),
1595        ));
1596        consumed_names_out.extend(to_emit);
1597
1598        let to_emit_insts: Vec<serialisation::GenericInst> = cinsts
1599            .into_iter()
1600            .filter(|i| emitted_insts.insert(i.ts_name()))
1601            .collect();
1602        for i in &to_emit_insts {
1603            consumed_insts_out.push(i.ts_name());
1604        }
1605        stmts.extend(serialisation::decls_as_stmts(
1606            emit_generic_helpers_qualified(&to_emit_insts, types_table, &qual, &ctx.runtime_use),
1607        ));
1608    }
1609
1610    (stmts, consumed_names_out, consumed_insts_out)
1611}
1612
1613/// #661: the cross-context services this unit actually **calls**, as `consumed
1614/// context → service names`. A copy of `project::called_cross_context_services`
1615/// over the emitter's AST view (`commons`) — the caller-side codec set follows
1616/// the *called* subset, not the callee's full provided surface, so it stays in
1617/// step with what the contract manifest records under `expects`.
1618///
1619/// P6.56 (design/tracks/the-ir.md §6b): reads the checker's own
1620/// already-resolved `Callee::Cross { unit, service }` for each visited call
1621/// site instead of reconstructing cross-context-ness syntactically
1622/// (`flatten_emit_ident_chain` on a `MethodCall` receiver, then
1623/// `info.resolve_prefix`) — the identical resolution `CrossContextInfo::
1624/// resolve_prefix` already performed once, at check time, per call site
1625/// (`project::called_cross_context_services`'s own #1187 conversion, the
1626/// direct precedent this mirrors). Same shadowing-hazard class `block_uses_emit`
1627/// closed for `Events.emit`.
1628fn called_consumed_services(
1629    commons: &TypedCommons,
1630    info: &bynk_check::resolver::CrossContextInfo,
1631) -> HashMap<String, HashSet<String>> {
1632    let mut out: HashMap<String, HashSet<String>> = HashMap::new();
1633    if info.consumed_contexts.is_empty() && info.aliases.is_empty() {
1634        return out;
1635    }
1636    let mut visit = |e: &Expr| {
1637        if let Some(bynk_check::checker::Callee::Cross { unit, service }) =
1638            commons.callees.get(&e.id)
1639        {
1640            out.entry(unit.clone()).or_default().insert(service.clone());
1641        }
1642    };
1643    for item in &commons.commons.items {
1644        match item {
1645            CommonsItem::Service(s) => {
1646                for h in &s.handlers {
1647                    walk_block_exprs(&h.body, &mut visit);
1648                }
1649            }
1650            CommonsItem::Agent(a) => {
1651                for h in &a.handlers {
1652                    walk_block_exprs(&h.body, &mut visit);
1653                }
1654            }
1655            CommonsItem::Provider(p) => {
1656                for op in &p.ops {
1657                    walk_block_exprs(&op.body, &mut visit);
1658                }
1659            }
1660            _ => {}
1661        }
1662    }
1663    out
1664}
1665
1666/// For each type imported via `uses` that's referenced in this file, emit:
1667/// 1. (Done in imports) an aliased import: `import { Money as __CommonsMoney } from ...`
1668/// 2. A rebranded type alias: `export type Money = __CommonsMoney & { readonly __ctxBrand: "..." }`
1669///
1670/// The brand makes two contexts that both `uses` the same commons see distinct
1671/// nominal `Money` types in their TypeScript output (v0.4 §3.4 / §6.2).
1672/// #1478: real-node-internally already — returns its real declarations
1673/// (a rebrand type alias, and — for a refined/opaque base — a forwarding
1674/// const, per name) instead of writing into `out: &mut String`.
1675fn emit_context_rebrands(
1676    refs: &ExternalReferences,
1677    commons: &TypedCommons,
1678    ctx: &EmitProjectCtx,
1679) -> Vec<bynk_ts::TsStmt> {
1680    let Some(owning) = &ctx.owning_context else {
1681        return Vec::new();
1682    };
1683    // Collect names imported via `uses` (kind == Commons in imported_from_kind).
1684    // R4.10/R8.2: reads `bynk_check::resolver::compute_is_uses_commons_type`, the same
1685    // predicate `prepare_unit_check_ctx` (`check_pipeline.rs`) computes its own
1686    // `uses_commons_type_names` from — one definition, not two independently
1687    // maintained copies linked only by a doc-comment promise (see that
1688    // function's own doc comment for the real defect this closes, ADR 0226).
1689    let mut names: Vec<String> = Vec::new();
1690    for set in refs.by_commons.values() {
1691        for n in set {
1692            // v0.20b: only *types* get the context rebrand — a
1693            // `uses`-imported function is a value and imports plainly.
1694            if bynk_check::resolver::compute_is_uses_commons_type(
1695                &ctx.imported_from_kind,
1696                &commons.types,
1697                n,
1698            ) {
1699                names.push(n.clone());
1700            }
1701        }
1702    }
1703    names.sort();
1704    names.dedup();
1705    if names.is_empty() {
1706        return Vec::new();
1707    }
1708    let mut stmts = Vec::new();
1709    for name in &names {
1710        // v0.174 (#592): a generic commons type keeps its parameters across the
1711        // rebrand — `Paginated[T]` aliases as `Paginated<T> =
1712        // __CommonsPaginated<T> & { … }`, not a bare `Paginated`, which would
1713        // both drop the parameter and make every `Paginated<User>` reference in
1714        // the context a "type is not generic" error.
1715        let params: Vec<&str> = commons
1716            .types
1717            .get(name)
1718            .map(|d| d.type_params.iter().map(|p| p.name.name.as_str()).collect())
1719            .unwrap_or_default();
1720        let generic_args: Vec<bynk_ts::TsType> =
1721            params.iter().map(|p| bynk_ts::TsType::named(*p)).collect();
1722        // Arc C, slice 30 (#1392): the SAME `TsDecl::TypeAlias` over
1723        // `TsType::Intersection` shape #1339's own `emit_refined_type`
1724        // already established for its sibling `__brand` alias (a real,
1725        // proven precedent, not this slice's own new gap).
1726        let type_alias = bynk_ts::TsStmt::decl(
1727            bynk_ts::TsDecl::Export(Box::new(bynk_ts::TsDecl::TypeAlias {
1728                name: name.clone(),
1729                type_params: params.iter().map(|p| p.to_string()).collect(),
1730                ty: bynk_ts::TsType::intersection(vec![
1731                    bynk_ts::TsType::named_with_args(format!("__Commons{name}"), generic_args),
1732                    bynk_ts::TsType::Object(vec![bynk_ts::TsTypeMember::readonly_prop(
1733                        "__ctxBrand",
1734                        bynk_ts::TsType::named(format!("\"{owning}\"")),
1735                    )]),
1736                ]),
1737            })),
1738            None,
1739        );
1740        stmts.push(type_alias);
1741        // v0.9.2: a commons refined/opaque type carries a value-side
1742        // constructor (`.of`, and `.unsafe` for opaque). Re-export it under the
1743        // rebranded name so a context calling `ShortCode.of(...)` resolves to a
1744        // value — delegating to the imported commons constructor but reporting
1745        // the context-branded type. (Without this, `ShortCode` is type-only in
1746        // the context and `.of` fails to resolve.)
1747        if let Some(base) = commons
1748            .types
1749            .get(name)
1750            .and_then(|d| refined_or_opaque_base(d))
1751        {
1752            let ts_base_name = ts_base(base);
1753            let is_opaque = matches!(
1754                commons.types.get(name).map(|d| &d.body),
1755                Some(TypeBody::Opaque { .. })
1756            );
1757            // `X.of(value) as unknown as Result<Name, ValidationError>` — a
1758            // real nested `TsExpr::As`, the same shape `emit_forwarded_
1759            // methods` (immediately below) already proves renders correctly
1760            // with no extra parens: the `As` arm's own inner-expr check
1761            // only guards `Binary`/`Arrow`/`Conditional`, not a nested `As`.
1762            let of_entry = bynk_ts::TsObjectEntry::Method {
1763                name: "of".to_string(),
1764                is_async: false,
1765                generics: Vec::new(),
1766                params: vec![bynk_ts::TsParam {
1767                    name: "value".to_string(),
1768                    ty: Some(bynk_ts::TsType::named(ts_base_name)),
1769                    optional: false,
1770                }],
1771                return_type: Some(bynk_ts::TsType::named_with_args(
1772                    "Result",
1773                    vec![
1774                        bynk_ts::TsType::named(name.clone()),
1775                        bynk_ts::TsType::named("ValidationError"),
1776                    ],
1777                )),
1778                doc: None,
1779                inline: true,
1780                body: vec![bynk_ts::TsStmt::return_stmt(
1781                    Some(bynk_ts::TsExpr::As {
1782                        expr: Box::new(bynk_ts::TsExpr::As {
1783                            expr: Box::new(bynk_ts::TsExpr::Call {
1784                                callee: Box::new(bynk_ts::TsExpr::Member {
1785                                    object: Box::new(bynk_ts::TsExpr::Ident(format!(
1786                                        "__Commons{name}"
1787                                    ))),
1788                                    property: "of".to_string(),
1789                                }),
1790                                args: vec![bynk_ts::TsExpr::Ident("value".to_string())],
1791                            }),
1792                            ty: bynk_ts::TsType::named("unknown"),
1793                        }),
1794                        ty: bynk_ts::TsType::named_with_args(
1795                            "Result",
1796                            vec![
1797                                bynk_ts::TsType::named(name.clone()),
1798                                bynk_ts::TsType::named("ValidationError"),
1799                            ],
1800                        ),
1801                    }),
1802                    None,
1803                )],
1804            };
1805            let mut entries = vec![of_entry];
1806            // ADR 0182: only opaque types have a public `.unsafe` to forward.
1807            // A refined/alias type has none — a consuming context brands an
1808            // admitted literal with an inline `as` cast, not a forwarder call.
1809            if is_opaque {
1810                entries.push(bynk_ts::TsObjectEntry::Method {
1811                    name: "unsafe".to_string(),
1812                    is_async: false,
1813                    generics: Vec::new(),
1814                    params: vec![bynk_ts::TsParam {
1815                        name: "value".to_string(),
1816                        ty: Some(bynk_ts::TsType::named(ts_base_name)),
1817                        optional: false,
1818                    }],
1819                    return_type: Some(bynk_ts::TsType::named(name.clone())),
1820                    doc: None,
1821                    inline: true,
1822                    body: vec![bynk_ts::TsStmt::return_stmt(
1823                        Some(bynk_ts::TsExpr::As {
1824                            expr: Box::new(bynk_ts::TsExpr::As {
1825                                expr: Box::new(bynk_ts::TsExpr::Call {
1826                                    callee: Box::new(bynk_ts::TsExpr::Member {
1827                                        object: Box::new(bynk_ts::TsExpr::Ident(format!(
1828                                            "__Commons{name}"
1829                                        ))),
1830                                        property: "unsafe".to_string(),
1831                                    }),
1832                                    args: vec![bynk_ts::TsExpr::Ident("value".to_string())],
1833                                }),
1834                                ty: bynk_ts::TsType::named("unknown"),
1835                            }),
1836                            ty: bynk_ts::TsType::named(name.clone()),
1837                        }),
1838                        None,
1839                    )],
1840                });
1841            }
1842            // v0.132.1 (#481): forward the commons' user-defined attached methods
1843            // (`Cents.fromInt`, …) so the rebranded const carries more than the
1844            // built-in `of`/`unsafe`. Without this a consumer's `Cents.fromInt(n)`
1845            // — which `bynkc check` accepts — fails `tsc`. The methods aren't in
1846            // this context's own `commons` (only imported *types* are merged);
1847            // they arrive via `ctx.imported_methods`, keyed by type name.
1848            if let Some(methods) = ctx.imported_methods.get(name) {
1849                entries.extend(emit_forwarded_methods(name, methods, &commons.ty_intern));
1850            }
1851            let const_decl = bynk_ts::TsStmt::decl(
1852                bynk_ts::TsDecl::Export(Box::new(bynk_ts::TsDecl::ConstDecl {
1853                    name: name.clone(),
1854                    ty: None,
1855                    init: bynk_ts::TsExpr::multiline_object_entries(entries),
1856                })),
1857                None,
1858            );
1859            stmts.push(const_decl);
1860        }
1861    }
1862    stmts.push(bynk_ts::TsStmt::blank(None));
1863    stmts
1864}
1865
1866/// If a type declaration is a refined or opaque base type, return its base
1867/// (both lower to a branded base with a `.of` / `.unsafe` constructor object).
1868///
1869/// P6.56 (design/tracks/the-ir.md §6b): investigated routing this through
1870/// `TypeShape::Refined` and declined — that variant's own `base` field is
1871/// `bynk_syntax::ast::BaseType` (P6.41 ruled it stays, phase 7 — the bounds
1872/// keep source lexemes for byte-stable emission), so this function's own
1873/// `Option<BaseType>` return type is identical either way. Converting would
1874/// add a `CheckedProgram`/`TypedCommons` dependency this function doesn't
1875/// have today for zero reduction in AST-type surface.
1876fn refined_or_opaque_base(decl: &TypeDecl) -> Option<BaseType> {
1877    match &decl.body {
1878        TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. } => Some(*base),
1879        _ => None,
1880    }
1881}
1882
1883/// Names that this file needs to import from elsewhere (sibling files of
1884/// the same commons, or other commons via `uses`).
1885#[derive(Default)]
1886struct ExternalReferences {
1887    /// `commons name` → set of names to import.
1888    by_commons: HashMap<String, HashSet<String>>,
1889    /// `sibling source path` → set of names to import (same-commons).
1890    by_sibling: HashMap<PathBuf, HashSet<String>>,
1891}
1892
1893impl ExternalReferences {
1894    fn is_empty(&self) -> bool {
1895        self.by_commons.is_empty() && self.by_sibling.is_empty()
1896    }
1897}
1898
1899fn collect_external_references(commons: &TypedCommons, ctx: &EmitProjectCtx) -> ExternalReferences {
1900    // Names declared in this file (so we know what's local-to-file).
1901    // A `messages` block declares no importable identifier of its own (its
1902    // `render` is synthesised separately), so `name()` is `None` there and it
1903    // contributes nothing to the local-name set.
1904    let local_to_file: HashSet<String> = commons
1905        .commons
1906        .items
1907        .iter()
1908        .filter_map(|i| i.name().map(|n| n.name.clone()))
1909        .collect();
1910
1911    let mut refs = ExternalReferences::default();
1912
1913    // Walk every expression and TypeRef in this file's items, recording
1914    // any reference that resolves to a name declared in a sibling file or
1915    // an imported commons.
1916    for item in &commons.commons.items {
1917        match item {
1918            CommonsItem::Type(t) => {
1919                collect_refs_in_type_decl(t, &local_to_file, ctx, &mut refs);
1920            }
1921            // Events track, slice 0 (spine #936): an `event`'s field types
1922            // are collected exactly like a `type`'s, via the same synthetic
1923            // `TypeDecl` `EventDecl::as_type_decl` builds.
1924            CommonsItem::Event(e) => {
1925                collect_refs_in_type_decl(&e.as_type_decl(), &local_to_file, ctx, &mut refs);
1926            }
1927            CommonsItem::Fn(f) => {
1928                collect_refs_in_fn(f, &local_to_file, commons, ctx, &mut refs);
1929            }
1930            CommonsItem::Capability(c) => {
1931                for op in &c.ops {
1932                    for p in &op.params {
1933                        collect_refs_in_typeref(&p.type_ref, &local_to_file, ctx, &mut refs);
1934                    }
1935                    collect_refs_in_typeref(&op.return_type, &local_to_file, ctx, &mut refs);
1936                }
1937            }
1938            CommonsItem::Provider(p) => {
1939                // Reference to the capability so we can import it (locally
1940                // declared, so usually no extra work).
1941                let _ = &p.capability;
1942                for op in &p.ops {
1943                    for param in &op.params {
1944                        collect_refs_in_typeref(&param.type_ref, &local_to_file, ctx, &mut refs);
1945                    }
1946                    collect_refs_in_typeref(&op.return_type, &local_to_file, ctx, &mut refs);
1947                    collect_refs_in_block(&op.body, &local_to_file, commons, ctx, &mut refs);
1948                }
1949            }
1950            CommonsItem::Service(s) => {
1951                for h in &s.handlers {
1952                    for p in &h.params {
1953                        collect_refs_in_typeref(&p.type_ref, &local_to_file, ctx, &mut refs);
1954                    }
1955                    collect_refs_in_typeref(&h.return_type, &local_to_file, ctx, &mut refs);
1956                    collect_refs_in_block(&h.body, &local_to_file, commons, ctx, &mut refs);
1957                }
1958            }
1959            CommonsItem::Agent(a) => {
1960                collect_refs_in_typeref(&a.key_type, &local_to_file, ctx, &mut refs);
1961                for f in &a.store_fields {
1962                    for arg in &f.kind.args {
1963                        collect_refs_in_typeref(arg, &local_to_file, ctx, &mut refs);
1964                    }
1965                }
1966                for h in &a.handlers {
1967                    for p in &h.params {
1968                        collect_refs_in_typeref(&p.type_ref, &local_to_file, ctx, &mut refs);
1969                    }
1970                    collect_refs_in_typeref(&h.return_type, &local_to_file, ctx, &mut refs);
1971                    collect_refs_in_block(&h.body, &local_to_file, commons, ctx, &mut refs);
1972                }
1973            }
1974            CommonsItem::Actor(a) => {
1975                if let Some(id) = &a.identity {
1976                    collect_refs_in_typeref(id, &local_to_file, ctx, &mut refs);
1977                }
1978            }
1979            // `MessageEntry.code`/`.template` are plain string literals with
1980            // no TypeRefs/exprs of their own to walk — but the generated
1981            // `render` (emit_messages) has a signature and body that name
1982            // `LocaleTag`/`Message`/`MessageArg` even though no expression in
1983            // this file's *source* does, so those three are registered here
1984            // by hand, the same way a real reference would be. `render`/
1985            // `renderArg` are deliberately NOT registered this way —
1986            // `render` collides with the generated function of the same
1987            // name, and both are instead imported together under
1988            // `emit_unit`'s (project.rs) hand-written, aliased extra import
1989            // line, bypassing this dedup/merge path entirely (importing
1990            // `renderArg` there too, alongside the aliased `render`, avoids a
1991            // duplicate import of it from here).
1992            CommonsItem::Messages(_) => {
1993                for name in ["LocaleTag", "Message", "MessageArg"] {
1994                    record_name_ref(name, &local_to_file, ctx, &mut refs);
1995                }
1996            }
1997        }
1998    }
1999    refs
2000}
2001
2002fn collect_refs_in_type_decl(
2003    t: &TypeDecl,
2004    local_to_file: &HashSet<String>,
2005    ctx: &EmitProjectCtx,
2006    out: &mut ExternalReferences,
2007) {
2008    match &t.body {
2009        TypeBody::Record(r) => {
2010            for f in &r.fields {
2011                collect_refs_in_typeref(&f.type_ref, local_to_file, ctx, out);
2012            }
2013        }
2014        TypeBody::Sum(s) => {
2015            for v in &s.variants {
2016                for p in &v.payload {
2017                    collect_refs_in_typeref(&p.type_ref, local_to_file, ctx, out);
2018                }
2019            }
2020        }
2021        _ => {}
2022    }
2023}
2024
2025fn collect_refs_in_fn(
2026    f: &FnDecl,
2027    local_to_file: &HashSet<String>,
2028    commons: &TypedCommons,
2029    ctx: &EmitProjectCtx,
2030    out: &mut ExternalReferences,
2031) {
2032    for p in &f.params {
2033        collect_refs_in_typeref(&p.type_ref, local_to_file, ctx, out);
2034    }
2035    collect_refs_in_typeref(&f.return_type, local_to_file, ctx, out);
2036    // For methods: the attached type may also be elsewhere.
2037    if let FnName::Method { type_name, .. } = &f.name {
2038        record_name_ref(&type_name.name, local_to_file, ctx, out);
2039    }
2040    collect_refs_in_block(&f.body, local_to_file, commons, ctx, out);
2041}
2042
2043fn collect_refs_in_typeref(
2044    r: &TypeRef,
2045    local_to_file: &HashSet<String>,
2046    ctx: &EmitProjectCtx,
2047    out: &mut ExternalReferences,
2048) {
2049    match r {
2050        TypeRef::Named(id) => record_name_ref(&id.name, local_to_file, ctx, out),
2051        TypeRef::Result(t, e, _) => {
2052            collect_refs_in_typeref(t, local_to_file, ctx, out);
2053            collect_refs_in_typeref(e, local_to_file, ctx, out);
2054        }
2055        // Exhaustive over the compound constructors (#527, the #507 disease):
2056        // the old `_ => {}` catch-all dropped `List[KindCount]` and friends,
2057        // so a name referenced only inside such a position was never
2058        // imported and the emitted module failed `tsc`.
2059        TypeRef::Option(t, _)
2060        | TypeRef::Effect(t, _)
2061        | TypeRef::HttpResult(t, _)
2062        | TypeRef::List(t, _)
2063        | TypeRef::Query(t, _)
2064        | TypeRef::Stream(t, _)
2065        | TypeRef::Connection(t, _)
2066        | TypeRef::History(t, _) => collect_refs_in_typeref(t, local_to_file, ctx, out),
2067        TypeRef::Map(k, v, _) => {
2068            collect_refs_in_typeref(k, local_to_file, ctx, out);
2069            collect_refs_in_typeref(v, local_to_file, ctx, out);
2070        }
2071        TypeRef::Fn(params, ret, _) => {
2072            for t in params {
2073                collect_refs_in_typeref(t, local_to_file, ctx, out);
2074            }
2075            collect_refs_in_typeref(ret, local_to_file, ctx, out);
2076        }
2077        // v0.157 (ADR 0183): a `Name[Arg, …]` application references the
2078        // generic type plus every argument — all must be imported.
2079        TypeRef::App { name, args, .. } => {
2080            record_name_ref(&name.name, local_to_file, ctx, out);
2081            for t in args {
2082                collect_refs_in_typeref(t, local_to_file, ctx, out);
2083            }
2084        }
2085        TypeRef::Base(..)
2086        | TypeRef::QueueResult(_)
2087        | TypeRef::ValidationError(_)
2088        | TypeRef::JsonError(_)
2089        | TypeRef::Unit(_) => {}
2090    }
2091}
2092
2093fn collect_refs_in_block(
2094    b: &Block,
2095    local_to_file: &HashSet<String>,
2096    commons: &TypedCommons,
2097    ctx: &EmitProjectCtx,
2098    out: &mut ExternalReferences,
2099) {
2100    for stmt in &b.statements {
2101        match stmt {
2102            Statement::Let(l) | Statement::EffectLet(l) => {
2103                if let Some(t) = &l.type_annot {
2104                    collect_refs_in_typeref(t, local_to_file, ctx, out);
2105                }
2106                collect_refs_in_expr(&l.value, local_to_file, commons, ctx, out);
2107            }
2108            Statement::Expect(a) => {
2109                collect_refs_in_expr(&a.value, local_to_file, commons, ctx, out);
2110            }
2111            Statement::Send(s) => {
2112                collect_refs_in_expr(&s.value, local_to_file, commons, ctx, out);
2113            }
2114            Statement::Do(d) => {
2115                collect_refs_in_expr(&d.value, local_to_file, commons, ctx, out);
2116            }
2117            Statement::Assign(a) => {
2118                collect_refs_in_expr(&a.value, local_to_file, commons, ctx, out);
2119            }
2120        }
2121    }
2122    collect_refs_in_expr(&b.tail, local_to_file, commons, ctx, out);
2123}
2124
2125fn collect_refs_in_expr(
2126    e: &Expr,
2127    local_to_file: &HashSet<String>,
2128    commons: &TypedCommons,
2129    ctx: &EmitProjectCtx,
2130    out: &mut ExternalReferences,
2131) {
2132    match &e.kind {
2133        // A bare ident the checker typed as a sum is a nullary variant
2134        // constructor — the lowering qualifies it to `Type.Variant`, so the
2135        // owning type must be imported (v0.18: first hit by `Get` from the
2136        // consumed bynk surface's `Method`).
2137        ExprKind::Ident(id) => {
2138            if let Some(type_name) = sum_owner_of_variant(&id.name, e.id, commons) {
2139                record_name_ref(&type_name, local_to_file, ctx, out);
2140            }
2141        }
2142        ExprKind::IntLit { .. }
2143        | ExprKind::FloatLit { .. }
2144        | ExprKind::DurationLit { .. }
2145        | ExprKind::StrLit(_)
2146        | ExprKind::BoolLit(_)
2147        | ExprKind::None
2148        | ExprKind::UnitLit => {}
2149        // v0.43: a hole's expression may reference imported names.
2150        ExprKind::Wire(inner) => collect_refs_in_expr(inner, local_to_file, commons, ctx, out),
2151        ExprKind::InterpStr(parts) => {
2152            for part in parts {
2153                if let InterpPart::Hole(hole) = part {
2154                    collect_refs_in_expr(hole, local_to_file, commons, ctx, out);
2155                }
2156            }
2157        }
2158        // v0.20a: a lambda — its annotated param types may reference
2159        // imported types; the body walks like any expression.
2160        ExprKind::Lambda(lambda) => {
2161            for p in &lambda.params {
2162                if let Some(tr) = &p.type_ref {
2163                    collect_refs_in_typeref(tr, local_to_file, ctx, out);
2164                }
2165            }
2166            collect_refs_in_expr(&lambda.body, local_to_file, commons, ctx, out);
2167        }
2168        ExprKind::EffectPure(inner) => {
2169            collect_refs_in_expr(inner, local_to_file, commons, ctx, out);
2170        }
2171        ExprKind::Expect(inner) => {
2172            collect_refs_in_expr(inner, local_to_file, commons, ctx, out);
2173        }
2174        ExprKind::Val { args, .. } => {
2175            for a in args {
2176                collect_refs_in_expr(a, local_to_file, commons, ctx, out);
2177            }
2178        }
2179        ExprKind::ListLit(elems) => {
2180            for el in elems {
2181                collect_refs_in_expr(el, local_to_file, commons, ctx, out);
2182            }
2183        }
2184        // v0.117: observation predicates may reference types/fns; `trace` does not.
2185        ExprKind::Observation(o) => {
2186            if let ObservationMatcher::Called { count, with_pred } = &o.matcher {
2187                if let Some(c) = count {
2188                    collect_refs_in_expr(c, local_to_file, commons, ctx, out);
2189                }
2190                if let Some(p) = with_pred {
2191                    collect_refs_in_expr(p, local_to_file, commons, ctx, out);
2192                }
2193            }
2194        }
2195        ExprKind::Trace { .. } => {}
2196        ExprKind::RecordSpread {
2197            type_name,
2198            base,
2199            overrides,
2200        } => {
2201            if let Some(tn) = type_name {
2202                record_name_ref(&tn.name, local_to_file, ctx, out);
2203            }
2204            collect_refs_in_expr(base, local_to_file, commons, ctx, out);
2205            for f in overrides {
2206                if let Some(v) = &f.value {
2207                    collect_refs_in_expr(v, local_to_file, commons, ctx, out);
2208                }
2209            }
2210        }
2211        ExprKind::Call { name, args, .. } => {
2212            record_name_ref(&name.name, local_to_file, ctx, out);
2213            // A payload-carrying bare variant call (`Won(prize)`) lowers to
2214            // `Type.Variant(…)` — import the owning sum type too.
2215            if let Some(type_name) = sum_owner_of_variant(&name.name, e.id, commons) {
2216                record_name_ref(&type_name, local_to_file, ctx, out);
2217            }
2218            // #527: a call to a commons-imported fn may lower with a rebrand
2219            // assertion naming its return type (`(decide(…) as Decision)`),
2220            // so the return type's names must be imported (and rebranded)
2221            // in step with the cast.
2222            if ctx.unit_kind == UnitKind::Context
2223                && ctx.imported_from_kind.get(&name.name) == Some(&UnitKind::Commons)
2224                && let Some(f) = commons.fns.get(&name.name)
2225            {
2226                collect_refs_in_typeref(&f.return_type, local_to_file, ctx, out);
2227            }
2228            for a in args {
2229                collect_refs_in_expr(a, local_to_file, commons, ctx, out);
2230            }
2231        }
2232        ExprKind::BinOp(_, l, r) => {
2233            collect_refs_in_expr(l, local_to_file, commons, ctx, out);
2234            collect_refs_in_expr(r, local_to_file, commons, ctx, out);
2235        }
2236        ExprKind::UnaryOp(_, i)
2237        | ExprKind::Paren(i)
2238        | ExprKind::Ok(i)
2239        | ExprKind::Err(i)
2240        | ExprKind::Some(i)
2241        | ExprKind::Question(i) => collect_refs_in_expr(i, local_to_file, commons, ctx, out),
2242        ExprKind::Block(b) => collect_refs_in_block(b, local_to_file, commons, ctx, out),
2243        ExprKind::If {
2244            cond,
2245            then_block,
2246            else_block,
2247        } => {
2248            collect_refs_in_expr(cond, local_to_file, commons, ctx, out);
2249            collect_refs_in_block(then_block, local_to_file, commons, ctx, out);
2250            collect_refs_in_block(else_block, local_to_file, commons, ctx, out);
2251        }
2252        ExprKind::ConstructorCall {
2253            type_name,
2254            method: _,
2255            args,
2256        } => {
2257            record_name_ref(&type_name.name, local_to_file, ctx, out);
2258            for a in args {
2259                collect_refs_in_expr(a, local_to_file, commons, ctx, out);
2260            }
2261        }
2262        ExprKind::RecordConstruction { type_name, fields } => {
2263            record_name_ref(&type_name.name, local_to_file, ctx, out);
2264            for f in fields {
2265                if let Some(v) = &f.value {
2266                    collect_refs_in_expr(v, local_to_file, commons, ctx, out);
2267                }
2268            }
2269        }
2270        ExprKind::FieldAccess { receiver, field: _ } => {
2271            // The bare-ident-as-type case (`TypeName.Variant`) — record the
2272            // name so we import the type.
2273            if let ExprKind::Ident(id) = &receiver.kind {
2274                record_name_ref(&id.name, local_to_file, ctx, out);
2275            } else {
2276                collect_refs_in_expr(receiver, local_to_file, commons, ctx, out);
2277            }
2278        }
2279        ExprKind::MethodCall {
2280            receiver,
2281            method: _,
2282            args,
2283            ..
2284        } => {
2285            if let ExprKind::Ident(id) = &receiver.kind {
2286                record_name_ref(&id.name, local_to_file, ctx, out);
2287            } else {
2288                collect_refs_in_expr(receiver, local_to_file, commons, ctx, out);
2289            }
2290            for a in args {
2291                collect_refs_in_expr(a, local_to_file, commons, ctx, out);
2292            }
2293        }
2294        ExprKind::Match { discriminant, arms } => {
2295            collect_refs_in_expr(discriminant, local_to_file, commons, ctx, out);
2296            for arm in arms {
2297                if let Pattern::Variant {
2298                    type_name: Some(tn),
2299                    ..
2300                } = &arm.pattern
2301                {
2302                    record_name_ref(&tn.name, local_to_file, ctx, out);
2303                }
2304                match &arm.body {
2305                    MatchBody::Expr(e) => collect_refs_in_expr(e, local_to_file, commons, ctx, out),
2306                    MatchBody::Block(b) => {
2307                        collect_refs_in_block(b, local_to_file, commons, ctx, out)
2308                    }
2309                }
2310            }
2311        }
2312        ExprKind::Is { value, pattern } => {
2313            collect_refs_in_expr(value, local_to_file, commons, ctx, out);
2314            if let Pattern::Variant {
2315                type_name: Some(tn),
2316                ..
2317            } = pattern.as_ref()
2318            {
2319                record_name_ref(&tn.name, local_to_file, ctx, out);
2320            }
2321        }
2322    }
2323}
2324
2325/// If `name` at `span` is a bare reference to a variant of a sum type (per
2326/// the checker's expression type), return the owning sum's name — the same
2327/// test the lowering uses to qualify it as `Type.Variant` (see the
2328/// `ExprKind::Ident` arm of `lower_expr_into`).
2329///
2330/// P6.56 (design/tracks/the-ir.md §6b): investigated routing variant
2331/// membership through a `TypedCommons`-only `TypeShape::Sum` lowering and
2332/// declined, not built — `lower_type_item_ir`'s own `TypeBody::Sum` arm
2333/// resolves every payload field's own `TyId` for every variant (an
2334/// `.unwrap_or_else(|| panic!(..))` on any resolution miss), just to answer
2335/// a name-membership question this call site can settle with a zero-cost,
2336/// infallible string comparison today. `positional_field_name` below has
2337/// the identical shape and the identical verdict, for the identical
2338/// reason.
2339fn sum_owner_of_variant(name: &str, id: ExprId, commons: &TypedCommons) -> Option<String> {
2340    if let Some(Ty::Named {
2341        kind: NamedKind::Sum,
2342        name: type_name,
2343        ..
2344    }) = commons.expr_ty(id).as_deref()
2345        && let Some(decl) = commons.types.get(type_name)
2346        && let TypeBody::Sum(s) = &decl.body
2347        && s.variants.iter().any(|v| v.name.name == name)
2348    {
2349        return Some(type_name.clone());
2350    }
2351    None
2352}
2353
2354fn record_name_ref(
2355    name: &str,
2356    local_to_file: &HashSet<String>,
2357    ctx: &EmitProjectCtx,
2358    out: &mut ExternalReferences,
2359) {
2360    if local_to_file.contains(name) {
2361        return;
2362    }
2363    // Imported from another commons?
2364    if let Some(commons_name) = ctx.imported_from.get(name) {
2365        out.by_commons
2366            .entry(commons_name.clone())
2367            .or_default()
2368            .insert(name.to_string());
2369        return;
2370    }
2371    // Sibling file in the same commons?
2372    if let Some(path) = ctx.file_decl_index.types.get(name)
2373        && path != &ctx.source_path
2374    {
2375        out.by_sibling
2376            .entry(path.clone())
2377            .or_default()
2378            .insert(name.to_string());
2379        return;
2380    }
2381    if let Some(path) = ctx.file_decl_index.fns.get(name)
2382        && path != &ctx.source_path
2383    {
2384        out.by_sibling
2385            .entry(path.clone())
2386            .or_default()
2387            .insert(name.to_string());
2388    }
2389}
2390
2391/// Emit `import * as <ns> from "..."` for each consumed context that
2392/// exposes services (so the consuming file can reference its `makeSurface`
2393/// return type and brand the cross-context call arguments).
2394/// #1478: real-node-internally already — returns one real `TsDecl::
2395/// ImportNamespace` per consumed context instead of writing into
2396/// `out: &mut String`.
2397fn emit_cross_context_namespace_imports(
2398    commons: &TypedCommons,
2399    ctx: &EmitProjectCtx,
2400) -> Vec<bynk_ts::TsStmt> {
2401    let info = &ctx.cross_context;
2402    // Consumed contexts that expose services (v0.6) plus, v0.15, those whose
2403    // capabilities this context references via `given B.Cap`.
2404    let mut needed: std::collections::BTreeSet<String> = info
2405        .consumed_services
2406        .iter()
2407        .filter(|(_, svcs)| !svcs.is_empty())
2408        .map(|(q, _)| q.clone())
2409        .collect();
2410    needed.extend(cross_context_cap_namespaces(commons, info));
2411    if needed.is_empty() {
2412        return Vec::new();
2413    }
2414    let mut stmts = Vec::new();
2415    let consumed_with_services: Vec<&String> = needed.iter().collect();
2416    for q in &consumed_with_services {
2417        // Pick the first known file path for the consumed context as the
2418        // import target. (The composition root lives in the consumed
2419        // context's directory; any of its files would work as an import
2420        // target since they're all in the same module namespace, but we
2421        // currently emit one file per .bynk source so a single import per
2422        // consumed name suffices for the surface contract.)
2423        let target_paths = ctx.imported_decl_paths.get(q.as_str());
2424        let target = target_paths
2425            .and_then(|m| m.values().next().cloned())
2426            .unwrap_or_else(|| {
2427                // No imported declaration pins the path (e.g. a capability-only
2428                // consumed context, v0.15). Fall back to the unit's own module:
2429                // its per-Worker handlers in workers mode, or its <segment>.bynk
2430                // source in bundle mode. v0.17: a consumed *adapter* is not a
2431                // Worker — its capability types live in its root module
2432                // (`<adapter>.ts`) in both targets.
2433                if ctx.consumed_adapters.contains(q.as_str()) {
2434                    let mut p = EmitProjectCtx::commons_path(q);
2435                    p.set_extension("bynk");
2436                    p
2437                } else {
2438                    match ctx.target {
2439                        BuildTarget::Workers => crate::project::worker_handlers_source_path(q),
2440                        BuildTarget::Bundle => {
2441                            let mut p = EmitProjectCtx::commons_path(q);
2442                            p.set_extension("bynk");
2443                            p
2444                        }
2445                    }
2446                }
2447            });
2448        let import =
2449            cross_commons_import_specifier_for_path(&ctx.source_path, &target, ctx.import_ext);
2450        let ns = qualified_to_ns(q);
2451        // #661: under `workers`, a consumed *context*'s module is imported for
2452        // its **types only** — the caller now generates its own codecs
2453        // (`emit_boundary_helpers`) and reaches the callee's types through this
2454        // alias in type position (`deps: { Clock: platform_time.Clock }`,
2455        // `Result<commerce_payment.AuthId, …>`). An `import type` is erased
2456        // outright, so the callee's *module* — and its provider implementation —
2457        // never enters the caller's Worker bundle. This does **not** apply to a
2458        // consumed *adapter* (its binding namespace, e.g. `tokens`, is a real
2459        // value import used by `compose.ts`) nor on `bundle` (contexts compile
2460        // together, and the value uses in `compose.ts` are legitimate).
2461        let type_only = matches!(ctx.target, BuildTarget::Workers)
2462            && !ctx.consumed_adapters.contains(q.as_str());
2463        // Arc C, slice 30 (#1392): a real `TsDecl::ImportNamespace` —
2464        // `type_only` (#1392's own new field) covers the `import type * as`
2465        // form.
2466        stmts.push(bynk_ts::TsStmt::decl(
2467            bynk_ts::TsDecl::ImportNamespace {
2468                type_only,
2469                alias: ns,
2470                from: import,
2471            },
2472            None,
2473        ));
2474    }
2475    stmts.push(bynk_ts::TsStmt::blank(None));
2476    stmts
2477}
2478
2479/// #1478: real-node-internally already — returns one real `TsDecl::Import`
2480/// per sibling/cross-unit import group instead of writing into
2481/// `out: &mut String`.
2482fn emit_project_imports(
2483    commons: &TypedCommons,
2484    ctx: &EmitProjectCtx,
2485    refs: &ExternalReferences,
2486) -> Vec<bynk_ts::TsStmt> {
2487    let mut stmts = Vec::new();
2488    // Events track, slice 0 (spine #936): the bare event-type names this
2489    // context's own `from Events(E)` service headers name — see the
2490    // Workers type-only-import narrowing below.
2491    // P6.24a/P6.19: reads the protocol's own resolved `ProtocolIr::Events`
2492    // instead of matching `ServiceProtocol::Events { event_type:
2493    // TypeRef::Named(id), .. }` directly — a resolve miss (`lower_protocol_
2494    // ir_from_commons` degrades to `Ty::Unit` on one, never panics) is
2495    // indistinguishable from a legitimately non-`Named` header, so falls
2496    // through to `None` exactly like the raw match's own `_ => None` arm
2497    // did, not a new failure mode.
2498    let subscribed_event_type_names: HashSet<String> = commons
2499        .commons
2500        .items
2501        .iter()
2502        .filter_map(|item| match item {
2503            CommonsItem::Service(s) => {
2504                let bynk_ir::ProtocolIr::Events { event, .. } =
2505                    bynk_lower::lower_protocol_ir_from_commons(&s.protocol, commons)
2506                else {
2507                    return None;
2508                };
2509                match &*commons.tys().get(event) {
2510                    Ty::Named { name, .. } => Some(name.clone()),
2511                    _ => None,
2512                }
2513            }
2514            _ => None,
2515        })
2516        .collect();
2517    // Sibling imports: relative path within the same commons/context directory.
2518    let mut sibling_paths: Vec<(&PathBuf, &HashSet<String>)> = refs.by_sibling.iter().collect();
2519    sibling_paths.sort_by(|a, b| a.0.cmp(b.0));
2520    for (path, names) in sibling_paths {
2521        let import = sibling_import_specifier(&ctx.source_path, path, ctx.import_ext);
2522        let mut sorted: Vec<&String> = names.iter().collect();
2523        sorted.sort();
2524        // Arc C, slice 30 (#1392): a real `TsDecl::Import`.
2525        stmts.push(bynk_ts::TsStmt::decl(
2526            bynk_ts::TsDecl::Import {
2527                type_only: false,
2528                names: sorted.iter().map(|s| ts_ident(s)).collect(),
2529                from: import,
2530            },
2531            None,
2532        ));
2533    }
2534    // Cross-unit imports: group by *target file path*.
2535    let mut unit_names: Vec<(&String, &HashSet<String>)> = refs.by_commons.iter().collect();
2536    unit_names.sort_by(|a, b| a.0.cmp(b.0));
2537    for (unit_name, names) in unit_names {
2538        let target_paths = ctx.imported_decl_paths.get(unit_name.as_str());
2539        let mut by_target: std::collections::BTreeMap<PathBuf, Vec<&String>> =
2540            std::collections::BTreeMap::new();
2541        for n in names {
2542            let path = target_paths
2543                .and_then(|p| p.get(n))
2544                .cloned()
2545                .unwrap_or_else(|| EmitProjectCtx::commons_path(unit_name));
2546            by_target.entry(path).or_default().push(n);
2547        }
2548        for (target, mut name_list) in by_target {
2549            name_list.sort();
2550            let import =
2551                cross_commons_import_specifier_for_path(&ctx.source_path, &target, ctx.import_ext);
2552            // For context units, aliase commons-source imports so we can emit
2553            // rebrand aliases of the same short name. Imports from consumed
2554            // contexts keep their original name. v0.20b: the rebrand applies
2555            // to *types* only — a `uses`-imported function (bynk.list's
2556            // `traverse`) is a value, imports plainly, and is never branded.
2557            let mut parts: Vec<String> = Vec::new();
2558            for n in &name_list {
2559                let is_subscribed_event_type = ctx.target == BuildTarget::Workers
2560                    && subscribed_event_type_names.contains(n.as_str());
2561                // R4.10/R8.2: the same shared `is_uses_commons_type` predicate
2562                // `emit_context_rebrands` below reads — this import-aliasing
2563                // site is that function's own step 1 ("Done in imports", its
2564                // own doc comment), and the two must agree exactly: an alias
2565                // narrower than the rebrand leaves an undefined name in the
2566                // generated import; a rebrand narrower than the alias leaves
2567                // an alias imported and never used. `ctx.unit_kind ==
2568                // UnitKind::Context` is the same guard as
2569                // `emit_context_rebrands`'s own `ctx.owning_context.is_some()`
2570                // early return (`owning_context` is `Some` exactly when
2571                // `unit_kind == Context`, `project.rs`'s own construction) —
2572                // kept explicit here since this loop runs for every unit
2573                // kind, not just contexts.
2574                if ctx.unit_kind == UnitKind::Context
2575                    && bynk_check::resolver::compute_is_uses_commons_type(
2576                        &ctx.imported_from_kind,
2577                        &commons.types,
2578                        n,
2579                    )
2580                {
2581                    parts.push(format!("{n} as __Commons{n}"));
2582                } else if is_subscribed_event_type {
2583                    // Events track, slice 0 (spine #936): under Workers, a
2584                    // context deploys as its own separate Worker script —
2585                    // there is no shared module graph to import a peer
2586                    // context's *value* across (the #661 hazard this
2587                    // mirrors: a caller generates its own codec rather than
2588                    // importing the callee's runtime code). `from
2589                    // Events(E)`'s `E` is the one plain named type crossing
2590                    // a context boundary directly by name (every other
2591                    // cross-context reference goes through a generated
2592                    // Service-Binding codec instead) — used only in type
2593                    // position (`e: E`), so this specific name is type-only.
2594                    // Narrowly scoped to event types specifically, not every
2595                    // cross-context import: a `type`/`enum` crossing via
2596                    // `uses`/`consumes` (e.g. `bynk`'s `Method`) is often
2597                    // used as a *value* too (`Method.Get`), which a blanket
2598                    // `import type` would wrongly break.
2599                    parts.push(format!("type {}", ts_ident(n)));
2600                } else {
2601                    parts.push(ts_ident(n));
2602                }
2603            }
2604            // Arc C, slice 30 (#1392): a real `TsDecl::Import` — `parts`'
2605            // own per-name `as __CommonsX`/`type X` prefixes already match
2606            // `names`'s own documented "raw text slot" convention, no new
2607            // gap.
2608            stmts.push(bynk_ts::TsStmt::decl(
2609                bynk_ts::TsDecl::Import {
2610                    type_only: false,
2611                    names: parts,
2612                    from: import,
2613                },
2614                None,
2615            ));
2616        }
2617    }
2618    // #527: imports the DO-side agent-deps expressions need (binding modules,
2619    // other Workers' handlers). Precomputed by the project driver — already
2620    // formed statement text, stays opaque `TsStmt::raw`, the established
2621    // "pre-formatted pass-through" pattern.
2622    for line in &ctx.extra_import_lines {
2623        stmts.push(bynk_ts::TsStmt::raw(format!("{line}\n"), None));
2624    }
2625    stmts
2626}
2627
2628/// Compute a relative import specifier from `from_source` (a `.bynk` path)
2629/// to `to_source` (another `.bynk` path), with `.bynk` rewritten to `.js`
2630/// for compatibility with NodeNext/strict TS resolution.
2631fn sibling_import_specifier(from_source: &Path, to_source: &Path, ext: ImportExt) -> String {
2632    let from_dir = from_source.parent().unwrap_or(Path::new(""));
2633    let target = to_source.with_extension(ext.as_str());
2634    let rel = relative_to(from_dir, &target);
2635    format!("./{}", ts_specifier(&rel))
2636}
2637
2638/// Render a path as a TypeScript module specifier: **always forward
2639/// slashes**. `Path::display()` uses the platform separator, and on Windows
2640/// that emitted `import ... from "./commerce\orders.js"` — broken ESM
2641/// output, caught by the first CI matrix run on windows-latest.
2642pub(crate) fn ts_specifier(p: &Path) -> String {
2643    p.to_string_lossy().replace('\\', "/")
2644}
2645
2646/// Compute a relative import specifier from this file's location to a
2647/// specific source file in another commons. `target_source` is the project-
2648/// relative path of the target `.bynk` file. The result is suitable for
2649/// `import { ... } from "..."` in NodeNext/strict TypeScript.
2650pub(crate) fn cross_commons_import_specifier_for_path(
2651    from_source: &Path,
2652    target_source: &Path,
2653    ext: ImportExt,
2654) -> String {
2655    let from_dir = from_source.parent().unwrap_or(Path::new(""));
2656    let target = target_source.with_extension(ext.as_str());
2657    let rel = relative_to(from_dir, &target);
2658    let display = ts_specifier(&rel);
2659    if display.starts_with("../") || display.starts_with("./") {
2660        display
2661    } else {
2662        format!("./{display}")
2663    }
2664}
2665
2666/// Compute `target` as a path relative to `from`. Handles parent traversal
2667/// (`..`) for cases where `target` lives in a sibling directory.
2668fn relative_to(from: &Path, target: &Path) -> PathBuf {
2669    use std::path::Component as C;
2670    let f_comps: Vec<C> = from.components().collect();
2671    let t_comps: Vec<C> = target.components().collect();
2672    let mut shared = 0;
2673    while shared < f_comps.len() && shared < t_comps.len() && f_comps[shared] == t_comps[shared] {
2674        shared += 1;
2675    }
2676    let mut out = PathBuf::new();
2677    for _ in shared..f_comps.len() {
2678        out.push("..");
2679    }
2680    for c in &t_comps[shared..] {
2681        out.push(c.as_os_str());
2682    }
2683    if out.as_os_str().is_empty() {
2684        out.push(".");
2685    }
2686    out
2687}
2688
2689/// #1478: real-node-internally already — returns its own real statements
2690/// (the two-comment banner, plus a conditional runtime-import declaration)
2691/// instead of writing into `out: &mut String`.
2692fn write_header(commons: &TypedCommons, ctx: &EmitProjectCtx) -> Vec<bynk_ts::TsStmt> {
2693    // Arc C, slice 30 (#1392): the 2-line banner converts to two real
2694    // `TsStmt::Comment` statements, the same `events_fanout.rs`-established
2695    // precedent (#1317) every other Arc C header already uses.
2696    let kind = match ctx.unit_kind {
2697        UnitKind::Commons => "commons",
2698        UnitKind::Context => "context",
2699        UnitKind::Test => "test",
2700        UnitKind::Integration => "integration test",
2701        UnitKind::Adapter => "adapter",
2702    };
2703    let mut stmts = vec![
2704        bynk_ts::TsStmt::comment("Generated by bynkc — do not edit by hand.", None),
2705        bynk_ts::TsStmt::comment(format!("{kind} {}", commons.commons.name.joined()), None),
2706        bynk_ts::TsStmt::blank(None),
2707    ];
2708    if !commons.commons.items.is_empty() {
2709        let runtime_import = runtime_import_for(&ctx.source_path, ctx.import_ext);
2710        let has_agent = commons
2711            .commons
2712            .items
2713            .iter()
2714            .any(|i| matches!(i, CommonsItem::Agent(_)));
2715        // v0.80: a file with any agent invariant imports the `invariantViolation`
2716        // fault helper used by the generated `commitState` gate. v0.116: a step
2717        // invariant (`transition`) uses the same fault helper, so a transition-only
2718        // agent must import it too.
2719        let has_agent_invariants = commons.commons.items.iter().any(|i| match i {
2720            CommonsItem::Agent(a) => !a.invariants.is_empty() || !a.transitions.is_empty(),
2721            _ => false,
2722        });
2723        // v0.153 (ADR 0177): a service HTTP handler imports `HttpResult`, and so
2724        // does any *free* `fn` / provider / capability whose signature names it
2725        // (the `?`-Option lift makes a bare `fn -> HttpResult[T]` emit
2726        // `HttpResult.NotFound`) — the structural scan covers both, closing the
2727        // free-fn gap the single-file path already handles.
2728        // P6.24a/P6.19: `h.kind`/`s.protocol` read through the IR-native
2729        // `IrHandlerKind`/`ProtocolIr` readers below — pure syntax, no body
2730        // lowering, safe regardless of the still-open `Question`/`Is` gap
2731        // (design/tracks/the-ir.md's own P6.3 correction).
2732        let has_http = commons.commons.items.iter().any(|i| match i {
2733            CommonsItem::Service(s) => s.handlers.iter().any(|h| {
2734                matches!(
2735                    bynk_lower::lower_handler_kind_ir(&h.kind),
2736                    bynk_ir::IrHandlerKind::Http { .. }
2737                )
2738            }),
2739            _ => false,
2740        }) || file_mentions_http_result(commons);
2741        // A `from queue` `on message` is the queue consumer (imports `QueueResult`);
2742        // a `from websocket` `on message` (slice 3b-iii) is the inbound handler and
2743        // is not a queue concern. #1319: a bare field/type-declaration mention
2744        // (the `ts_any` residual's own real-type-cast sites) is a second, real
2745        // way to need the import with no consumer handler at all — closed via
2746        // `file_mentions_queue_result`, the same structural-scan pattern
2747        // `file_mentions_http_result` already uses for its own type.
2748        let has_queue = commons.commons.items.iter().any(|i| match i {
2749            CommonsItem::Service(s) => {
2750                !matches!(
2751                    bynk_lower::lower_protocol_ir_from_commons(&s.protocol, commons),
2752                    bynk_ir::ProtocolIr::WebSocket { .. }
2753                ) && s.handlers.iter().any(|h| {
2754                    matches!(
2755                        bynk_lower::lower_handler_kind_ir(&h.kind),
2756                        bynk_ir::IrHandlerKind::Message
2757                    )
2758                })
2759            }
2760            _ => false,
2761        }) || file_mentions_queue_result(commons);
2762        let workers = matches!(ctx.target, BuildTarget::Workers);
2763        let mut parts: Vec<&str> = vec![
2764            "Ok",
2765            "Err",
2766            "Some",
2767            "None",
2768            "type Result",
2769            "type Option",
2770            "type ValidationError",
2771        ];
2772        // v0.22b: the codec types are imported only when the file uses the
2773        // `Json` codec (or names `JsonError` in a signature) — keeping every
2774        // non-codec module's header byte-identical to v0.22a.
2775        let uses_codec = !collect_json_codec_roots(commons).is_empty();
2776        let mentions_json_error = file_mentions_json_error(commons);
2777        if uses_codec || mentions_json_error {
2778            parts.push("type JsonError");
2779        }
2780        // v0.102: a file naming `Connection[F]` imports the runtime interface.
2781        if file_mentions_connection(commons) {
2782            parts.push("type Connection");
2783        }
2784        if has_agent {
2785            // v0.9.2: agent-declaring files lower instantiation through the
2786            // `makeAgent` helper and a per-agent `StateRegistry`, and the
2787            // generated factory's signature names `DurableObjectNamespace`.
2788            parts.push("type DurableObjectState");
2789            parts.push("type DurableObjectNamespace");
2790            parts.push("StateRegistry");
2791            parts.push("makeAgent");
2792        }
2793        if has_agent_invariants {
2794            parts.push("invariantViolation");
2795        }
2796        // Events track, slice 0 (spine #936): an agent whose own handler body
2797        // emits directly needs its Workers-mode DO fetch dispatcher to
2798        // rebuild `deps.__eventsDispatch` from `env.EVENTS_FANOUT` (mirrors
2799        // the `#527` `given`-provider rebuild — see `emit_agent`) — a
2800        // function does not survive the JSON wire any better than a
2801        // provider does.
2802        let has_agent_uses_emit = workers
2803            && commons.commons.items.iter().any(|i| match i {
2804                CommonsItem::Agent(a) => a
2805                    .handlers
2806                    .iter()
2807                    .any(|h| block_uses_emit(&h.body, &commons.callees)),
2808                _ => false,
2809            });
2810        if has_agent_uses_emit {
2811            parts.push("dispatchToEventsFanout");
2812        }
2813        // v0.96 (ADR 0124): an agent whose load-time validation gate fires imports
2814        // the `rehydrationViolation` fault helper.
2815        let has_rehydration_gate = commons.commons.items.iter().any(|i| match i {
2816            CommonsItem::Agent(a) => emit::agent_needs_rehydrate(a, &commons.types),
2817            _ => false,
2818        });
2819        if has_rehydration_gate {
2820            parts.push("rehydrationViolation");
2821        }
2822        // v0.104/v0.105 (real-time track slice 3b): on Workers a `store Map[K,
2823        // Connection]` persists the connection id; its entry ops re-resolve the live
2824        // socket via `resolveConnection` and read a connection's id via `connIdOf`.
2825        if workers
2826            && commons.commons.items.iter().any(|i| match i {
2827                CommonsItem::Agent(a) => emit::agent_has_held_storage(a),
2828                _ => false,
2829            })
2830        {
2831            parts.push("resolveConnection");
2832            parts.push("connIdOf");
2833        }
2834        // v0.104/v0.105 (real-time track slice 3b): on Workers a context hosting a
2835        // `from websocket` `on open` accepts the socket inside its Durable Object via
2836        // the hibernatable API — `acceptHibernatableConnection` (accept + tag + wrap),
2837        // a `WebSocketPair`, and the `101` upgrade response. (The service and its
2838        // hosting agent share the one Worker module, so these land in one
2839        // `handlers.ts`.)
2840        let hosts_ws_open = commons.commons.items.iter().any(|i| match i {
2841            CommonsItem::Service(s) => s.handlers.iter().any(|h| {
2842                matches!(
2843                    bynk_lower::lower_handler_kind_ir(&h.kind),
2844                    bynk_ir::IrHandlerKind::Open
2845                )
2846            }),
2847            _ => false,
2848        });
2849        if workers && hosts_ws_open {
2850            parts.push("acceptHibernatableConnection");
2851            parts.push("newWebSocketPair");
2852            parts.push("webSocketUpgradeResponse");
2853        }
2854        // v0.106 (slice 3b-iii): a context with an inbound/close handler re-wraps
2855        // the firing socket as a `WorkersConnection` in `webSocketMessage`/
2856        // `webSocketClose`.
2857        let hosts_ws_inbound = commons.commons.items.iter().any(|i| match i {
2858            CommonsItem::Service(s) => {
2859                matches!(
2860                    bynk_lower::lower_protocol_ir_from_commons(&s.protocol, commons),
2861                    bynk_ir::ProtocolIr::WebSocket { .. }
2862                ) && s.handlers.iter().any(|h| {
2863                    matches!(
2864                        bynk_lower::lower_handler_kind_ir(&h.kind),
2865                        bynk_ir::IrHandlerKind::Message | bynk_ir::IrHandlerKind::Close
2866                    )
2867                })
2868            }
2869            _ => false,
2870        });
2871        if workers && hosts_ws_inbound {
2872            parts.push("WorkersConnection");
2873        }
2874        if has_http {
2875            // `HttpResult` is both a value (the constructor namespace) and a
2876            // type (the discriminated union). A bare named import brings both
2877            // in — `type HttpResult` would duplicate the identifier.
2878            parts.push(HTTP_RESULT);
2879        }
2880        if has_queue {
2881            // v0.44: `QueueResult` is both a value (the verdict namespace) and a
2882            // type; a bare named import brings both in.
2883            parts.push(QUEUE_RESULT);
2884        }
2885        if workers {
2886            parts.push("type JsonValue");
2887            parts.push("type BoundaryError");
2888            parts.push("type ServiceBinding");
2889            parts.push("callService");
2890            parts.push("boundaryError");
2891        } else if uses_codec || has_agent {
2892            // v0.22b: the bundle-mode codec helpers reference JsonValue and
2893            // BoundaryError. v0.96 (ADR 0124): so do an agent's emitted
2894            // rehydration deserialisers and the gate's inline base checks — the
2895            // boundary helpers now emit on bundle too (for the rehydration gate).
2896            parts.push("type JsonValue");
2897            parts.push("type BoundaryError");
2898        }
2899        // #1476: `bytes()`/`icu()` fold in here directly now, in the same order
2900        // the old post-print `inject_runtime_imports` pass appended them (bytes
2901        // first, then icu) — `ctx.runtime_use` is already fully populated by the
2902        // time `write_header` runs (moved to the end of `emit_project`), so this
2903        // needs no post-print text surgery at all, just reading the same two
2904        // flags one function call later than before.
2905        //
2906        // Review of #1490: this drops `inject_runtime_imports`'s own
2907        // `missing_bindings` dedup (matching a `type Foo` group binding against
2908        // an already-bound bare `Foo` and vice versa) — safe only because
2909        // neither `BYTES_RUNTIME_IMPORTS`'s nor `MESSAGES_RUNTIME_IMPORTS`'s own
2910        // names overlap anything `parts` already carries above (confirmed by
2911        // direct inspection, not assumed). A future group that *does* overlap
2912        // needs that filter reinstated here, not just appended unconditionally
2913        // — `missing_bindings`'s own doc named exactly this case (the
2914        // test-scaffold module's own `Ok`/`Err` overlap) before this issue
2915        // deleted the only place that reasoning was written down.
2916        if ctx.runtime_use.bytes() {
2917            parts.extend(BYTES_RUNTIME_IMPORTS.trim_start_matches(", ").split(", "));
2918        }
2919        if ctx.runtime_use.icu() {
2920            parts.extend(
2921                MESSAGES_RUNTIME_IMPORTS
2922                    .trim_start_matches(", ")
2923                    .split(", "),
2924            );
2925        }
2926        stmts.push(bynk_ts::TsStmt::decl(
2927            bynk_ts::TsDecl::Import {
2928                type_only: false,
2929                names: parts.into_iter().map(str::to_string).collect(),
2930                from: runtime_import,
2931            },
2932            None,
2933        ));
2934        stmts.push(bynk_ts::TsStmt::blank(None));
2935    }
2936    stmts
2937}
2938
2939/// Variant of write_header for single-file (no project context) emission.
2940fn write_header_single(
2941    out: &mut String,
2942    commons: &TypedCommons,
2943    uses_bytes: bool,
2944    uses_http: bool,
2945    uses_queue: bool,
2946) {
2947    // Arc C, slice 30 (#1392): the same 2-comment-statement conversion
2948    // `write_header` above already established.
2949    out.push_str(&bynk_ts::print_stmt(
2950        &bynk_ts::TsStmt::comment("Generated by bynkc — do not edit by hand.", None),
2951        0,
2952    ));
2953    out.push_str(&bynk_ts::print_stmt(
2954        &bynk_ts::TsStmt::comment(format!("commons {}", commons.commons.name.joined()), None),
2955        0,
2956    ));
2957    writeln!(out).unwrap();
2958    if !commons.commons.items.is_empty() {
2959        // v0.22b: codec imports only when the file uses the `Json` codec.
2960        let uses_codec = !collect_json_codec_roots(commons).is_empty();
2961        let codec_imports = if uses_codec {
2962            ", type JsonError, type JsonValue, type BoundaryError"
2963        } else if file_mentions_json_error(commons) {
2964            ", type JsonError"
2965        } else {
2966            ""
2967        };
2968        // v0.110 (ADR 0142): the `Bytes` runtime helpers, imported only when a
2969        // `Bytes` value is constructed or compared in the body.
2970        let bytes_imports = if uses_bytes {
2971            BYTES_RUNTIME_IMPORTS
2972        } else {
2973            ""
2974        };
2975        // v0.153 (ADR 0177): `HttpResult` is a value (its variant namespace) and
2976        // a type, so it imports without a `type` prefix — one binding serves
2977        // both `HttpResult.NotFound` and the `HttpResult<T>` annotation.
2978        let http_imports = if uses_http { ", HttpResult" } else { "" };
2979        // #1319: `QueueResult` is likewise a value (its verdict namespace) and
2980        // a type; a bare named import brings both in, matching `HttpResult`'s
2981        // own shape immediately above.
2982        let queue_imports = if uses_queue { ", QueueResult" } else { "" };
2983        // The fixed prefix plus each optional group joins as one comma-space
2984        // separated run either way — split back into individual names for
2985        // `TsDecl::Import.names` rather than re-deriving each optional
2986        // group's own name list a second time (the string-building above,
2987        // unchanged, already gets this exactly right).
2988        let inside = format!(
2989            "Ok, Err, Some, None, type Result, type Option, type ValidationError{codec_imports}{bytes_imports}{http_imports}{queue_imports}"
2990        );
2991        out.push_str(&bynk_ts::print_stmt(
2992            &bynk_ts::TsStmt::decl(
2993                bynk_ts::TsDecl::Import {
2994                    type_only: false,
2995                    names: inside.split(", ").map(str::to_string).collect(),
2996                    from: "./runtime.js".to_string(),
2997                },
2998                None,
2999            ),
3000            0,
3001        ));
3002        writeln!(out).unwrap();
3003    }
3004}
3005
3006/// v0.110 (ADR 0142): the `Bytes` runtime helpers, appended to a module's
3007/// import list when the emitted body references them. `bytesEqual` backs `==`;
3008/// the base64/UTF-8 helpers back the kernel and codec.
3009pub(crate) const BYTES_RUNTIME_IMPORTS: &str =
3010    ", __bynkBytesEqual, __bynkBytesToBase64, __bynkBytesFromBase64, __bynkBytesDecodeUtf8";
3011
3012/// message-bundles slice 3 (#878, Decision G): the ICU-formatting runtime
3013/// helpers, appended to a module's import list when an emitted `messages`
3014/// bundle's `render` references any of them (`emit_icu_placeholder`,
3015/// `bynk-emit/src/emitter/emit.rs`).
3016const MESSAGES_RUNTIME_IMPORTS: &str = ", selectPluralArm, formatIcuNumber, formatIcuDate";
3017
3018/// #914: the names an **inlined** boundary deserialiser builds directly, appended
3019/// to the import list of a module that curates its own — a Worker's `compose.ts`
3020/// and the test-scaffold modules. Every other module imports `Ok`/`Err`/`Result`
3021/// unconditionally, so most of this is inert there and never applied.
3022///
3023/// A codec for a *named* type delegates (`handlers.deserialise_Order(…)`) and needs
3024/// none of these; one for a base type or a `Bytes` inlines the construction. Two
3025/// arms — `Unit` and the runtime-owned error types — additionally annotate the
3026/// result (`Ok(undefined) as Result<void, BoundaryError>`), which `compose.ts`'s
3027/// structural list never carries; `Result` is in the group for those. The dedupe
3028/// in [`inject_runtime_imports`] makes it free wherever it is already imported.
3029pub(crate) const BOUNDARY_CODEC_RUNTIME_IMPORTS: &str =
3030    ", Ok, Err, type Result, type BoundaryError";
3031
3032/// #914: the names the `Json.decode[T]` wrapper puts in the module — its own
3033/// `Result<T, JsonError>` signature and the `JsonValue` it parses into
3034/// (`lower_json_codec_call`, `bynk-emit/src/emitter/lower.rs`).
3035///
3036/// A sibling group rather than part of [`BOUNDARY_CODEC_RUNTIME_IMPORTS`]: the
3037/// producer is the wrapper, not the codec, and it names these whichever arm the
3038/// inner deserialiser takes — including the delegating ones, which set no
3039/// boundary-codec flag at all. `Json.encode` needs nothing for its own wrapper
3040/// text either (it lowers to a bare `JSON.stringify`).
3041///
3042/// The delegating arm — `Json.decode[SomeRecord]` / `Json.encode(someRecord)`
3043/// — used to be broken in a test-scaffold module for an unrelated reason
3044/// (issue #917): the call lowers to a bare `deserialise_SomeRecord(…)` /
3045/// `serialise_SomeRecord(…)`, and no such codec was emitted anywhere — the
3046/// unit module exports the record's interface and no codec of its own. Fixed
3047/// by generating the test module's *own* closure for every root a case body's
3048/// `Json` call reaches for (`RuntimeUse::note_json_codec_root`, drained by
3049/// `tests_emit.rs`'s `emit_test_module`), namespace-qualifying the TS type
3050/// positions through the target/`uses` unit's own namespace import
3051/// (`RuntimeUse::json_codec_qual`) — the same caller-generates-its-own-codec
3052/// pattern `emit_consumed_context_helpers` uses for a workers cross-context
3053/// caller's consumed-boundary types (#661). See
3054/// `918_json_decode_in_test_case` (base type, delegation-free) and
3055/// `919_json_decode_named_record_in_test_case` (named record, delegating).
3056pub(crate) const JSON_CODEC_RUNTIME_IMPORTS: &str =
3057    ", Ok, Err, type Result, type JsonValue, type JsonError";
3058
3059/// Emit the commons-level doc block (if any) at the current position.
3060/// #1478: real-node-internally already (a single `TsStmtKind::DocComment`,
3061/// the same shape [`emit_doc_block`] itself builds — inlined directly here
3062/// rather than calling that `out: &mut String`-shaped helper, since its own
3063/// signature and 14 real callers stay unchanged, #1333's own doc).
3064fn write_commons_doc(commons: &TypedCommons) -> Vec<bynk_ts::TsStmt> {
3065    let mut stmts = Vec::new();
3066    if let Some(doc) = &commons.commons.documentation {
3067        stmts.push(bynk_ts::TsStmt::doc_comment(doc, None));
3068        stmts.push(bynk_ts::TsStmt::blank(None));
3069    }
3070    stmts
3071}
3072
3073/// The module-level state-registry constant name for an agent class.
3074fn agent_registry_name(agent: &str) -> String {
3075    format!("__{agent}Registry")
3076}
3077
3078/// The exported agent-construction factory name for an agent class.
3079pub(crate) fn agent_factory_name(agent: &str) -> String {
3080    format!("__make{agent}")
3081}
3082
3083/// Lowering state that is genuinely **module-invariant**: the same value at
3084/// every body-lowering site within one emitted module, and never written by the
3085/// recursive lowering itself. Grouped out of [`LowerCtx`] so a new lowering kind
3086/// inherits the whole set wholesale instead of re-deriving each default.
3087///
3088/// Nothing the lowering mutates *per body* may move in here — see the
3089/// scratch-state fields at the bottom of [`LowerCtx`]. A `ModuleCtx` is still
3090/// built fresh alongside each `LowerCtx` today, but filing a per-body counter
3091/// under a name that says "module" is exactly how such state starts leaking
3092/// between handler bodies.
3093pub(crate) struct ModuleCtx<'a> {
3094    /// Typed-commons handle (used to look up receiver types for method-call
3095    /// UFCS lowering).
3096    commons: &'a TypedCommons,
3097    /// Cross-context info for v0.6 cross-context call lowering.
3098    cross_context: &'a bynk_check::resolver::CrossContextInfo,
3099    /// The emitted module's conditional-runtime-helper accumulator.
3100    ///
3101    /// The `Bytes` lowerings (kernel, `==`, base64 codec) call
3102    /// [`RuntimeUse::note_bytes`] through this, so the module's import line is
3103    /// decided from what lowering actually emitted rather than by scanning the
3104    /// generated text for the helper's own name.
3105    ///
3106    /// Required rather than optional: a missing accumulator would make
3107    /// `note_bytes` a silent no-op, which is exactly the failure this replaces —
3108    /// a module that references `__bynkBytesEqual` without importing it. A
3109    /// lowering whose imports are decided elsewhere (the test scaffolds) owns a
3110    /// throwaway one, which reads as the deliberate choice it is.
3111    runtime_use: &'a RuntimeUse,
3112    /// v0.8 build target. In workers mode cross-context calls lower to
3113    /// `callService(...)` instead of `deps.surface.<key>.<method>(...)`.
3114    target: BuildTarget,
3115    /// #527: agent → method → the method's `given` caps (mirrors
3116    /// [`crate::project::EmitProjectCtx::agent_method_givens`]). Consulted by
3117    /// the agent-call lowering to record capability requirements.
3118    agent_method_givens: HashMap<String, HashMap<String, Vec<bynk_ir::CapRefIr>>>,
3119    /// Events slice 3b (#978): each locally-declared event's resolved
3120    /// `@schema(N)` version (mirrors
3121    /// [`crate::project::EmitProjectCtx::event_schema_versions`]). Default-
3122    /// empty like `agent_method_givens`, not a required constructor
3123    /// parameter like `runtime_use` — a miss here degrades to `schemaVersion:
3124    /// 1`, exactly every event's behaviour before this map existed, not a
3125    /// hard failure the way a missing `runtime_use` would be.
3126    event_schema_versions: HashMap<String, i64>,
3127    /// #527: type names this context *rebrands* (`uses`-imported commons
3128    /// types re-exported as `T & { __ctxBrand }`). Drives brand-assertion
3129    /// casts where unbranded commons values meet branded local positions.
3130    rebranded_types: HashSet<String>,
3131    /// #527: fn names imported from a commons. Such a fn's signature uses the
3132    /// *unbranded* commons types, so calls whose return mentions a rebranded
3133    /// type are asserted back into the local (branded) namespace.
3134    commons_imported_fns: HashSet<String>,
3135    /// #934: true when the unit being emitted is the reserved first-party
3136    /// `bynk` adapter itself. `bynk` is a reserved namespace, so a capability
3137    /// literally named `Idempotency` declared *in this unit* is unambiguously
3138    /// the real one — used alongside `CrossContextInfo::flattened_caps` (the
3139    /// consumed-from-elsewhere case) to confirm a flattened `Idempotency`
3140    /// call is genuinely first-party before scoping its key, not a same-named
3141    /// capability some other adapter or context happens to declare.
3142    in_bynk_unit: bool,
3143}
3144
3145impl<'a> ModuleCtx<'a> {
3146    fn new(
3147        commons: &'a TypedCommons,
3148        cross_context: &'a bynk_check::resolver::CrossContextInfo,
3149        runtime_use: &'a RuntimeUse,
3150    ) -> Self {
3151        Self {
3152            commons,
3153            cross_context,
3154            runtime_use,
3155            target: BuildTarget::Bundle,
3156            agent_method_givens: HashMap::new(),
3157            event_schema_versions: HashMap::new(),
3158            rebranded_types: HashSet::new(),
3159            commons_imported_fns: HashSet::new(),
3160            in_bynk_unit: false,
3161        }
3162    }
3163
3164    /// #527: derive which imported names this context rebrands and which fns
3165    /// come from a commons (and so speak the unbranded types). Mirrors the
3166    /// alias predicate in `emit_project_imports`.
3167    pub(crate) fn set_rebrand_info(
3168        &mut self,
3169        commons: &TypedCommons,
3170        ctx: &crate::project::EmitProjectCtx,
3171    ) {
3172        if ctx.unit_kind != UnitKind::Context {
3173            return;
3174        }
3175        for (name, kind) in &ctx.imported_from_kind {
3176            if *kind != UnitKind::Commons {
3177                continue;
3178            }
3179            if commons.types.contains_key(name) {
3180                self.rebranded_types.insert(name.clone());
3181            } else if commons.fns.contains_key(name) {
3182                self.commons_imported_fns.insert(name.clone());
3183            }
3184        }
3185    }
3186}
3187
3188/// The lowering state shared by the four **capability-bearing** body kinds — a
3189/// service handler, a composed provider op, an agent handler, and a websocket
3190/// lifecycle DO method. Every other kind carries no `HandlerShared` at all, and
3191/// the [`LowerCtx`] accessors below hand those kinds the same defaults the flat
3192/// struct used to give them (an empty capability set, no scope, `deps`).
3193pub(crate) struct HandlerShared {
3194    /// Names of capabilities in scope as `given C1, C2, ...`. Used to lower
3195    /// `Capability.op(args)` calls to `deps.Capability.op(args)`.
3196    capabilities: HashSet<String>,
3197    /// #934: the calling handler's own qualified name (`<unit>.<service or
3198    /// agent>.<handler>`, e.g. `shop.reserve.ordering.call`). Read only by the
3199    /// `Idempotency.dedup`/`remember` lowering, which prefixes the
3200    /// developer-supplied key with it so two unrelated handlers using the same
3201    /// literal key never collide (design/tracks/idempotency-capability.md
3202    /// §3.4). `None` anywhere a capability call cannot occur (a plain method, a
3203    /// free fn, an invariant/transition predicate, a static field initialiser) —
3204    /// those kinds carry no `HandlerShared`, and the accessor reports `None`.
3205    handler_scope: Option<String>,
3206    /// Events track, slice 2 (spine #936): the qualified name of the unit
3207    /// this body is emitted into (`ctx.commons_name`), read by the
3208    /// `Events.emit[E](event)` lowering to mint the envelope's
3209    /// `publisherId`. Context-scoped rather than agent-scoped: `Events.emit`
3210    /// is legal from a plain, keyless service handler with no agent
3211    /// instance to report, so this is the only identity available
3212    /// uniformly at every legal emission site (an amendment to
3213    /// design/bynk-design-notes.md §7's "the publisher is the emitting
3214    /// agent" framing — see the events-envelope ADR). Always populated
3215    /// alongside `handler_scope` at every construction site; never `None`
3216    /// in practice for a body that could contain an `Events.emit` call.
3217    owning_context: String,
3218    /// v0.12: the receiver expression a capability call resolves against —
3219    /// `deps` in a handler body, `this.deps` in a composed provider body.
3220    cap_deps_expr: String,
3221    /// True if the current handler made at least one cross-context call
3222    /// (drives whether `deps` gets a `surface` field type).
3223    cross_context_used: bool,
3224    /// v0.9.2: set when the body instantiates a local agent. In workers mode
3225    /// this drives `env` (carrying the DO namespaces) into the handler's deps
3226    /// type so the agent factory can reach its Durable Object binding.
3227    agents_instantiated: bool,
3228    /// #527: capabilities required by agent methods this body calls, keyed by
3229    /// deps key. After body lowering these widen the handler's deps *type* to
3230    /// match the runtime value compose builds (which always carried them).
3231    agent_given_caps_used: std::collections::BTreeMap<String, bynk_ir::CapRefIr>,
3232}
3233
3234impl Default for HandlerShared {
3235    fn default() -> Self {
3236        Self {
3237            capabilities: HashSet::new(),
3238            handler_scope: None,
3239            owning_context: String::new(),
3240            cap_deps_expr: "deps".to_string(),
3241            cross_context_used: false,
3242            agents_instantiated: false,
3243            agent_given_caps_used: std::collections::BTreeMap::new(),
3244        }
3245    }
3246}
3247
3248/// The lowering state shared by the three **generated test-scaffold** body
3249/// kinds — a `stub`/`where`/`requires` predicate value, a unit test case, and an
3250/// integration test case.
3251#[derive(Default)]
3252pub(crate) struct TestShared {
3253    /// True when lowering **any** generated test-scaffold body — distinct from
3254    /// `assert_loc`, which is only ever `Some` for the two real `case` bodies
3255    /// and carries an unrelated payload (a diagnostic location). Kept as its own
3256    /// field (Locale capability track, slice 1, #844 review) rather than
3257    /// overloading `assert_loc.is_some()`, since that conflated "has a location"
3258    /// with "is test scaffolding" for a caller that has no location to give.
3259    test_scaffold: bool,
3260    /// v0.59: the source text and project-relative path of the file the body
3261    /// came from, so an `assert` can emit a real `path:line:col` location (for
3262    /// `--format json` click-through) rather than a bare byte offset. Stays
3263    /// `None` for a predicate scaffold, which emits no `assert`.
3264    assert_loc: Option<AssertLoc>,
3265}
3266
3267/// The `store`-agent sub-state of an [`BodyMode::AgentHandler`] body: present
3268/// only when the hosting agent is a `store` agent, absent for a plain
3269/// state-record agent (whose handler reads `currentState`/`self.state` instead).
3270pub(crate) struct AgentStoreState {
3271    /// v0.81 (storage track): the name of the mutable working-state variable
3272    /// (`__state`) and the set of `Cell` field names. A bare `Cell` read lowers
3273    /// to `<var>.<cell>`, and a `cell := v` write lowers to `<var>.<cell> = <v>`
3274    /// — read-your-writes via the in-memory record, flushed once at handler end
3275    /// (ADR 0109).
3276    state: (String, HashSet<String>),
3277    /// v0.82 (ADR 0110): the agent's `store` `Map` field names. A method call
3278    /// whose receiver is one lowers to an entry operation over `__state.<map>`
3279    /// (a JSON-serialisable `Record<string, V>`), staged in the working record
3280    /// and flushed at commit like any other state field.
3281    maps: HashSet<String>,
3282    /// v0.83: the agent's `store` `Set` field names. A method call whose
3283    /// receiver is one lowers to an entry operation over `__state.<set>` (a
3284    /// `Record<string, boolean>`), staged in the working record.
3285    sets: HashSet<String>,
3286    /// v0.87 (ADR 0113): the agent's `store` `Cache` fields (name → ttl millis).
3287    /// A method call whose receiver is one lowers to an entry op over
3288    /// `__state.<cache>` (a `Record<string, { v, exp }>`), applying TTL expiry
3289    /// against the injected `Clock`.
3290    caches: HashMap<String, i64>,
3291    /// v0.95 (ADR 0121): the agent's `store` `Log` fields (name → optional
3292    /// `@retain` millis). `<log>.append` pushes `{ t: now(), v }` to
3293    /// `__state.<log>` (an array) and prunes past the retain horizon; the
3294    /// time-window roots / builders lower to a query pipeline over the array.
3295    logs: HashMap<String, Option<i64>>,
3296    /// v0.93 (ADR 0118): the agent's `@indexed` secondary indexes (map name →
3297    /// the value-record fields indexed on). A mutating op on the map maintains a
3298    /// sibling posting-list `Record<string, string[]>` per field (`<map>__idx_<f>`);
3299    /// an equality `filter` on an indexed field routes to a posting lookup.
3300    indexes: HashMap<String, Vec<String>>,
3301    /// v0.104/v0.105 (real-time track slice 3b): the agent's held `store Map[K,
3302    /// Connection]` fields (name → the connection's **frame type** `F`, e.g.
3303    /// `ServerFrame`). On Workers these persist `K → connId` in the durable state
3304    /// record; a method call whose receiver is one lowers to an entry op over
3305    /// `__state.<map>` (the connId record) with `connIdOf`/`resolveConnection<F>` —
3306    /// not the plain `Record<string, V>` ops (held maps are excluded from
3307    /// [`AgentStoreState::maps`]).
3308    held_maps: HashMap<String, String>,
3309    /// Review of #1460: a plain `store Map[K, V]` field's own value type `V`
3310    /// (name → its rendered TS type), for `lower_query_method`'s own
3311    /// collection-kernel sites (`distinctBy`/`joinOn`/`leftJoin`/`groupBy`)
3312    /// when the receiver is a plain map's own value scan lifted into the
3313    /// general query vocabulary — mirrors `held_maps`'s own construction
3314    /// exactly, just for the non-held case.
3315    map_values: HashMap<String, String>,
3316    /// Review of #1460: a `store Log[T]` field's own element type `T` (name →
3317    /// its rendered TS type), for the identical reason `map_values` exists —
3318    /// a Log's value scan is also lifted into the general query vocabulary.
3319    log_values: HashMap<String, String>,
3320}
3321
3322/// What [`LowerCtx`] is lowering *right now*. One variant per real body-emission
3323/// site; each carries exactly the state that site populates and nothing else, so
3324/// "not applicable to this kind" is expressed in the type rather than left
3325/// indistinguishable from "deliberately defaulted".
3326pub(crate) enum BodyMode {
3327    /// A type's method body (`emit_method`).
3328    Method,
3329    /// A free function body (`emit_free_fn`).
3330    FreeFn,
3331    /// An agent field's static initialiser expression (`emit_agent`).
3332    StaticInit,
3333    /// v0.80: an agent invariant predicate. Carries the name of the
3334    /// proposed-state variable (the `commitState` parameter) and the set of
3335    /// state field names — a bare ident matching a state field lowers to
3336    /// `<var>.<field>`, since invariants read state fields directly (§14).
3337    Invariant {
3338        name: String,
3339        fields: HashSet<String>,
3340    },
3341    /// v0.116 (testing track slice 4): a `transition` predicate. Carries the JS
3342    /// names bound to the contextual `old` and `new` state records. The Bynk
3343    /// identifiers `old`/`new` lower to these (`new` is a JS reserved word, so
3344    /// both are renamed), and field access `old.<field>` reads off the `old`
3345    /// record.
3346    Transition { old: String, new: String },
3347    /// A service handler body (`emit_service`).
3348    ServiceHandler {
3349        handler: HandlerShared,
3350        /// v0.47: the `by` binder whose `.identity` is threaded through `deps`
3351        /// (so `<binder>.identity` lowers to `deps.identity` rather than the
3352        /// unit-value `undefined`).
3353        deps_identity_binder: Option<String>,
3354        /// v0.52: when lowering a multi-actor sum handler body, the `by` binder
3355        /// that names the resolved-actor value (threaded through `deps`, so the
3356        /// binder ident lowers to `deps.who` — the tagged union the body
3357        /// `match`es).
3358        actor_sum_binder: Option<String>,
3359    },
3360    /// A composed provider's operation body (`emit_provider`).
3361    ProviderOp { handler: HandlerShared },
3362    /// An agent handler body (`emit_agent`).
3363    AgentHandler {
3364        handler: HandlerShared,
3365        /// True when lowering an agent handler body. Used to rewrite
3366        /// `self.<keyField>` access into the appropriate local.
3367        in_agent_handler: bool,
3368        /// The name of the agent's `key id` field (so `self.<id>` resolves).
3369        agent_key_field: Option<String>,
3370        /// The `store`-agent working-record state, when the hosting agent is a
3371        /// `store` agent. Boxed: it is by far the largest payload in this enum,
3372        /// and every other body kind would otherwise pay for it.
3373        store: Option<Box<AgentStoreState>>,
3374    },
3375    /// A websocket lifecycle method on the hosting Durable Object
3376    /// (`emit_ws_do_method`).
3377    WsDoMethod {
3378        handler: HandlerShared,
3379        /// v0.47: as [`BodyMode::ServiceHandler::deps_identity_binder`].
3380        deps_identity_binder: Option<String>,
3381        /// v0.104 (real-time track slice 3b): when lowering a `from websocket`
3382        /// `on open` body **into its hosting Durable Object** (the agent the
3383        /// upgrade transfers the connection to), the name of that agent. A
3384        /// transfer call `<Agent>(<key>).method(args)` whose `<Agent>` is this
3385        /// self-agent lowers to a direct `this.method(args, deps)` self-call
3386        /// rather than the cross-instance `__make<Agent>(key)` factory — the
3387        /// connection is already in this DO, so it never crosses an RPC boundary
3388        /// (DECISION A).
3389        ws_self_agent: Option<String>,
3390    },
3391    /// A `stub`/`where`/`requires` predicate value lowered via
3392    /// `lower_block_to_async_body` — test/property/contract scaffolding, never a
3393    /// real production provider body.
3394    PredicateScaffold { test: TestShared },
3395    /// A unit test `case` body (`lower_test_case_body`).
3396    TestCase {
3397        test: TestShared,
3398        /// v0.117 (testing track slice 5): the name of the recorded-call trace
3399        /// object (`__obs`), over which an observation (`Cap.op called …`) and
3400        /// `trace(Cap.op)` are lowered.
3401        observation_trace: Option<String>,
3402        /// v0.7: the target context's local service names. A `service.call(args)`
3403        /// or `service(args)` invocation where `service` is in this set lowers to
3404        /// `<service>.call(args, deps)` so the test wires its `deps` through.
3405        test_services: HashSet<String>,
3406        /// v0.182 (#664): the ordered handler kinds of each test service, so a
3407        /// cron (`svc.schedule("…")`) or queue (`svc.message(m)`) address can
3408        /// recover the position index the emitted key encodes (`cron_<svc>_<i>` /
3409        /// `queue_…`). http keys are a pure function of verb + path and need no
3410        /// lookup here. P6.37 (design/tracks/the-ir.md §6a): `IrHandlerKind`
3411        /// (P6.24a's own pure, unconditional mirror), not the raw AST
3412        /// `HandlerKind` this field used to store.
3413        test_service_handlers: HashMap<String, Vec<bynk_ir::IrHandlerKind>>,
3414    },
3415    /// An integration test `case` body (`lower_integration_case_body`).
3416    IntegrationCase {
3417        test: TestShared,
3418        /// v0.182 (Slice B, #667): the target's http service names. An http
3419        /// address on one of these lowers to a driver call
3420        /// (`__sysdrive_<svc>_<key>(args, sub)`) that drives a real
3421        /// `worker.fetch` with a signed credential, instead of the unit-tier
3422        /// direct handler call. Empty at the unit tier.
3423        system_http_services: std::collections::HashSet<String>,
3424        /// #707: the declared `(service, method, path)` http routes of the system
3425        /// target. A `(method, path)` call whose method is absent here but whose
3426        /// path is present is a **wrong-method** call — it drives the `405`
3427        /// fall-through through the generic `__sysdrive_wrongmethod_<svc>` driver.
3428        system_http_routes: std::collections::HashSet<(String, String, String)>,
3429        /// #708: for each declared `(service, method, path)` route that has a
3430        /// body param, the body's zero-based position among the route's
3431        /// positional call args (i.e. within `args[1..]`, matching handler-param
3432        /// declaration order) and its declared type. The raw driver
3433        /// (`__sysdrive_raw_*`, Slice C) forwards every slot as a `string`; a
3434        /// `Wire(…)` arg already lowers to that raw string, but a *typed* arg
3435        /// mixed into the same call must be converted: the body slot serialises
3436        /// through the same wire codec the typed driver uses
3437        /// (`JSON.stringify(serialise_expr_via(...))`), any other (path) slot
3438        /// just coerces via `String(...)`. Absent for a bodyless route.
3439        system_http_route_body:
3440            HashMap<(String, String, String), (usize, bynk_syntax::ast::TypeRef)>,
3441        /// #708: the type namespace (`<target>.`) `serialise_expr_via` needs to
3442        /// resolve a body param's custom codec when converting a typed arg for
3443        /// the raw driver. Mirrors the `type_ns` `emit_system_http_support`
3444        /// computes from the same suite target.
3445        system_http_type_ns: String,
3446    },
3447}
3448
3449/// Per-body lowering context: what module we are emitting into ([`ModuleCtx`]),
3450/// what kind of body we are lowering ([`BodyMode`]), and the scratch state the
3451/// recursive lowering accumulates as it goes.
3452///
3453/// Everything below `mode` is deliberately **not** in `ModuleCtx`: a fresh
3454/// `LowerCtx` is built at every body-emission site and never reused across two
3455/// bodies, so these are implicitly reset per body today. Moving any of them up a
3456/// level would leak state between handlers in the same module — most visibly the
3457/// `next_tmp` counter, which would stop restarting `__r0` at each function and
3458/// so rename every generated temp in the emitted TypeScript.
3459pub(crate) struct LowerCtx<'a> {
3460    module: ModuleCtx<'a>,
3461    mode: BodyMode,
3462    /// Agent names declared in the surrounding context. Drives lowering of
3463    /// `Agent(key)` (to `new Agent(makeTestState(String(key)))`) and of
3464    /// `agent_instance.method(args)` (to `instance.method(args, deps)`) in
3465    /// service and agent-handler bodies. Populated by the caller for non-test
3466    /// emission and from the *test's own* agent set in test emission — which is
3467    /// why this is not a [`ModuleCtx`] field despite being module-wide at the
3468    /// nine non-test sites.
3469    pub local_agents: HashSet<String>,
3470    /// v0.154 (ADR 0178): the enclosing function/handler's resolved return type,
3471    /// set at each body-emission site that has one. The `?` lowering reads it to
3472    /// decide whether a declared error embedding (`embeds E as V`) converts the
3473    /// propagated `Err` — via the same `embedding_for` rule the checker used.
3474    /// Genuinely cross-cutting rather than kind-specific: it is saved/restored
3475    /// around lambda bodies, `?`-embedding and match arms *within* whichever
3476    /// kind is being lowered.
3477    return_ty: Option<bynk_check::checker::TyId>,
3478    next_tmp: u32,
3479    /// #908: a stack of per-block frames tracking `let`/`let <-` names that
3480    /// needed a fresh emitted identifier because the name was already bound
3481    /// by an enclosing (or the same) block's `let` — the checker allows
3482    /// re-binding a name (`let x = 1; let x = x + 1`, a deliberate ML-family
3483    /// idiom, ADR 0064), but each `let` still lowers to its own `const`, so
3484    /// without renaming a same-block re-`let` collides with the first
3485    /// (TS2451), and a nested block's re-`let` — while a legal *redeclaration*
3486    /// on its own — would put an RHS read of the outer binding in its own
3487    /// declaration's temporal dead zone. Pushed/popped in lock-step with
3488    /// [`emit_block_inner`] — the single choke point every block (function,
3489    /// lambda, if/else branch, match arm) lowers through — so a read
3490    /// (`lower_ident`, and the agent-dispatch receiver text) resolves a name
3491    /// by walking the stack innermost-out and falls back to the natural
3492    /// `ts_ident` name when no frame renamed it.
3493    pub shadow_scopes: Vec<HashMap<String, String>>,
3494    /// When an `is` receiver is not a simple, repeatable lvalue (e.g. a call
3495    /// like `parse(x) is Ok(n)`), it is evaluated once into a temp; the temp
3496    /// name is cached here keyed by the receiver expression's span so the
3497    /// `.tag` check and every pattern binding reference the *same* single
3498    /// evaluation. Simple receivers (idents / field chains) are never cached
3499    /// and continue to be rendered inline as before.
3500    is_receiver_temps: HashMap<bynk_syntax::span::Span, String>,
3501    /// Variable bindings that point at agent instances. Updated by the
3502    /// statement emitter when it sees `let x = AgentName(key)`. Used by
3503    /// the method-call lowering so `x.method(args)` resolves through
3504    /// the agent's class rather than via the receiver-namespace lookup.
3505    pub local_agent_vars: HashMap<String, String>,
3506    /// v0.182 (#664): while lowering an `EffectLet` whose value addresses a
3507    /// service handler, the call-site principal's identity expression (already
3508    /// lowered), if the statement carries `by <Actor>(<identity>)`. The
3509    /// address-call lowering reads it to build the handler's `deps.identity`.
3510    /// `None` for a unit-identity actor or a non-principal statement.
3511    pub call_site_identity: Option<String>,
3512    /// #706: the call-site principal is `by Nobody` — drive the route with no
3513    /// `Authorization` header so the real auth seam rejects it (`401` →
3514    /// `Rejected(Unauthorized)`). Routes a `system` http address to the no-auth
3515    /// driver. `false` for any other (or no) principal.
3516    pub call_site_no_credential: bool,
3517    /// Slice 1 (ADR 0103): the source-map builder for the file being emitted, if
3518    /// any. The deep lowering chain records `(generated offset → source span)`
3519    /// checkpoints here; `emit_project` owns the `RefCell` and threads a shared
3520    /// borrow in. `None` for the single-file `emit()` path and any body emitted
3521    /// outside a project, where no map is produced.
3522    pub source_map: Option<&'a RefCell<SourceMapBuilder>>,
3523    /// T2.2 (R6.4): set at the two statement sites that emit a literal `await`
3524    /// (`EffectLet`, `Do`) and read-and-reset around a value-position `match`/`if`
3525    /// IIFE's own body construction — the flag a synchronous arrow reads to decide
3526    /// whether it must become `async` and be awaited at its call site. Replaces a
3527    /// scan of the built string for the substring `"await "`, which over-matched
3528    /// on a self-contained `async (...) => {...}` embedded as an arm's value (an
3529    /// iterator terminal like `forEach`) without anything in *this* arrow's own
3530    /// scope needing to await. Not isolated around a lambda body — a nested
3531    /// effectful lambda still marks the enclosing IIFE async, exactly as the old
3532    /// scan did (its own body text also contained `"await "`); closing that is a
3533    /// separate, unscoped defect, not this one.
3534    pub(crate) emitted_await: bool,
3535    /// T2.3 (R6.3): set when lowering a `?` pushes a propagating early-return
3536    /// statement (`if (...) return ...;`) into the current `Pre`. Read (and
3537    /// reset) around a short-circuit operand's own lowering in `lower_bin_op`/
3538    /// `lower_and_with_is`, so those can tell a hoisted `?` apart from an
3539    /// ordinary hoisted statement: a plain `(() => { ...; return expr; })()`
3540    /// wrap is safe for the latter (nothing inside needs to escape the arrow)
3541    /// but captures the former's `return` instead of letting it exit the
3542    /// enclosing function — the residual gap `hoist_if_as_statement` (built for
3543    /// T2.1's `if`-hoisting) also closes here, once this flag says it's needed.
3544    pub(crate) emitted_early_return: bool,
3545}
3546
3547/// v0.59: the source context an `assert` lowering needs to turn its span into a
3548/// `path:line:col` location. Owned (cloned once per test-case body) to keep the
3549/// lowering free of extra lifetime threading; test-file sources are small and
3550/// this is compile-time only.
3551#[derive(Clone)]
3552pub(crate) struct AssertLoc {
3553    pub source: String,
3554    pub rel_path: String,
3555}
3556
3557impl<'a> LowerCtx<'a> {
3558    fn new(module: ModuleCtx<'a>, mode: BodyMode) -> Self {
3559        Self {
3560            module,
3561            mode,
3562            local_agents: HashSet::new(),
3563            return_ty: None,
3564            // Every field below is per-body scratch state: a fresh `LowerCtx` is
3565            // built at each body-emission site and never reused, so these must
3566            // re-initialise here on every construction. In particular `next_tmp`
3567            // restarting at 0 is what makes each emitted function's temps begin
3568            // at `__r0`.
3569            next_tmp: 0,
3570            shadow_scopes: vec![HashMap::new()],
3571            is_receiver_temps: HashMap::new(),
3572            local_agent_vars: HashMap::new(),
3573            call_site_identity: None,
3574            call_site_no_credential: false,
3575            source_map: None,
3576            emitted_await: false,
3577            emitted_early_return: false,
3578        }
3579    }
3580
3581    // ---- `ModuleCtx` passthroughs -----------------------------------------
3582    //
3583    // Returned with the `'a` module lifetime rather than the `&self` borrow, so
3584    // a `&mut self` lowering step can hold onto a commons/runtime handle across
3585    // its own recursive calls exactly as it did when these were plain fields.
3586
3587    /// Typed-commons handle for the module being emitted.
3588    pub(crate) fn commons(&self) -> &'a TypedCommons {
3589        self.module.commons
3590    }
3591
3592    /// Cross-context info for v0.6 cross-context call lowering.
3593    pub(crate) fn cross_context(&self) -> &'a bynk_check::resolver::CrossContextInfo {
3594        self.module.cross_context
3595    }
3596
3597    /// The emitted module's conditional-runtime-helper accumulator.
3598    pub(crate) fn runtime_use(&self) -> &'a RuntimeUse {
3599        self.module.runtime_use
3600    }
3601
3602    /// v0.8 build target.
3603    pub(crate) fn target(&self) -> BuildTarget {
3604        self.module.target
3605    }
3606
3607    /// #527: type names this context rebrands.
3608    pub(crate) fn rebranded_types(&self) -> &HashSet<String> {
3609        &self.module.rebranded_types
3610    }
3611
3612    /// #527: fn names imported from a commons.
3613    pub(crate) fn commons_imported_fns(&self) -> &HashSet<String> {
3614        &self.module.commons_imported_fns
3615    }
3616
3617    /// #934: true when the unit being emitted is the first-party `bynk` adapter.
3618    pub(crate) fn in_bynk_unit(&self) -> bool {
3619        self.module.in_bynk_unit
3620    }
3621
3622    // ---- capability-bearing (`HandlerShared`) state ------------------------
3623    //
3624    // Every accessor here reports the same default a non-handler kind used to
3625    // get from the flat struct (no capabilities, no scope, `deps`), so a caller
3626    // that does not care which kind it is in reads unchanged.
3627
3628    fn handler(&self) -> Option<&HandlerShared> {
3629        match &self.mode {
3630            BodyMode::ServiceHandler { handler, .. }
3631            | BodyMode::ProviderOp { handler }
3632            | BodyMode::AgentHandler { handler, .. }
3633            | BodyMode::WsDoMethod { handler, .. } => Some(handler),
3634            _ => None,
3635        }
3636    }
3637
3638    fn handler_mut(&mut self) -> Option<&mut HandlerShared> {
3639        match &mut self.mode {
3640            BodyMode::ServiceHandler { handler, .. }
3641            | BodyMode::ProviderOp { handler }
3642            | BodyMode::AgentHandler { handler, .. }
3643            | BodyMode::WsDoMethod { handler, .. } => Some(handler),
3644            _ => None,
3645        }
3646    }
3647
3648    /// Whether `name` is a capability in scope as `given C1, C2, ...`.
3649    pub(crate) fn has_capability(&self, name: &str) -> bool {
3650        self.handler()
3651            .is_some_and(|h| h.capabilities.contains(name))
3652    }
3653
3654    /// #934: the calling handler's own qualified name, if a capability call can
3655    /// occur in this body at all. `None` for every non-handler kind — the
3656    /// `Idempotency` key-scoping lowering treats that as a compiler bug and
3657    /// panics, exactly as it did when this was a flat `Option` field.
3658    pub(crate) fn handler_scope(&self) -> Option<&str> {
3659        self.handler().and_then(|h| h.handler_scope.as_deref())
3660    }
3661
3662    /// Events track, slice 2: the qualified name of the unit this body is
3663    /// emitted into, for the `Events.emit[E](event)` lowering's
3664    /// `publisherId`. `None` for a body kind that carries no `HandlerShared`
3665    /// at all (an `Events.emit` call cannot occur there).
3666    pub(crate) fn owning_context(&self) -> Option<&str> {
3667        self.handler().map(|h| h.owning_context.as_str())
3668    }
3669
3670    /// v0.12: the receiver expression a capability call resolves against.
3671    pub(crate) fn cap_deps_expr(&self) -> &str {
3672        self.handler().map_or("deps", |h| h.cap_deps_expr.as_str())
3673    }
3674
3675    /// Note that this body made a cross-context call. A no-op in a body kind
3676    /// that carries no deps shape to widen (a plain method, a predicate, a test
3677    /// case) — those never read the flag back.
3678    pub(crate) fn note_cross_context_used(&mut self) {
3679        if let Some(h) = self.handler_mut() {
3680            h.cross_context_used = true;
3681        }
3682    }
3683
3684    /// True if this handler made at least one cross-context call.
3685    pub(crate) fn cross_context_used(&self) -> bool {
3686        self.handler().is_some_and(|h| h.cross_context_used)
3687    }
3688
3689    /// v0.9.2: true if this body instantiated a local agent.
3690    pub(crate) fn agents_instantiated(&self) -> bool {
3691        self.handler().is_some_and(|h| h.agents_instantiated)
3692    }
3693
3694    /// #527: capabilities required by agent methods this body calls.
3695    pub(crate) fn agent_given_caps_used(
3696        &self,
3697    ) -> Option<&std::collections::BTreeMap<String, bynk_ir::CapRefIr>> {
3698        self.handler().map(|h| &h.agent_given_caps_used)
3699    }
3700
3701    // ---- test-scaffold (`TestShared`) state --------------------------------
3702
3703    fn test(&self) -> Option<&TestShared> {
3704        match &self.mode {
3705            BodyMode::PredicateScaffold { test }
3706            | BodyMode::TestCase { test, .. }
3707            | BodyMode::IntegrationCase { test, .. } => Some(test),
3708            _ => None,
3709        }
3710    }
3711
3712    /// True when lowering **generated test-scaffold** TypeScript (a test-case
3713    /// body, or a `stub`/`where`/`requires` predicate value), where branded
3714    /// types are destructured into `any`-typed value bindings rather than
3715    /// referenced as types. Callers that emit a branded `as`-cast consult this
3716    /// to pick `unchecked_construct_test` (→ `(v as any)`) over the production
3717    /// `(v as T)` form, which cannot resolve `T` in the test module's scope.
3718    pub(crate) fn in_test_scaffold(&self) -> bool {
3719        self.test().is_some_and(|t| t.test_scaffold)
3720    }
3721
3722    /// v0.59: the test body's source context, for `assert`/`expect` locations.
3723    pub(crate) fn assert_loc(&self) -> Option<&AssertLoc> {
3724        self.test().and_then(|t| t.assert_loc.as_ref())
3725    }
3726
3727    // ---- single-kind state -------------------------------------------------
3728
3729    /// v0.80: inside an invariant predicate, the proposed-state variable and the
3730    /// agent's state field names.
3731    pub(crate) fn invariant_state(&self) -> Option<(&str, &HashSet<String>)> {
3732        match &self.mode {
3733            BodyMode::Invariant { name, fields } => Some((name.as_str(), fields)),
3734            _ => None,
3735        }
3736    }
3737
3738    /// v0.116: inside a `transition` predicate, the JS names bound to the
3739    /// contextual `old`/`new` state records.
3740    pub(crate) fn transition_states(&self) -> Option<(&str, &str)> {
3741        match &self.mode {
3742            BodyMode::Transition { old, new } => Some((old.as_str(), new.as_str())),
3743            _ => None,
3744        }
3745    }
3746
3747    /// v0.117: the recorded-call trace object a test case's observations read.
3748    pub(crate) fn observation_trace(&self) -> Option<&str> {
3749        match &self.mode {
3750            BodyMode::TestCase {
3751                observation_trace, ..
3752            } => observation_trace.as_deref(),
3753            _ => None,
3754        }
3755    }
3756
3757    fn agent_store(&self) -> Option<&AgentStoreState> {
3758        match &self.mode {
3759            BodyMode::AgentHandler { store, .. } => store.as_deref(),
3760            _ => None,
3761        }
3762    }
3763
3764    /// v0.81: the mutable working-state variable a `store`-agent handler stages
3765    /// its writes into. `__state` is the name every real site uses; the fallback
3766    /// keeps the (defensive) non-store paths rendering as they did before.
3767    pub(crate) fn agent_store_var(&self) -> &str {
3768        self.agent_store().map_or("__state", |s| s.state.0.as_str())
3769    }
3770
3771    /// v0.81: the working-state variable plus the `Cell` field names it holds.
3772    pub(crate) fn agent_store_cells(&self) -> Option<(&str, &HashSet<String>)> {
3773        self.agent_store().map(|s| (s.state.0.as_str(), &s.state.1))
3774    }
3775
3776    /// v0.82: whether `name` is a persisted `store Map` field (held connection
3777    /// maps are deliberately excluded — they use the connId lowering).
3778    pub(crate) fn is_agent_store_map(&self, name: &str) -> bool {
3779        self.agent_store().is_some_and(|s| s.maps.contains(name))
3780    }
3781
3782    /// v0.83: whether `name` is a `store Set` field.
3783    pub(crate) fn is_agent_store_set(&self, name: &str) -> bool {
3784        self.agent_store().is_some_and(|s| s.sets.contains(name))
3785    }
3786
3787    /// v0.87: the ttl (millis) of the `store Cache` field `name`, if it is one.
3788    pub(crate) fn agent_store_cache_ttl(&self, name: &str) -> Option<i64> {
3789        self.agent_store().and_then(|s| s.caches.get(name).copied())
3790    }
3791
3792    /// v0.95: the `@retain` horizon of the `store Log` field `name`, if it is
3793    /// one. The outer `Option` is "is a log"; the inner is "has a retain".
3794    pub(crate) fn agent_store_log_retain(&self, name: &str) -> Option<Option<i64>> {
3795        self.agent_store().and_then(|s| s.logs.get(name).copied())
3796    }
3797
3798    /// v0.95: whether `name` is a `store Log` field.
3799    pub(crate) fn is_agent_store_log(&self, name: &str) -> bool {
3800        self.agent_store()
3801            .is_some_and(|s| s.logs.contains_key(name))
3802    }
3803
3804    /// v0.93: the value-record fields the `store Map` `name` is `@indexed(by:)`
3805    /// on — empty when it has no secondary index.
3806    pub(crate) fn agent_store_index_fields(&self, name: &str) -> Vec<String> {
3807        self.agent_store()
3808            .and_then(|s| s.indexes.get(name).cloned())
3809            .unwrap_or_default()
3810    }
3811
3812    /// v0.105: the connection **frame type** of the held `store Map[K,
3813    /// Connection]` field `name`, if it is one.
3814    pub(crate) fn agent_held_map_frame(&self, name: &str) -> Option<&String> {
3815        self.agent_store().and_then(|s| s.held_maps.get(name))
3816    }
3817
3818    /// v0.105: whether `name` is a held `store Map[K, Connection]` field.
3819    pub(crate) fn is_agent_held_map(&self, name: &str) -> bool {
3820        self.agent_store()
3821            .is_some_and(|s| s.held_maps.contains_key(name))
3822    }
3823
3824    /// Review of #1460: the value type `V` of the plain `store Map[K, V]`
3825    /// field `name`, if it is one (`None` for a held map — see
3826    /// [`Self::agent_held_map_frame`] instead).
3827    pub(crate) fn agent_store_map_value_ts(&self, name: &str) -> Option<&String> {
3828        self.agent_store().and_then(|s| s.map_values.get(name))
3829    }
3830
3831    /// Review of #1460: the element type `T` of the `store Log[T]` field
3832    /// `name`, if it is one.
3833    pub(crate) fn agent_store_log_value_ts(&self, name: &str) -> Option<&String> {
3834        self.agent_store().and_then(|s| s.log_values.get(name))
3835    }
3836
3837    /// True when lowering an agent handler body — drives the `self.<keyField>`
3838    /// rewrite.
3839    pub(crate) fn in_agent_handler(&self) -> bool {
3840        match &self.mode {
3841            BodyMode::AgentHandler {
3842                in_agent_handler, ..
3843            } => *in_agent_handler,
3844            _ => false,
3845        }
3846    }
3847
3848    /// The name of the agent's `key id` field, inside an agent handler body.
3849    pub(crate) fn agent_key_field(&self) -> Option<&str> {
3850        match &self.mode {
3851            BodyMode::AgentHandler {
3852                agent_key_field, ..
3853            } => agent_key_field.as_deref(),
3854            _ => None,
3855        }
3856    }
3857
3858    /// v0.104: the agent hosting the websocket lifecycle body being lowered.
3859    pub(crate) fn ws_self_agent(&self) -> Option<&str> {
3860        match &self.mode {
3861            BodyMode::WsDoMethod { ws_self_agent, .. } => ws_self_agent.as_deref(),
3862            _ => None,
3863        }
3864    }
3865
3866    /// v0.47: the `by` binder whose `.identity` is threaded through `deps`.
3867    pub(crate) fn deps_identity_binder(&self) -> Option<&str> {
3868        match &self.mode {
3869            BodyMode::ServiceHandler {
3870                deps_identity_binder,
3871                ..
3872            }
3873            | BodyMode::WsDoMethod {
3874                deps_identity_binder,
3875                ..
3876            } => deps_identity_binder.as_deref(),
3877            _ => None,
3878        }
3879    }
3880
3881    /// v0.52: the multi-actor sum handler's resolved-actor binder.
3882    pub(crate) fn actor_sum_binder(&self) -> Option<&str> {
3883        match &self.mode {
3884            BodyMode::ServiceHandler {
3885                actor_sum_binder, ..
3886            } => actor_sum_binder.as_deref(),
3887            _ => None,
3888        }
3889    }
3890
3891    /// v0.7: whether `name` is a local service of the test case's target context.
3892    pub(crate) fn is_test_service(&self, name: &str) -> bool {
3893        match &self.mode {
3894            BodyMode::TestCase { test_services, .. } => test_services.contains(name),
3895            _ => false,
3896        }
3897    }
3898
3899    /// v0.182 (#664): the ordered handler kinds of the test service `name`.
3900    pub(crate) fn test_service_handlers(&self, name: &str) -> Option<&[bynk_ir::IrHandlerKind]> {
3901        match &self.mode {
3902            BodyMode::TestCase {
3903                test_service_handlers,
3904                ..
3905            } => test_service_handlers.get(name).map(Vec::as_slice),
3906            _ => None,
3907        }
3908    }
3909
3910    /// v0.182 (Slice B, #667): whether `name` is an http service of the system
3911    /// target being driven.
3912    pub(crate) fn is_system_http_service(&self, name: &str) -> bool {
3913        match &self.mode {
3914            BodyMode::IntegrationCase {
3915                system_http_services,
3916                ..
3917            } => system_http_services.contains(name),
3918            _ => false,
3919        }
3920    }
3921
3922    /// #707: whether `(service, verb, path)` is a declared route of the system
3923    /// target — an undeclared one drives the `405` fall-through.
3924    pub(crate) fn has_system_http_route(&self, route: &(String, String, String)) -> bool {
3925        match &self.mode {
3926            BodyMode::IntegrationCase {
3927                system_http_routes, ..
3928            } => system_http_routes.contains(route),
3929            _ => false,
3930        }
3931    }
3932
3933    /// #708: the body param position and declared type of a system http route.
3934    pub(crate) fn system_http_route_body(
3935        &self,
3936        route: &(String, String, String),
3937    ) -> Option<&(usize, bynk_syntax::ast::TypeRef)> {
3938        match &self.mode {
3939            BodyMode::IntegrationCase {
3940                system_http_route_body,
3941                ..
3942            } => system_http_route_body.get(route),
3943            _ => None,
3944        }
3945    }
3946
3947    /// #708: the type namespace a system http body param's codec resolves in.
3948    pub(crate) fn system_http_type_ns(&self) -> &str {
3949        match &self.mode {
3950            BodyMode::IntegrationCase {
3951                system_http_type_ns,
3952                ..
3953            } => system_http_type_ns.as_str(),
3954            _ => "",
3955        }
3956    }
3957
3958    /// Events track, slice 0 (spine #936): true when a bare `Events`
3959    /// receiver in this unit is genuinely the first-party `bynk.Events`
3960    /// capability — declared here because this unit *is* `bynk`, or
3961    /// flattened in from it (`consumes bynk { Events }`) — not some other,
3962    /// unrelated capability that merely happens to share the name. Mirrors
3963    /// #934's `Idempotency` distinction (`is_first_party` at the
3964    /// `Idempotency.dedup`/`remember` lowering site). Both the call-site
3965    /// interception (`lower.rs`) and the `__events` buffer declaration
3966    /// (`block_uses_emit`'s gate in `emit.rs`) must agree on this, or a
3967    /// custom same-named `Events` capability's calls get silently rewritten
3968    /// into a buffer nothing constructs a provider for.
3969    pub(crate) fn is_first_party_events(&self) -> bool {
3970        self.in_bynk_unit()
3971            || self
3972                .cross_context()
3973                .flattened_caps
3974                .get("Events")
3975                .map(String::as_str)
3976                == Some("bynk")
3977    }
3978
3979    /// Events slice 3b (#978): the declared `@schema(N)` version of the
3980    /// locally-declared event `name`, or `1` if it has none (including if
3981    /// `name` isn't a locally-declared event at all — `Events.emit[E]` only
3982    /// ever names an owned event, checker-enforced, so a miss here can only
3983    /// mean a broken build already reported elsewhere, and this degrades to
3984    /// today's pre-existing output rather than panicking).
3985    pub(crate) fn event_schema_version(&self, name: &str) -> i64 {
3986        self.module
3987            .event_schema_versions
3988            .get(name)
3989            .copied()
3990            .unwrap_or(1)
3991    }
3992
3993    /// Attach the file's source-map builder (slice 1, ADR 0103). Builder-style so
3994    /// the emission sites with no builder leave `LowerCtx::new(module, mode)`
3995    /// untouched — only the project-emission path that has one calls this.
3996    fn with_source_map(mut self, map: Option<&'a RefCell<SourceMapBuilder>>) -> Self {
3997        self.source_map = map;
3998        self
3999    }
4000
4001    /// Record that this lowering emitted a reference to the `Bytes` runtime
4002    /// helpers, so the module imports them.
4003    fn note_bytes(&self) {
4004        self.runtime_use().note_bytes();
4005    }
4006
4007    /// Record a checkpoint: generated text from `out_len` onward originates at
4008    /// `span`, until the next checkpoint (ADR 0103 D2, nearest-enclosing). A
4009    /// no-op when no builder is attached. `out_len` is the buffer length *before*
4010    /// the statement's text is appended.
4011    ///
4012    /// `out_len` only means something relative to the *top-level module
4013    /// buffer* the attached builder is tracking. A caller building an IIFE
4014    /// into its own local `String` — `lower_if`'s value-position wrapper,
4015    /// `build_match_iife`'s — before splicing it elsewhere must not call this
4016    /// with that buffer's own length; see [`Self::without_source_map`].
4017    fn record_span(&self, out_len: usize, span: bynk_syntax::span::Span) {
4018        if let Some(map) = self.source_map {
4019            map.borrow_mut().record(out_len, span);
4020        }
4021    }
4022
4023    /// #4 review: run `f` with source-map recording suppressed, restoring it
4024    /// after. For lowering into a local IIFE buffer that will later be
4025    /// spliced into the real module text at some other offset — `record_span`
4026    /// has no way to know that offset, so a checkpoint taken here would
4027    /// silently corrupt the map with a position relative to the wrong
4028    /// buffer. `SourceMapBuilder::merge` already solves the equivalent
4029    /// problem one level up (a handler/test body's own local buffer, spliced
4030    /// into the module) by recording into a *sub*-builder and rebasing at the
4031    /// splice — but that needs a builder that outlives the call, and
4032    /// `source_map` is `Option<&'a RefCell<SourceMapBuilder>>` tied to the
4033    /// whole emission's lifetime, so a function-local sub-builder can't be
4034    /// substituted in. Suppressing instead of mis-recording means the
4035    /// nearest-enclosing-checkpoint rule (ADR 0103 D2) falls back to whatever
4036    /// was correctly mapped just before the IIFE started, rather than a wrong
4037    /// one silently taking over — degraded stepping through the IIFE's own
4038    /// lines in `bynkc test --inspect`, not a corrupted map.
4039    fn without_source_map<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
4040        let saved = self.source_map.take();
4041        let result = f(self);
4042        self.source_map = saved;
4043        result
4044    }
4045    /// v0.9.2: lower an agent instantiation `AgentName(key)` to its factory
4046    /// call. Bundle/test mode passes only the key; workers mode also threads
4047    /// `deps.env` so the factory can reach the agent's DO namespace.
4048    fn agent_construct(&mut self, agent: &str, key_expr: &str) -> String {
4049        if let Some(h) = self.handler_mut() {
4050            h.agents_instantiated = true;
4051        }
4052        let factory = agent_factory_name(agent);
4053        if matches!(self.target(), BuildTarget::Workers) {
4054            format!("{factory}({key_expr}, deps.env)")
4055        } else {
4056            format!("{factory}({key_expr})")
4057        }
4058    }
4059
4060    /// #527: note that the body calls `agent.method`, folding the method's
4061    /// `given` capabilities into this handler's requirement set. A no-op in a
4062    /// body kind that has no deps shape to widen — those never read it back.
4063    pub(crate) fn record_agent_call(&mut self, agent: &str, method: &str) {
4064        let givens = self
4065            .module
4066            .agent_method_givens
4067            .get(agent)
4068            .and_then(|m| m.get(method))
4069            .cloned()
4070            .unwrap_or_default();
4071        if let Some(h) = self.handler_mut() {
4072            for c in givens {
4073                h.agent_given_caps_used.entry(c.name.clone()).or_insert(c);
4074            }
4075        }
4076    }
4077    fn fresh(&mut self) -> String {
4078        let n = self.next_tmp;
4079        self.next_tmp += 1;
4080        format!("__r{n}")
4081    }
4082    /// #908: bind a `let`/`let <-` LHS to its emitted JS identifier. Returns
4083    /// the natural `ts_ident` name unless `name` is already bound *anywhere*
4084    /// in the enclosing block chain — not only the current block. A nested
4085    /// block re-`let`-ing an outer name is ordinary, valid lexical shadowing
4086    /// in JS on its own, but this `let`'s own RHS may still read the outer
4087    /// binding (`let n = n + 1` one block in); a plain `const n` there
4088    /// would put the read inside its own declaration's temporal dead zone
4089    /// (JS hoists a block's `let`/`const` names to the top of that block),
4090    /// turning a correct read of the outer value into a TDZ ReferenceError.
4091    /// Renaming whenever *any* enclosing frame already has the name sidesteps
4092    /// that regardless of whether this particular RHS reads it. Allocates a
4093    /// fresh name via [`Self::fresh`] when so. `_` never collides (each is
4094    /// already a fresh throwaway) and is never registered, since it is never
4095    /// read.
4096    pub(crate) fn bind_local_name(&mut self, name: &str) -> String {
4097        if name == "_" {
4098            return self.fresh();
4099        }
4100        let natural = ts_ident(name);
4101        let js_name = if self.shadow_scopes.iter().any(|f| f.contains_key(name)) {
4102            self.fresh()
4103        } else {
4104            natural
4105        };
4106        self.shadow_scopes
4107            .last_mut()
4108            .expect("shadow_scopes always has a root frame")
4109            .insert(name.to_string(), js_name.clone());
4110        js_name
4111    }
4112    /// #908: the emitted JS identifier currently bound to a local name, if a
4113    /// `let` re-bind renamed it somewhere in the enclosing block chain.
4114    /// Walked innermost-out so a nested block sees an outer rename that was
4115    /// still active when it was entered. `None` means no rename applies —
4116    /// callers fall back to the natural `ts_ident` name.
4117    pub(crate) fn resolved_local_name(&self, name: &str) -> Option<String> {
4118        self.shadow_scopes
4119            .iter()
4120            .rev()
4121            .find_map(|f| f.get(name).cloned())
4122    }
4123    /// Whether `name` is bound by an enclosing local (a `let`, match-arm/`is`
4124    /// binding, or lambda param) rather than free to refer to a store field.
4125    /// A local always wins: store-field dispatch by bare receiver name must
4126    /// check this first, or a parameter/binding that happens to share a store
4127    /// field's name is silently treated as the store field.
4128    pub(crate) fn is_local(&self, name: &str) -> bool {
4129        self.shadow_scopes.iter().any(|f| f.contains_key(name))
4130    }
4131    /// #908: register a non-`let` binder (a match-arm/`is` pattern binding, or
4132    /// a lambda param) into the current frame under its natural `ts_ident`
4133    /// name — never renamed, since each such binder already lowers inside its
4134    /// own JS block/arrow scope with no risk of colliding with a sibling
4135    /// declaration of the same name. Without this, a read inside the binder's
4136    /// scope would fall through [`Self::resolved_local_name`]'s stack walk
4137    /// past this (unregistered) declaration to an outer `let` rename that is
4138    /// no longer the right value here — silently wrong output, not a `tsc`
4139    /// error. Every construct that introduces a binder outside `bind_local_name`
4140    /// (match arms, `is`, lambda params) must call this for each name it binds.
4141    pub(crate) fn declare_binder(&mut self, name: &str) {
4142        if name == "_" {
4143            return;
4144        }
4145        self.shadow_scopes
4146            .last_mut()
4147            .expect("shadow_scopes always has a root frame")
4148            .insert(name.to_string(), ts_ident(name));
4149    }
4150    /// Return a stable textual reference to an `is` receiver, used by the
4151    /// `.tag` check in `lower_is`. A simple, repeatable lvalue is lowered
4152    /// inline exactly as before (preserving rewrites such as `self.state` or
4153    /// capability access). A complex receiver (anything `value_text_for_is`
4154    /// could not render — e.g. a call) is evaluated once into a fresh temp
4155    /// hoisted into the returned `Lowered` and cached by span, so the bindings
4156    /// gathered later reference the same evaluation rather than re-running the
4157    /// expression.
4158    fn is_receiver_ref(&mut self, value: &Expr) -> Lowered {
4159        if let Some(t) = self.is_receiver_temps.get(&value.span) {
4160            return Lowered::bare(t.clone());
4161        }
4162        let mut pre = Pre::new();
4163        let lowered = pre.lower(value, self);
4164        if is_simple_is_receiver(value) {
4165            return pre.finish(lowered);
4166        }
4167        let tmp = self.fresh();
4168        pre.push(format!("const {tmp} = {lowered};"));
4169        self.is_receiver_temps.insert(value.span, tmp.clone());
4170        pre.finish(tmp)
4171    }
4172
4173    /// v0.13: like `is_receiver_ref` but always lifts to a temp, even for a
4174    /// simple ident. A refined `is`-narrowing re-binds the value's name to the
4175    /// branded refined type (`const n = <temp> as Quantity`); that shadowing
4176    /// const cannot reference the same name (TDZ), so the value is captured in a
4177    /// temp first and both the check and the binding read the temp.
4178    fn is_receiver_ref_forced(&mut self, value: &Expr) -> Lowered {
4179        if let Some(t) = self.is_receiver_temps.get(&value.span) {
4180            return Lowered::bare(t.clone());
4181        }
4182        let mut pre = Pre::new();
4183        let lowered = pre.lower(value, self);
4184        let tmp = self.fresh();
4185        pre.push(format!("const {tmp} = {lowered};"));
4186        self.is_receiver_temps.insert(value.span, tmp.clone());
4187        pre.finish(tmp)
4188    }
4189
4190    /// v0.13: true when `value is Name` is a *refinement* check — the value is a
4191    /// base/refined value and `Name` is a refined type — rather than a sum
4192    /// variant test. Mirrors the checker's disambiguation.
4193    ///
4194    /// P6.56 (design/tracks/the-ir.md §6b): `name_refined` reads only
4195    /// `TypeBody`'s own discriminant, never `base`/`refinement` — the two
4196    /// fields `TypeShape::Refined` would add real construction cost for
4197    /// (`refined_or_opaque_base`'s own doc comment has the full reasoning).
4198    /// Investigated and declined on the identical grounds.
4199    fn is_refined_is_check(&self, value: &Expr, name: &str) -> bool {
4200        let value_baseish = matches!(
4201            self.commons().expr_ty(value.id).as_deref(),
4202            Some(Ty::Base(_))
4203                | Some(Ty::Named {
4204                    kind: NamedKind::Refined(_),
4205                    ..
4206                })
4207        );
4208        let name_refined = matches!(
4209            self.commons().types.get(name).map(|d| &d.body),
4210            Some(TypeBody::Refined { .. })
4211        );
4212        value_baseish && name_refined
4213    }
4214    /// Read-only counterpart for the binding gatherer (which returns no
4215    /// `Lowered`, so it has nowhere to hoist and cannot lift). If the receiver was already lifted to a temp during
4216    /// condition lowering, reuse that temp; otherwise it must be a simple
4217    /// repeatable lvalue, rendered inline. The "lower the condition before
4218    /// gathering its bindings" ordering in `emit_if_tail` / `lower_and_with_is`
4219    /// guarantees the temp exists before this is called for complex receivers.
4220    fn is_receiver_text(&self, value: &Expr) -> String {
4221        if let Some(t) = self.is_receiver_temps.get(&value.span) {
4222            return t.clone();
4223        }
4224        value_text_for_is(value)
4225    }
4226    fn receiver_namespace(&self, e: &Expr) -> Option<String> {
4227        let ty = self.commons().expr_ty(e.id)?;
4228        if let Ty::Named { name, .. } = &*ty {
4229            Some(name.clone())
4230        } else {
4231            None
4232        }
4233    }
4234    /// Resolve the payload field name for the i-th positional binding of
4235    /// a variant. Built-ins are recognised by name; user variants are
4236    /// looked up via the type tables.
4237    ///
4238    /// P6.56 (design/tracks/the-ir.md §6b): see `sum_owner_of_variant`'s own
4239    /// doc comment (`emitter.rs`) — a `TypeShape::Sum`-routed conversion was
4240    /// investigated and declined here on the identical grounds.
4241    fn positional_field_name(
4242        &self,
4243        discriminant_ty: Option<TyId>,
4244        variant: &str,
4245        idx: usize,
4246        tys: &Arc<Types>,
4247    ) -> String {
4248        match (variant, idx) {
4249            ("Ok", 0) | ("Some", 0) => return "value".to_string(),
4250            ("Err", 0) => return "error".to_string(),
4251            _ => {}
4252        }
4253        // v0.52: a multi-actor sum arm binds the resolved actor's identity,
4254        // carried in the `identity` field of the tagged object.
4255        let disc_node = discriminant_ty.map(|t| tys.get(t));
4256        if let Some(Ty::ActorSum(_)) = disc_node.as_deref() {
4257            return "identity".to_string();
4258        }
4259        if let Some(Ty::Named {
4260            kind: NamedKind::Sum,
4261            name,
4262            ..
4263        }) = disc_node.as_deref()
4264            && let Some(decl) = self.commons().types.get(name)
4265            && let TypeBody::Sum(s) = &decl.body
4266            && let Some(v) = s.variants.iter().find(|v| v.name.name == variant)
4267            && let Some(f) = v.payload.get(idx)
4268        {
4269            return f.name.name.clone();
4270        }
4271        // Single-field fallback. The checker rejects mixed bindings already.
4272        "value".to_string()
4273    }
4274
4275    /// The type of a variant's `idx`-th payload field, when resolvable — used to
4276    /// recurse field-name resolution through nested payload patterns (ADR 0169).
4277    /// Precise for `Result`/`Option`/`HttpResult` and user sums; `None` otherwise
4278    /// (callers fall back to the single-field `"value"` name).
4279    fn payload_field_ty(
4280        &self,
4281        ty: Option<TyId>,
4282        variant: &str,
4283        idx: usize,
4284        tys: &Arc<Types>,
4285    ) -> Option<TyId> {
4286        match ty.map(|t| tys.get(t)).as_deref() {
4287            Some(Ty::Result(t, e)) => match (variant, idx) {
4288                ("Ok", 0) => Some(*t),
4289                ("Err", 0) => Some(*e),
4290                _ => None,
4291            },
4292            Some(Ty::HttpResult(t)) if variant == "Ok" && idx == 0 => Some(*t),
4293            Some(Ty::Option(t)) if variant == "Some" && idx == 0 => Some(*t),
4294            Some(Ty::Named {
4295                kind: NamedKind::Sum,
4296                name,
4297                args,
4298            }) => {
4299                let decl = self.commons().types.get(name)?;
4300                let TypeBody::Sum(s) = &decl.body else {
4301                    return None;
4302                };
4303                let v = s.variants.iter().find(|v| v.name.name == variant)?;
4304                let f = v.payload.get(idx)?;
4305                // #593: substitute the instantiation's type arguments into the
4306                // payload field type — a bare type parameter (`Loaded(value: T)`)
4307                // resolves to its concrete argument, exactly as the checker's
4308                // `variants_of` does. Plain resolve for a non-generic sum (empty
4309                // `args`), so a nested positional binding recovers the real field
4310                // name instead of falling back to the generic `"value"`.
4311                bynk_check::checker::instantiate_field_ty(
4312                    decl,
4313                    args,
4314                    &f.type_ref,
4315                    &self.commons().types,
4316                    tys,
4317                )
4318            }
4319            _ => None,
4320        }
4321    }
4322}
4323
4324/// Unchecked construction of a branded value in emitted TypeScript.
4325///
4326/// ADR 0182: an **opaque** type exposes a runtime `.unsafe(value)` constructor
4327/// (source-callable within its defining commons, and the target of its internal
4328/// uses), so opaque construction stays `T.unsafe(value)`. A **refined** or
4329/// **alias** type has **no** public `.unsafe`: exposing one let hand-written host
4330/// or adapter code bypass the refinement predicate, the credibility hole #545
4331/// closed. Its admitted / generated values are branded with an inline `as` cast
4332/// — byte-for-byte the old `.unsafe` body (`return value as T`) at the call site,
4333/// but not a callable API surface a consumer can reach.
4334pub(crate) fn unchecked_construct(name: &str, value: &str, is_opaque: bool) -> String {
4335    if is_opaque {
4336        format!("{name}.unsafe({value})")
4337    } else {
4338        format!("({value} as {name})")
4339    }
4340}
4341
4342/// Unchecked construction inside GENERATED TEST scaffolding (`tests/*.test.ts`).
4343///
4344/// There a branded type is in scope only as an `any`-typed value binding
4345/// (`const {{ T }} = ns as any`) — never as a type — so the production
4346/// `(value as T)` form fails to resolve `T`. Opaque still constructs through its
4347/// `.unsafe` value method (kept, ADR 0182); a refined/alias value brands to `any`,
4348/// which is exactly the type the pre-0182 `T.unsafe(value)` already produced here
4349/// (`T` being `any`) and erases to the raw value at runtime — without
4350/// reintroducing a callable refined `.unsafe`.
4351pub(crate) fn unchecked_construct_test(name: &str, value: &str, is_opaque: bool) -> String {
4352    if is_opaque {
4353        format!("{name}.unsafe({value})")
4354    } else {
4355        // P7.2: deferred, not narrowed — checked, not skipped. This doc comment's
4356        // own explanation still holds: `name` genuinely isn't resolvable as a
4357        // type at this call site (only as an `any`-typed value binding), so
4358        // there is no real type to cast to here. Fixing this needs restructuring
4359        // what the test scaffold imports, not a same-line text change.
4360        format!("({value} as any)")
4361    }
4362}
4363
4364fn ts_base(b: BaseType) -> &'static str {
4365    match b {
4366        BaseType::Int => "number",
4367        BaseType::String => "string",
4368        BaseType::Bool => "boolean",
4369        BaseType::Float => "number",
4370        BaseType::Duration | BaseType::Instant => "number",
4371        // v0.110 (ADR 0142): `Bytes` is the one base type that does NOT erase
4372        // to `number` — it lowers to an immutable octet sequence, `Uint8Array`.
4373        BaseType::Bytes => "Uint8Array",
4374    }
4375}
4376
4377pub(crate) fn ts_type_ref(r: &TypeRef) -> String {
4378    ts_type_ref_with(r, None)
4379}
4380
4381/// [`TsType`]-returning sibling of `ts_type_ref`, qualifying named types that
4382/// live in `scope` with the namespace `ns` (`Order` → `Ns.Order`). Used by
4383/// `observation_call_record_types` (`project/tests_emit.rs`) for mock method
4384/// signatures that sit outside the destructuring that brings a namespace's
4385/// value-side names into local scope, so the types must be referenced fully
4386/// qualified. Qualification recurses through generic arguments; base/unit
4387/// types are unaffected.
4388///
4389/// Originally paired with a `String`-returning `ts_type_ref_qualified` (Decision
4390/// B, #1321) — `workers.rs`'s own type-position needs (Arc C slice 3) wanted
4391/// the structured `TsType` this function returns, added *alongside* rather
4392/// than replacing, since `ts_type_ref_qualified` itself was still needed by a
4393/// `String`-based caller. Arc C, slice 32 (`tests_emit.rs` slice B, #1399)
4394/// converted that one remaining caller (`observation_call_record_types`) to
4395/// call this function directly instead — `ts_type_ref_qualified` had no
4396/// other production caller left, so it was deleted rather than kept as dead
4397/// code (the same "close it, don't leave a substitute-around orphan" call
4398/// slice 6's `ts_string_literal` deletion already made); its own direct unit
4399/// tests below now pin this function's output through `bynk_ts::print_type`
4400/// instead.
4401///
4402/// Not a new structural walk — `ts_type_ref_to_ts_type` (below) already
4403/// builds a real `TsType` from every `TypeRef` variant; this function is
4404/// that same build, just with the qualifying closure threaded through.
4405pub(crate) fn ts_type_ref_qualified_ts_type(
4406    r: &TypeRef,
4407    scope: &HashSet<String>,
4408    ns: &str,
4409) -> TsType {
4410    ts_type_ref_to_ts_type(
4411        r,
4412        Some(&|name| scope.contains(name).then(|| ns.to_string())),
4413    )
4414}
4415
4416/// [`TsType`]-returning sibling of `ts_type_ref`, like `ts_type_ref_qualified`
4417/// but with each in-scope name carrying its *own* namespace via `type_ns`
4418/// rather than one shared `ns` — needed when a signature mixes names owned
4419/// by the target unit with names reached only through a `uses`d commons
4420/// (e.g. a stub class implementing an adapter-sourced capability whose
4421/// return type lives in a commons the capability's own unit `uses`, never
4422/// in the target context itself — Locale capability track, slice 1, #844).
4423/// Qualifying such a name under the target's own namespace would reference
4424/// an export `emit_context_rebrands` never emits (it only rebrands names
4425/// the target's *own* lowered body references), so each name is qualified
4426/// under the namespace that actually exports it. Used by `emit_stub_class`
4427/// (`project/tests_emit.rs`) for its own method params/return-type.
4428///
4429/// Originally paired with a `String`-returning `ts_type_ref_qualified_multi`
4430/// — Arc C slice 33 (`tests_emit.rs` slice C, #1401) converted `emit_stub_
4431/// class`'s own two real call sites (params/return-type) to call this
4432/// function directly instead, the same cleanup Arc C slice 32/#1399 already
4433/// made for `ts_type_ref_qualified`'s own identical pairing — `ts_type_ref_
4434/// qualified_multi` had no other production caller left, so it was deleted
4435/// rather than kept as dead code, with its own direct unit test rerouted
4436/// through `bynk_ts::print_type` instead.
4437pub(crate) fn ts_type_ref_qualified_multi_ts_type(
4438    r: &TypeRef,
4439    type_ns: &HashMap<String, String>,
4440) -> TsType {
4441    ts_type_ref_to_ts_type(r, Some(&|name| type_ns.get(name).cloned()))
4442}
4443
4444/// A name → owning-namespace lookup for `ts_type_ref_with`'s `qualify` arm.
4445type QualifyFn<'a> = &'a dyn Fn(&str) -> Option<String>;
4446
4447/// Shared renderer behind `ts_type_ref` (`qualify = None`) and the two
4448/// `ts_type_ref_qualified*` helpers above (`qualify = Some(name -> namespace)`).
4449/// With `None` it is output-identical to the historic `ts_type_ref`; the only
4450/// divergence is the `Named`/`App` arms, which qualify in-scope names when
4451/// `qualify` is set.
4452/// P7.9 (#1315, R7.2): renders `r` by building a real [`bynk_ts::TsType`]
4453/// and printing it through [`bynk_ts::print_type`], instead of `format!`-ing
4454/// the type text by hand — this function's own signature and every one of
4455/// its ~110 real callers (via `ts_type_ref`/`ts_type_ref_qualified*`) are
4456/// unchanged; only the internal construction moved. `TypeRef` has no shape
4457/// this crate's `TsType` (`Named`/`Array`/`Object`/`Fn`, all extended for
4458/// this slice's own grounded gaps — a `readonly` modifier, and `Fn` itself)
4459/// can't represent — confirmed by matching every `TypeRef` variant below.
4460fn ts_type_ref_with(r: &TypeRef, qualify: Option<QualifyFn<'_>>) -> String {
4461    bynk_ts::print_type(&ts_type_ref_to_ts_type(r, qualify))
4462}
4463
4464/// Whether `ty` is a bare (no type arguments) `Named` type whose name is
4465/// exactly `target` — used by the two arms below that special-case a
4466/// `Promise`/function return of `void`, checking the *structure* of the
4467/// already-built `TsType` instead of the pre-P7.9 code's own `inner == "()"
4468/// || inner == "void"` text comparison. No arm in [`ts_type_ref_to_ts_type`]
4469/// ever builds a literal `"()"`-named type, so that half of the check is
4470/// dead in practice today, same as it was before this slice — inherited
4471/// unchanged rather than dropped, since removing "impossible" defensive
4472/// code isn't this slice's job.
4473fn is_bare_named(ty: &TsType, target: &str) -> bool {
4474    matches!(ty, TsType::Named { name, type_args } if type_args.is_empty() && name == target)
4475}
4476
4477fn ts_type_ref_to_ts_type(r: &TypeRef, qualify: Option<QualifyFn<'_>>) -> TsType {
4478    match r {
4479        TypeRef::Base(b, _) => TsType::named(ts_base(*b)),
4480        TypeRef::Named(id) => {
4481            let name = if let Some(f) = qualify
4482                && let Some(ns) = f(&id.name)
4483            {
4484                format!("{ns}.{}", id.name)
4485            } else {
4486                id.name.clone()
4487            };
4488            TsType::named(name)
4489        }
4490        TypeRef::Result(t, e, _) => TsType::named_with_args(
4491            "Result",
4492            vec![
4493                ts_type_ref_to_ts_type(t, qualify),
4494                ts_type_ref_to_ts_type(e, qualify),
4495            ],
4496        ),
4497        TypeRef::Option(t, _) => {
4498            TsType::named_with_args("Option", vec![ts_type_ref_to_ts_type(t, qualify)])
4499        }
4500        TypeRef::Effect(t, _) => {
4501            let inner = ts_type_ref_to_ts_type(t, qualify);
4502            if is_bare_named(&inner, "()") || is_bare_named(&inner, "void") {
4503                TsType::named_with_args("Promise", vec![TsType::named("void")])
4504            } else {
4505                TsType::named_with_args("Promise", vec![inner])
4506            }
4507        }
4508        TypeRef::HttpResult(t, _) => {
4509            TsType::named_with_args("HttpResult", vec![ts_type_ref_to_ts_type(t, qualify)])
4510        }
4511        // v0.20b: collections lower to immutable TS shapes.
4512        TypeRef::List(t, _) => TsType::readonly_array(ts_type_ref_to_ts_type(t, qualify)),
4513        // A `Query[T]`'s own real shape is `(() => readonly T[])` — an
4514        // *outer* paren pair around the whole function type (found by the
4515        // zero-diff check this slice's own "Done when" requires: the
4516        // general `TsType::Fn` rendering, correctly matching the real
4517        // parametered-function-type case, has no such wrap, but every
4518        // fixture's own Query annotation does — e.g.
4519        // `bynkc/tests/fixtures/positive/302_query_annotated_let/expected/
4520        // probe.ts:46`). `TsType` has no general parenthesisation wrapper
4521        // (adding one for this single real use would be inventing a new
4522        // variant beyond this slice's own authorised gap list — `readonly`
4523        // and `Fn`, nothing else); pre-rendering the `Fn` type immediately
4524        // and wrapping the *text* in parens, carried as an opaque `Named`
4525        // with no type arguments (which prints its `name` verbatim,
4526        // byte-for-byte, whatever it contains), reproduces the real shape
4527        // exactly without widening the algebra. Worth reconsidering if a
4528        // second real case ever needs the same wrap.
4529        TypeRef::Query(t, _) => {
4530            let fn_ty = TsType::Fn {
4531                params: vec![],
4532                ret: Box::new(TsType::readonly_array(ts_type_ref_to_ts_type(t, qualify))),
4533            };
4534            TsType::named(format!("({})", bynk_ts::print_type(&fn_ty)))
4535        }
4536        // v0.100: `Stream[T]` lowers to a host async iterable.
4537        TypeRef::Stream(t, _) => {
4538            TsType::named_with_args("AsyncIterable", vec![ts_type_ref_to_ts_type(t, qualify)])
4539        }
4540        // v0.102: a `Connection[F]` lowers to the runtime `Connection<F>`
4541        // interface (the concrete implementation arrives with the protocol).
4542        TypeRef::Connection(t, _) => {
4543            TsType::named_with_args("Connection", vec![ts_type_ref_to_ts_type(t, qualify)])
4544        }
4545        // v0.119: `History[Agent]` is a test-only generator with no emitted TS
4546        // type — it never reaches a signature/field position (the property runner
4547        // binds the driven history as an ordinary array). Rendered defensively.
4548        TypeRef::History(_, _) => TsType::named("never"),
4549        TypeRef::Map(k, v, _) => TsType::named_with_args(
4550            "ReadonlyMap",
4551            vec![
4552                ts_type_ref_to_ts_type(k, qualify),
4553                ts_type_ref_to_ts_type(v, qualify),
4554            ],
4555        ),
4556        TypeRef::QueueResult(_) => TsType::named("QueueResult"),
4557        TypeRef::ValidationError(_) => TsType::named("ValidationError"),
4558        TypeRef::JsonError(_) => TsType::named("JsonError"),
4559        TypeRef::Unit(_) => TsType::named("void"),
4560        // v0.157 (ADR 0183): `Name[Arg, …]` lowers to the erased TS generic
4561        // `Name<Arg, …>` — the generic record's interface is emitted with the
4562        // same type parameters (like a generic function's erased `<A, B>`).
4563        TypeRef::App { name, args, .. } => {
4564            // Review of #1315/#1316: pre-slice this arm interpolated `head`
4565            // unconditionally, so a hypothetical empty-args `App` rendered
4566            // `Head<>` — `TsType::named_with_args(head, vec![])` instead
4567            // renders bare `Head` (`TsType::Named`'s own printer only opens
4568            // `<...>` when `type_args` is non-empty), a real behaviour
4569            // change *if* this arm is ever reached with empty `args`. It
4570            // isn't today: `ty_to_type_ref` only ever builds `App` with a
4571            // non-empty argument list (this file, `ty_to_type_ref`'s own
4572            // `Ty::Named` arm), and the resolver arity-checks a
4573            // `Name[Arg, …]` application before either side ever sees one.
4574            // Asserted, not silently relied on, so a future caller that
4575            // *can* reach here with no args fails loudly instead of
4576            // silently changing bytes.
4577            debug_assert!(
4578                !args.is_empty(),
4579                "TypeRef::App is only ever built with a non-empty argument list"
4580            );
4581            let head = if let Some(f) = qualify
4582                && let Some(ns) = f(&name.name)
4583            {
4584                format!("{ns}.{}", name.name)
4585            } else {
4586                name.name.clone()
4587            };
4588            let rendered: Vec<TsType> = args
4589                .iter()
4590                .map(|a| ts_type_ref_to_ts_type(a, qualify))
4591                .collect();
4592            TsType::named_with_args(head, rendered)
4593        }
4594        // v0.20a: a function type lowers to a TS function type. Positional
4595        // parameter names (`a0`, `a1`, …) are the printer's own job now
4596        // (`bynk_ts::print_type`'s `TsType::Fn` rendering) — TS requires
4597        // names in function type syntax; an Effect return is already
4598        // Promise via recursion.
4599        TypeRef::Fn(params, ret, _) => {
4600            let params: Vec<TsType> = params
4601                .iter()
4602                .map(|p| ts_type_ref_to_ts_type(p, qualify))
4603                .collect();
4604            let ret = ts_type_ref_to_ts_type(ret, qualify);
4605            let ret = if is_bare_named(&ret, "()") {
4606                TsType::named("void")
4607            } else {
4608                ret
4609            };
4610            TsType::Fn {
4611                params,
4612                ret: Box::new(ret),
4613            }
4614        }
4615    }
4616}
4617
4618/// P7.9 (#1315): pins `ts_type_ref`'s exact pre-slice text for a
4619/// representative `TypeRef` from every real shape category, the same
4620/// discipline this track's own history names for "prove the test would
4621/// actually catch it" (P7.2's `brand_assertion` gap, P7.3's escaping gap —
4622/// both invisible to the byte-golden fixture corpus alone). These pin the
4623/// literal strings `ts_type_ref_with` built by hand before this slice
4624/// rebuilt it to construct a real `bynk_ts::TsType` and print that instead
4625/// — a regression here is a regression in every type-position string the
4626/// compiler emits, not caught by any one fixture necessarily exercising
4627/// this exact shape.
4628#[cfg(test)]
4629mod ts_type_ref_tests {
4630    use super::*;
4631
4632    fn sp() -> bynk_syntax::span::Span {
4633        bynk_syntax::span::Span::new(0, 0)
4634    }
4635
4636    fn base(b: BaseType) -> TypeRef {
4637        TypeRef::Base(b, sp())
4638    }
4639
4640    fn named(name: &str) -> TypeRef {
4641        TypeRef::Named(Ident {
4642            name: name.to_string(),
4643            span: sp(),
4644        })
4645    }
4646
4647    #[test]
4648    fn bare_named_type() {
4649        assert_eq!(ts_type_ref(&named("Order")), "Order");
4650    }
4651
4652    #[test]
4653    fn result_type() {
4654        let r = TypeRef::Result(
4655            Box::new(base(BaseType::Int)),
4656            Box::new(named("MyError")),
4657            sp(),
4658        );
4659        assert_eq!(ts_type_ref(&r), "Result<number, MyError>");
4660    }
4661
4662    #[test]
4663    fn option_type() {
4664        let r = TypeRef::Option(Box::new(base(BaseType::String)), sp());
4665        assert_eq!(ts_type_ref(&r), "Option<string>");
4666    }
4667
4668    #[test]
4669    fn effect_of_a_non_unit_type_becomes_promise() {
4670        let r = TypeRef::Effect(Box::new(base(BaseType::Int)), sp());
4671        assert_eq!(ts_type_ref(&r), "Promise<number>");
4672    }
4673
4674    #[test]
4675    fn effect_of_unit_becomes_promise_void() {
4676        let r = TypeRef::Effect(Box::new(TypeRef::Unit(sp())), sp());
4677        assert_eq!(ts_type_ref(&r), "Promise<void>");
4678    }
4679
4680    #[test]
4681    fn http_result_type() {
4682        let r = TypeRef::HttpResult(Box::new(named("Order")), sp());
4683        assert_eq!(ts_type_ref(&r), "HttpResult<Order>");
4684    }
4685
4686    #[test]
4687    fn list_is_a_readonly_array() {
4688        let r = TypeRef::List(Box::new(named("Order")), sp());
4689        assert_eq!(ts_type_ref(&r), "readonly Order[]");
4690    }
4691
4692    /// The one shape this slice's own gap analysis found: `Query`'s real
4693    /// text wraps the whole function type in an *extra* outer paren pair
4694    /// (`(() => readonly T[])`), distinct from a bare `Fn` type's own
4695    /// convention (no outer wrap) — caught only by this zero-diff check
4696    /// against `bynkc/tests/fixtures/positive/302_query_annotated_let/
4697    /// expected/probe.ts`, not by reasoning about the algebra alone.
4698    #[test]
4699    fn query_wraps_the_whole_function_type_in_parens() {
4700        let r = TypeRef::Query(Box::new(named("Order")), sp());
4701        assert_eq!(ts_type_ref(&r), "(() => readonly Order[])");
4702    }
4703
4704    #[test]
4705    fn stream_is_an_async_iterable() {
4706        let r = TypeRef::Stream(Box::new(named("Order")), sp());
4707        assert_eq!(ts_type_ref(&r), "AsyncIterable<Order>");
4708    }
4709
4710    #[test]
4711    fn connection_type() {
4712        let r = TypeRef::Connection(Box::new(named("Frame")), sp());
4713        assert_eq!(ts_type_ref(&r), "Connection<Frame>");
4714    }
4715
4716    #[test]
4717    fn history_renders_defensively_as_never() {
4718        let r = TypeRef::History(Box::new(named("Agent")), sp());
4719        assert_eq!(ts_type_ref(&r), "never");
4720    }
4721
4722    #[test]
4723    fn map_is_a_readonly_map() {
4724        let r = TypeRef::Map(
4725            Box::new(base(BaseType::String)),
4726            Box::new(named("Order")),
4727            sp(),
4728        );
4729        assert_eq!(ts_type_ref(&r), "ReadonlyMap<string, Order>");
4730    }
4731
4732    #[test]
4733    fn the_four_bare_named_types() {
4734        assert_eq!(ts_type_ref(&TypeRef::QueueResult(sp())), "QueueResult");
4735        assert_eq!(
4736            ts_type_ref(&TypeRef::ValidationError(sp())),
4737            "ValidationError"
4738        );
4739        assert_eq!(ts_type_ref(&TypeRef::JsonError(sp())), "JsonError");
4740        assert_eq!(ts_type_ref(&TypeRef::Unit(sp())), "void");
4741    }
4742
4743    #[test]
4744    fn app_is_a_generic_instantiation() {
4745        let r = TypeRef::App {
4746            name: Ident {
4747                name: "MyGeneric".to_string(),
4748                span: sp(),
4749            },
4750            args: vec![base(BaseType::Int), named("Order")],
4751            span: sp(),
4752        };
4753        assert_eq!(ts_type_ref(&r), "MyGeneric<number, Order>");
4754    }
4755
4756    #[test]
4757    fn real_function_type_uses_positional_parameter_names() {
4758        let r = TypeRef::Fn(
4759            vec![base(BaseType::String), base(BaseType::Int)],
4760            Box::new(named("Order")),
4761            sp(),
4762        );
4763        assert_eq!(ts_type_ref(&r), "(a0: string, a1: number) => Order");
4764    }
4765
4766    #[test]
4767    fn base_types() {
4768        assert_eq!(ts_type_ref(&base(BaseType::Int)), "number");
4769        assert_eq!(ts_type_ref(&base(BaseType::Float)), "number");
4770        assert_eq!(ts_type_ref(&base(BaseType::Duration)), "number");
4771        assert_eq!(ts_type_ref(&base(BaseType::Instant)), "number");
4772        assert_eq!(ts_type_ref(&base(BaseType::String)), "string");
4773        assert_eq!(ts_type_ref(&base(BaseType::Bool)), "boolean");
4774        assert_eq!(ts_type_ref(&base(BaseType::Bytes)), "Uint8Array");
4775    }
4776
4777    /// Coverage gap named in review of #1315/#1316: every test above goes
4778    /// through `ts_type_ref` (`qualify = None`) — the qualifying branches
4779    /// this slice's own refactor most directly restructured (`App`'s `head`
4780    /// now flows through `TsType::named_with_args` instead of being
4781    /// interpolated) were unpinned. Asserts both that the head qualifies
4782    /// *and* that qualification recurses into the type arguments.
4783    #[test]
4784    fn qualified_generic_instantiation_qualifies_head_and_recurses_into_args() {
4785        let r = TypeRef::App {
4786            name: Ident {
4787                name: "MyGeneric".to_string(),
4788                span: sp(),
4789            },
4790            args: vec![base(BaseType::Int), named("Order")],
4791            span: sp(),
4792        };
4793        let mut scope = HashSet::new();
4794        scope.insert("MyGeneric".to_string());
4795        scope.insert("Order".to_string());
4796        assert_eq!(
4797            bynk_ts::print_type(&ts_type_ref_qualified_ts_type(&r, &scope, "Ns")),
4798            "Ns.MyGeneric<number, Ns.Order>"
4799        );
4800    }
4801
4802    /// Same shape as above, through `ts_type_ref_qualified_multi_ts_type` —
4803    /// no direct test existed for this function at all before this fix.
4804    #[test]
4805    fn qualified_multi_generic_instantiation_qualifies_head_and_recurses_into_args() {
4806        let r = TypeRef::App {
4807            name: Ident {
4808                name: "MyGeneric".to_string(),
4809                span: sp(),
4810            },
4811            args: vec![base(BaseType::Int), named("Order")],
4812            span: sp(),
4813        };
4814        let mut type_ns = HashMap::new();
4815        type_ns.insert("MyGeneric".to_string(), "A".to_string());
4816        type_ns.insert("Order".to_string(), "B".to_string());
4817        assert_eq!(
4818            bynk_ts::print_type(&ts_type_ref_qualified_multi_ts_type(&r, &type_ns)),
4819            "A.MyGeneric<number, B.Order>"
4820        );
4821    }
4822}
4823
4824/// v0.20b: render a checker `Ty` as a TypeScript type. Used by the inline
4825/// kernel-method lowerings, whose IIFE parameters must be annotated
4826/// (`noImplicitAny`). Rigid type variables render as themselves — inside an
4827/// emitted generic function they are in scope as TS type parameters.
4828/// P7.9 (#1315, R7.2): renders `t` by building a real [`bynk_ts::TsType`]
4829/// and printing it through [`bynk_ts::print_type`], instead of `format!`-ing
4830/// the type text by hand — this function's own signature and every one of
4831/// its ~45 real callers are unchanged; only the internal construction moved.
4832/// `Ty::ActorSum` needed a new gap closed beyond the accepted proposal's own
4833/// `readonly`/`Fn` list — a real, grounded one (found during implementation
4834/// review, not speculative): a resolved multi-actor sum lowers to a genuine
4835/// type-position union no existing `TsType` variant could represent, closed
4836/// by adding `TsType::Union` (see its own doc).
4837fn ts_ty(t: TyId, tys: &Arc<Types>) -> String {
4838    bynk_ts::print_type(&ts_ty_to_ts_type(t, tys))
4839}
4840
4841fn ts_ty_to_ts_type(t: TyId, tys: &Arc<Types>) -> TsType {
4842    match &*tys.get(t) {
4843        // bynk internal error (finding #28, R4.3): `Ty::Error` records a
4844        // resolution failure, which per R4.3 is always accompanied by a
4845        // pushed diagnostic — the check that produced it should have failed
4846        // the whole program and never reached emission. A loud failure here
4847        // beats silently emitting a type for a node the checker gave up on.
4848        Ty::Error => panic!(
4849            "bynk internal error (finding #28): emitter asked to render `Ty::Error` as a \
4850             TypeScript type — a checked program should never contain one"
4851        ),
4852        Ty::Base(b) => TsType::named(ts_base(*b)),
4853        // v0.157 (ADR 0183): a generic record instantiation renders as the
4854        // erased TS generic `Name<Arg, …>`; a non-generic named type is bare.
4855        Ty::Named { name, args, .. } if args.is_empty() => TsType::named(name.clone()),
4856        Ty::Named { name, args, .. } => TsType::named_with_args(
4857            name.clone(),
4858            args.iter().map(|a| ts_ty_to_ts_type(*a, tys)).collect(),
4859        ),
4860        Ty::Result(t, e) => TsType::named_with_args(
4861            "Result",
4862            vec![ts_ty_to_ts_type(*t, tys), ts_ty_to_ts_type(*e, tys)],
4863        ),
4864        Ty::Option(t) => TsType::named_with_args("Option", vec![ts_ty_to_ts_type(*t, tys)]),
4865        Ty::Effect(t) => match &*tys.get(*t) {
4866            Ty::Unit => TsType::named_with_args("Promise", vec![TsType::named("void")]),
4867            _ => TsType::named_with_args("Promise", vec![ts_ty_to_ts_type(*t, tys)]),
4868        },
4869        Ty::HttpResult(t) => TsType::named_with_args("HttpResult", vec![ts_ty_to_ts_type(*t, tys)]),
4870        Ty::List(t) => TsType::readonly_array(ts_ty_to_ts_type(*t, tys)),
4871        // v0.91 (ADR 0119): a `Query[T]` lowers to a deferred producer of its
4872        // elements — a thunk run by the terminal. Same outer-paren shape as
4873        // `TypeRef::Query` (`ts_type_ref_to_ts_type`'s own comment explains
4874        // the opaque-`Named` wrap this reuses, unchanged reasoning).
4875        Ty::Query(t) => {
4876            let fn_ty = TsType::Fn {
4877                params: vec![],
4878                ret: Box::new(TsType::readonly_array(ts_ty_to_ts_type(*t, tys))),
4879            };
4880            TsType::named(format!("({})", bynk_ts::print_type(&fn_ty)))
4881        }
4882        // v0.100: a `Stream[T]` lowers to a host async iterable.
4883        Ty::Stream(t) => TsType::named_with_args("AsyncIterable", vec![ts_ty_to_ts_type(*t, tys)]),
4884        // v0.102: a `Connection[F]` lowers to the runtime `Connection<F>` interface.
4885        Ty::Connection(t) => TsType::named_with_args("Connection", vec![ts_ty_to_ts_type(*t, tys)]),
4886        Ty::Map(k, v) => TsType::named_with_args(
4887            "ReadonlyMap",
4888            vec![ts_ty_to_ts_type(*k, tys), ts_ty_to_ts_type(*v, tys)],
4889        ),
4890        Ty::QueueResult => TsType::named("QueueResult"),
4891        Ty::ValidationError => TsType::named("ValidationError"),
4892        Ty::JsonError => TsType::named("JsonError"),
4893        Ty::Unit => TsType::named("void"),
4894        Ty::Fn { params, ret } => TsType::Fn {
4895            params: params.iter().map(|p| ts_ty_to_ts_type(*p, tys)).collect(),
4896            ret: Box::new(ts_ty_to_ts_type(*ret, tys)),
4897        },
4898        Ty::Var(n) => TsType::named(n.clone()),
4899        // The identity type the actor binding yields (`name.identity`).
4900        Ty::Actor(id) => ts_ty_to_ts_type(*id, tys),
4901        // v0.52: a resolved multi-actor sum lowers to a discriminated union
4902        // tagged by actor name; non-unit members carry their identity.
4903        // Each member's own real text is `, `-separated (`{ tag: "x",
4904        // identity: T }`) — `TsType::Object`'s own renderer is `; `-
4905        // separated (the ordinary TS object-*type* convention, correct for
4906        // every other real caller, e.g. `events_fanout.rs`'s own interface
4907        // members), so building each member as an `Object` would silently
4908        // change this one shape's separator and fail the zero-diff check
4909        // (caught exactly that way — a fixture-corpus-adjacent direct test
4910        // failure, not reasoning about the algebra). Each member is instead
4911        // an opaque `Named`-carries-verbatim-text node, the same convention
4912        // `ts_type_ref_to_ts_type`'s own `Query` arm already established for
4913        // a single real shape `TsType`'s general renderers don't reproduce
4914        // byte-for-byte — not a new pattern, the second real use of one.
4915        Ty::ActorSum(members) => TsType::union(
4916            members
4917                .iter()
4918                .map(|(name, id)| match &*tys.get(*id) {
4919                    Ty::Unit => TsType::named(format!("{{ tag: \"{name}\" }}")),
4920                    _ => TsType::named(format!(
4921                        "{{ tag: \"{name}\", identity: {} }}",
4922                        bynk_ts::print_type(&ts_ty_to_ts_type(*id, tys))
4923                    )),
4924                })
4925                .collect(),
4926        ),
4927    }
4928}
4929
4930/// P7.9 (#1315): pins `ts_ty`'s exact pre-slice text for a representative
4931/// `Ty` from every real shape category — the same "prove the test would
4932/// actually catch it" discipline `ts_type_ref_tests` above already applies
4933/// to `ts_type_ref`, and doubly important here since `ts_ty` is where
4934/// `TsType::Union`/`ActorSum` (added in review of this same PR, beyond the
4935/// accepted proposal's own gap list) is the one real, exercised caller.
4936#[cfg(test)]
4937mod ts_ty_tests {
4938    use super::*;
4939    use bynk_check::checker::{NamedKind, Types};
4940
4941    #[test]
4942    fn base_types() {
4943        let tys = Arc::new(Types::new());
4944        assert_eq!(ts_ty(tys.intern(Ty::Base(BaseType::Int)), &tys), "number");
4945    }
4946
4947    #[test]
4948    fn named_type_bare_and_generic() {
4949        let tys = Arc::new(Types::new());
4950        let bare = tys.intern(Ty::Named {
4951            name: "Order".to_string(),
4952            kind: NamedKind::Record,
4953            args: vec![],
4954        });
4955        assert_eq!(ts_ty(bare, &tys), "Order");
4956
4957        let arg = tys.intern(Ty::Base(BaseType::String));
4958        let generic = tys.intern(Ty::Named {
4959            name: "Paginated".to_string(),
4960            kind: NamedKind::Record,
4961            args: vec![arg],
4962        });
4963        assert_eq!(ts_ty(generic, &tys), "Paginated<string>");
4964    }
4965
4966    #[test]
4967    fn result_option_effect_http_result() {
4968        let tys = Arc::new(Types::new());
4969        let int = tys.intern(Ty::Base(BaseType::Int));
4970        let err = tys.intern(Ty::Named {
4971            name: "MyError".to_string(),
4972            kind: NamedKind::Record,
4973            args: vec![],
4974        });
4975        let result = tys.intern(Ty::Result(int, err));
4976        assert_eq!(ts_ty(result, &tys), "Result<number, MyError>");
4977
4978        let option = tys.intern(Ty::Option(int));
4979        assert_eq!(ts_ty(option, &tys), "Option<number>");
4980
4981        let effect = tys.intern(Ty::Effect(int));
4982        assert_eq!(ts_ty(effect, &tys), "Promise<number>");
4983
4984        let unit = tys.intern(Ty::Unit);
4985        let effect_unit = tys.intern(Ty::Effect(unit));
4986        assert_eq!(ts_ty(effect_unit, &tys), "Promise<void>");
4987
4988        let http_result = tys.intern(Ty::HttpResult(int));
4989        assert_eq!(ts_ty(http_result, &tys), "HttpResult<number>");
4990    }
4991
4992    #[test]
4993    fn list_map_stream_connection() {
4994        let tys = Arc::new(Types::new());
4995        let order = tys.intern(Ty::Named {
4996            name: "Order".to_string(),
4997            kind: NamedKind::Record,
4998            args: vec![],
4999        });
5000        assert_eq!(ts_ty(tys.intern(Ty::List(order)), &tys), "readonly Order[]");
5001
5002        let s = tys.intern(Ty::Base(BaseType::String));
5003        assert_eq!(
5004            ts_ty(tys.intern(Ty::Map(s, order)), &tys),
5005            "ReadonlyMap<string, Order>"
5006        );
5007        assert_eq!(
5008            ts_ty(tys.intern(Ty::Stream(order)), &tys),
5009            "AsyncIterable<Order>"
5010        );
5011        assert_eq!(
5012            ts_ty(tys.intern(Ty::Connection(order)), &tys),
5013            "Connection<Order>"
5014        );
5015    }
5016
5017    /// The same shared shape as `TypeRef::Query` — pins that `ts_ty`'s own
5018    /// `Query` arm reproduces the identical outer-paren wrap after
5019    /// conversion, not just `ts_type_ref`'s.
5020    #[test]
5021    fn query_wraps_the_whole_function_type_in_parens() {
5022        let tys = Arc::new(Types::new());
5023        let order = tys.intern(Ty::Named {
5024            name: "Order".to_string(),
5025            kind: NamedKind::Record,
5026            args: vec![],
5027        });
5028        assert_eq!(
5029            ts_ty(tys.intern(Ty::Query(order)), &tys),
5030            "(() => readonly Order[])"
5031        );
5032    }
5033
5034    #[test]
5035    fn bare_types_and_fn() {
5036        let tys = Arc::new(Types::new());
5037        assert_eq!(ts_ty(tys.intern(Ty::QueueResult), &tys), "QueueResult");
5038        assert_eq!(
5039            ts_ty(tys.intern(Ty::ValidationError), &tys),
5040            "ValidationError"
5041        );
5042        assert_eq!(ts_ty(tys.intern(Ty::JsonError), &tys), "JsonError");
5043        assert_eq!(ts_ty(tys.intern(Ty::Unit), &tys), "void");
5044
5045        let int = tys.intern(Ty::Base(BaseType::Int));
5046        let s = tys.intern(Ty::Base(BaseType::String));
5047        let f = tys.intern(Ty::Fn {
5048            params: vec![int, s],
5049            ret: int,
5050        });
5051        assert_eq!(ts_ty(f, &tys), "(a0: number, a1: string) => number");
5052    }
5053
5054    /// The real gap found during implementation review: a resolved
5055    /// multi-actor sum lowers to a genuine type-position union, tagged by
5056    /// actor name, non-unit members carrying their identity — the shape
5057    /// that motivated adding `TsType::Union` beyond the accepted proposal's
5058    /// own `readonly`/`Fn` gap list.
5059    #[test]
5060    fn actor_sum_is_a_tagged_union() {
5061        let tys = Arc::new(Types::new());
5062        let unit = tys.intern(Ty::Unit);
5063        let identity = tys.intern(Ty::Named {
5064            name: "AdminIdentity".to_string(),
5065            kind: NamedKind::Record,
5066            args: vec![],
5067        });
5068        let sum = tys.intern(Ty::ActorSum(vec![
5069            ("Guest".to_string(), unit),
5070            ("Admin".to_string(), identity),
5071        ]));
5072        assert_eq!(
5073            ts_ty(sum, &tys),
5074            "{ tag: \"Guest\" } | { tag: \"Admin\", identity: AdminIdentity }"
5075        );
5076    }
5077
5078    /// Coverage gap named in review of #1315/#1316: `Ty::Var` (a type
5079    /// variable's own bare name) and `Ty::Actor` (delegates to its
5080    /// identity type) were unpinned.
5081    #[test]
5082    fn var_and_actor() {
5083        let tys = Arc::new(Types::new());
5084        assert_eq!(ts_ty(tys.intern(Ty::Var("T".to_string())), &tys), "T");
5085
5086        let identity = tys.intern(Ty::Named {
5087            name: "AdminIdentity".to_string(),
5088            kind: NamedKind::Record,
5089            args: vec![],
5090        });
5091        let actor = tys.intern(Ty::Actor(identity));
5092        assert_eq!(ts_ty(actor, &tys), "AdminIdentity");
5093    }
5094}
5095
5096/// P6.56 (design/tracks/the-ir.md §6b): mirrors `IrBinOp` (`ir.rs`)
5097/// field-for-field, but converting this function to take `IrBinOp` was
5098/// investigated and declined — its sole caller (`emitter/lower.rs`) holds
5099/// an AST `BinOp` from `ExprKind::BinOp` and separately compares `op ==
5100/// BinOp::Eq` a few lines away; converting here would only relocate the
5101/// AST read into that still-AST-walking caller, net zero.
5102fn ts_binop(op: BinOp) -> &'static str {
5103    match op {
5104        // `implies` has no single TS operator — `lower_bin_op` rewrites it to
5105        // `(!(P) || Q)` before reaching here, so this arm is never used.
5106        BinOp::Implies => "||",
5107        BinOp::Or => "||",
5108        BinOp::And => "&&",
5109        BinOp::Eq => "===",
5110        BinOp::NotEq => "!==",
5111        BinOp::Lt => "<",
5112        BinOp::LtEq => "<=",
5113        BinOp::Gt => ">",
5114        BinOp::GtEq => ">=",
5115        BinOp::Add => "+",
5116        BinOp::Sub => "-",
5117        BinOp::Mul => "*",
5118        BinOp::Div => "/",
5119    }
5120}
5121
5122/// The TypeScript spelling of a user identifier in a *binding or reference*
5123/// position (params, locals, function names, import names). Bynk identifiers
5124/// that are illegal as TS binding names — the JS reserved words plus the
5125/// strict-mode/module sets (emitted modules are always strict ESM) — and
5126/// names the emitter itself introduces alongside user bindings (`deps`) are
5127/// renamed into the generated-name namespace (`__id_<name>`), which the
5128/// parser keeps free of user identifiers. Property/field names never pass
5129/// through here: reserved words are legal there, and record field names are
5130/// wire format.
5131pub(crate) fn ts_ident(name: &str) -> String {
5132    const RESERVED: &[&str] = &[
5133        // ES reserved words.
5134        "break",
5135        "case",
5136        "catch",
5137        "class",
5138        "const",
5139        "continue",
5140        "debugger",
5141        "default",
5142        "delete",
5143        "do",
5144        "else",
5145        "enum",
5146        "export",
5147        "extends",
5148        "false",
5149        "finally",
5150        "for",
5151        "function",
5152        "if",
5153        "import",
5154        "in",
5155        "instanceof",
5156        "new",
5157        "null",
5158        "return",
5159        "super",
5160        "switch",
5161        "this",
5162        "throw",
5163        "true",
5164        "try",
5165        "typeof",
5166        "var",
5167        "void",
5168        "while",
5169        "with",
5170        // Strict-mode reserved (emitted modules are always strict).
5171        "implements",
5172        "interface",
5173        "let",
5174        "package",
5175        "private",
5176        "protected",
5177        "public",
5178        "static",
5179        "yield",
5180        // Module-code reserved.
5181        "await",
5182        // Illegal binding targets in strict mode.
5183        "arguments",
5184        "eval",
5185        // Generated identifiers a user binding may sit next to: handler
5186        // signatures append a `deps` parameter, so a user param named `deps`
5187        // would otherwise duplicate it.
5188        "deps",
5189    ];
5190    if RESERVED.contains(&name) {
5191        format!("__id_{name}")
5192    } else {
5193        name.to_string()
5194    }
5195}
5196
5197/// Delegates to `bynk_check::wire_default::escape_ts_literal` — the two
5198/// splice into generated TypeScript from opposite sides (real emission here,
5199/// event-field wire defaults there), so a correction to the escaping rules
5200/// must land once, not drift between two copies.
5201pub(crate) fn escape_ts_string(s: &str) -> String {
5202    bynk_check::wire_default::escape_ts_literal(s)
5203}
5204
5205/// #661 (Decision D)/#70 review: the one `PredKind` → runtime-check mapping,
5206/// shared by the owner-side check (`emit::emit_pred_check`, over a `value`
5207/// binding) and the boundary-side inline check
5208/// (`serialisation::emit_inline_pred_check`, over a `json` binding) — the
5209/// two used to hand-roll this mapping independently, pinned identical only by
5210/// a comment, so amending one (e.g. the `Matches` regex's `^(?:…)$` anchoring)
5211/// could silently drift from the other. `receiver` is the bound name the
5212/// generated condition reads (`value` or `json`); the returned message is the
5213/// same either side of the boundary by construction.
5214///
5215/// #1471: `cond` is a real [`bynk_ts::TsExpr`], not opaque text — the
5216/// blocker (`bynk_ts::TsBinaryOp` had no `>=`/`<=`) is resolved by that
5217/// enum's own new `GreaterThanEq`/`LessThanEq` variants. Both real callers
5218/// wrap the returned expression in their own `Unary::Not`/`Paren` unchanged;
5219/// only this function's own arms changed, from `format!`ing condition text
5220/// to building the equivalent node tree.
5221///
5222/// Review of #1336: both real callers (`emit::emit_pred_check`,
5223/// `serialisation::emit_inline_pred_check`) splice the returned `message`
5224/// straight into a TypeScript string literal **unescaped** — safe today only
5225/// because every arm's own message is either static English text or already
5226/// `escape_ts_string`-escaped (the `Matches` arm's own pattern). A future arm
5227/// returning raw, unescaped text (a predicate carrying a user-supplied string
5228/// operand, say) would emit a malformed or injectable literal at both call
5229/// sites, and nothing in the existing fixture corpus would catch it. This
5230/// invariant must match at every arm added here, not just the ones that exist
5231/// today — return plain text or already-`escape_ts_string`-escaped text only.
5232/// `msg` stays exactly this opaque `String`, unchanged by #1471: only the
5233/// `cond` side of the pair became a real node (see the `Matches` arm below
5234/// for why the message keeps its own already-escaped copy of the pattern
5235/// separate from the condition's raw, unescaped one).
5236pub(crate) fn pred_condition_and_message(
5237    pred: &PredKind,
5238    receiver: &str,
5239) -> (bynk_ts::TsExpr, String) {
5240    use bynk_ts::{TsBinaryOp, TsExpr, TsLit};
5241
5242    let recv = || TsExpr::Ident(receiver.to_string());
5243    let recv_length = || TsExpr::Member {
5244        object: Box::new(recv()),
5245        property: "length".to_string(),
5246    };
5247    let num = |n: String| TsExpr::Lit(TsLit::Num(n));
5248    let cmp = |op, left, right| TsExpr::Binary {
5249        op,
5250        left: Box::new(left),
5251        right: Box::new(right),
5252    };
5253
5254    match pred {
5255        PredKind::NonNegative => (
5256            cmp(TsBinaryOp::GreaterThanEq, recv(), num("0".to_string())),
5257            "must be non-negative".to_string(),
5258        ),
5259        PredKind::Positive => (
5260            cmp(TsBinaryOp::GreaterThan, recv(), num("0".to_string())),
5261            "must be positive".to_string(),
5262        ),
5263        PredKind::InRange(a, b) => {
5264            let (a, b) = (a.value, b.value);
5265            (
5266                cmp(
5267                    TsBinaryOp::And,
5268                    cmp(TsBinaryOp::GreaterThanEq, recv(), num(a.to_string())),
5269                    cmp(TsBinaryOp::LessThanEq, recv(), num(b.to_string())),
5270                ),
5271                format!("must be in range [{a}, {b}]"),
5272            )
5273        }
5274        PredKind::InRangeF(a, b) => {
5275            let (a, b) = (&a.lexeme, &b.lexeme);
5276            (
5277                cmp(
5278                    TsBinaryOp::And,
5279                    cmp(TsBinaryOp::GreaterThanEq, recv(), num(a.clone())),
5280                    cmp(TsBinaryOp::LessThanEq, recv(), num(b.clone())),
5281                ),
5282                format!("must be in range [{a}, {b}]"),
5283            )
5284        }
5285        PredKind::NonEmpty => (
5286            cmp(TsBinaryOp::GreaterThan, recv_length(), num("0".to_string())),
5287            "must be non-empty".to_string(),
5288        ),
5289        PredKind::MinLength(n) => (
5290            cmp(TsBinaryOp::GreaterThanEq, recv_length(), num(n.to_string())),
5291            format!("length must be at least {n}"),
5292        ),
5293        PredKind::MaxLength(n) => (
5294            cmp(TsBinaryOp::LessThanEq, recv_length(), num(n.to_string())),
5295            format!("length must be at most {n}"),
5296        ),
5297        PredKind::Length(n) => (
5298            cmp(TsBinaryOp::StrictEq, recv_length(), num(n.to_string())),
5299            format!("length must be exactly {n}"),
5300        ),
5301        PredKind::Matches(pat) => {
5302            // The condition's own `RegExp` source uses `pat` raw, not
5303            // `escaped` — `TsLit::Str`'s printer already applies the exact
5304            // same escaping as `escape_ts_string`
5305            // (`bynk_check::wire_default::escape_ts_literal`, kept
5306            // deliberately identical — see `bynk-ts/src/printer.rs`'s own
5307            // `render_lit`), so escaping `pat` here too would double-escape
5308            // it, same bug class `msg`'s own doc above already argues
5309            // against. `escaped` is still needed for `msg`, which is opaque
5310            // text spliced with no further escaping.
5311            let escaped = escape_ts_string(pat);
5312            let pattern = cmp(
5313                TsBinaryOp::Add,
5314                cmp(
5315                    TsBinaryOp::Add,
5316                    TsExpr::Lit(TsLit::Str("^(?:".to_string())),
5317                    TsExpr::Lit(TsLit::Str(pat.clone())),
5318                ),
5319                TsExpr::Lit(TsLit::Str(")$".to_string())),
5320            );
5321            let regexp = TsExpr::New {
5322                callee: Box::new(TsExpr::Ident("RegExp".to_string())),
5323                args: vec![pattern],
5324            };
5325            let test_call = TsExpr::Call {
5326                callee: Box::new(TsExpr::Member {
5327                    object: Box::new(regexp),
5328                    property: "test".to_string(),
5329                }),
5330                args: vec![recv()],
5331            };
5332            (test_call, format!("must match /{escaped}/"))
5333        }
5334    }
5335}
5336
5337#[allow(dead_code)]
5338fn _unused_hashmap(_h: HashMap<String, ()>) {}
5339
5340#[cfg(test)]
5341mod runtime_tests {
5342    use super::*;
5343
5344    #[test]
5345    fn runtime_emits_all_required_exports() {
5346        let s = emit_runtime_module();
5347        // Core types and constructors used by every emitted module.
5348        assert!(s.contains("export type Result<T, E>"));
5349        assert!(s.contains("export const Ok"));
5350        assert!(s.contains("export const Err"));
5351        assert!(s.contains("export type Option<T>"));
5352        assert!(s.contains("export const Some"));
5353        assert!(s.contains("export const None"));
5354        assert!(s.contains("export interface ValidationError"));
5355        // Durable Object surface used by agent classes.
5356        assert!(s.contains("export interface DurableObjectStorage"));
5357        assert!(s.contains("export interface DurableObjectState"));
5358        assert!(s.contains("export class InMemoryStorage"));
5359        assert!(s.contains("export function makeTestState"));
5360        // Discriminator must be `tag` to match emitted code.
5361        assert!(s.contains("tag: \"Ok\""));
5362        assert!(s.contains("tag: \"Err\""));
5363        assert!(s.contains("tag: \"Some\""));
5364        assert!(s.contains("tag: \"None\""));
5365    }
5366
5367    #[test]
5368    fn tsconfig_is_well_formed_json() {
5369        let s = emit_tsconfig();
5370        // Spot-check the key fields; we don't reach for a JSON parser.
5371        assert!(s.contains("\"target\": \"ES2022\""));
5372        assert!(s.contains("\"strict\": true"));
5373        assert!(s.contains("\"include\""));
5374    }
5375
5376    #[test]
5377    fn coverage_tsconfig_enables_source_maps() {
5378        // #854: the coverage remap consumes tsc's `.js.map`s, so the variant must
5379        // set `sourceMap` — a guard against a silent string-replace miss if the
5380        // base config's `outDir` line is ever reworded. The default stays map-free
5381        // so a normal `bynkc test` / deployment build ships no `.js.map`s.
5382        let cov = emit_tsconfig_with_source_maps();
5383        assert!(
5384            cov.contains("\"sourceMap\": true"),
5385            "coverage config: {cov}"
5386        );
5387        assert!(cov.contains("\"outDir\": \"../out-js\""));
5388        assert!(!emit_tsconfig().contains("sourceMap"));
5389    }
5390
5391    #[test]
5392    fn workers_dir_name_replaces_dots_with_dashes() {
5393        assert_eq!(
5394            crate::project::worker_dir_name("commerce.payment"),
5395            "commerce-payment"
5396        );
5397        assert_eq!(crate::project::worker_dir_name("a.b.c"), "a-b-c");
5398    }
5399
5400    // Refactor track: characterisation pin for the canonical `escape_ts_string`.
5401    // It escapes backslash/quote/newline/tab and carriage return (`\r` → `\r`).
5402    #[test]
5403    fn escape_ts_string_escapes_cr() {
5404        assert_eq!(escape_ts_string("a\\b"), "a\\\\b");
5405        assert_eq!(escape_ts_string("a\"b"), "a\\\"b");
5406        assert_eq!(escape_ts_string("a\nb"), "a\\nb");
5407        assert_eq!(escape_ts_string("a\tb"), "a\\tb");
5408        assert_eq!(escape_ts_string("a\rb"), "a\\rb"); // CR escaped here; raw in project copy
5409    }
5410
5411    #[test]
5412    fn runtime_import_depth_resolves_correctly() {
5413        assert_eq!(
5414            runtime_import_for(Path::new("compose.ts"), ImportExt::Js),
5415            "./runtime.js"
5416        );
5417        assert_eq!(
5418            runtime_import_for(Path::new("commerce/payment.ts"), ImportExt::Js),
5419            "../runtime.js"
5420        );
5421        assert_eq!(
5422            runtime_import_for(Path::new("commerce/orders/types.ts"), ImportExt::Js),
5423            "../../runtime.js"
5424        );
5425        assert_eq!(
5426            runtime_import_for(Path::new("tests/commerce_payment.test.ts"), ImportExt::Js),
5427            "../runtime.js"
5428        );
5429    }
5430}
5431
5432/// Which conditional runtime helpers a module's import line ends up carrying.
5433///
5434/// These drive the single-file `emit()` path end-to-end (parse → resolve → check
5435/// → emit), so they exercise the real producers rather than the accumulator in
5436/// isolation. Before `RuntimeUse`, the decision was `body.contains("__bynkBytes")`
5437/// — a scan of the generated text — and `escapes_a_marker_in_a_string_literal`
5438/// below is the case that got wrong.
5439#[cfg(test)]
5440mod conditional_runtime_import_tests {
5441    use crate::testkit::{emit_bundle, emit_source};
5442
5443    /// The import line is the first `import { … } from "./runtime.js"` in the
5444    /// emitted module.
5445    fn runtime_import_line(ts: &str) -> &str {
5446        ts.lines()
5447            .find(|l| l.starts_with("import {") && l.contains("runtime.js"))
5448            .unwrap_or("")
5449    }
5450
5451    #[test]
5452    fn bytes_helpers_are_imported_when_a_bytes_value_is_built() {
5453        let ts = emit_source(
5454            "commons b\n\nfn decode(s: String) -> Option[Bytes] {\n  Bytes.fromBase64(s)\n}\n",
5455        );
5456        assert!(
5457            runtime_import_line(&ts).contains("__bynkBytesFromBase64"),
5458            "{ts}"
5459        );
5460    }
5461
5462    #[test]
5463    fn bytes_helpers_are_imported_for_content_equality() {
5464        let ts = emit_source("commons b\n\nfn same(a: Bytes, b: Bytes) -> Bool {\n  a == b\n}\n");
5465        assert!(
5466            runtime_import_line(&ts).contains("__bynkBytesEqual"),
5467            "{ts}"
5468        );
5469    }
5470
5471    #[test]
5472    fn bytes_helpers_are_absent_from_a_module_that_never_uses_bytes() {
5473        let ts = emit_source("commons b\n\nfn double(n: Int) -> Int {\n  n * 2\n}\n");
5474        assert!(!runtime_import_line(&ts).contains("__bynkBytes"), "{ts}");
5475    }
5476
5477    /// The regression this replaced a text scan for: a `Bytes` helper name
5478    /// appearing inside a user **string literal** is not a reference to the
5479    /// helper, and must not pull the import in. `body.contains("__bynkBytes")`
5480    /// could not tell the two apart, because the literal is emitted verbatim
5481    /// into the same buffer it scanned.
5482    #[test]
5483    fn escapes_a_marker_in_a_string_literal() {
5484        let ts = emit_source("commons b\n\nfn label() -> String {\n  \"__bynkBytesEqual\"\n}\n");
5485        assert!(
5486            ts.contains("\"__bynkBytesEqual\""),
5487            "the literal should survive into the body: {ts}"
5488        );
5489        assert!(
5490            !runtime_import_line(&ts).contains("__bynkBytes"),
5491            "a marker inside a string literal is not a helper reference: {ts}"
5492        );
5493    }
5494
5495    // -- the ICU formatters ---------------------------------------------------
5496
5497    const ICU_HELPERS: [&str; 3] = ["selectPluralArm", "formatIcuNumber", "formatIcuDate"];
5498
5499    /// The case the per-arm recording exists for. A `select` placeholder lowers to
5500    /// `Object.hasOwn` over an arm table and calls no formatter, so a bundle whose
5501    /// only ICU construct is a `select` must import none of the three — recording
5502    /// once per placeholder instead of per arm would import all three here.
5503    #[test]
5504    fn a_select_only_bundle_imports_no_icu_formatter() {
5505        let ts = emit_bundle(
5506            "messages \"en\" @reference {\n  \"greeting\" => \"{g, select, male {He} female {She} other {They}} liked this.\"\n}\n",
5507        );
5508        assert!(
5509            ts.contains("Object.hasOwn"),
5510            "the select arm table should have been emitted, else this proves nothing: {ts}"
5511        );
5512        for helper in ICU_HELPERS {
5513            assert!(
5514                !runtime_import_line(&ts).contains(helper),
5515                "a select-only bundle calls no formatter, so `{helper}` must not be imported: {ts}"
5516            );
5517        }
5518    }
5519
5520    /// The opposite direction: a `plural` placeholder does call a formatter, and
5521    /// the three are imported as a group.
5522    #[test]
5523    fn a_plural_bundle_imports_the_icu_formatters() {
5524        let ts = emit_bundle(
5525            "messages \"en\" @reference {\n  \"cart\" => \"You have {n, plural, one {# item} other {# items}} in your cart\"\n}\n",
5526        );
5527        assert!(
5528            ts.contains("selectPluralArm("),
5529            "the plural dispatch should have been emitted: {ts}"
5530        );
5531        for helper in ICU_HELPERS {
5532            assert!(
5533                runtime_import_line(&ts).contains(helper),
5534                "`{helper}` should be imported for a plural bundle: {ts}"
5535            );
5536        }
5537    }
5538
5539    /// A bundle with no ICU dispatch at all — a plain `{name}` placeholder goes
5540    /// through `renderArg`, not a formatter.
5541    #[test]
5542    fn a_plain_placeholder_bundle_imports_no_icu_formatter() {
5543        let ts =
5544            emit_bundle("messages \"en\" @reference {\n  \"hello\" => \"Hello, {name}!\"\n}\n");
5545        for helper in ICU_HELPERS {
5546            assert!(
5547                !runtime_import_line(&ts).contains(helper),
5548                "`{helper}` must not be imported for a bundle with no ICU dispatch: {ts}"
5549            );
5550        }
5551    }
5552}
5553
5554/// #914: `inject_runtime_imports` must not add a binding the target line already
5555/// has. The test-scaffold module lists `Ok`/`Err`/`Result` but not
5556/// `BoundaryError`, so the boundary group is a partial overlap — injecting it
5557/// wholesale would emit a duplicate identifier, trading one uncompilable module
5558/// for another.
5559#[cfg(test)]
5560mod inject_runtime_imports_tests {
5561    use super::*;
5562
5563    const SPEC: &str = "./runtime.js";
5564
5565    fn line(bindings: &str) -> String {
5566        format!("import {{ {bindings} }} from \"{SPEC}\";\nconst x = 1;\n")
5567    }
5568
5569    #[test]
5570    fn appends_bindings_that_are_absent() {
5571        let out = inject_runtime_imports(line("Ok, Err"), SPEC, BYTES_RUNTIME_IMPORTS);
5572        assert!(out.contains("Ok, Err, __bynkBytesEqual"), "{out}");
5573        assert!(out.contains("__bynkBytesDecodeUtf8 } from"), "{out}");
5574    }
5575
5576    #[test]
5577    fn skips_bindings_already_present() {
5578        let out = inject_runtime_imports(
5579            line("Ok, Err, type Result"),
5580            SPEC,
5581            BOUNDARY_CODEC_RUNTIME_IMPORTS,
5582        );
5583        assert_eq!(
5584            out.matches("Ok").count(),
5585            1,
5586            "`Ok` was already imported and must not repeat: {out}"
5587        );
5588        assert!(
5589            out.contains("Ok, Err, type Result, type BoundaryError"),
5590            "{out}"
5591        );
5592    }
5593
5594    /// The bare name is what collides, so `type BoundaryError` must match an
5595    /// existing `BoundaryError`.
5596    #[test]
5597    fn matches_a_type_prefixed_group_binding_against_a_bare_one() {
5598        let out = inject_runtime_imports(
5599            line("Ok, Err, type Result, BoundaryError"),
5600            SPEC,
5601            BOUNDARY_CODEC_RUNTIME_IMPORTS,
5602        );
5603        assert_eq!(
5604            out,
5605            line("Ok, Err, type Result, BoundaryError"),
5606            "every binding was already present, so the line is untouched"
5607        );
5608    }
5609
5610    /// …and the other direction: a bare group binding against an existing
5611    /// `type`-prefixed one. This is the case that would regress if `bare` were
5612    /// applied to only one side of the comparison.
5613    #[test]
5614    fn matches_a_bare_group_binding_against_a_type_prefixed_one() {
5615        // A group whose bindings are bare, against a line that `type`-prefixes
5616        // them. `Result` is the realistic instance — the fixed test-scaffold
5617        // list writes `type Result`.
5618        let out = inject_runtime_imports(line("type Ok, type Err"), SPEC, ", Ok, Err");
5619        assert_eq!(
5620            out,
5621            line("type Ok, type Err"),
5622            "a bare binding must match an existing `type`-prefixed one: {out}"
5623        );
5624    }
5625
5626    /// The two injections run back to back over the same line, so the second
5627    /// sees the first's output as `existing` — the overlap between the groups
5628    /// (`Ok`, `Err`, `type Result`) must not double up.
5629    #[test]
5630    fn composes_across_two_sequential_injections() {
5631        let out = inject_runtime_imports(
5632            line("Ok, Err, type Result"),
5633            SPEC,
5634            BOUNDARY_CODEC_RUNTIME_IMPORTS,
5635        );
5636        let out = inject_runtime_imports(out, SPEC, JSON_CODEC_RUNTIME_IMPORTS);
5637        assert_eq!(
5638            out,
5639            line("Ok, Err, type Result, type BoundaryError, type JsonValue, type JsonError"),
5640            "the shared bindings must be injected once: {out}"
5641        );
5642    }
5643
5644    #[test]
5645    fn leaves_a_line_for_another_specifier_alone() {
5646        let other = "import { Ok } from \"./elsewhere.js\";\n".to_string();
5647        assert_eq!(
5648            inject_runtime_imports(other.clone(), SPEC, BYTES_RUNTIME_IMPORTS),
5649            other
5650        );
5651    }
5652}
5653
5654/// P6.32 (design/tracks/the-ir.md §6a): pins the marker-parameterised
5655/// `type_ref_mentions` against the exact truth table its three former
5656/// hand-rolled copies (`file_mentions_json_error`/`_http_result`/
5657/// `_connection`) each implemented independently — in particular, that a
5658/// marker's own wrapper variant stops the recursion (matching the original
5659/// `=> true` arms) rather than also searching that variant's own inner type,
5660/// and that a non-matching wrapper variant recurses rather than reporting
5661/// `false` outright.
5662#[cfg(test)]
5663mod type_ref_mentions_tests {
5664    use super::*;
5665
5666    fn base(b: BaseType) -> TypeRef {
5667        TypeRef::Base(b, bynk_syntax::span::Span::new(0, 0))
5668    }
5669
5670    fn http_result(inner: TypeRef) -> TypeRef {
5671        TypeRef::HttpResult(Box::new(inner), bynk_syntax::span::Span::new(0, 0))
5672    }
5673
5674    fn connection(inner: TypeRef) -> TypeRef {
5675        TypeRef::Connection(Box::new(inner), bynk_syntax::span::Span::new(0, 0))
5676    }
5677
5678    fn json_error() -> TypeRef {
5679        TypeRef::JsonError(bynk_syntax::span::Span::new(0, 0))
5680    }
5681
5682    #[test]
5683    fn a_markers_own_wrapper_matches_regardless_of_its_inner_type() {
5684        // `HttpResult[String]` matches the `HttpResult` marker even though its
5685        // own inner type (`String`) does not itself mention `HttpResult` —
5686        // the wrapper variant itself is the match, mirroring each original
5687        // function's own unconditional `=> true` arm.
5688        let t = http_result(base(BaseType::String));
5689        assert!(type_ref_mentions(&t, TypeRefMarker::HttpResult));
5690        assert!(!type_ref_mentions(&t, TypeRefMarker::Connection));
5691        assert!(!type_ref_mentions(&t, TypeRefMarker::JsonError));
5692    }
5693
5694    #[test]
5695    fn a_non_matching_wrapper_still_recurses_into_its_inner_type() {
5696        // `HttpResult[Connection[String]]` does not match the `HttpResult`
5697        // marker itself when the marker is `Connection` — it must still find
5698        // the `Connection` nested one level in.
5699        let t = http_result(connection(base(BaseType::String)));
5700        assert!(type_ref_mentions(&t, TypeRefMarker::Connection));
5701        assert!(type_ref_mentions(&t, TypeRefMarker::HttpResult));
5702        assert!(!type_ref_mentions(&t, TypeRefMarker::JsonError));
5703    }
5704
5705    #[test]
5706    fn json_error_has_no_inner_type_to_recurse_into() {
5707        let t = json_error();
5708        assert!(type_ref_mentions(&t, TypeRefMarker::JsonError));
5709        assert!(!type_ref_mentions(&t, TypeRefMarker::HttpResult));
5710        assert!(!type_ref_mentions(&t, TypeRefMarker::Connection));
5711    }
5712
5713    #[test]
5714    fn a_plain_base_type_matches_no_marker() {
5715        let t = base(BaseType::Int);
5716        assert!(!type_ref_mentions(&t, TypeRefMarker::JsonError));
5717        assert!(!type_ref_mentions(&t, TypeRefMarker::HttpResult));
5718        assert!(!type_ref_mentions(&t, TypeRefMarker::Connection));
5719    }
5720}