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