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