bynk_check/wire.rs
1//! #855: the wire-contract IR — the shape a Bynk type takes crossing a
2//! context boundary, derived once from the AST + type table so both the
3//! emitter's codec generation and the editor's "wire contract" peek render
4//! the *same* derivation instead of two hand-synchronised ones.
5//!
6//! **The seam:** *`bynk-check` says what the boundary is. `bynk-emit` says
7//! how that reads as TypeScript.*
8//!
9//! Crossing this module boundary is legal for `BaseType`, `PredKind`,
10//! `TypeRef`, `Expr`, `TypeDecl` (all `bynk-syntax`) — the vocabulary a
11//! boundary type is built from. It is **not** legal for any TS-token string,
12//! `pred_condition_and_message` (`bynk-emit/src/emitter.rs`, stays put),
13//! `lower_field_default_wire` (`bynk-emit/src/emitter/serialisation.rs`,
14//! stays put — a default's *wire literal* is a rendering, not a boundary
15//! fact), or the `Qual` type-name→TS-namespace-prefix map (`bynk-emit`'s
16//! `serialisation.rs`; see the [`Provenance`] doc for why it cannot move
17//! here). `bynk-emit` renders this IR into TypeScript; it does not re-derive
18//! it.
19//!
20//! **Derived from AST + type table, not `checker::Ty`.** `Ty::Named { name,
21//! kind, args }` carries no refinement predicates, so a `Ty`-based
22//! derivation would still need this same type-table lookup for `PredKind`s —
23//! `Ty` would buy only generic substitution, which the moved walks below
24//! already implement directly over `TypeRef`. `contract.rs` (this crate)
25//! also derives straight from the AST + type table and must not depend on
26//! checker output being available; this module keeps the same shape of
27//! dependency.
28//!
29//! **This IR is *not* `contract.rs`'s canonical form**, and the two must
30//! never be unified — they disagree on purpose, on every axis that matters:
31//!
32//! | | `contract.rs` (hash) | `wire.rs` (this module) |
33//! |---|---|---|
34//! | predicates | sorted, deduped | **declaration order**, not deduped |
35//! | record fields | sorted by name | declaration order (emitted key order) |
36//! | sum variants | sorted by name | declaration order (`switch` arm order) |
37//! | opaque predicate | **elided** — unobservable to the consumer | **present** — the owner still re-validates |
38//!
39//! `contract.rs:248` documents the sorting as a *precondition* for hash
40//! correctness — hashing predicates in source order "would make two contexts
41//! that agree perfectly fail closed against each other." The hash is a
42//! **type identity** (order-insensitive by design); this module's shapes are
43//! an **emission order** (order-sensitive by necessity, since a JS `switch`
44//! and an inlined `if` chain both have a literal source order a reader can
45//! see). Merging them would create the exact spurious-409 failure the hash
46//! exists to prevent. What the two genuinely share — and what a cross-check
47//! test elsewhere asserts — is *boundary-type reachability*, not shape.
48
49use std::collections::{BTreeSet, HashMap};
50use std::sync::Arc;
51
52use bynk_syntax::ast::*;
53
54// ---------------------------------------------------------------------
55// Core vocabulary
56// ---------------------------------------------------------------------
57
58/// The JSON value shape a [`BaseType`] occupies on the wire. Replaces
59/// `bynk-emit`'s `ts_base_for_serialisation` *classification* — the TS-token
60/// spelling of each kind (`"number"`, `"string"`, …) stays in `bynk-emit`,
61/// since a TS token is exactly what this seam excludes.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum JsonKind {
64 Number,
65 String,
66 Boolean,
67 Object,
68 Array,
69 Null,
70}
71
72/// The JSON kind a base type wires as. Same mapping as the `ts_base_for_serialisation`
73/// it replaces: `Int`/`Float`/`Duration`/`Instant` → `Number` (all four erase to a TS
74/// `number`), `String`/`Bytes` → `String` (a `Bytes` wires as a base64 string, ADR
75/// 0142 D5), `Bool` → `Boolean`.
76pub fn json_kind_of(b: BaseType) -> JsonKind {
77 match b {
78 BaseType::Int | BaseType::Float | BaseType::Duration | BaseType::Instant => {
79 JsonKind::Number
80 }
81 BaseType::String | BaseType::Bytes => JsonKind::String,
82 BaseType::Bool => JsonKind::Boolean,
83 }
84}
85
86/// An extra structural guard a base-typed wire value must pass beyond its
87/// `typeof`: an `Int`/`Instant` must be whole, a `Float` must be finite.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum BaseGuard {
90 Integral,
91 Finite,
92}
93
94/// What a structural-mismatch error reports as `expected` — the vocabulary a
95/// renderer needs to explain *why* a wire value was rejected, independent of
96/// the TS spelling of the check that rejected it.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Expected {
99 Json(JsonKind),
100 Integer,
101 FiniteNumber,
102 Base64String,
103 SumVariantKind,
104}
105
106/// Where a boundary type's declaration lives relative to the module doing
107/// the crossing.
108///
109/// Carries the owner's *qualified unit name*, never a TS namespace prefix —
110/// the IR picks the re-validation *strategy* ([`Revalidation`]); `bynk-emit`
111/// alone knows how to spell that owner as an `import type * as <ns>` alias
112/// (`bynk-emit/src/emitter/serialisation.rs`'s `Qual`/`qual_prefix`, built
113/// per-emission from build-mode facts this crate does not have). Unifying
114/// the two would drag `BuildTarget` into the checker.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum Provenance {
117 /// The emitting module's own declaration: re-validation routes through
118 /// the type's own constructor.
119 Owned,
120 /// A type declared by another unit and reached through it — `owner_unit`
121 /// is that unit's qualified name (`commerce.payment`, never a TS
122 /// namespace spelling).
123 Consumed { owner_unit: String },
124}
125
126/// #661 Decisions C/D plus the owner path, stated once: how a boundary
127/// scalar's refinement is re-checked on the way in, given who declared it
128/// and where the check is happening.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Revalidation {
131 /// Owner's own module: run the type's own `.of`.
132 ViaConstructor,
133 /// Consumed + transparent (Decision D): re-check each predicate inline,
134 /// since the consumer knows the shape by declaration even without an
135 /// importable constructor.
136 Inline,
137 /// Consumed + opaque (Decision C): the predicate is the owner's secret —
138 /// cast structurally after the base check; skew is caught by the
139 /// contract hash instead.
140 StructuralOnly,
141 /// `Bytes`: decoded, not cast — the one base type whose wire value is
142 /// not a direct cast of its erased representation. Applies regardless of
143 /// provenance, mirroring `emit_refined`'s early return to the dedicated
144 /// `Bytes` codec before the owned/consumed split is even considered.
145 Base64Decode,
146}
147
148/// A named boundary type's declared shape, refinement-independent of who is
149/// asking. `owner`-relative facts ([`Revalidation`]) live one level down, in
150/// [`WireScalar`] — the shape itself does not vary with provenance, only how
151/// hard the receiver re-checks it.
152#[derive(Debug, Clone)]
153pub struct WireType {
154 pub name: String,
155 /// The codec-name suffix this type resolves to (`Order`, or a
156 /// monomorphised generic's `Paginated_User`). Equal to `name` for every
157 /// non-generic declaration `wire_type` produces; kept as its own field
158 /// because a future generic-instantiation `WireType` (Phase 2+) needs
159 /// the two to diverge the same way `serialisation.rs`'s `fn_suffix` /
160 /// `ts_type` pair already does.
161 pub codec_suffix: String,
162 pub provenance: Provenance,
163 pub body: WireBody,
164}
165
166#[derive(Debug, Clone)]
167pub enum WireBody {
168 Scalar(WireScalar),
169 Record { fields: Vec<WireField> },
170 Sum(WireSum),
171}
172
173/// A refined- or opaque-base-type boundary scalar.
174#[derive(Debug, Clone)]
175pub struct WireScalar {
176 pub base: BaseType,
177 pub json: JsonKind,
178 /// `true` for `opaque BaseType`, `false` for a transparent refined type.
179 pub opaque: bool,
180 /// **Declaration order**, not sorted, not deduped — this is the
181 /// highest-risk drift trap between this IR and `contract.rs`'s canonical
182 /// form (see the module doc's comparison table). `Inline` revalidation
183 /// emits one check per predicate in exactly this order.
184 pub predicates: Vec<PredKind>,
185 /// The base-type guards this scalar's `Inline` revalidation applies,
186 /// *before* the declared predicates. Mirrors
187 /// `emit_inline_refinement_checks` exactly: `Int` guards `Integral`,
188 /// `Float` guards `Finite` — deliberately **not** `Instant`, which is
189 /// guarded at record-*field* position (`emit_field_deserialise`'s
190 /// `TypeRef::Base` arm) but not here. That asymmetry already exists in
191 /// the emitter this IR was extracted from; it is preserved verbatim
192 /// rather than "fixed" by this move, since fixing it would be a
193 /// behaviour change this phase must not make. (See `field_base_guards`
194 /// for the field-position set, which does include `Instant`.)
195 pub base_guards: Vec<BaseGuard>,
196 pub revalidation: Revalidation,
197}
198
199/// One field of a boundary record, in **declaration order** (the emitted
200/// JSON key order).
201#[derive(Debug, Clone)]
202pub struct WireField {
203 pub name: String,
204 pub shape: WireRef,
205 /// The JSON path segment this field contributes to a validation error's
206 /// `path` (`` `${path}.<segment>` ``) — equal to `name` today; kept
207 /// distinct because a future field-rename annotation would move the wire
208 /// key without moving the path segment a hover/error should still name.
209 pub path_segment: String,
210 /// The field's default initialiser, if any, carried as raw AST — its
211 /// *wire-JSON literal* rendering is `lower_field_default_wire`
212 /// (`bynk-emit`), which stays in `bynk-emit` per the module doc: a
213 /// default's lowered literal is a rendering, not a boundary fact.
214 pub default: Option<(Expr, TypeRef)>,
215}
216
217/// A boundary sum type. The wire and in-memory discriminants are carried
218/// side by side because the codec's whole job is translating between them —
219/// `memory_discriminant` is the softest part of this seam (a host
220/// representation choice, not a wire fact); moving it back into `bynk-emit`
221/// alone is a one-field change if a reviewer objects.
222#[derive(Debug, Clone)]
223pub struct WireSum {
224 pub wire_discriminant: &'static str,
225 pub memory_discriminant: &'static str,
226 pub variants: Vec<WireVariant>,
227}
228
229/// One variant of a [`WireSum`], in **declaration order** (the emitted
230/// `switch` arm order).
231#[derive(Debug, Clone)]
232pub struct WireVariant {
233 pub name: String,
234 pub payload: Vec<WireField>,
235}
236
237/// Why a [`WireRef`] carries no generated codec — the runtime-owned error
238/// family, which has no `TypeDecl` to derive a shape from.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum UncheckedReason {
241 /// An `Effect[T]` reached in a field/element position (never a top-level
242 /// handler return, which strips it before it gets here).
243 Effect,
244 ValidationError,
245 JsonError,
246 HttpResult,
247 QueueResult,
248}
249
250/// The resolved shape of one `TypeRef` occurrence — a field's type, a sum
251/// variant payload's type, a generic instantiation's argument. Mirrors the
252/// exhaustive dispatch `bynk-emit`'s `emit_field_deserialise` performs today,
253/// one level removed from any TS string.
254#[derive(Debug, Clone)]
255pub enum WireRef {
256 Base {
257 base: BaseType,
258 json: JsonKind,
259 guards: Vec<BaseGuard>,
260 expected: Expected,
261 },
262 /// `Bytes`: base64 string on the wire, decoded (never cast) into a
263 /// `Uint8Array`-shaped value.
264 Bytes,
265 /// A named boundary type — resolve further via the model's own
266 /// `WireType` table.
267 Named { name: String },
268 /// A generic instantiation — `Result[A, B]`, `Option[A]`, `List[A]`,
269 /// `Map[K, V]`, or a generic record/sum application (`Paginated[User]`).
270 /// `key` is the same codec-suffix string in every case
271 /// (`Result_Int_String`, `Paginated_User`, …) — resolve further via the
272 /// model's `instantiations` list, keyed by [`WireInst::ts_name`].
273 Inst { key: String },
274 /// `()` — no wire content; the wire slot is `null`, the value `undefined`.
275 Unit,
276 /// The runtime-owned error family (plus a stray field-position `Effect`)
277 /// — cast through unchecked, since there is no `TypeDecl` to derive a
278 /// shape from. See [`UncheckedReason`].
279 Unchecked { reason: UncheckedReason },
280}
281
282/// The set of `TypeRef`s a boundary walk resolved and their re-derived
283/// structural shapes — the single source of truth both the emitter's codec
284/// generation and the wire-contract peek render from.
285#[derive(Debug, Clone)]
286pub struct WireModel {
287 pub types: Vec<WireType>,
288 pub instantiations: Vec<WireInst>,
289 /// The generic-record names (v0.174 #592) that are transitively
290 /// self-referential and therefore have no finite monomorphised codec set
291 /// — rejected at the boundary before emit, carried here as the same
292 /// membership set the codec walks use to short-circuit.
293 pub recursive: BTreeSet<String>,
294}
295
296// ---------------------------------------------------------------------
297// Moved verbatim from `bynk-emit/src/emitter/serialisation.rs` (pure,
298// AST-only walks — no TS string ever touches these). `pub(crate)` in the
299// original became `pub` here; everything else is unchanged apart from the
300// renames the plan calls for (`inner_ts_name` → `codec_suffix`, `app_ts_name`
301// → `inst_codec_suffix`).
302//
303// `GenericInst` moved as `WireInst` but **keeps its original variant names**
304// (`ResultInst`/`OptionInst`/…, not the plan's bare `Result`/`Option`/…):
305// `bynk-emit/src/emitter/serialisation.rs`'s `emit_generic_helpers_qualified`
306// — a codec-*emission* function, out of scope for this phase — pattern-matches
307// those variants directly, and this phase must not touch codec-emission call
308// sites.
309//
310// #855 (Phase 2 step 9, decided): the plan allowed renaming to the bare
311// spelling here since this step touches `emit_generic_helpers_qualified`
312// anyway — but the RecordInst/SumInst arms it touches already consume the
313// IR's `WireField`/`WireSum` shapes (steps 7/8's byte-identical rewrite of
314// `emit_record_codec`/`emit_sum_codec` covers them), and the other four arms
315// (`ResultInst`/`OptionInst`/`ListInst`/`MapInst`) build their TS inline —
316// they were never going to move to a `Result`/`Option`/`List`/`Map` spelling
317// of *this* enum either way. A pure rename would touch six match arms here
318// and this module's doc/tests for zero behavioural or architectural gain, so
319// the `*Inst`-suffixed names are kept. Revisit only if a future consumer
320// (e.g. the Phase 3+ peek) finds the bare spelling actually reads better at
321// its own call sites.
322// ---------------------------------------------------------------------
323
324/// Compute the set of type names (transitively reachable) that need
325/// serialise/deserialise helpers for this context: any type used in the
326/// argument or return position of a service handler exposed by this
327/// context, walked through record fields, sum payloads, and the generic
328/// type parameters of Result/Option/Effect.
329pub fn collect_boundary_types(
330 types: &HashMap<String, Arc<TypeDecl>>,
331 services: &HashMap<String, ServiceDecl>,
332 // v0.96 (ADR 0124): rehydration is a trust boundary — an agent's persisted
333 // `store`-field types are validated on load, so they need their deserialisers
334 // emitted. Register every store field's kind-argument types (the element /
335 // key / value types of `Cell`/`Map`/`Set`/`Cache`/`Log`).
336 agents: &HashMap<String, AgentDecl>,
337 // #1678: further boundary positions to root the walk at. On the `workers`
338 // target an agent call crosses a Durable Object `fetch`, so the emitter
339 // passes every agent handler's parameter and return types here.
340 extra_roots: &[TypeRef],
341) -> Vec<String> {
342 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
343 let mut out: Vec<String> = Vec::new();
344 let mut stack: Vec<String> = Vec::new();
345 let recursive_set = recursive_generic_names(types);
346 let recursive = &recursive_set;
347 for r in extra_roots {
348 collect_type_names(r, &mut stack, types, recursive);
349 }
350
351 let mut svc_names: Vec<&String> = services.keys().collect();
352 svc_names.sort();
353 for name in svc_names {
354 let service = &services[name];
355 for h in &service.handlers {
356 for p in &h.params {
357 collect_type_names(&p.type_ref, &mut stack, types, recursive);
358 }
359 collect_type_names(&h.return_type, &mut stack, types, recursive);
360 }
361 }
362
363 let mut agent_names: Vec<&String> = agents.keys().collect();
364 agent_names.sort();
365 for name in agent_names {
366 for f in &agents[name].store_fields {
367 for arg in &f.kind.args {
368 collect_type_names(arg, &mut stack, types, recursive);
369 }
370 }
371 }
372
373 while let Some(name) = stack.pop() {
374 if !seen.insert(name.clone()) {
375 continue;
376 }
377 out.push(name.clone());
378 let Some(decl) = types.get(&name) else {
379 continue;
380 };
381 match &decl.body {
382 TypeBody::Record(r) => {
383 for f in &r.fields {
384 collect_type_names(&f.type_ref, &mut stack, types, recursive);
385 }
386 }
387 TypeBody::Sum(s) => {
388 for v in &s.variants {
389 for p in &v.payload {
390 collect_type_names(&p.type_ref, &mut stack, types, recursive);
391 }
392 }
393 }
394 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
395 }
396 }
397
398 out.sort();
399 out
400}
401
402/// v0.174 (#592): the set of generic-record names that are *recursive* — they
403/// transitively contain themselves, so they have no finite monomorphised codec
404/// (rejected at the boundary by the checker before emit). Precomputed once per
405/// collector so the per-`App` guard in the codec walks is an O(1) membership test
406/// rather than a fresh graph reachability walk at every occurrence.
407fn recursive_generic_names(
408 types: &HashMap<String, Arc<TypeDecl>>,
409) -> std::collections::HashSet<String> {
410 types
411 .iter()
412 .filter(|(_, d)| !d.type_params.is_empty())
413 .map(|(n, _)| n.clone())
414 .filter(|n| generic_record_is_recursive(n, types))
415 .collect()
416}
417
418fn collect_type_names(
419 t: &TypeRef,
420 stack: &mut Vec<String>,
421 types: &HashMap<String, Arc<TypeDecl>>,
422 recursive: &std::collections::HashSet<String>,
423) {
424 match t {
425 TypeRef::Named(id) => stack.push(id.name.clone()),
426 // Query/Stream/Connection types carry no boundary-collectable user
427 // types (non-boundary).
428 TypeRef::Query(..)
429 | TypeRef::Stream(..)
430 | TypeRef::Connection(..)
431 | TypeRef::History(..) => {}
432 // v0.174 (#592): a generic-record instantiation is boundary-serialisable
433 // through its monomorphised codec (`serialise_Paginated_User`). The
434 // *named* helpers that codec calls come from its concrete field types —
435 // the type arguments (`User`) and any non-parameter named field types
436 // (`Envelope[T] = { meta: Metadata, … }`) — so walk the substituted
437 // fields. A *recursive* generic record has no finite codec set and is
438 // rejected at the boundary before emit; the guard here is defence in
439 // depth so this walk can never fail to terminate.
440 TypeRef::App { name, args, .. } => {
441 if recursive.contains(&name.name) {
442 return;
443 }
444 // #593: a generic-sum instantiation's codec (`serialise_ApiResult_User`)
445 // likewise calls the named helpers of its *substituted variant
446 // payloads* (`serialise_User` for `Loaded(value: T)` at `T = User`),
447 // so walk those the same way records walk their fields.
448 if let Some(fields) = record_inst_fields(&name.name, args, types) {
449 for (_, ft) in &fields {
450 collect_type_names(ft, stack, types, recursive);
451 }
452 } else if let Some(variants) = sum_inst_variants(&name.name, args, types) {
453 for (_, payload) in &variants {
454 for (_, ft) in payload {
455 collect_type_names(ft, stack, types, recursive);
456 }
457 }
458 }
459 }
460 // v0.20a: function types carry no user-named types to collect and are
461 // rejected at boundaries anyway.
462 TypeRef::Fn(..) => {}
463 TypeRef::Result(a, b, _) => {
464 collect_type_names(a, stack, types, recursive);
465 collect_type_names(b, stack, types, recursive);
466 }
467 TypeRef::Option(a, _) => collect_type_names(a, stack, types, recursive),
468 TypeRef::Effect(a, _) => collect_type_names(a, stack, types, recursive),
469 TypeRef::HttpResult(a, _) => collect_type_names(a, stack, types, recursive),
470 // v0.20b: collections serialise element-/entry-wise; their inner
471 // named types need helpers.
472 TypeRef::List(a, _) => collect_type_names(a, stack, types, recursive),
473 TypeRef::Map(k, v, _) => {
474 collect_type_names(k, stack, types, recursive);
475 collect_type_names(v, stack, types, recursive);
476 }
477 TypeRef::Base(_, _)
478 | TypeRef::QueueResult(_)
479 | TypeRef::ValidationError(_)
480 | TypeRef::JsonError(_)
481 | TypeRef::Unit(_) => {}
482 }
483}
484
485/// v0.174 (#592): substitute a generic record's declared field type — replacing
486/// each type-parameter name with the concrete argument type-ref — so a
487/// per-instantiation codec sees fully concrete field types.
488/// `Paginated[User]`'s `items: List[T]` becomes `items: List[User]`.
489fn subst_type_ref(t: &TypeRef, subst: &HashMap<String, TypeRef>) -> TypeRef {
490 match t {
491 TypeRef::Named(id) => match subst.get(&id.name) {
492 Some(replacement) => replacement.clone(),
493 None => t.clone(),
494 },
495 TypeRef::App { name, args, span } => TypeRef::App {
496 name: name.clone(),
497 args: args.iter().map(|a| subst_type_ref(a, subst)).collect(),
498 span: *span,
499 },
500 TypeRef::Result(a, b, s) => TypeRef::Result(
501 Box::new(subst_type_ref(a, subst)),
502 Box::new(subst_type_ref(b, subst)),
503 *s,
504 ),
505 TypeRef::Option(a, s) => TypeRef::Option(Box::new(subst_type_ref(a, subst)), *s),
506 TypeRef::Effect(a, s) => TypeRef::Effect(Box::new(subst_type_ref(a, subst)), *s),
507 TypeRef::HttpResult(a, s) => TypeRef::HttpResult(Box::new(subst_type_ref(a, subst)), *s),
508 TypeRef::List(a, s) => TypeRef::List(Box::new(subst_type_ref(a, subst)), *s),
509 TypeRef::Map(k, v, s) => TypeRef::Map(
510 Box::new(subst_type_ref(k, subst)),
511 Box::new(subst_type_ref(v, subst)),
512 *s,
513 ),
514 TypeRef::Query(a, s) => TypeRef::Query(Box::new(subst_type_ref(a, subst)), *s),
515 TypeRef::Stream(a, s) => TypeRef::Stream(Box::new(subst_type_ref(a, subst)), *s),
516 TypeRef::Connection(a, s) => TypeRef::Connection(Box::new(subst_type_ref(a, subst)), *s),
517 TypeRef::History(a, s) => TypeRef::History(Box::new(subst_type_ref(a, subst)), *s),
518 TypeRef::Fn(ps, r, s) => TypeRef::Fn(
519 ps.iter().map(|p| subst_type_ref(p, subst)).collect(),
520 Box::new(subst_type_ref(r, subst)),
521 *s,
522 ),
523 TypeRef::Base(..)
524 | TypeRef::QueueResult(_)
525 | TypeRef::ValidationError(_)
526 | TypeRef::JsonError(_)
527 | TypeRef::Unit(_) => t.clone(),
528 }
529}
530
531/// v0.174 (#592): the concrete `(field-name, field-type)` list for a generic
532/// record instantiation `Name[args…]` — the declared fields with every type
533/// parameter substituted by the matching argument. Returns `None` if `name` is
534/// not a declared generic record or the arity does not match (both guaranteed
535/// impossible by the checker, so this is purely defensive).
536pub fn record_inst_fields(
537 name: &str,
538 args: &[TypeRef],
539 types: &HashMap<String, Arc<TypeDecl>>,
540) -> Option<Vec<(String, TypeRef)>> {
541 let decl = types.get(name)?;
542 let TypeBody::Record(r) = &decl.body else {
543 return None;
544 };
545 if decl.type_params.len() != args.len() {
546 return None;
547 }
548 let subst: HashMap<String, TypeRef> = decl
549 .type_params
550 .iter()
551 .map(|p| p.name.name.clone())
552 .zip(args.iter().cloned())
553 .collect();
554 Some(
555 r.fields
556 .iter()
557 .map(|f| (f.name.name.clone(), subst_type_ref(&f.type_ref, &subst)))
558 .collect(),
559 )
560}
561
562/// #593: the concrete `(variant-name, [(field-name, field-type)])` list for a
563/// generic sum instantiation `Name[args…]` — the declared variants with every
564/// type parameter substituted by the matching argument. The sum analogue of
565/// [`record_inst_fields`]; `None` (defensively) if `name` is not a declared
566/// generic sum or the arity does not match.
567#[allow(clippy::type_complexity)]
568pub fn sum_inst_variants(
569 name: &str,
570 args: &[TypeRef],
571 types: &HashMap<String, Arc<TypeDecl>>,
572) -> Option<Vec<(String, Vec<(String, TypeRef)>)>> {
573 let decl = types.get(name)?;
574 let TypeBody::Sum(s) = &decl.body else {
575 return None;
576 };
577 if decl.type_params.len() != args.len() {
578 return None;
579 }
580 let subst: HashMap<String, TypeRef> = decl
581 .type_params
582 .iter()
583 .map(|p| p.name.name.clone())
584 .zip(args.iter().cloned())
585 .collect();
586 Some(
587 s.variants
588 .iter()
589 .map(|v| {
590 (
591 v.name.name.clone(),
592 v.payload
593 .iter()
594 .map(|f| (f.name.name.clone(), subst_type_ref(&f.type_ref, &subst)))
595 .collect(),
596 )
597 })
598 .collect(),
599 )
600}
601
602/// v0.174 (#592): the monomorphised codec suffix for a generic-record
603/// instantiation — `Paginated[User]` → `Paginated_User`,
604/// `Pair[User, String]` → `Pair_User_String`. #593: shared with generic sums.
605/// Renamed from `app_ts_name` in the move from `bynk-emit`.
606pub fn inst_codec_suffix(name: &str, args: &[TypeRef]) -> String {
607 let mut s = name.to_string();
608 for a in args {
609 s.push('_');
610 s.push_str(&codec_suffix(a));
611 }
612 s
613}
614
615/// The codec-name suffix a `TypeRef` resolves to — `Int`, `Order`,
616/// `Result_Int_String`, `Paginated_User`. Renamed from `inner_ts_name` in the
617/// move from `bynk-emit`; used both to key a [`WireInst`] and, unqualified,
618/// as the bare codec function suffix a same-module call reaches.
619pub fn codec_suffix(t: &TypeRef) -> String {
620 match t {
621 TypeRef::Base(b, _) => b.name().to_string(),
622 // v0.20a: function types are confined to non-boundary positions
623 // (`bynk.types.function_at_boundary`), so the serialisation machinery
624 // can never legally see one.
625 TypeRef::Fn(..)
626 | TypeRef::Query(..)
627 | TypeRef::Stream(..)
628 | TypeRef::Connection(..)
629 | TypeRef::History(..) => {
630 unreachable!("function/query/stream types are rejected at boundaries")
631 }
632 // v0.174 (#592): the codec suffix for a generic-record instantiation —
633 // `Paginated[User]` → `Paginated_User`.
634 TypeRef::App { name, args, .. } => inst_codec_suffix(&name.name, args),
635 TypeRef::Named(id) => id.name.clone(),
636 TypeRef::Result(a, b, _) => format!("Result_{}_{}", codec_suffix(a), codec_suffix(b)),
637 TypeRef::Option(a, _) => format!("Option_{}", codec_suffix(a)),
638 TypeRef::Effect(a, _) => format!("Effect_{}", codec_suffix(a)),
639 TypeRef::HttpResult(a, _) => format!("HttpResult_{}", codec_suffix(a)),
640 TypeRef::List(a, _) => format!("List_{}", codec_suffix(a)),
641 TypeRef::Map(k, v, _) => format!("Map_{}_{}", codec_suffix(k), codec_suffix(v)),
642 TypeRef::QueueResult(_) => "QueueResult".to_string(),
643 TypeRef::ValidationError(_) => "ValidationError".to_string(),
644 TypeRef::JsonError(_) => "JsonError".to_string(),
645 TypeRef::Unit(_) => "Unit".to_string(),
646 }
647}
648
649/// v0.22b: the codec closure for a set of `Json.encode`/`Json.decode[T]`
650/// target type-refs — the named types needing per-type helpers (transitively
651/// through record fields and sum payloads) plus the generic instantiations
652/// needing specialised helpers. The same closure logic as the boundary
653/// collectors, rooted at expressions instead of service signatures.
654pub fn collect_codec_closure(
655 roots: &[TypeRef],
656 types: &HashMap<String, Arc<TypeDecl>>,
657) -> (Vec<String>, Vec<WireInst>) {
658 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
659 let mut names: Vec<String> = Vec::new();
660 let mut stack: Vec<String> = Vec::new();
661 let recursive_set = recursive_generic_names(types);
662 let recursive = &recursive_set;
663 for r in roots {
664 collect_type_names(r, &mut stack, types, recursive);
665 }
666 while let Some(name) = stack.pop() {
667 if !seen.insert(name.clone()) {
668 continue;
669 }
670 names.push(name.clone());
671 let Some(decl) = types.get(&name) else {
672 continue;
673 };
674 match &decl.body {
675 TypeBody::Record(r) => {
676 for f in &r.fields {
677 collect_type_names(&f.type_ref, &mut stack, types, recursive);
678 }
679 }
680 TypeBody::Sum(s) => {
681 for v in &s.variants {
682 for p in &v.payload {
683 collect_type_names(&p.type_ref, &mut stack, types, recursive);
684 }
685 }
686 }
687 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
688 }
689 }
690 names.sort();
691
692 let mut insts: Vec<WireInst> = Vec::new();
693 let mut inst_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
694 for r in roots {
695 walk_generic_inst(r, &mut insts, &mut inst_seen, types, recursive);
696 }
697 for name in &names {
698 let Some(decl) = types.get(name) else {
699 continue;
700 };
701 match &decl.body {
702 TypeBody::Record(r) => {
703 for f in &r.fields {
704 walk_generic_inst(&f.type_ref, &mut insts, &mut inst_seen, types, recursive);
705 }
706 }
707 TypeBody::Sum(s) => {
708 for v in &s.variants {
709 for p in &v.payload {
710 walk_generic_inst(
711 &p.type_ref,
712 &mut insts,
713 &mut inst_seen,
714 types,
715 recursive,
716 );
717 }
718 }
719 }
720 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
721 }
722 }
723 (names, insts)
724}
725
726/// Collect the set of `Result<A, B>` / `Option<A>` instantiations used in
727/// boundary positions so the emitter can synthesise the specialised
728/// helpers. v0.18: an instantiation may also appear in the *fields* of a
729/// boundary record or sum payload (e.g. the bynk surface's
730/// `Request.contentType: Option[String]`) — the per-type serialisers
731/// delegate to the specialised generic helpers, so walk those too.
732pub fn collect_generic_instantiations(
733 services: &HashMap<String, ServiceDecl>,
734 // v0.96 (ADR 0124): an agent's `store`-field element types are validated on
735 // rehydration, so a `Cell[Option[Int]]` / `Log[List[T]]` needs its specialised
736 // generic helper emitted just like a boundary signature does.
737 agents: &HashMap<String, AgentDecl>,
738 boundary_type_names: &[String],
739 types: &HashMap<String, Arc<TypeDecl>>,
740 // #1678: further boundary positions, as for `collect_boundary_types`. Walked
741 // after the services and agent stores, so existing emission order is kept.
742 extra_roots: &[TypeRef],
743) -> Vec<WireInst> {
744 let mut out: Vec<WireInst> = Vec::new();
745 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
746 let recursive_set = recursive_generic_names(types);
747 let recursive = &recursive_set;
748 // Iterate services in name order: `HashMap::values()` order varies per
749 // process, and the *emission order* of the specialised helpers follows
750 // first-encounter order here. Surfaced by the first fixture with
751 // multiple same-file services carrying different instantiations (v0.23
752 // #35 CI); latent since v0.8.
753 let mut svc_names: Vec<&String> = services.keys().collect();
754 svc_names.sort();
755 for name in svc_names {
756 let s = &services[name];
757 for h in &s.handlers {
758 for p in &h.params {
759 walk_generic_inst(&p.type_ref, &mut out, &mut seen, types, recursive);
760 }
761 walk_generic_inst(&h.return_type, &mut out, &mut seen, types, recursive);
762 }
763 }
764 let mut agent_names: Vec<&String> = agents.keys().collect();
765 agent_names.sort();
766 for name in agent_names {
767 for f in &agents[name].store_fields {
768 for arg in &f.kind.args {
769 walk_generic_inst(arg, &mut out, &mut seen, types, recursive);
770 }
771 }
772 }
773 for r in extra_roots {
774 walk_generic_inst(r, &mut out, &mut seen, types, recursive);
775 }
776 for name in boundary_type_names {
777 let Some(decl) = types.get(name) else {
778 continue;
779 };
780 // v0.174 (#592): never walk a *generic* declaration's own fields — they
781 // are the declared, unsubstituted body (`Paginated[T] = { items: List[T]
782 // }`), so walking `List[T]` would emit a bogus `serialise_List_T` over the
783 // unbound type variable `T`. The instantiations a generic record needs
784 // come from its *use* sites (`Paginated[User]`, a `TypeRef::App`), which
785 // `walk_generic_inst` expands with concrete arguments. This mirrors the
786 // `emit_helpers_for_owner` skip that keeps a bare `serialise_Paginated`
787 // from being emitted.
788 if !decl.type_params.is_empty() {
789 continue;
790 }
791 match &decl.body {
792 TypeBody::Record(r) => {
793 for f in &r.fields {
794 walk_generic_inst(&f.type_ref, &mut out, &mut seen, types, recursive);
795 }
796 }
797 TypeBody::Sum(s) => {
798 for v in &s.variants {
799 for p in &v.payload {
800 walk_generic_inst(&p.type_ref, &mut out, &mut seen, types, recursive);
801 }
802 }
803 }
804 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
805 }
806 }
807 out
808}
809
810/// A boundary occurrence of a generic instantiation needing its own
811/// specialised codec — `Result<A, B>`, `Option<A>`, `List<A>`, `Map<K, V>`,
812/// or a generic user record/sum applied to concrete arguments
813/// (`Paginated[User]`, `ApiResult[User]`).
814///
815/// Moved from `bynk-emit/src/emitter/serialisation.rs`'s `GenericInst` as
816/// part of the #855 IR extraction. Variant names are kept exactly as they
817/// were (`ResultInst`, not the plan's bare `Result`, …): `bynk-emit`'s
818/// `emit_generic_helpers_qualified` pattern-matches these variants directly.
819/// Phase 2 step 9 ("Generic helpers") revisited the plan's bare
820/// `Result`/`Option`/`List`/`Map`/`Record`/`Sum` spelling and **kept** the
821/// `*Inst` suffix — see the "Moved verbatim" comment block above this
822/// module's walk functions for the reasoning (a pure rename of six match
823/// arms with no behavioural or architectural payoff).
824#[derive(Debug, Clone)]
825#[allow(clippy::enum_variant_names)]
826pub enum WireInst {
827 ResultInst {
828 ok: TypeRef,
829 err: TypeRef,
830 },
831 OptionInst {
832 inner: TypeRef,
833 },
834 /// v0.20b: a `List[T]` boundary instantiation — element-wise wire format.
835 ListInst {
836 elem: TypeRef,
837 },
838 /// v0.20b: a `Map[K, V]` boundary instantiation — entries-array wire
839 /// format (`[[k, v], …]`), insertion-ordered.
840 MapInst {
841 key: TypeRef,
842 val: TypeRef,
843 },
844 /// v0.174 (#592): a generic user-record instantiation `Name[args…]` — a
845 /// monomorphised per-instantiation record codec (`serialise_Paginated_User`)
846 /// specialised to the concrete arguments (ADR 0183 Decision C's follow-on).
847 RecordInst {
848 name: String,
849 args: Vec<TypeRef>,
850 },
851 /// #593: a generic user-sum instantiation `Name[args…]` — a monomorphised
852 /// per-instantiation discriminated-union codec (`serialise_ApiResult_User`),
853 /// the sum analogue of [`WireInst::RecordInst`].
854 SumInst {
855 name: String,
856 args: Vec<TypeRef>,
857 },
858}
859
860impl WireInst {
861 pub fn ts_name(&self) -> String {
862 match self {
863 WireInst::ResultInst { ok, err } => {
864 format!("Result_{}_{}", codec_suffix(ok), codec_suffix(err))
865 }
866 WireInst::OptionInst { inner } => {
867 format!("Option_{}", codec_suffix(inner))
868 }
869 WireInst::ListInst { elem } => format!("List_{}", codec_suffix(elem)),
870 WireInst::MapInst { key, val } => {
871 format!("Map_{}_{}", codec_suffix(key), codec_suffix(val))
872 }
873 WireInst::RecordInst { name, args } => inst_codec_suffix(name, args),
874 WireInst::SumInst { name, args } => inst_codec_suffix(name, args),
875 }
876 }
877}
878
879fn walk_generic_inst(
880 t: &TypeRef,
881 out: &mut Vec<WireInst>,
882 seen: &mut std::collections::HashSet<String>,
883 types: &HashMap<String, Arc<TypeDecl>>,
884 recursive: &std::collections::HashSet<String>,
885) {
886 match t {
887 // v0.174 (#592): a generic-record instantiation needs a monomorphised
888 // codec, and so do the generic instantiations reachable through its
889 // concrete field types (`Paginated[User]` → `List[User]` →
890 // `serialise_List_User`, `Envelope[Box[User]]` → `Box[User]` →
891 // `serialise_Box_User`). Substitute the fields and walk them. A recursive
892 // generic record (no finite codec set) is rejected at the boundary before
893 // emit; the guard here is defence in depth so this walk always terminates
894 // (the `seen` dedup alone cannot bound *polymorphic* recursion, whose
895 // instantiations each carry a distinct name).
896 TypeRef::App { name, args, .. } => {
897 if recursive.contains(&name.name) {
898 return;
899 }
900 // #593: an `App` names a generic record OR a generic sum; dispatch on
901 // the declaration's body so the right monomorphised codec is emitted,
902 // and walk the reachable instantiations through its concrete member
903 // types (a sum's variant payloads, a record's fields).
904 let is_sum = matches!(
905 types.get(&name.name).map(|d| &d.body),
906 Some(TypeBody::Sum(_))
907 );
908 let inst = if is_sum {
909 WireInst::SumInst {
910 name: name.name.clone(),
911 args: args.clone(),
912 }
913 } else {
914 WireInst::RecordInst {
915 name: name.name.clone(),
916 args: args.clone(),
917 }
918 };
919 let key = inst.ts_name();
920 if !seen.insert(key) {
921 return;
922 }
923 out.push(inst);
924 for a in args {
925 walk_generic_inst(a, out, seen, types, recursive);
926 }
927 if is_sum {
928 if let Some(variants) = sum_inst_variants(&name.name, args, types) {
929 for (_, payload) in &variants {
930 for (_, ft) in payload {
931 walk_generic_inst(ft, out, seen, types, recursive);
932 }
933 }
934 }
935 } else if let Some(fields) = record_inst_fields(&name.name, args, types) {
936 for (_, ft) in &fields {
937 walk_generic_inst(ft, out, seen, types, recursive);
938 }
939 }
940 }
941 TypeRef::Result(a, b, _) => {
942 let inst = WireInst::ResultInst {
943 ok: (**a).clone(),
944 err: (**b).clone(),
945 };
946 let key = inst.ts_name();
947 if seen.insert(key) {
948 out.push(inst);
949 }
950 walk_generic_inst(a, out, seen, types, recursive);
951 walk_generic_inst(b, out, seen, types, recursive);
952 }
953 TypeRef::Option(a, _) => {
954 let inst = WireInst::OptionInst {
955 inner: (**a).clone(),
956 };
957 let key = inst.ts_name();
958 if seen.insert(key) {
959 out.push(inst);
960 }
961 walk_generic_inst(a, out, seen, types, recursive);
962 }
963 TypeRef::Effect(a, _) => walk_generic_inst(a, out, seen, types, recursive),
964 TypeRef::HttpResult(a, _) => walk_generic_inst(a, out, seen, types, recursive),
965 TypeRef::List(a, _) => {
966 let inst = WireInst::ListInst {
967 elem: (**a).clone(),
968 };
969 let key = inst.ts_name();
970 if seen.insert(key) {
971 out.push(inst);
972 }
973 walk_generic_inst(a, out, seen, types, recursive);
974 }
975 TypeRef::Map(k, v, _) => {
976 let inst = WireInst::MapInst {
977 key: (**k).clone(),
978 val: (**v).clone(),
979 };
980 let key = inst.ts_name();
981 if seen.insert(key) {
982 out.push(inst);
983 }
984 walk_generic_inst(k, out, seen, types, recursive);
985 walk_generic_inst(v, out, seen, types, recursive);
986 }
987 _ => {}
988 }
989}
990
991// ---------------------------------------------------------------------
992// New: resolving a `TypeRef` / `TypeDecl` into the IR above. Not yet called
993// by any codec path — `bynk-emit` still derives its own shape inline
994// (Phase 2 switches it over). Exercised only by this module's own tests
995// until then.
996// ---------------------------------------------------------------------
997
998/// The base-type guards a bare **field-position** value (a record field, a
999/// sum-variant payload field, a collection element) is checked against,
1000/// mirroring `emit_field_deserialise`'s `TypeRef::Base` arm: `Int`/`Instant`
1001/// must be whole, `Float` must be finite. Contrast [`WireScalar::base_guards`]
1002/// (a **named-type** consumed-inline revalidation), which guards `Int`/`Float`
1003/// only — the two positions are not the same check in the code this was
1004/// extracted from, and that asymmetry is preserved rather than merged.
1005fn field_base_guards(b: BaseType) -> Vec<BaseGuard> {
1006 match b {
1007 BaseType::Int | BaseType::Instant => vec![BaseGuard::Integral],
1008 BaseType::Float => vec![BaseGuard::Finite],
1009 _ => Vec::new(),
1010 }
1011}
1012
1013/// The base-type guards a named type's `Inline` (consumed, transparent)
1014/// revalidation applies, mirroring `emit_inline_refinement_checks` exactly:
1015/// `Int` → `Integral`, `Float` → `Finite`. See [`WireScalar::base_guards`]'s
1016/// doc for why `Instant` is deliberately absent here despite being guarded
1017/// at field position.
1018fn scalar_base_guards(b: BaseType) -> Vec<BaseGuard> {
1019 match b {
1020 BaseType::Int => vec![BaseGuard::Integral],
1021 BaseType::Float => vec![BaseGuard::Finite],
1022 _ => Vec::new(),
1023 }
1024}
1025
1026/// Resolve one `TypeRef` occurrence (a field's type, a variant payload
1027/// field's type, a generic argument) into its [`WireRef`] shape — the same
1028/// dispatch `emit_field_deserialise` performs today, one level removed from
1029/// any TS string.
1030///
1031/// `types` is threaded through for signature symmetry with the walks above
1032/// and forward compatibility with a future disambiguation need; this
1033/// resolution is single-level (unlike `collect_type_names`'s transitive
1034/// walk) and does not currently consult it.
1035///
1036/// **This encodes the *deserialise*-side dispatch only** (mirroring
1037/// `emit_field_deserialise`'s arms one-for-one, including its `Effect` /
1038/// `HttpResult` arm, which it folds into the same unchecked cast as
1039/// `ValidationError`/`JsonError`/`QueueResult`). The emitter does not
1040/// actually agree with itself on those two shapes across its other three
1041/// dispatches: `serialise_field_expr_via` *recurses* through `Effect` rather
1042/// than casting it unchecked, `deserialise_expr_via` also recurses through
1043/// `Effect`, and `codec_suffix` gives both `Effect` and `HttpResult` their
1044/// own composed names (`Effect_<inner>`, `HttpResult_<inner>`) rather than
1045/// treating them as opaque. A future serialise-side or codec-suffix-side
1046/// `WireRef` derivation must **not** assume this function's `Unchecked`
1047/// answer already covers it — that would silently change `Effect`-at-field-
1048/// position from "recurse into the inner type" to "cast unchecked" the
1049/// moment Phase 2 routes the serialise path through this same resolver.
1050/// Reconciling (or deliberately keeping two resolvers) is a Phase 2 decision,
1051/// not implied by this function's existence.
1052pub fn wire_ref(t: &TypeRef, _types: &HashMap<String, Arc<TypeDecl>>) -> WireRef {
1053 match t {
1054 // v0.20a: function types are confined to non-boundary positions
1055 // (`bynk.types.function_at_boundary`), so the serialisation machinery
1056 // can never legally see one — mirrors `emit_field_deserialise`'s
1057 // `unreachable!` on this same arm.
1058 TypeRef::Fn(..)
1059 | TypeRef::Query(..)
1060 | TypeRef::Stream(..)
1061 | TypeRef::Connection(..)
1062 | TypeRef::History(..) => {
1063 unreachable!(
1064 "function/query/stream/connection/history types are rejected at boundaries"
1065 )
1066 }
1067 // v0.110 (ADR 0142 D5): a bare `Bytes` field is a base64 JSON string,
1068 // decoded (not cast) into a `Uint8Array`.
1069 TypeRef::Base(BaseType::Bytes, _) => WireRef::Bytes,
1070 TypeRef::Base(b, _) => {
1071 let json = json_kind_of(*b);
1072 WireRef::Base {
1073 base: *b,
1074 json,
1075 guards: field_base_guards(*b),
1076 expected: Expected::Json(json),
1077 }
1078 }
1079 TypeRef::Named(id) => WireRef::Named {
1080 name: id.name.clone(),
1081 },
1082 // Every generic instantiation — App/Result/Option/List/Map — resolves
1083 // to the same codec-suffix key; `codec_suffix` already builds exactly
1084 // that string for each of these shapes.
1085 TypeRef::App { .. }
1086 | TypeRef::Result(..)
1087 | TypeRef::Option(..)
1088 | TypeRef::List(..)
1089 | TypeRef::Map(..) => WireRef::Inst {
1090 key: codec_suffix(t),
1091 },
1092 TypeRef::Effect(..) => WireRef::Unchecked {
1093 reason: UncheckedReason::Effect,
1094 },
1095 TypeRef::HttpResult(..) => WireRef::Unchecked {
1096 reason: UncheckedReason::HttpResult,
1097 },
1098 TypeRef::ValidationError(_) => WireRef::Unchecked {
1099 reason: UncheckedReason::ValidationError,
1100 },
1101 TypeRef::JsonError(_) => WireRef::Unchecked {
1102 reason: UncheckedReason::JsonError,
1103 },
1104 TypeRef::QueueResult(_) => WireRef::Unchecked {
1105 reason: UncheckedReason::QueueResult,
1106 },
1107 TypeRef::Unit(_) => WireRef::Unit,
1108 }
1109}
1110
1111fn wire_fields(fields: &[RecordField], types: &HashMap<String, Arc<TypeDecl>>) -> Vec<WireField> {
1112 fields
1113 .iter()
1114 .map(|f| WireField {
1115 name: f.name.name.clone(),
1116 shape: wire_ref(&f.type_ref, types),
1117 path_segment: f.name.name.clone(),
1118 default: f.init.as_ref().map(|e| (e.clone(), f.type_ref.clone())),
1119 })
1120 .collect()
1121}
1122
1123fn wire_sum(body: &SumBody, types: &HashMap<String, Arc<TypeDecl>>) -> WireSum {
1124 WireSum {
1125 wire_discriminant: "kind",
1126 memory_discriminant: "tag",
1127 variants: body
1128 .variants
1129 .iter()
1130 .map(|v| WireVariant {
1131 name: v.name.name.clone(),
1132 payload: v
1133 .payload
1134 .iter()
1135 .map(|f| WireField {
1136 name: f.name.name.clone(),
1137 shape: wire_ref(&f.type_ref, types),
1138 path_segment: f.name.name.clone(),
1139 // Sum-variant payload fields carry no default in the
1140 // surface grammar (`VariantField` has no `init`).
1141 default: None,
1142 })
1143 .collect(),
1144 })
1145 .collect(),
1146 }
1147}
1148
1149/// Build one [`WireScalar`], choosing [`Revalidation`] per #661 Decisions
1150/// C/D plus the owner path: `Owned` → `ViaConstructor`; consumed + opaque →
1151/// `StructuralOnly`; consumed + transparent → `Inline`; a `Bytes` base →
1152/// `Base64Decode` regardless of provenance, mirroring `emit_refined`'s early
1153/// return to the dedicated `Bytes` codec before the owned/consumed split is
1154/// even considered.
1155fn wire_scalar(
1156 base: BaseType,
1157 opaque: bool,
1158 refinement: Option<&Refinement>,
1159 prov: &Provenance,
1160) -> WireScalar {
1161 let json = json_kind_of(base);
1162 let predicates: Vec<PredKind> = refinement
1163 .map(|r| r.predicates.iter().map(|p| p.kind.clone()).collect())
1164 .unwrap_or_default();
1165 let revalidation = if base == BaseType::Bytes {
1166 Revalidation::Base64Decode
1167 } else {
1168 match prov {
1169 Provenance::Owned => Revalidation::ViaConstructor,
1170 Provenance::Consumed { .. } if opaque => Revalidation::StructuralOnly,
1171 Provenance::Consumed { .. } => Revalidation::Inline,
1172 }
1173 };
1174 WireScalar {
1175 base,
1176 json,
1177 opaque,
1178 predicates,
1179 base_guards: scalar_base_guards(base),
1180 revalidation,
1181 }
1182}
1183
1184/// Build a [`WireType`] from a `TypeDecl`, using the same base/predicate/
1185/// opaque logic currently inline in `bynk-emit`'s `emit_refined` and
1186/// `emit_bytes_named_codec` — without any TS string emission. Returns `None`
1187/// for a **generic** declaration (`decl.type_params` non-empty): a generic
1188/// record/sum has no single bare `WireType` of its own, only per-instantiation
1189/// shapes (`WireInst`), mirroring the skip in `emit_helpers_for_owner_qualified`.
1190pub fn wire_type(
1191 name: &str,
1192 decl: &TypeDecl,
1193 types: &HashMap<String, Arc<TypeDecl>>,
1194 prov: Provenance,
1195) -> Option<WireType> {
1196 if !decl.type_params.is_empty() {
1197 return None;
1198 }
1199 let body = match &decl.body {
1200 TypeBody::Refined {
1201 base, refinement, ..
1202 } => WireBody::Scalar(wire_scalar(*base, false, refinement.as_ref(), &prov)),
1203 TypeBody::Opaque {
1204 base, refinement, ..
1205 } => WireBody::Scalar(wire_scalar(*base, true, refinement.as_ref(), &prov)),
1206 TypeBody::Record(r) => WireBody::Record {
1207 fields: wire_fields(&r.fields, types),
1208 },
1209 TypeBody::Sum(s) => WireBody::Sum(wire_sum(s, types)),
1210 };
1211 Some(WireType {
1212 name: name.to_string(),
1213 codec_suffix: name.to_string(),
1214 provenance: prov,
1215 body,
1216 })
1217}
1218
1219/// Build the full [`WireModel`] for a boundary: every non-generic named type
1220/// in `type_names` resolved through `types`, plus the generic instantiations
1221/// the caller already collected (`collect_generic_instantiations` /
1222/// `collect_codec_closure`). `provenance` is supplied by the caller —
1223/// `bynk-emit` knows the build target and the consumed set; this crate must
1224/// not (see the [`Provenance`] doc).
1225pub fn boundary_model(
1226 type_names: &[String],
1227 types: &HashMap<String, Arc<TypeDecl>>,
1228 instantiations: Vec<WireInst>,
1229 provenance: impl Fn(&str) -> Provenance,
1230) -> WireModel {
1231 let recursive: BTreeSet<String> = recursive_generic_names(types).into_iter().collect();
1232 let mut wtypes = Vec::with_capacity(type_names.len());
1233 for name in type_names {
1234 let Some(decl) = types.get(name) else {
1235 continue;
1236 };
1237 if let Some(wt) = wire_type(name, decl, types, provenance(name)) {
1238 wtypes.push(wt);
1239 }
1240 }
1241 WireModel {
1242 types: wtypes,
1243 instantiations,
1244 recursive,
1245 }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use super::*;
1251
1252 fn types_of(src: &str) -> HashMap<String, Arc<TypeDecl>> {
1253 let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
1254 let commons = bynk_syntax::parser::parse(&tokens, src).expect("parse");
1255 commons
1256 .items
1257 .iter()
1258 .filter_map(|i| match i {
1259 CommonsItem::Type(t) => Some((t.name.name.clone(), Arc::new(t.clone()))),
1260 _ => None,
1261 })
1262 .collect()
1263 }
1264
1265 /// Parse a `context` unit and split it into its `types` and `services`
1266 /// tables, mirroring `bynk-emit`'s own boundary-collection call sites.
1267 fn context_of(src: &str) -> (HashMap<String, Arc<TypeDecl>>, HashMap<String, ServiceDecl>) {
1268 let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
1269 let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
1270 let SourceUnit::Context(ctx) = unit else {
1271 panic!("expected a context unit");
1272 };
1273 let mut types = HashMap::new();
1274 let mut services = HashMap::new();
1275 for item in &ctx.items {
1276 match item {
1277 CommonsItem::Type(t) => {
1278 types.insert(t.name.name.clone(), Arc::new(t.clone()));
1279 }
1280 CommonsItem::Service(s) => {
1281 services.insert(s.name.name.clone(), s.clone());
1282 }
1283 _ => {}
1284 }
1285 }
1286 (types, services)
1287 }
1288
1289 #[test]
1290 fn json_kind_of_matches_the_ts_base_for_serialisation_mapping() {
1291 assert_eq!(json_kind_of(BaseType::Int), JsonKind::Number);
1292 assert_eq!(json_kind_of(BaseType::Float), JsonKind::Number);
1293 assert_eq!(json_kind_of(BaseType::Duration), JsonKind::Number);
1294 assert_eq!(json_kind_of(BaseType::Instant), JsonKind::Number);
1295 assert_eq!(json_kind_of(BaseType::String), JsonKind::String);
1296 // v0.110 (ADR 0142 D5): a `Bytes` wires as a base64 JSON string.
1297 assert_eq!(json_kind_of(BaseType::Bytes), JsonKind::String);
1298 assert_eq!(json_kind_of(BaseType::Bool), JsonKind::Boolean);
1299 }
1300
1301 #[test]
1302 fn collect_boundary_types_walks_params_return_and_record_fields() {
1303 let src = r#"
1304context test
1305
1306type ClientId = String where NonEmpty
1307type Rate = { count: Int, limit: Int }
1308
1309service Api {
1310 on call(client: ClientId) -> Effect[Rate] {
1311 client
1312 }
1313}
1314"#;
1315 let (types, services) = context_of(src);
1316 let agents: HashMap<String, AgentDecl> = HashMap::new();
1317 let names = collect_boundary_types(&types, &services, &agents, &[]);
1318 assert_eq!(names, vec!["ClientId".to_string(), "Rate".to_string()]);
1319 }
1320
1321 #[test]
1322 fn collect_boundary_types_ignores_types_unreachable_from_any_handler() {
1323 let src = r#"
1324context test
1325
1326type Used = String where NonEmpty
1327type Unused = { n: Int }
1328
1329service Api {
1330 on call(x: Used) -> Effect[Used] {
1331 x
1332 }
1333}
1334"#;
1335 let (types, services) = context_of(src);
1336 let agents: HashMap<String, AgentDecl> = HashMap::new();
1337 let names = collect_boundary_types(&types, &services, &agents, &[]);
1338 assert_eq!(names, vec!["Used".to_string()]);
1339 }
1340
1341 /// The highest-risk drift trap the plan flags: `contract.rs` sorts
1342 /// predicates (a precondition for hash correctness), but this IR must
1343 /// carry **declaration order** — `Inline` revalidation emits one `if`
1344 /// per predicate in source order. `NonEmpty` before `MaxLength` sorts
1345 /// (alphabetically, as `contract.rs`'s `canon_refinement` would — folding
1346 /// `NonEmpty` to `MinLength(1)` there does not change this: `MaxLength` <
1347 /// `MinLength` alphabetically, same relative order as `MaxLength` <
1348 /// `NonEmpty` before the fold) to `MaxLength` first, so this fixture's
1349 /// declaration order visibly differs from the sorted order — a positive
1350 /// assertion that the trap stays closed.
1351 #[test]
1352 fn wire_scalar_predicates_preserve_declaration_order_not_sorted() {
1353 let types = types_of("commons x\n\ntype Id = String where NonEmpty && MaxLength(20)\n");
1354 let decl = &types["Id"];
1355 let wt = wire_type("Id", decl, &types, Provenance::Owned).expect("scalar wire type");
1356 let WireBody::Scalar(scalar) = &wt.body else {
1357 panic!("expected a scalar body, got {:?}", wt.body);
1358 };
1359 assert_eq!(scalar.predicates.len(), 2);
1360 assert!(
1361 matches!(scalar.predicates[0], PredKind::NonEmpty),
1362 "predicates[0] must be the declared-first NonEmpty, got {:?}",
1363 scalar.predicates[0]
1364 );
1365 assert!(
1366 matches!(scalar.predicates[1], PredKind::MaxLength(20)),
1367 "predicates[1] must be the declared-second MaxLength(20), got {:?}",
1368 scalar.predicates[1]
1369 );
1370 // The sorted (contract.rs-style) order would put MaxLength first —
1371 // assert this IR does not match that order.
1372 assert!(
1373 !matches!(scalar.predicates[0], PredKind::MaxLength(_)),
1374 "predicates must be in declaration order, not sorted"
1375 );
1376 }
1377
1378 #[test]
1379 fn wire_type_selects_revalidation_by_provenance_and_opacity() {
1380 let types = types_of(
1381 "commons x\n\ntype Refined = String where NonEmpty\ntype Opaque = opaque String where NonEmpty\ntype Raw = Bytes\n",
1382 );
1383
1384 let refined = wire_type("Refined", &types["Refined"], &types, Provenance::Owned)
1385 .expect("owned scalar");
1386 let WireBody::Scalar(s) = &refined.body else {
1387 panic!("expected scalar")
1388 };
1389 assert_eq!(s.revalidation, Revalidation::ViaConstructor);
1390
1391 let consumed = Provenance::Consumed {
1392 owner_unit: "other".to_string(),
1393 };
1394 let refined_consumed =
1395 wire_type("Refined", &types["Refined"], &types, consumed.clone()).expect("consumed");
1396 let WireBody::Scalar(s) = &refined_consumed.body else {
1397 panic!("expected scalar")
1398 };
1399 assert_eq!(s.revalidation, Revalidation::Inline);
1400
1401 let opaque_consumed =
1402 wire_type("Opaque", &types["Opaque"], &types, consumed.clone()).expect("consumed");
1403 let WireBody::Scalar(s) = &opaque_consumed.body else {
1404 panic!("expected scalar")
1405 };
1406 assert_eq!(s.revalidation, Revalidation::StructuralOnly);
1407
1408 // Bytes: Base64Decode regardless of provenance.
1409 let raw_owned =
1410 wire_type("Raw", &types["Raw"], &types, Provenance::Owned).expect("owned bytes");
1411 let WireBody::Scalar(s) = &raw_owned.body else {
1412 panic!("expected scalar")
1413 };
1414 assert_eq!(s.revalidation, Revalidation::Base64Decode);
1415 let raw_consumed = wire_type("Raw", &types["Raw"], &types, consumed).expect("consumed");
1416 let WireBody::Scalar(s) = &raw_consumed.body else {
1417 panic!("expected scalar")
1418 };
1419 assert_eq!(s.revalidation, Revalidation::Base64Decode);
1420 }
1421
1422 #[test]
1423 fn wire_type_returns_none_for_a_generic_declaration() {
1424 let types = types_of("commons x\n\ntype Page[T] = { items: List[T] }\n");
1425 assert!(wire_type("Page", &types["Page"], &types, Provenance::Owned).is_none());
1426 }
1427
1428 /// Part 1.4's cross-check: for an `on call` handler, `wire.rs`'s codec
1429 /// walk (`collect_boundary_types`, restricted to that one handler) and
1430 /// `contract.rs`'s canonical hash form (`service_normal_form`, over the
1431 /// same handler projected as a `CrossContextService`) must reach the
1432 /// exact same *set* of named boundary types — even though the module
1433 /// doc's comparison table says the two disagree on purpose about
1434 /// *ordering* (declaration order vs sorted) and about whether an opaque
1435 /// predicate is shown. This test asserts REACHABILITY only: the set of
1436 /// type names each derivation's walk visits from the same root, not any
1437 /// string equality or order between the two renderings.
1438 ///
1439 /// The type names in the fixture are chosen so none is a substring of
1440 /// another (`ClientId`, `Address`, `Profile`, `Unrelated`) —
1441 /// `contract.rs`'s `canon_type`/`canon_named_in` are private to that
1442 /// module, so rather than duplicating their traversal here, this test
1443 /// recovers the set of types `service_normal_form`'s rendered string
1444 /// reaches by substring containment against every declared type name.
1445 /// With non-overlapping names that containment test is unambiguous.
1446 ///
1447 /// `Unrelated` is declared but reachable from no handler, so the
1448 /// equality assertion below is not vacuously true merely because this
1449 /// fixture happens to make every *other* declared type reachable — a
1450 /// regression that made `collect_boundary_types` return every declared
1451 /// name instead of the reachable closure would leak `Unrelated` into
1452 /// `boundary_names`, and this test would catch it.
1453 #[test]
1454 fn boundary_reachability_agrees_with_contract_normal_form() {
1455 let src = r#"
1456context test
1457
1458type ClientId = String where NonEmpty
1459type Address = { street: String, city: String }
1460type Profile = { id: ClientId, home: Address }
1461
1462-- Declared, but reachable from no handler below (see the doc comment on
1463-- this test for why).
1464type Unrelated = { n: Int }
1465
1466service Api {
1467 on call(client: ClientId) -> Effect[Profile] {
1468 Profile { id: client, home: Address { street: "x", city: "y" } }
1469 }
1470}
1471"#;
1472 let (types, services) = context_of(src);
1473 let service = &services["Api"];
1474 let handler = &service.handlers[0];
1475
1476 // Restrict `collect_boundary_types`'s walk to exactly this handler —
1477 // the same synthetic-single-handler-service narrowing
1478 // `bynk-ide`'s `wire_contract_for_service` performs, so a service
1479 // with more than one handler could not leak an unrelated handler's
1480 // types into either side of this comparison.
1481 let mut narrowed = service.clone();
1482 narrowed.handlers = vec![handler.clone()];
1483 let narrowed_services: HashMap<String, ServiceDecl> =
1484 HashMap::from([("Api".to_string(), narrowed)]);
1485 let agents: HashMap<String, AgentDecl> = HashMap::new();
1486 let boundary_names: std::collections::HashSet<String> =
1487 collect_boundary_types(&types, &narrowed_services, &agents, &[])
1488 .into_iter()
1489 .collect();
1490
1491 // The same handler, projected as a `CrossContextService` exactly as
1492 // `bynk-emit`'s `own_contract_hashes` / `bynk-ide`'s
1493 // `wire_contract::contract_form` project it, canonicalised through
1494 // the same type table.
1495 let svc = crate::resolver::CrossContextService {
1496 name: "Api".to_string(),
1497 params: handler
1498 .params
1499 .iter()
1500 .map(|p| (p.name.name.clone(), p.type_ref.clone()))
1501 .collect(),
1502 return_type: handler.return_type.clone(),
1503 span: handler.span,
1504 };
1505 let normal_form = crate::contract::service_normal_form(&svc, &types);
1506
1507 let contract_reachable: std::collections::HashSet<String> = types
1508 .keys()
1509 .filter(|name| normal_form.contains(name.as_str()))
1510 .cloned()
1511 .collect();
1512
1513 assert_eq!(
1514 boundary_names, contract_reachable,
1515 "wire::collect_boundary_types and contract::service_normal_form must \
1516 agree on *which* named types a handler's contract reaches, even though \
1517 they render/order that set completely differently (see this module's \
1518 doc comparison table):\n\
1519 wire.rs reached: {boundary_names:?}\n\
1520 contract.rs reached: {contract_reachable:?}\n\
1521 normal form: {normal_form:?}"
1522 );
1523 assert!(
1524 !boundary_names.contains("Unrelated") && !contract_reachable.contains("Unrelated"),
1525 "`Unrelated` is declared but reachable from no handler — both derivations \
1526 must exclude it, not merely agree on every declared type by coincidence:\n\
1527 wire.rs reached: {boundary_names:?}\n\
1528 contract.rs reached: {contract_reachable:?}"
1529 );
1530 }
1531
1532 /// `wire_ref` is the plan's flagged highest-risk function ("its two
1533 /// functions must stay in exact agreement about which `WireRef` arm a
1534 /// `TypeRef` resolves to"). Pin the arm each shape lands on before Phase 2
1535 /// starts consuming it.
1536 #[test]
1537 fn wire_ref_dispatches_every_type_ref_shape_to_the_expected_arm() {
1538 let types: HashMap<String, Arc<TypeDecl>> = HashMap::new();
1539 let sp = || bynk_syntax::span::Span::new(0, 0);
1540 let named = |n: &str| {
1541 TypeRef::Named(Ident {
1542 name: n.to_string(),
1543 span: sp(),
1544 })
1545 };
1546 let base = |b: BaseType| TypeRef::Base(b, sp());
1547
1548 assert!(matches!(
1549 wire_ref(&base(BaseType::Int), &types),
1550 WireRef::Base {
1551 base: BaseType::Int,
1552 json: JsonKind::Number,
1553 ..
1554 }
1555 ));
1556 assert!(matches!(
1557 wire_ref(&base(BaseType::Bytes), &types),
1558 WireRef::Bytes
1559 ));
1560 assert!(
1561 matches!(wire_ref(&named("ClientId"), &types), WireRef::Named { name } if name == "ClientId")
1562 );
1563 assert!(matches!(
1564 wire_ref(
1565 &TypeRef::Result(Box::new(base(BaseType::Int)), Box::new(base(BaseType::String)), sp()),
1566 &types
1567 ),
1568 WireRef::Inst { key } if key == "Result_Int_String"
1569 ));
1570 assert!(matches!(
1571 wire_ref(&TypeRef::Option(Box::new(base(BaseType::Int)), sp()), &types),
1572 WireRef::Inst { key } if key == "Option_Int"
1573 ));
1574 assert!(matches!(
1575 wire_ref(&TypeRef::List(Box::new(base(BaseType::Int)), sp()), &types),
1576 WireRef::Inst { key } if key == "List_Int"
1577 ));
1578 assert!(matches!(
1579 wire_ref(
1580 &TypeRef::Map(Box::new(base(BaseType::String)), Box::new(base(BaseType::Int)), sp()),
1581 &types
1582 ),
1583 WireRef::Inst { key } if key == "Map_String_Int"
1584 ));
1585 assert!(matches!(
1586 wire_ref(
1587 &TypeRef::App {
1588 name: Ident { name: "Paginated".to_string(), span: sp() },
1589 args: vec![named("User")],
1590 span: sp(),
1591 },
1592 &types
1593 ),
1594 WireRef::Inst { key } if key == "Paginated_User"
1595 ));
1596 assert!(matches!(
1597 wire_ref(&TypeRef::Unit(sp()), &types),
1598 WireRef::Unit
1599 ));
1600 assert!(matches!(
1601 wire_ref(
1602 &TypeRef::Effect(Box::new(base(BaseType::Int)), sp()),
1603 &types
1604 ),
1605 WireRef::Unchecked {
1606 reason: UncheckedReason::Effect
1607 }
1608 ));
1609 assert!(matches!(
1610 wire_ref(
1611 &TypeRef::HttpResult(Box::new(base(BaseType::Int)), sp()),
1612 &types
1613 ),
1614 WireRef::Unchecked {
1615 reason: UncheckedReason::HttpResult
1616 }
1617 ));
1618 assert!(matches!(
1619 wire_ref(&TypeRef::ValidationError(sp()), &types),
1620 WireRef::Unchecked {
1621 reason: UncheckedReason::ValidationError
1622 }
1623 ));
1624 assert!(matches!(
1625 wire_ref(&TypeRef::JsonError(sp()), &types),
1626 WireRef::Unchecked {
1627 reason: UncheckedReason::JsonError
1628 }
1629 ));
1630 assert!(matches!(
1631 wire_ref(&TypeRef::QueueResult(sp()), &types),
1632 WireRef::Unchecked {
1633 reason: UncheckedReason::QueueResult
1634 }
1635 ));
1636 }
1637}