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