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) -> Vec<String> {
338 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
339 let mut out: Vec<String> = Vec::new();
340 let mut stack: Vec<String> = Vec::new();
341 let recursive_set = recursive_generic_names(types);
342 let recursive = &recursive_set;
343
344 let mut svc_names: Vec<&String> = services.keys().collect();
345 svc_names.sort();
346 for name in svc_names {
347 let service = &services[name];
348 for h in &service.handlers {
349 for p in &h.params {
350 collect_type_names(&p.type_ref, &mut stack, types, recursive);
351 }
352 collect_type_names(&h.return_type, &mut stack, types, recursive);
353 }
354 }
355
356 let mut agent_names: Vec<&String> = agents.keys().collect();
357 agent_names.sort();
358 for name in agent_names {
359 for f in &agents[name].store_fields {
360 for arg in &f.kind.args {
361 collect_type_names(arg, &mut stack, types, recursive);
362 }
363 }
364 }
365
366 while let Some(name) = stack.pop() {
367 if !seen.insert(name.clone()) {
368 continue;
369 }
370 out.push(name.clone());
371 let Some(decl) = types.get(&name) else {
372 continue;
373 };
374 match &decl.body {
375 TypeBody::Record(r) => {
376 for f in &r.fields {
377 collect_type_names(&f.type_ref, &mut stack, types, recursive);
378 }
379 }
380 TypeBody::Sum(s) => {
381 for v in &s.variants {
382 for p in &v.payload {
383 collect_type_names(&p.type_ref, &mut stack, types, recursive);
384 }
385 }
386 }
387 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
388 }
389 }
390
391 out.sort();
392 out
393}
394
395/// v0.174 (#592): the set of generic-record names that are *recursive* — they
396/// transitively contain themselves, so they have no finite monomorphised codec
397/// (rejected at the boundary by the checker before emit). Precomputed once per
398/// collector so the per-`App` guard in the codec walks is an O(1) membership test
399/// rather than a fresh graph reachability walk at every occurrence.
400fn recursive_generic_names(
401 types: &HashMap<String, Arc<TypeDecl>>,
402) -> std::collections::HashSet<String> {
403 types
404 .iter()
405 .filter(|(_, d)| !d.type_params.is_empty())
406 .map(|(n, _)| n.clone())
407 .filter(|n| generic_record_is_recursive(n, types))
408 .collect()
409}
410
411fn collect_type_names(
412 t: &TypeRef,
413 stack: &mut Vec<String>,
414 types: &HashMap<String, Arc<TypeDecl>>,
415 recursive: &std::collections::HashSet<String>,
416) {
417 match t {
418 TypeRef::Named(id) => stack.push(id.name.clone()),
419 // Query/Stream/Connection types carry no boundary-collectable user
420 // types (non-boundary).
421 TypeRef::Query(..)
422 | TypeRef::Stream(..)
423 | TypeRef::Connection(..)
424 | TypeRef::History(..) => {}
425 // v0.174 (#592): a generic-record instantiation is boundary-serialisable
426 // through its monomorphised codec (`serialise_Paginated_User`). The
427 // *named* helpers that codec calls come from its concrete field types —
428 // the type arguments (`User`) and any non-parameter named field types
429 // (`Envelope[T] = { meta: Metadata, … }`) — so walk the substituted
430 // fields. A *recursive* generic record has no finite codec set and is
431 // rejected at the boundary before emit; the guard here is defence in
432 // depth so this walk can never fail to terminate.
433 TypeRef::App { name, args, .. } => {
434 if recursive.contains(&name.name) {
435 return;
436 }
437 // #593: a generic-sum instantiation's codec (`serialise_ApiResult_User`)
438 // likewise calls the named helpers of its *substituted variant
439 // payloads* (`serialise_User` for `Loaded(value: T)` at `T = User`),
440 // so walk those the same way records walk their fields.
441 if let Some(fields) = record_inst_fields(&name.name, args, types) {
442 for (_, ft) in &fields {
443 collect_type_names(ft, stack, types, recursive);
444 }
445 } else if let Some(variants) = sum_inst_variants(&name.name, args, types) {
446 for (_, payload) in &variants {
447 for (_, ft) in payload {
448 collect_type_names(ft, stack, types, recursive);
449 }
450 }
451 }
452 }
453 // v0.20a: function types carry no user-named types to collect and are
454 // rejected at boundaries anyway.
455 TypeRef::Fn(..) => {}
456 TypeRef::Result(a, b, _) => {
457 collect_type_names(a, stack, types, recursive);
458 collect_type_names(b, stack, types, recursive);
459 }
460 TypeRef::Option(a, _) => collect_type_names(a, stack, types, recursive),
461 TypeRef::Effect(a, _) => collect_type_names(a, stack, types, recursive),
462 TypeRef::HttpResult(a, _) => collect_type_names(a, stack, types, recursive),
463 // v0.20b: collections serialise element-/entry-wise; their inner
464 // named types need helpers.
465 TypeRef::List(a, _) => collect_type_names(a, stack, types, recursive),
466 TypeRef::Map(k, v, _) => {
467 collect_type_names(k, stack, types, recursive);
468 collect_type_names(v, stack, types, recursive);
469 }
470 TypeRef::Base(_, _)
471 | TypeRef::QueueResult(_)
472 | TypeRef::ValidationError(_)
473 | TypeRef::JsonError(_)
474 | TypeRef::Unit(_) => {}
475 }
476}
477
478/// v0.174 (#592): substitute a generic record's declared field type — replacing
479/// each type-parameter name with the concrete argument type-ref — so a
480/// per-instantiation codec sees fully concrete field types.
481/// `Paginated[User]`'s `items: List[T]` becomes `items: List[User]`.
482fn subst_type_ref(t: &TypeRef, subst: &HashMap<String, TypeRef>) -> TypeRef {
483 match t {
484 TypeRef::Named(id) => match subst.get(&id.name) {
485 Some(replacement) => replacement.clone(),
486 None => t.clone(),
487 },
488 TypeRef::App { name, args, span } => TypeRef::App {
489 name: name.clone(),
490 args: args.iter().map(|a| subst_type_ref(a, subst)).collect(),
491 span: *span,
492 },
493 TypeRef::Result(a, b, s) => TypeRef::Result(
494 Box::new(subst_type_ref(a, subst)),
495 Box::new(subst_type_ref(b, subst)),
496 *s,
497 ),
498 TypeRef::Option(a, s) => TypeRef::Option(Box::new(subst_type_ref(a, subst)), *s),
499 TypeRef::Effect(a, s) => TypeRef::Effect(Box::new(subst_type_ref(a, subst)), *s),
500 TypeRef::HttpResult(a, s) => TypeRef::HttpResult(Box::new(subst_type_ref(a, subst)), *s),
501 TypeRef::List(a, s) => TypeRef::List(Box::new(subst_type_ref(a, subst)), *s),
502 TypeRef::Map(k, v, s) => TypeRef::Map(
503 Box::new(subst_type_ref(k, subst)),
504 Box::new(subst_type_ref(v, subst)),
505 *s,
506 ),
507 TypeRef::Query(a, s) => TypeRef::Query(Box::new(subst_type_ref(a, subst)), *s),
508 TypeRef::Stream(a, s) => TypeRef::Stream(Box::new(subst_type_ref(a, subst)), *s),
509 TypeRef::Connection(a, s) => TypeRef::Connection(Box::new(subst_type_ref(a, subst)), *s),
510 TypeRef::History(a, s) => TypeRef::History(Box::new(subst_type_ref(a, subst)), *s),
511 TypeRef::Fn(ps, r, s) => TypeRef::Fn(
512 ps.iter().map(|p| subst_type_ref(p, subst)).collect(),
513 Box::new(subst_type_ref(r, subst)),
514 *s,
515 ),
516 TypeRef::Base(..)
517 | TypeRef::QueueResult(_)
518 | TypeRef::ValidationError(_)
519 | TypeRef::JsonError(_)
520 | TypeRef::Unit(_) => t.clone(),
521 }
522}
523
524/// v0.174 (#592): the concrete `(field-name, field-type)` list for a generic
525/// record instantiation `Name[args…]` — the declared fields with every type
526/// parameter substituted by the matching argument. Returns `None` if `name` is
527/// not a declared generic record or the arity does not match (both guaranteed
528/// impossible by the checker, so this is purely defensive).
529pub fn record_inst_fields(
530 name: &str,
531 args: &[TypeRef],
532 types: &HashMap<String, Arc<TypeDecl>>,
533) -> Option<Vec<(String, TypeRef)>> {
534 let decl = types.get(name)?;
535 let TypeBody::Record(r) = &decl.body else {
536 return None;
537 };
538 if decl.type_params.len() != args.len() {
539 return None;
540 }
541 let subst: HashMap<String, TypeRef> = decl
542 .type_params
543 .iter()
544 .map(|p| p.name.name.clone())
545 .zip(args.iter().cloned())
546 .collect();
547 Some(
548 r.fields
549 .iter()
550 .map(|f| (f.name.name.clone(), subst_type_ref(&f.type_ref, &subst)))
551 .collect(),
552 )
553}
554
555/// #593: the concrete `(variant-name, [(field-name, field-type)])` list for a
556/// generic sum instantiation `Name[args…]` — the declared variants with every
557/// type parameter substituted by the matching argument. The sum analogue of
558/// [`record_inst_fields`]; `None` (defensively) if `name` is not a declared
559/// generic sum or the arity does not match.
560#[allow(clippy::type_complexity)]
561pub fn sum_inst_variants(
562 name: &str,
563 args: &[TypeRef],
564 types: &HashMap<String, Arc<TypeDecl>>,
565) -> Option<Vec<(String, Vec<(String, TypeRef)>)>> {
566 let decl = types.get(name)?;
567 let TypeBody::Sum(s) = &decl.body else {
568 return None;
569 };
570 if decl.type_params.len() != args.len() {
571 return None;
572 }
573 let subst: HashMap<String, TypeRef> = decl
574 .type_params
575 .iter()
576 .map(|p| p.name.name.clone())
577 .zip(args.iter().cloned())
578 .collect();
579 Some(
580 s.variants
581 .iter()
582 .map(|v| {
583 (
584 v.name.name.clone(),
585 v.payload
586 .iter()
587 .map(|f| (f.name.name.clone(), subst_type_ref(&f.type_ref, &subst)))
588 .collect(),
589 )
590 })
591 .collect(),
592 )
593}
594
595/// v0.174 (#592): the monomorphised codec suffix for a generic-record
596/// instantiation — `Paginated[User]` → `Paginated_User`,
597/// `Pair[User, String]` → `Pair_User_String`. #593: shared with generic sums.
598/// Renamed from `app_ts_name` in the move from `bynk-emit`.
599pub fn inst_codec_suffix(name: &str, args: &[TypeRef]) -> String {
600 let mut s = name.to_string();
601 for a in args {
602 s.push('_');
603 s.push_str(&codec_suffix(a));
604 }
605 s
606}
607
608/// The codec-name suffix a `TypeRef` resolves to — `Int`, `Order`,
609/// `Result_Int_String`, `Paginated_User`. Renamed from `inner_ts_name` in the
610/// move from `bynk-emit`; used both to key a [`WireInst`] and, unqualified,
611/// as the bare codec function suffix a same-module call reaches.
612pub fn codec_suffix(t: &TypeRef) -> String {
613 match t {
614 TypeRef::Base(b, _) => b.name().to_string(),
615 // v0.20a: function types are confined to non-boundary positions
616 // (`bynk.types.function_at_boundary`), so the serialisation machinery
617 // can never legally see one.
618 TypeRef::Fn(..)
619 | TypeRef::Query(..)
620 | TypeRef::Stream(..)
621 | TypeRef::Connection(..)
622 | TypeRef::History(..) => {
623 unreachable!("function/query/stream types are rejected at boundaries")
624 }
625 // v0.174 (#592): the codec suffix for a generic-record instantiation —
626 // `Paginated[User]` → `Paginated_User`.
627 TypeRef::App { name, args, .. } => inst_codec_suffix(&name.name, args),
628 TypeRef::Named(id) => id.name.clone(),
629 TypeRef::Result(a, b, _) => format!("Result_{}_{}", codec_suffix(a), codec_suffix(b)),
630 TypeRef::Option(a, _) => format!("Option_{}", codec_suffix(a)),
631 TypeRef::Effect(a, _) => format!("Effect_{}", codec_suffix(a)),
632 TypeRef::HttpResult(a, _) => format!("HttpResult_{}", codec_suffix(a)),
633 TypeRef::List(a, _) => format!("List_{}", codec_suffix(a)),
634 TypeRef::Map(k, v, _) => format!("Map_{}_{}", codec_suffix(k), codec_suffix(v)),
635 TypeRef::QueueResult(_) => "QueueResult".to_string(),
636 TypeRef::ValidationError(_) => "ValidationError".to_string(),
637 TypeRef::JsonError(_) => "JsonError".to_string(),
638 TypeRef::Unit(_) => "Unit".to_string(),
639 }
640}
641
642/// v0.22b: the codec closure for a set of `Json.encode`/`Json.decode[T]`
643/// target type-refs — the named types needing per-type helpers (transitively
644/// through record fields and sum payloads) plus the generic instantiations
645/// needing specialised helpers. The same closure logic as the boundary
646/// collectors, rooted at expressions instead of service signatures.
647pub fn collect_codec_closure(
648 roots: &[TypeRef],
649 types: &HashMap<String, Arc<TypeDecl>>,
650) -> (Vec<String>, Vec<WireInst>) {
651 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
652 let mut names: Vec<String> = Vec::new();
653 let mut stack: Vec<String> = Vec::new();
654 let recursive_set = recursive_generic_names(types);
655 let recursive = &recursive_set;
656 for r in roots {
657 collect_type_names(r, &mut stack, types, recursive);
658 }
659 while let Some(name) = stack.pop() {
660 if !seen.insert(name.clone()) {
661 continue;
662 }
663 names.push(name.clone());
664 let Some(decl) = types.get(&name) else {
665 continue;
666 };
667 match &decl.body {
668 TypeBody::Record(r) => {
669 for f in &r.fields {
670 collect_type_names(&f.type_ref, &mut stack, types, recursive);
671 }
672 }
673 TypeBody::Sum(s) => {
674 for v in &s.variants {
675 for p in &v.payload {
676 collect_type_names(&p.type_ref, &mut stack, types, recursive);
677 }
678 }
679 }
680 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
681 }
682 }
683 names.sort();
684
685 let mut insts: Vec<WireInst> = Vec::new();
686 let mut inst_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
687 for r in roots {
688 walk_generic_inst(r, &mut insts, &mut inst_seen, types, recursive);
689 }
690 for name in &names {
691 let Some(decl) = types.get(name) else {
692 continue;
693 };
694 match &decl.body {
695 TypeBody::Record(r) => {
696 for f in &r.fields {
697 walk_generic_inst(&f.type_ref, &mut insts, &mut inst_seen, types, recursive);
698 }
699 }
700 TypeBody::Sum(s) => {
701 for v in &s.variants {
702 for p in &v.payload {
703 walk_generic_inst(
704 &p.type_ref,
705 &mut insts,
706 &mut inst_seen,
707 types,
708 recursive,
709 );
710 }
711 }
712 }
713 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
714 }
715 }
716 (names, insts)
717}
718
719/// Collect the set of `Result<A, B>` / `Option<A>` instantiations used in
720/// boundary positions so the emitter can synthesise the specialised
721/// helpers. v0.18: an instantiation may also appear in the *fields* of a
722/// boundary record or sum payload (e.g. the bynk surface's
723/// `Request.contentType: Option[String]`) — the per-type serialisers
724/// delegate to the specialised generic helpers, so walk those too.
725pub fn collect_generic_instantiations(
726 services: &HashMap<String, ServiceDecl>,
727 // v0.96 (ADR 0124): an agent's `store`-field element types are validated on
728 // rehydration, so a `Cell[Option[Int]]` / `Log[List[T]]` needs its specialised
729 // generic helper emitted just like a boundary signature does.
730 agents: &HashMap<String, AgentDecl>,
731 boundary_type_names: &[String],
732 types: &HashMap<String, Arc<TypeDecl>>,
733) -> Vec<WireInst> {
734 let mut out: Vec<WireInst> = Vec::new();
735 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
736 let recursive_set = recursive_generic_names(types);
737 let recursive = &recursive_set;
738 // Iterate services in name order: `HashMap::values()` order varies per
739 // process, and the *emission order* of the specialised helpers follows
740 // first-encounter order here. Surfaced by the first fixture with
741 // multiple same-file services carrying different instantiations (v0.23
742 // #35 CI); latent since v0.8.
743 let mut svc_names: Vec<&String> = services.keys().collect();
744 svc_names.sort();
745 for name in svc_names {
746 let s = &services[name];
747 for h in &s.handlers {
748 for p in &h.params {
749 walk_generic_inst(&p.type_ref, &mut out, &mut seen, types, recursive);
750 }
751 walk_generic_inst(&h.return_type, &mut out, &mut seen, types, recursive);
752 }
753 }
754 let mut agent_names: Vec<&String> = agents.keys().collect();
755 agent_names.sort();
756 for name in agent_names {
757 for f in &agents[name].store_fields {
758 for arg in &f.kind.args {
759 walk_generic_inst(arg, &mut out, &mut seen, types, recursive);
760 }
761 }
762 }
763 for name in boundary_type_names {
764 let Some(decl) = types.get(name) else {
765 continue;
766 };
767 // v0.174 (#592): never walk a *generic* declaration's own fields — they
768 // are the declared, unsubstituted body (`Paginated[T] = { items: List[T]
769 // }`), so walking `List[T]` would emit a bogus `serialise_List_T` over the
770 // unbound type variable `T`. The instantiations a generic record needs
771 // come from its *use* sites (`Paginated[User]`, a `TypeRef::App`), which
772 // `walk_generic_inst` expands with concrete arguments. This mirrors the
773 // `emit_helpers_for_owner` skip that keeps a bare `serialise_Paginated`
774 // from being emitted.
775 if !decl.type_params.is_empty() {
776 continue;
777 }
778 match &decl.body {
779 TypeBody::Record(r) => {
780 for f in &r.fields {
781 walk_generic_inst(&f.type_ref, &mut out, &mut seen, types, recursive);
782 }
783 }
784 TypeBody::Sum(s) => {
785 for v in &s.variants {
786 for p in &v.payload {
787 walk_generic_inst(&p.type_ref, &mut out, &mut seen, types, recursive);
788 }
789 }
790 }
791 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
792 }
793 }
794 out
795}
796
797/// A boundary occurrence of a generic instantiation needing its own
798/// specialised codec — `Result<A, B>`, `Option<A>`, `List<A>`, `Map<K, V>`,
799/// or a generic user record/sum applied to concrete arguments
800/// (`Paginated[User]`, `ApiResult[User]`).
801///
802/// Moved from `bynk-emit/src/emitter/serialisation.rs`'s `GenericInst` as
803/// part of the #855 IR extraction. Variant names are kept exactly as they
804/// were (`ResultInst`, not the plan's bare `Result`, …): `bynk-emit`'s
805/// `emit_generic_helpers_qualified` pattern-matches these variants directly.
806/// Phase 2 step 9 ("Generic helpers") revisited the plan's bare
807/// `Result`/`Option`/`List`/`Map`/`Record`/`Sum` spelling and **kept** the
808/// `*Inst` suffix — see the "Moved verbatim" comment block above this
809/// module's walk functions for the reasoning (a pure rename of six match
810/// arms with no behavioural or architectural payoff).
811#[derive(Debug, Clone)]
812#[allow(clippy::enum_variant_names)]
813pub enum WireInst {
814 ResultInst {
815 ok: TypeRef,
816 err: TypeRef,
817 },
818 OptionInst {
819 inner: TypeRef,
820 },
821 /// v0.20b: a `List[T]` boundary instantiation — element-wise wire format.
822 ListInst {
823 elem: TypeRef,
824 },
825 /// v0.20b: a `Map[K, V]` boundary instantiation — entries-array wire
826 /// format (`[[k, v], …]`), insertion-ordered.
827 MapInst {
828 key: TypeRef,
829 val: TypeRef,
830 },
831 /// v0.174 (#592): a generic user-record instantiation `Name[args…]` — a
832 /// monomorphised per-instantiation record codec (`serialise_Paginated_User`)
833 /// specialised to the concrete arguments (ADR 0183 Decision C's follow-on).
834 RecordInst {
835 name: String,
836 args: Vec<TypeRef>,
837 },
838 /// #593: a generic user-sum instantiation `Name[args…]` — a monomorphised
839 /// per-instantiation discriminated-union codec (`serialise_ApiResult_User`),
840 /// the sum analogue of [`WireInst::RecordInst`].
841 SumInst {
842 name: String,
843 args: Vec<TypeRef>,
844 },
845}
846
847impl WireInst {
848 pub fn ts_name(&self) -> String {
849 match self {
850 WireInst::ResultInst { ok, err } => {
851 format!("Result_{}_{}", codec_suffix(ok), codec_suffix(err))
852 }
853 WireInst::OptionInst { inner } => {
854 format!("Option_{}", codec_suffix(inner))
855 }
856 WireInst::ListInst { elem } => format!("List_{}", codec_suffix(elem)),
857 WireInst::MapInst { key, val } => {
858 format!("Map_{}_{}", codec_suffix(key), codec_suffix(val))
859 }
860 WireInst::RecordInst { name, args } => inst_codec_suffix(name, args),
861 WireInst::SumInst { name, args } => inst_codec_suffix(name, args),
862 }
863 }
864}
865
866fn walk_generic_inst(
867 t: &TypeRef,
868 out: &mut Vec<WireInst>,
869 seen: &mut std::collections::HashSet<String>,
870 types: &HashMap<String, Arc<TypeDecl>>,
871 recursive: &std::collections::HashSet<String>,
872) {
873 match t {
874 // v0.174 (#592): a generic-record instantiation needs a monomorphised
875 // codec, and so do the generic instantiations reachable through its
876 // concrete field types (`Paginated[User]` → `List[User]` →
877 // `serialise_List_User`, `Envelope[Box[User]]` → `Box[User]` →
878 // `serialise_Box_User`). Substitute the fields and walk them. A recursive
879 // generic record (no finite codec set) is rejected at the boundary before
880 // emit; the guard here is defence in depth so this walk always terminates
881 // (the `seen` dedup alone cannot bound *polymorphic* recursion, whose
882 // instantiations each carry a distinct name).
883 TypeRef::App { name, args, .. } => {
884 if recursive.contains(&name.name) {
885 return;
886 }
887 // #593: an `App` names a generic record OR a generic sum; dispatch on
888 // the declaration's body so the right monomorphised codec is emitted,
889 // and walk the reachable instantiations through its concrete member
890 // types (a sum's variant payloads, a record's fields).
891 let is_sum = matches!(
892 types.get(&name.name).map(|d| &d.body),
893 Some(TypeBody::Sum(_))
894 );
895 let inst = if is_sum {
896 WireInst::SumInst {
897 name: name.name.clone(),
898 args: args.clone(),
899 }
900 } else {
901 WireInst::RecordInst {
902 name: name.name.clone(),
903 args: args.clone(),
904 }
905 };
906 let key = inst.ts_name();
907 if !seen.insert(key) {
908 return;
909 }
910 out.push(inst);
911 for a in args {
912 walk_generic_inst(a, out, seen, types, recursive);
913 }
914 if is_sum {
915 if let Some(variants) = sum_inst_variants(&name.name, args, types) {
916 for (_, payload) in &variants {
917 for (_, ft) in payload {
918 walk_generic_inst(ft, out, seen, types, recursive);
919 }
920 }
921 }
922 } else if let Some(fields) = record_inst_fields(&name.name, args, types) {
923 for (_, ft) in &fields {
924 walk_generic_inst(ft, out, seen, types, recursive);
925 }
926 }
927 }
928 TypeRef::Result(a, b, _) => {
929 let inst = WireInst::ResultInst {
930 ok: (**a).clone(),
931 err: (**b).clone(),
932 };
933 let key = inst.ts_name();
934 if seen.insert(key) {
935 out.push(inst);
936 }
937 walk_generic_inst(a, out, seen, types, recursive);
938 walk_generic_inst(b, out, seen, types, recursive);
939 }
940 TypeRef::Option(a, _) => {
941 let inst = WireInst::OptionInst {
942 inner: (**a).clone(),
943 };
944 let key = inst.ts_name();
945 if seen.insert(key) {
946 out.push(inst);
947 }
948 walk_generic_inst(a, out, seen, types, recursive);
949 }
950 TypeRef::Effect(a, _) => walk_generic_inst(a, out, seen, types, recursive),
951 TypeRef::HttpResult(a, _) => walk_generic_inst(a, out, seen, types, recursive),
952 TypeRef::List(a, _) => {
953 let inst = WireInst::ListInst {
954 elem: (**a).clone(),
955 };
956 let key = inst.ts_name();
957 if seen.insert(key) {
958 out.push(inst);
959 }
960 walk_generic_inst(a, out, seen, types, recursive);
961 }
962 TypeRef::Map(k, v, _) => {
963 let inst = WireInst::MapInst {
964 key: (**k).clone(),
965 val: (**v).clone(),
966 };
967 let key = inst.ts_name();
968 if seen.insert(key) {
969 out.push(inst);
970 }
971 walk_generic_inst(k, out, seen, types, recursive);
972 walk_generic_inst(v, out, seen, types, recursive);
973 }
974 _ => {}
975 }
976}
977
978// ---------------------------------------------------------------------
979// New: resolving a `TypeRef` / `TypeDecl` into the IR above. Not yet called
980// by any codec path — `bynk-emit` still derives its own shape inline
981// (Phase 2 switches it over). Exercised only by this module's own tests
982// until then.
983// ---------------------------------------------------------------------
984
985/// The base-type guards a bare **field-position** value (a record field, a
986/// sum-variant payload field, a collection element) is checked against,
987/// mirroring `emit_field_deserialise`'s `TypeRef::Base` arm: `Int`/`Instant`
988/// must be whole, `Float` must be finite. Contrast [`WireScalar::base_guards`]
989/// (a **named-type** consumed-inline revalidation), which guards `Int`/`Float`
990/// only — the two positions are not the same check in the code this was
991/// extracted from, and that asymmetry is preserved rather than merged.
992fn field_base_guards(b: BaseType) -> Vec<BaseGuard> {
993 match b {
994 BaseType::Int | BaseType::Instant => vec![BaseGuard::Integral],
995 BaseType::Float => vec![BaseGuard::Finite],
996 _ => Vec::new(),
997 }
998}
999
1000/// The base-type guards a named type's `Inline` (consumed, transparent)
1001/// revalidation applies, mirroring `emit_inline_refinement_checks` exactly:
1002/// `Int` → `Integral`, `Float` → `Finite`. See [`WireScalar::base_guards`]'s
1003/// doc for why `Instant` is deliberately absent here despite being guarded
1004/// at field position.
1005fn scalar_base_guards(b: BaseType) -> Vec<BaseGuard> {
1006 match b {
1007 BaseType::Int => vec![BaseGuard::Integral],
1008 BaseType::Float => vec![BaseGuard::Finite],
1009 _ => Vec::new(),
1010 }
1011}
1012
1013/// Resolve one `TypeRef` occurrence (a field's type, a variant payload
1014/// field's type, a generic argument) into its [`WireRef`] shape — the same
1015/// dispatch `emit_field_deserialise` performs today, one level removed from
1016/// any TS string.
1017///
1018/// `types` is threaded through for signature symmetry with the walks above
1019/// and forward compatibility with a future disambiguation need; this
1020/// resolution is single-level (unlike `collect_type_names`'s transitive
1021/// walk) and does not currently consult it.
1022///
1023/// **This encodes the *deserialise*-side dispatch only** (mirroring
1024/// `emit_field_deserialise`'s arms one-for-one, including its `Effect` /
1025/// `HttpResult` arm, which it folds into the same unchecked cast as
1026/// `ValidationError`/`JsonError`/`QueueResult`). The emitter does not
1027/// actually agree with itself on those two shapes across its other three
1028/// dispatches: `serialise_field_expr_via` *recurses* through `Effect` rather
1029/// than casting it unchecked, `deserialise_expr_via` also recurses through
1030/// `Effect`, and `codec_suffix` gives both `Effect` and `HttpResult` their
1031/// own composed names (`Effect_<inner>`, `HttpResult_<inner>`) rather than
1032/// treating them as opaque. A future serialise-side or codec-suffix-side
1033/// `WireRef` derivation must **not** assume this function's `Unchecked`
1034/// answer already covers it — that would silently change `Effect`-at-field-
1035/// position from "recurse into the inner type" to "cast unchecked" the
1036/// moment Phase 2 routes the serialise path through this same resolver.
1037/// Reconciling (or deliberately keeping two resolvers) is a Phase 2 decision,
1038/// not implied by this function's existence.
1039pub fn wire_ref(t: &TypeRef, _types: &HashMap<String, Arc<TypeDecl>>) -> WireRef {
1040 match t {
1041 // v0.20a: function types are confined to non-boundary positions
1042 // (`bynk.types.function_at_boundary`), so the serialisation machinery
1043 // can never legally see one — mirrors `emit_field_deserialise`'s
1044 // `unreachable!` on this same arm.
1045 TypeRef::Fn(..)
1046 | TypeRef::Query(..)
1047 | TypeRef::Stream(..)
1048 | TypeRef::Connection(..)
1049 | TypeRef::History(..) => {
1050 unreachable!(
1051 "function/query/stream/connection/history types are rejected at boundaries"
1052 )
1053 }
1054 // v0.110 (ADR 0142 D5): a bare `Bytes` field is a base64 JSON string,
1055 // decoded (not cast) into a `Uint8Array`.
1056 TypeRef::Base(BaseType::Bytes, _) => WireRef::Bytes,
1057 TypeRef::Base(b, _) => {
1058 let json = json_kind_of(*b);
1059 WireRef::Base {
1060 base: *b,
1061 json,
1062 guards: field_base_guards(*b),
1063 expected: Expected::Json(json),
1064 }
1065 }
1066 TypeRef::Named(id) => WireRef::Named {
1067 name: id.name.clone(),
1068 },
1069 // Every generic instantiation — App/Result/Option/List/Map — resolves
1070 // to the same codec-suffix key; `codec_suffix` already builds exactly
1071 // that string for each of these shapes.
1072 TypeRef::App { .. }
1073 | TypeRef::Result(..)
1074 | TypeRef::Option(..)
1075 | TypeRef::List(..)
1076 | TypeRef::Map(..) => WireRef::Inst {
1077 key: codec_suffix(t),
1078 },
1079 TypeRef::Effect(..) => WireRef::Unchecked {
1080 reason: UncheckedReason::Effect,
1081 },
1082 TypeRef::HttpResult(..) => WireRef::Unchecked {
1083 reason: UncheckedReason::HttpResult,
1084 },
1085 TypeRef::ValidationError(_) => WireRef::Unchecked {
1086 reason: UncheckedReason::ValidationError,
1087 },
1088 TypeRef::JsonError(_) => WireRef::Unchecked {
1089 reason: UncheckedReason::JsonError,
1090 },
1091 TypeRef::QueueResult(_) => WireRef::Unchecked {
1092 reason: UncheckedReason::QueueResult,
1093 },
1094 TypeRef::Unit(_) => WireRef::Unit,
1095 }
1096}
1097
1098fn wire_fields(fields: &[RecordField], types: &HashMap<String, Arc<TypeDecl>>) -> Vec<WireField> {
1099 fields
1100 .iter()
1101 .map(|f| WireField {
1102 name: f.name.name.clone(),
1103 shape: wire_ref(&f.type_ref, types),
1104 path_segment: f.name.name.clone(),
1105 default: f.init.as_ref().map(|e| (e.clone(), f.type_ref.clone())),
1106 })
1107 .collect()
1108}
1109
1110fn wire_sum(body: &SumBody, types: &HashMap<String, Arc<TypeDecl>>) -> WireSum {
1111 WireSum {
1112 wire_discriminant: "kind",
1113 memory_discriminant: "tag",
1114 variants: body
1115 .variants
1116 .iter()
1117 .map(|v| WireVariant {
1118 name: v.name.name.clone(),
1119 payload: v
1120 .payload
1121 .iter()
1122 .map(|f| WireField {
1123 name: f.name.name.clone(),
1124 shape: wire_ref(&f.type_ref, types),
1125 path_segment: f.name.name.clone(),
1126 // Sum-variant payload fields carry no default in the
1127 // surface grammar (`VariantField` has no `init`).
1128 default: None,
1129 })
1130 .collect(),
1131 })
1132 .collect(),
1133 }
1134}
1135
1136/// Build one [`WireScalar`], choosing [`Revalidation`] per #661 Decisions
1137/// C/D plus the owner path: `Owned` → `ViaConstructor`; consumed + opaque →
1138/// `StructuralOnly`; consumed + transparent → `Inline`; a `Bytes` base →
1139/// `Base64Decode` regardless of provenance, mirroring `emit_refined`'s early
1140/// return to the dedicated `Bytes` codec before the owned/consumed split is
1141/// even considered.
1142fn wire_scalar(
1143 base: BaseType,
1144 opaque: bool,
1145 refinement: Option<&Refinement>,
1146 prov: &Provenance,
1147) -> WireScalar {
1148 let json = json_kind_of(base);
1149 let predicates: Vec<PredKind> = refinement
1150 .map(|r| r.predicates.iter().map(|p| p.kind.clone()).collect())
1151 .unwrap_or_default();
1152 let revalidation = if base == BaseType::Bytes {
1153 Revalidation::Base64Decode
1154 } else {
1155 match prov {
1156 Provenance::Owned => Revalidation::ViaConstructor,
1157 Provenance::Consumed { .. } if opaque => Revalidation::StructuralOnly,
1158 Provenance::Consumed { .. } => Revalidation::Inline,
1159 }
1160 };
1161 WireScalar {
1162 base,
1163 json,
1164 opaque,
1165 predicates,
1166 base_guards: scalar_base_guards(base),
1167 revalidation,
1168 }
1169}
1170
1171/// Build a [`WireType`] from a `TypeDecl`, using the same base/predicate/
1172/// opaque logic currently inline in `bynk-emit`'s `emit_refined` and
1173/// `emit_bytes_named_codec` — without any TS string emission. Returns `None`
1174/// for a **generic** declaration (`decl.type_params` non-empty): a generic
1175/// record/sum has no single bare `WireType` of its own, only per-instantiation
1176/// shapes (`WireInst`), mirroring the skip in `emit_helpers_for_owner_qualified`.
1177pub fn wire_type(
1178 name: &str,
1179 decl: &TypeDecl,
1180 types: &HashMap<String, Arc<TypeDecl>>,
1181 prov: Provenance,
1182) -> Option<WireType> {
1183 if !decl.type_params.is_empty() {
1184 return None;
1185 }
1186 let body = match &decl.body {
1187 TypeBody::Refined {
1188 base, refinement, ..
1189 } => WireBody::Scalar(wire_scalar(*base, false, refinement.as_ref(), &prov)),
1190 TypeBody::Opaque {
1191 base, refinement, ..
1192 } => WireBody::Scalar(wire_scalar(*base, true, refinement.as_ref(), &prov)),
1193 TypeBody::Record(r) => WireBody::Record {
1194 fields: wire_fields(&r.fields, types),
1195 },
1196 TypeBody::Sum(s) => WireBody::Sum(wire_sum(s, types)),
1197 };
1198 Some(WireType {
1199 name: name.to_string(),
1200 codec_suffix: name.to_string(),
1201 provenance: prov,
1202 body,
1203 })
1204}
1205
1206/// Build the full [`WireModel`] for a boundary: every non-generic named type
1207/// in `type_names` resolved through `types`, plus the generic instantiations
1208/// the caller already collected (`collect_generic_instantiations` /
1209/// `collect_codec_closure`). `provenance` is supplied by the caller —
1210/// `bynk-emit` knows the build target and the consumed set; this crate must
1211/// not (see the [`Provenance`] doc).
1212pub fn boundary_model(
1213 type_names: &[String],
1214 types: &HashMap<String, Arc<TypeDecl>>,
1215 instantiations: Vec<WireInst>,
1216 provenance: impl Fn(&str) -> Provenance,
1217) -> WireModel {
1218 let recursive: BTreeSet<String> = recursive_generic_names(types).into_iter().collect();
1219 let mut wtypes = Vec::with_capacity(type_names.len());
1220 for name in type_names {
1221 let Some(decl) = types.get(name) else {
1222 continue;
1223 };
1224 if let Some(wt) = wire_type(name, decl, types, provenance(name)) {
1225 wtypes.push(wt);
1226 }
1227 }
1228 WireModel {
1229 types: wtypes,
1230 instantiations,
1231 recursive,
1232 }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238
1239 fn types_of(src: &str) -> HashMap<String, Arc<TypeDecl>> {
1240 let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
1241 let commons = bynk_syntax::parser::parse(&tokens, src).expect("parse");
1242 commons
1243 .items
1244 .iter()
1245 .filter_map(|i| match i {
1246 CommonsItem::Type(t) => Some((t.name.name.clone(), Arc::new(t.clone()))),
1247 _ => None,
1248 })
1249 .collect()
1250 }
1251
1252 /// Parse a `context` unit and split it into its `types` and `services`
1253 /// tables, mirroring `bynk-emit`'s own boundary-collection call sites.
1254 fn context_of(src: &str) -> (HashMap<String, Arc<TypeDecl>>, HashMap<String, ServiceDecl>) {
1255 let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
1256 let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
1257 let SourceUnit::Context(ctx) = unit else {
1258 panic!("expected a context unit");
1259 };
1260 let mut types = HashMap::new();
1261 let mut services = HashMap::new();
1262 for item in &ctx.items {
1263 match item {
1264 CommonsItem::Type(t) => {
1265 types.insert(t.name.name.clone(), Arc::new(t.clone()));
1266 }
1267 CommonsItem::Service(s) => {
1268 services.insert(s.name.name.clone(), s.clone());
1269 }
1270 _ => {}
1271 }
1272 }
1273 (types, services)
1274 }
1275
1276 #[test]
1277 fn json_kind_of_matches_the_ts_base_for_serialisation_mapping() {
1278 assert_eq!(json_kind_of(BaseType::Int), JsonKind::Number);
1279 assert_eq!(json_kind_of(BaseType::Float), JsonKind::Number);
1280 assert_eq!(json_kind_of(BaseType::Duration), JsonKind::Number);
1281 assert_eq!(json_kind_of(BaseType::Instant), JsonKind::Number);
1282 assert_eq!(json_kind_of(BaseType::String), JsonKind::String);
1283 // v0.110 (ADR 0142 D5): a `Bytes` wires as a base64 JSON string.
1284 assert_eq!(json_kind_of(BaseType::Bytes), JsonKind::String);
1285 assert_eq!(json_kind_of(BaseType::Bool), JsonKind::Boolean);
1286 }
1287
1288 #[test]
1289 fn collect_boundary_types_walks_params_return_and_record_fields() {
1290 let src = r#"
1291context test
1292
1293type ClientId = String where NonEmpty
1294type Rate = { count: Int, limit: Int }
1295
1296service Api {
1297 on call(client: ClientId) -> Effect[Rate] {
1298 client
1299 }
1300}
1301"#;
1302 let (types, services) = context_of(src);
1303 let agents: HashMap<String, AgentDecl> = HashMap::new();
1304 let names = collect_boundary_types(&types, &services, &agents);
1305 assert_eq!(names, vec!["ClientId".to_string(), "Rate".to_string()]);
1306 }
1307
1308 #[test]
1309 fn collect_boundary_types_ignores_types_unreachable_from_any_handler() {
1310 let src = r#"
1311context test
1312
1313type Used = String where NonEmpty
1314type Unused = { n: Int }
1315
1316service Api {
1317 on call(x: Used) -> Effect[Used] {
1318 x
1319 }
1320}
1321"#;
1322 let (types, services) = context_of(src);
1323 let agents: HashMap<String, AgentDecl> = HashMap::new();
1324 let names = collect_boundary_types(&types, &services, &agents);
1325 assert_eq!(names, vec!["Used".to_string()]);
1326 }
1327
1328 /// The highest-risk drift trap the plan flags: `contract.rs` sorts
1329 /// predicates (a precondition for hash correctness), but this IR must
1330 /// carry **declaration order** — `Inline` revalidation emits one `if`
1331 /// per predicate in source order. `NonEmpty` before `MaxLength` sorts
1332 /// (alphabetically, as `contract.rs`'s `canon_refinement` would — folding
1333 /// `NonEmpty` to `MinLength(1)` there does not change this: `MaxLength` <
1334 /// `MinLength` alphabetically, same relative order as `MaxLength` <
1335 /// `NonEmpty` before the fold) to `MaxLength` first, so this fixture's
1336 /// declaration order visibly differs from the sorted order — a positive
1337 /// assertion that the trap stays closed.
1338 #[test]
1339 fn wire_scalar_predicates_preserve_declaration_order_not_sorted() {
1340 let types = types_of("commons x\n\ntype Id = String where NonEmpty && MaxLength(20)\n");
1341 let decl = &types["Id"];
1342 let wt = wire_type("Id", decl, &types, Provenance::Owned).expect("scalar wire type");
1343 let WireBody::Scalar(scalar) = &wt.body else {
1344 panic!("expected a scalar body, got {:?}", wt.body);
1345 };
1346 assert_eq!(scalar.predicates.len(), 2);
1347 assert!(
1348 matches!(scalar.predicates[0], PredKind::NonEmpty),
1349 "predicates[0] must be the declared-first NonEmpty, got {:?}",
1350 scalar.predicates[0]
1351 );
1352 assert!(
1353 matches!(scalar.predicates[1], PredKind::MaxLength(20)),
1354 "predicates[1] must be the declared-second MaxLength(20), got {:?}",
1355 scalar.predicates[1]
1356 );
1357 // The sorted (contract.rs-style) order would put MaxLength first —
1358 // assert this IR does not match that order.
1359 assert!(
1360 !matches!(scalar.predicates[0], PredKind::MaxLength(_)),
1361 "predicates must be in declaration order, not sorted"
1362 );
1363 }
1364
1365 #[test]
1366 fn wire_type_selects_revalidation_by_provenance_and_opacity() {
1367 let types = types_of(
1368 "commons x\n\ntype Refined = String where NonEmpty\ntype Opaque = opaque String where NonEmpty\ntype Raw = Bytes\n",
1369 );
1370
1371 let refined = wire_type("Refined", &types["Refined"], &types, Provenance::Owned)
1372 .expect("owned scalar");
1373 let WireBody::Scalar(s) = &refined.body else {
1374 panic!("expected scalar")
1375 };
1376 assert_eq!(s.revalidation, Revalidation::ViaConstructor);
1377
1378 let consumed = Provenance::Consumed {
1379 owner_unit: "other".to_string(),
1380 };
1381 let refined_consumed =
1382 wire_type("Refined", &types["Refined"], &types, consumed.clone()).expect("consumed");
1383 let WireBody::Scalar(s) = &refined_consumed.body else {
1384 panic!("expected scalar")
1385 };
1386 assert_eq!(s.revalidation, Revalidation::Inline);
1387
1388 let opaque_consumed =
1389 wire_type("Opaque", &types["Opaque"], &types, consumed.clone()).expect("consumed");
1390 let WireBody::Scalar(s) = &opaque_consumed.body else {
1391 panic!("expected scalar")
1392 };
1393 assert_eq!(s.revalidation, Revalidation::StructuralOnly);
1394
1395 // Bytes: Base64Decode regardless of provenance.
1396 let raw_owned =
1397 wire_type("Raw", &types["Raw"], &types, Provenance::Owned).expect("owned bytes");
1398 let WireBody::Scalar(s) = &raw_owned.body else {
1399 panic!("expected scalar")
1400 };
1401 assert_eq!(s.revalidation, Revalidation::Base64Decode);
1402 let raw_consumed = wire_type("Raw", &types["Raw"], &types, consumed).expect("consumed");
1403 let WireBody::Scalar(s) = &raw_consumed.body else {
1404 panic!("expected scalar")
1405 };
1406 assert_eq!(s.revalidation, Revalidation::Base64Decode);
1407 }
1408
1409 #[test]
1410 fn wire_type_returns_none_for_a_generic_declaration() {
1411 let types = types_of("commons x\n\ntype Page[T] = { items: List[T] }\n");
1412 assert!(wire_type("Page", &types["Page"], &types, Provenance::Owned).is_none());
1413 }
1414
1415 /// Part 1.4's cross-check: for an `on call` handler, `wire.rs`'s codec
1416 /// walk (`collect_boundary_types`, restricted to that one handler) and
1417 /// `contract.rs`'s canonical hash form (`service_normal_form`, over the
1418 /// same handler projected as a `CrossContextService`) must reach the
1419 /// exact same *set* of named boundary types — even though the module
1420 /// doc's comparison table says the two disagree on purpose about
1421 /// *ordering* (declaration order vs sorted) and about whether an opaque
1422 /// predicate is shown. This test asserts REACHABILITY only: the set of
1423 /// type names each derivation's walk visits from the same root, not any
1424 /// string equality or order between the two renderings.
1425 ///
1426 /// The type names in the fixture are chosen so none is a substring of
1427 /// another (`ClientId`, `Address`, `Profile`, `Unrelated`) —
1428 /// `contract.rs`'s `canon_type`/`canon_named_in` are private to that
1429 /// module, so rather than duplicating their traversal here, this test
1430 /// recovers the set of types `service_normal_form`'s rendered string
1431 /// reaches by substring containment against every declared type name.
1432 /// With non-overlapping names that containment test is unambiguous.
1433 ///
1434 /// `Unrelated` is declared but reachable from no handler, so the
1435 /// equality assertion below is not vacuously true merely because this
1436 /// fixture happens to make every *other* declared type reachable — a
1437 /// regression that made `collect_boundary_types` return every declared
1438 /// name instead of the reachable closure would leak `Unrelated` into
1439 /// `boundary_names`, and this test would catch it.
1440 #[test]
1441 fn boundary_reachability_agrees_with_contract_normal_form() {
1442 let src = r#"
1443context test
1444
1445type ClientId = String where NonEmpty
1446type Address = { street: String, city: String }
1447type Profile = { id: ClientId, home: Address }
1448
1449-- Declared, but reachable from no handler below (see the doc comment on
1450-- this test for why).
1451type Unrelated = { n: Int }
1452
1453service Api {
1454 on call(client: ClientId) -> Effect[Profile] {
1455 Profile { id: client, home: Address { street: "x", city: "y" } }
1456 }
1457}
1458"#;
1459 let (types, services) = context_of(src);
1460 let service = &services["Api"];
1461 let handler = &service.handlers[0];
1462
1463 // Restrict `collect_boundary_types`'s walk to exactly this handler —
1464 // the same synthetic-single-handler-service narrowing
1465 // `bynk-ide`'s `wire_contract_for_service` performs, so a service
1466 // with more than one handler could not leak an unrelated handler's
1467 // types into either side of this comparison.
1468 let mut narrowed = service.clone();
1469 narrowed.handlers = vec![handler.clone()];
1470 let narrowed_services: HashMap<String, ServiceDecl> =
1471 HashMap::from([("Api".to_string(), narrowed)]);
1472 let agents: HashMap<String, AgentDecl> = HashMap::new();
1473 let boundary_names: std::collections::HashSet<String> =
1474 collect_boundary_types(&types, &narrowed_services, &agents)
1475 .into_iter()
1476 .collect();
1477
1478 // The same handler, projected as a `CrossContextService` exactly as
1479 // `bynk-emit`'s `own_contract_hashes` / `bynk-ide`'s
1480 // `wire_contract::contract_form` project it, canonicalised through
1481 // the same type table.
1482 let svc = crate::resolver::CrossContextService {
1483 name: "Api".to_string(),
1484 params: handler
1485 .params
1486 .iter()
1487 .map(|p| (p.name.name.clone(), p.type_ref.clone()))
1488 .collect(),
1489 return_type: handler.return_type.clone(),
1490 span: handler.span,
1491 };
1492 let normal_form = crate::contract::service_normal_form(&svc, &types);
1493
1494 let contract_reachable: std::collections::HashSet<String> = types
1495 .keys()
1496 .filter(|name| normal_form.contains(name.as_str()))
1497 .cloned()
1498 .collect();
1499
1500 assert_eq!(
1501 boundary_names, contract_reachable,
1502 "wire::collect_boundary_types and contract::service_normal_form must \
1503 agree on *which* named types a handler's contract reaches, even though \
1504 they render/order that set completely differently (see this module's \
1505 doc comparison table):\n\
1506 wire.rs reached: {boundary_names:?}\n\
1507 contract.rs reached: {contract_reachable:?}\n\
1508 normal form: {normal_form:?}"
1509 );
1510 assert!(
1511 !boundary_names.contains("Unrelated") && !contract_reachable.contains("Unrelated"),
1512 "`Unrelated` is declared but reachable from no handler — both derivations \
1513 must exclude it, not merely agree on every declared type by coincidence:\n\
1514 wire.rs reached: {boundary_names:?}\n\
1515 contract.rs reached: {contract_reachable:?}"
1516 );
1517 }
1518
1519 /// `wire_ref` is the plan's flagged highest-risk function ("its two
1520 /// functions must stay in exact agreement about which `WireRef` arm a
1521 /// `TypeRef` resolves to"). Pin the arm each shape lands on before Phase 2
1522 /// starts consuming it.
1523 #[test]
1524 fn wire_ref_dispatches_every_type_ref_shape_to_the_expected_arm() {
1525 let types: HashMap<String, Arc<TypeDecl>> = HashMap::new();
1526 let sp = || bynk_syntax::span::Span::new(0, 0);
1527 let named = |n: &str| {
1528 TypeRef::Named(Ident {
1529 name: n.to_string(),
1530 span: sp(),
1531 })
1532 };
1533 let base = |b: BaseType| TypeRef::Base(b, sp());
1534
1535 assert!(matches!(
1536 wire_ref(&base(BaseType::Int), &types),
1537 WireRef::Base {
1538 base: BaseType::Int,
1539 json: JsonKind::Number,
1540 ..
1541 }
1542 ));
1543 assert!(matches!(
1544 wire_ref(&base(BaseType::Bytes), &types),
1545 WireRef::Bytes
1546 ));
1547 assert!(
1548 matches!(wire_ref(&named("ClientId"), &types), WireRef::Named { name } if name == "ClientId")
1549 );
1550 assert!(matches!(
1551 wire_ref(
1552 &TypeRef::Result(Box::new(base(BaseType::Int)), Box::new(base(BaseType::String)), sp()),
1553 &types
1554 ),
1555 WireRef::Inst { key } if key == "Result_Int_String"
1556 ));
1557 assert!(matches!(
1558 wire_ref(&TypeRef::Option(Box::new(base(BaseType::Int)), sp()), &types),
1559 WireRef::Inst { key } if key == "Option_Int"
1560 ));
1561 assert!(matches!(
1562 wire_ref(&TypeRef::List(Box::new(base(BaseType::Int)), sp()), &types),
1563 WireRef::Inst { key } if key == "List_Int"
1564 ));
1565 assert!(matches!(
1566 wire_ref(
1567 &TypeRef::Map(Box::new(base(BaseType::String)), Box::new(base(BaseType::Int)), sp()),
1568 &types
1569 ),
1570 WireRef::Inst { key } if key == "Map_String_Int"
1571 ));
1572 assert!(matches!(
1573 wire_ref(
1574 &TypeRef::App {
1575 name: Ident { name: "Paginated".to_string(), span: sp() },
1576 args: vec![named("User")],
1577 span: sp(),
1578 },
1579 &types
1580 ),
1581 WireRef::Inst { key } if key == "Paginated_User"
1582 ));
1583 assert!(matches!(
1584 wire_ref(&TypeRef::Unit(sp()), &types),
1585 WireRef::Unit
1586 ));
1587 assert!(matches!(
1588 wire_ref(
1589 &TypeRef::Effect(Box::new(base(BaseType::Int)), sp()),
1590 &types
1591 ),
1592 WireRef::Unchecked {
1593 reason: UncheckedReason::Effect
1594 }
1595 ));
1596 assert!(matches!(
1597 wire_ref(
1598 &TypeRef::HttpResult(Box::new(base(BaseType::Int)), sp()),
1599 &types
1600 ),
1601 WireRef::Unchecked {
1602 reason: UncheckedReason::HttpResult
1603 }
1604 ));
1605 assert!(matches!(
1606 wire_ref(&TypeRef::ValidationError(sp()), &types),
1607 WireRef::Unchecked {
1608 reason: UncheckedReason::ValidationError
1609 }
1610 ));
1611 assert!(matches!(
1612 wire_ref(&TypeRef::JsonError(sp()), &types),
1613 WireRef::Unchecked {
1614 reason: UncheckedReason::JsonError
1615 }
1616 ));
1617 assert!(matches!(
1618 wire_ref(&TypeRef::QueueResult(sp()), &types),
1619 WireRef::Unchecked {
1620 reason: UncheckedReason::QueueResult
1621 }
1622 ));
1623 }
1624}