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