bynk_lower/lib.rs
1//! `bynk-lower`: the AST-analysis helpers `bynk-emit` reads resolved
2//! declaration-level facts through — handler kinds and `given` clauses,
3//! service protocols, store-field shapes, capability op and attached-method
4//! signatures, route cache/limit annotations, event-subscriber shapes, and
5//! the store-write walk behind `emit_agent`'s implicit-commit decision. Each
6//! takes a checked program (or its `TypedCommons`) and a syntax-tree node
7//! and returns a `bynk_ir` value; none lowers an expression body.
8//!
9//! **Reachability, corrected twice and now settled (Slice D0 of `#1542`,
10//! the IR cutover, closing summary in `design/archive/retired-tracks.md`).** Through the crate carve (Arc D,
11//! P7.12) this paragraph claimed nothing here was reached from `bynk-emit`'s
12//! emission path; that was false — `lower_event_subscriber_shapes_ir`
13//! (called from `bynk-emit/src/project.rs`) went through
14//! `lower_service_item_ir`, which lowers every handler's own *body*, so the
15//! whole recursive expression-lowering machinery (`lower_service_handler_ir`
16//! → `lower_service_handler_body_ir` → `lower_block_ir` → `lower_expr_ir`/
17//! `lower_stmt_ir`) ran in production for every `from Events(E)` service and
18//! had its result discarded. Slice D0 repointed that one caller at the
19//! shape-only helpers it actually needs (see its own doc comment), so the
20//! original claim is now *true* by construction rather than false by
21//! oversight: **every item constructor and the expression/statement/body
22//! lowering beneath it — `lower_service_item_ir`, `lower_agent_item_ir`,
23//! `lower_provider_item_ir`, `lower_fn_item_ir`, `lower_type_item_ir`,
24//! `lower_capability_item_ir`, the handler/store-field/commit-shape/
25//! invariant/transition helpers, `lower_fn_body_ir`, `lower_block_ir`,
26//! `lower_expr_ir` and everything they call — has no caller outside this
27//! crate's own test module.** The 30 August 2026 review's "zero callers"
28//! finding was right about the end state and wrong about the path; the
29//! track doc's §10.2 has the trace. Slice D1 of the same track then deleted
30//! that machinery outright — 48 functions, the lowering context's scope
31//! stack/temp counter/return-type/store-queryable fields, and every
32//! `todo!()` this crate ever carried — so `rustc`'s own dead-code analysis,
33//! not this paragraph, is the reachability record from here on. What stays
34//! is the AST-analysis helper vocabulary `bynk-emit` consumes today —
35//! `lower_handler_kind_ir`, `lower_handler_given_ir`,
36//! `lower_protocol_ir{,_from_commons}`, `lower_type_shape_ir`,
37//! `lower_service_handler_signature_ir`, `body_writes_state`, and their
38//! siblings — each with a production call site.
39//!
40//! **Totality discipline (ADR 0334, Q2), as it stands after Slice D1:** the
41//! rule was "every entry point takes a certified `&CheckedProgram`, so a
42//! per-expression type lookup that misses is a compiler bug, not a
43//! recoverable state." The per-expression lookup went with the expression
44//! lowering, so what the rule now governs is narrower: a helper that reads
45//! a *declaration's* own type references still takes `&CheckedProgram` by
46//! default (`lower_type_shape_ir`'s doc comment has the full argument — a
47//! bare `TypedCommons` is not certified by construction), and the four that
48//! take a bare `&TypedCommons` instead — `body_writes_state`,
49//! `lower_protocol_ir_from_commons`, `capability_op_sig_from_commons`,
50//! `lower_attached_fn_sig_ir_from_types` — each document why their reads
51//! cannot reach a certification-dependent panic. The same discipline
52//! `bynk-emit/src/emitter/emit.rs`'s `lower_workers_cross_context_call`
53//! applies to its own `bynk.emit.unresolved_cross_context_signature` panic.
54
55use std::collections::{HashMap, HashSet};
56use std::sync::Arc;
57
58use bynk_check::checker::{self, Callee, CheckedProgram, Ty, TyId, TypedCommons, Types};
59use bynk_check::resolver::MethodTable;
60use bynk_syntax::ast::{
61 ActorDecl, Block, CapRef, CapabilityDecl, CapabilityOp, CommonsItem, EventPattern,
62 EventPatternValue, Expr, ExprKind, FnDecl, FnName, Handler, HandlerKind, HttpMethod,
63 LiteralValue, MatchBody, ProviderDecl, QualifiedName, ServiceProtocol, Statement, StoreField,
64 TypeBody, TypeDecl, TypeRef, expr_children,
65};
66
67use bynk_ir::{
68 ActorSeamIr, CacheIr, CapRefIr, ConstVal, EventPatternIr, EventPatternValueIr,
69 EventSubscriberShape, FnSig, IrHandlerKind, IrHttpMethod, OpSig, ProtocolIr, StoreFieldIr,
70 StoreKindIr, TypeShape,
71};
72
73/// The resolution context the AST-analysis helpers below share: the checked
74/// program's own `TypedCommons` (for type interning and declared-type
75/// lookups) plus the enclosing fn/method's own rigid type variables
76/// (`fn identity[T](x: T)`, and a generic type's own params on one of its
77/// methods), needed by `resolve_type_ref_in` the same way `Ctx::type_vars`
78/// is (`bynk-check/src/checker.rs`); `resolve_type_ref` (no `vars` set)
79/// would otherwise resolve a rigid `T` as an unknown declared type and
80/// silently fail.
81///
82/// Slice D1 of `#1542` (the IR cutover, `design/archive/retired-tracks.md`) slimmed
83/// this from the expression lowerer's full context — the lexical scope
84/// stack, the synthetic-temp counter, the enclosing return type and the
85/// agent handler's store-queryable field set all went with the body
86/// lowering that was their only reader. What remains is exactly what a
87/// signature/shape reader needs.
88pub(crate) struct LowerIrCtx<'a> {
89 program: &'a TypedCommons,
90 type_vars: HashSet<String>,
91}
92
93impl<'a> LowerIrCtx<'a> {
94 fn new(program: &'a CheckedProgram, type_vars: HashSet<String>) -> Self {
95 Self::from_commons(program.program(), type_vars)
96 }
97
98 /// #1187's own closing scoping pass: a `TypedCommons`-only constructor,
99 /// for the call paths (`lower_op_sig_ir_from_commons` and
100 /// `lower_attached_fn_sig_ir_from_types`, below) that never have a
101 /// `&CheckedProgram` to unwrap. See `lower_op_sig_ir_from_commons`'s
102 /// own doc comment for why that is sound.
103 fn from_commons(commons: &'a TypedCommons, type_vars: HashSet<String>) -> Self {
104 Self {
105 program: commons,
106 type_vars,
107 }
108 }
109
110 /// A type reference resolved in this pass's own rigid-variable scope —
111 /// the `resolve_type_ref_in` counterpart to `Ctx::resolve_type_ref_in`
112 /// call sites (e.g. `checker.rs:2816,2897`), not the bare
113 /// `resolve_type_ref` (which has no `vars` set and cannot resolve a
114 /// generic fn/method's own type parameters).
115 fn resolve_type_ref(&self, r: &bynk_syntax::ast::TypeRef) -> Option<TyId> {
116 checker::resolve_type_ref_in(
117 r,
118 &self.program.types,
119 &self.type_vars,
120 &self.program.ty_intern,
121 )
122 }
123
124 fn unit_ty(&self) -> TyId {
125 self.program.ty_intern.intern(Ty::Unit)
126 }
127}
128
129/// `(params, given, ret, effectful)` — [`lower_service_handler_signature_ir`]'s
130/// return shape (#1187's slice 5), a named alias rather than a bare tuple
131/// because its consumer (`emit_service`, `bynk-emit/src/emitter/emit.rs`)
132/// has to spell it out in a function signature. (The agent-handler
133/// signature reader that shared it went with Slice D1 of `#1542`.)
134pub type HandlerSignatureIr = (Vec<(String, TyId)>, Vec<String>, TyId, bool);
135
136/// P6.50 (design/tracks/the-ir.md §6b): a return type's own syntactic
137/// `Effect[...]` wrapper — `TypeRef::Effect(_, _)`, not the *resolved*
138/// `Ty::Effect(_)` shape `lower_handler_signature_ir` reads above via
139/// `cx.program.ty_intern`. Relocated here from `emitter/emit.rs` (its
140/// original home, `#[allow(dead_code)]`-free and with eight call sites
141/// across `emit.rs`/`workers.rs`/`workers_entry.rs`) because
142/// [`lower_service_handler_signature_ir`] below was already calling *up*
143/// into it (`bynk_emit::emitter::is_effectful_return`) — the `Ast → Ir` boundary
144/// running backwards, an `Ir`-side lowering function reaching into the
145/// `emitter` module it should only ever be called *from*. `emit.rs` and
146/// friends now call `bynk_lower::is_effectful_return` instead (relocated
147/// again at the P7.12 crate carve — `emitter`/`ir::lower` are now separate
148/// crates, `bynk-emit`/`bynk-lower` respectively).
149pub fn is_effectful_return(r: &TypeRef) -> bool {
150 matches!(r, TypeRef::Effect(_, _))
151}
152
153/// A service handler's resolved *signature* — `params`/`given`/`ret`/
154/// `effectful` — and never its body. This is what `emit_service`
155/// (`bynk-emit/src/emitter/emit.rs`) reads per handler, and what
156/// `lower_event_subscriber_shapes_ir` reads for a subscriber's parameter
157/// count. Mirrors [`body_writes_state`]'s posture (#1196): a narrow,
158/// standalone reader of already-resolved data, applied to signature data
159/// instead of a body walk.
160///
161/// A service handler's own param type is *not* resolution-checked by the
162/// checker at all (`1199_service_handler_unresolvable_param_type_no_ice`
163/// pins it), so a resolve miss degrades to `Ty::Unit` here rather than
164/// panicking — the same fallback `lower_protocol_ir` documents for a
165/// WebSocket frame type, and for the same reason: an ADR 0334 panic may
166/// only assert a guarantee the checker actually gives. (The agent-handler
167/// signature reader that did panic on a miss was correct for *its* input,
168/// which the checker does guarantee resolves; it went with the body
169/// lowering in Slice D1 of `#1542`.)
170pub fn lower_service_handler_signature_ir(
171 h: &Handler,
172 program: &CheckedProgram,
173) -> HandlerSignatureIr {
174 let cx = LowerIrCtx::new(program, HashSet::new());
175 let params: Vec<(String, TyId)> = h
176 .params
177 .iter()
178 .map(|p| {
179 let ty = cx
180 .resolve_type_ref(&p.type_ref)
181 .unwrap_or_else(|| cx.unit_ty());
182 (p.name.name.clone(), ty)
183 })
184 .collect();
185 let given: Vec<String> = h.given.iter().map(|c| c.key().to_string()).collect();
186 let ret = cx
187 .resolve_type_ref(&h.return_type)
188 .unwrap_or_else(|| cx.unit_ty());
189 let effectful = is_effectful_return(&h.return_type);
190 (params, given, ret, effectful)
191}
192
193/// A `type` declaration's resolved structure as a [`TypeShape`] — the
194/// reader `emitter.rs`'s own `type_shape_for` calls directly. (Slice 1 of
195/// `#1542` split this out of a full `IrItem::Type` constructor whose single
196/// field it was, ending a build-then-`unreachable!`-discard round-trip;
197/// Slice D1 then deleted that constructor, leaving this as the type
198/// reader.)
199///
200/// Takes a certified `&CheckedProgram`, matching this module's own
201/// categorical discipline (this file's own header doc: "every entry point
202/// here takes a `&CheckedProgram`"), even though only
203/// `TypedCommons::types`/`ty_intern` are read — no per-expression
204/// `expr_types` lookup is involved (Q2, `design/tracks/the-ir.md` §3.2), but
205/// *which fields are read* isn't the discipline; *which failures are allowed
206/// to `panic!`* is. Every panic below asserts "the checker already accepted
207/// this declaration" — true only once `certify` has run: a bare
208/// `TypedCommons` is not certified by construction (`checker.rs`'s own
209/// `CheckedProgram` doc notes the project/batch path holds per-unit
210/// `TypedCommons` values *before* that unit's build-wide gate is decided),
211/// so accepting one here would make `resolve_type_ref_in` returning `None` a
212/// reachable, not just a buggy, outcome.
213pub fn lower_type_shape_ir(decl: &Arc<TypeDecl>, program: &CheckedProgram) -> TypeShape {
214 let program = program.program();
215 let type_vars: HashSet<String> = decl
216 .type_params
217 .iter()
218 .map(|tp| tp.name.name.clone())
219 .collect();
220 let resolve = |r: &bynk_syntax::ast::TypeRef| {
221 checker::resolve_type_ref_in(r, &program.types, &type_vars, &program.ty_intern)
222 };
223 match &decl.body {
224 TypeBody::Record(r) => TypeShape::Record {
225 fields: r
226 .fields
227 .iter()
228 .map(|f| {
229 let ty = resolve(&f.type_ref).unwrap_or_else(|| {
230 panic!(
231 "bynk internal error (ADR 0334): field `{}` of type `{}` does not \
232 resolve, but the checker already accepted this declaration",
233 f.name.name, decl.name.name
234 )
235 });
236 (f.name.name.clone(), ty)
237 })
238 .collect(),
239 },
240 TypeBody::Sum(s) => TypeShape::Sum {
241 variants: s
242 .variants
243 .iter()
244 .map(|v| {
245 let payload = v
246 .payload
247 .iter()
248 .map(|vf| {
249 let ty = resolve(&vf.type_ref).unwrap_or_else(|| {
250 panic!(
251 "bynk internal error (ADR 0334): field `{}` of variant `{}` \
252 of type `{}` does not resolve, but the checker already \
253 accepted this declaration",
254 vf.name.name, v.name.name, decl.name.name
255 )
256 });
257 (vf.name.name.clone(), ty)
258 })
259 .collect();
260 (v.name.name.clone(), payload)
261 })
262 .collect(),
263 embeds: s
264 .embeds
265 .iter()
266 .map(|e| {
267 let source = resolve(&e.source_type).unwrap_or_else(|| {
268 panic!(
269 "bynk internal error (ADR 0334): `embeds` clause source type on \
270 variant `{}` of type `{}` does not resolve, but the checker \
271 already accepted this declaration",
272 e.variant.name, decl.name.name
273 )
274 });
275 (source, e.variant.name.clone())
276 })
277 .collect(),
278 },
279 TypeBody::Refined {
280 base, refinement, ..
281 } => TypeShape::Refined {
282 base: *base,
283 refinement: refinement.clone(),
284 opaque: false,
285 },
286 TypeBody::Opaque {
287 base, refinement, ..
288 } => TypeShape::Refined {
289 base: *base,
290 refinement: refinement.clone(),
291 opaque: true,
292 },
293 }
294}
295
296/// A store field's own element/key/value type, resolved with no rigid type
297/// variables in scope — `AgentDecl` carries no `type_params` of its own
298/// (its own struct shape, `bynk-syntax/src/ast.rs:908-934`), so
299/// [`lower_store_field_ir`] never needs the `fn_rigid_type_vars`-shaped
300/// seeding every fn/method-level constructor here does. Shared by every
301/// arm of that function's own kind dispatch, and by [`lower_store_field_shape_ir`].
302///
303/// **Not an ADR 0334 `.expect()`-style panic on a resolve miss, deliberately**
304/// — the same posture `lower_op_sig_ir`'s own doc comment already argues
305/// for a capability op's `params`/`return_type`, confirmed empirically for
306/// store fields specifically (#1187's Agent state-field slice, step 0):
307/// `store x: Cell[Bogus] = "hello"` certified then (exit 0, no diagnostic),
308/// so a `Bogus` store-field type reached this pass with no `expr_types`/
309/// `types` entry at all. Since #1679 the resolver rejects an unknown store
310/// field type (`bynk.resolve.unknown_type`), so this fallback is defensive:
311/// it keeps lowering total rather than asserting a guarantee it does not
312/// itself check.
313fn resolve_store_field_ty(cx: &LowerIrCtx, r: &bynk_syntax::ast::TypeRef) -> TyId {
314 cx.resolve_type_ref(r).unwrap_or_else(|| cx.unit_ty())
315}
316
317/// A `@name(<duration literal>)` annotation's own millisecond value — the
318/// shared "find the annotation, read its first argument's `DurationLit`"
319/// step both `@ttl` (`Cache`) and `@retain` (`Log`) need, factored out so
320/// the two [`lower_store_field_ir`] arms are one call each rather than two
321/// near-identical inline `find`/`and_then` chains. Mirrors, but does not
322/// call, `cache_ttl_millis` (`bynk-check/src/context_checks.rs`) and the
323/// shipped emitter's own equivalent extraction (`emit.rs`'s
324/// `store_cache_fields`/`store_log_fields`) — those thread a
325/// `&mut Vec<CompileError>` for a missing-`@ttl` diagnostic and are private
326/// to their own module, so reusing them directly here is not architecturally
327/// available; see [`lower_store_field_ir`]'s own doc comment for why this is
328/// an accepted, named duplication rather than a gap this slice closes.
329fn duration_millis_annotation(
330 annotations: &[bynk_syntax::ast::Annotation],
331 name: &str,
332) -> Option<i64> {
333 annotations
334 .iter()
335 .find(|a| a.name.name == name)
336 .and_then(|a| match a.args.first().map(|arg| &arg.value.kind) {
337 Some(ExprKind::DurationLit { millis, .. }) => Some(*millis),
338 _ => None,
339 })
340}
341
342/// The shape half of [`lower_store_field_ir`] — its `kind`/`indexed`
343/// computation, factored out so [`lower_store_field_shape_ir`] can share it
344/// without either duplicating the `Cell`/`Map`/`Set`/`Cache`/`Log` dispatch
345/// or paying for a `&mut LowerIrCtx` it never needs (nothing here lowers an
346/// expression).
347fn store_field_kind_and_indexed(f: &StoreField, cx: &LowerIrCtx) -> (StoreKindIr, Vec<String>) {
348 let head = f.kind.head.name.as_str();
349 let kind = match head {
350 "Cell" => StoreKindIr::Cell(resolve_store_field_ty(cx, &f.kind.args[0])),
351 "Map" => StoreKindIr::Map(
352 resolve_store_field_ty(cx, &f.kind.args[0]),
353 resolve_store_field_ty(cx, &f.kind.args[1]),
354 ),
355 "Set" => StoreKindIr::Set(resolve_store_field_ty(cx, &f.kind.args[0])),
356 "Cache" => {
357 let k = resolve_store_field_ty(cx, &f.kind.args[0]);
358 let v = resolve_store_field_ty(cx, &f.kind.args[1]);
359 let ttl = duration_millis_annotation(&f.annotations, "ttl").unwrap_or_else(|| {
360 panic!(
361 "bynk internal error (ADR 0334): `Cache` field `{}` has no resolvable \
362 `@ttl` millis, but the checker already accepted this declaration — \
363 bynk.store.cache_ttl_required gates a missing or malformed `@ttl` \
364 before certify",
365 f.name.name
366 )
367 });
368 StoreKindIr::Cache(k, v, ttl)
369 }
370 "Log" => {
371 let elem = resolve_store_field_ty(cx, &f.kind.args[0]);
372 let retain = duration_millis_annotation(&f.annotations, "retain");
373 StoreKindIr::Log(elem, retain)
374 }
375 other => panic!(
376 "bynk internal error (ADR 0334): store field `{}` has storage kind `{other}`, which \
377 cannot reach a certified program — only Cell/Map/Set/Cache/Log are functional \
378 (Queue is gated by bynk.store.kind_unsupported before certify)",
379 f.name.name
380 ),
381 };
382 // [DECISION C]/[DECISION E]: one entry per distinct `by:` argument,
383 // declaration order, no sort — legal only on `Map` (`ANNOTATIONS`'s own
384 // registry), so this is empty by construction for every other kind.
385 // Deduplicated: `validate_indexed_keys` (`context_checks.rs`) validates
386 // each `by:` argument independently with no duplicate check, so
387 // `@indexed(by: k, by: k)` certifies — mirrors the shipped emitter's own
388 // `store_map_indexes` guard (`emit.rs`'s `!fields.contains(&k.name)`),
389 // grounded during P6.7's own review (#1163): dropping this would mean a
390 // duplicate `by:` produces the same sibling index table twice.
391 let mut indexed: Vec<String> = Vec::new();
392 for arg in f
393 .annotations
394 .iter()
395 .filter(|a| a.name.name == "indexed")
396 .flat_map(|a| &a.args)
397 {
398 if let (Some(l), ExprKind::Ident(k)) = (&arg.label, &arg.value.kind)
399 && l.name == "by"
400 && !indexed.contains(&k.name)
401 {
402 indexed.push(k.name.clone());
403 }
404 }
405 (kind, indexed)
406}
407
408/// A store field's storage *shape* — its `Cell`/`Map`/`Set`/`Cache`/`Log`
409/// kind and `@indexed` keys (via `store_field_kind_and_indexed`), with
410/// `init` always `None`. This is the entry point `emit_agent`'s own state
411/// section actually needs; a `Cell` field's zero/initial-value expression is
412/// rendered by the emitter from the AST, never lowered here. (An
413/// `init`-lowering sibling existed until Slice D1 of `#1542`; it lowered the
414/// initialiser through the deleted expression lowerer and had no caller.)
415///
416/// The field's own type reference falls back to `Ty::Unit` on a resolve
417/// miss rather than panicking — no checker pass validates a store field's
418/// type reference, only its shape and annotation legality, so `store x:
419/// Cell[Bogus]` certifies today and this reader must not turn that into an
420/// ICE (`store_field_falls_back_to_unit_on_an_unresolvable_type_like_the_checker_does`
421/// pins it).
422pub fn lower_store_field_shape_ir(f: &StoreField, program: &CheckedProgram) -> StoreFieldIr {
423 let cx = LowerIrCtx::new(program, HashSet::new());
424 let (kind, indexed) = store_field_kind_and_indexed(f, &cx);
425 StoreFieldIr {
426 field: f.name.name.clone(),
427 kind,
428 indexed,
429 }
430}
431
432/// [DECISION B]/[DECISION C] (#1165): does `body` reach a mutating
433/// `Callee::Store` write, or an unconditional `Statement::Assign` (`:=`),
434/// anywhere — including inside a nested `if`/`match`/lambda? Drives, as of
435/// #1196 (the #1187 emitter-cutover track's own R6.5 stake), `emit_agent`'s
436/// (`bynk-emit/src/emitter/emit.rs`) real implicit-commit-wrapper decision
437/// (it also drove the IR-side `CommitShape::Transactional` decision until
438/// Slice D1 of `#1542` deleted that constructor) — its previous own
439/// name-matching `block_writes_state`
440/// (`emitter.rs`) is deleted, this function is its sole, direct
441/// replacement. The walk's own shape is that deleted function's own
442/// already-correct skeleton reused structurally, not re-derived:
443/// `Block`/`If`/`Match` are hand-matched so crossing a nested block
444/// re-enters the statement-aware case (an `expr_children` descent alone
445/// flattens a block straight to its statements' *values*, losing the
446/// `Statement::Assign` tag), everywhere else recurses over
447/// `expr_children`'s total child iterator.
448///
449/// Unlike the deleted function's own name-based `mutating_op`, this walk
450/// needs no per-kind receiver-name set: a `Callee::Store { op, .. }` already
451/// carries the field's own resolved identity (the checker only ever records
452/// one for a method the field's own kind actually declares), so `op`'s
453/// membership in the store-op registry's mutating set
454/// (`bynk_check::store_ops::is_mutating_any`, #2194) is unambiguous checked
455/// flat, across every kind at once — a locally-shadowed name that would false-positive
456/// `mutating_op` cannot false-positive here at all, the exact fix Decision
457/// B's own Risk names, and the exact defect `#1196_agent_write_detection_
458/// via_resolved_callee`'s own fixture pins at the emitted-output level.
459pub fn body_writes_state(body: &Block, program: &TypedCommons) -> bool {
460 fn is_mutating_store_write(e: &Expr, program: &TypedCommons) -> bool {
461 match program.callees.get(&e.id) {
462 // #2194: the mutating set is the store-op registry's own
463 // `mutates` flag. A `Callee::Store` already carries the op
464 // resolved against its field's kind, so the op name alone decides.
465 Some(Callee::Store { op, .. }) => bynk_check::store_ops::is_mutating_any(op),
466 _ => false,
467 }
468 }
469 fn stmt(s: &Statement, program: &TypedCommons) -> bool {
470 match s {
471 Statement::Assign(_) => true,
472 Statement::Let(l) | Statement::EffectLet(l) => expr(&l.value, program),
473 Statement::Expect(a) => expr(&a.value, program),
474 Statement::Send(s) => expr(&s.value, program),
475 Statement::Do(d) => expr(&d.value, program),
476 Statement::Advance(a) => expr(&a.amount, program),
477 }
478 }
479 fn expr(e: &Expr, program: &TypedCommons) -> bool {
480 if is_mutating_store_write(e, program) {
481 return true;
482 }
483 match &e.kind {
484 ExprKind::Block(b) => body_writes_state(b, program),
485 ExprKind::If {
486 cond,
487 then_block,
488 else_block,
489 } => {
490 expr(cond, program)
491 || body_writes_state(then_block, program)
492 || body_writes_state(else_block, program)
493 }
494 // #1800: a guard is evaluated like any other expression, so a
495 // write in one (`Some(x) if if x > 0 { hits := x; true } …`) runs
496 // and must be committed. Skipping it dropped the commit wrapper.
497 ExprKind::Match { discriminant, arms } => {
498 expr(discriminant, program)
499 || arms.iter().any(|a| {
500 a.guard.as_ref().is_some_and(|g| expr(g, program))
501 || match &a.body {
502 MatchBody::Expr(e) => expr(e, program),
503 MatchBody::Block(b) => body_writes_state(b, program),
504 }
505 })
506 }
507 _ => expr_children(e).into_iter().any(|c| expr(c, program)),
508 }
509 }
510 body.statements.iter().any(|s| stmt(s, program)) || expr(&body.tail, program)
511}
512
513/// P6.11 ([DECISION C], #1171): reshape a `from Events(E { … })` pattern
514/// into a real [`EventPatternIr`] — pure structural reshaping, no
515/// `&CheckedProgram` parameter and no resolution: every field's own
516/// matched value is either already a literal or an unresolved variant tag,
517/// neither needing a `TyId`. `EventPatternValue::Literal` lowers through
518/// the same `LiteralValue -> ConstVal` match [`lower_pattern_ir`] already
519/// uses for `Pattern::Literal`'s identical closed set.
520fn lower_event_pattern_ir(pattern: &EventPattern) -> EventPatternIr {
521 EventPatternIr {
522 fields: pattern
523 .fields
524 .iter()
525 .map(|f| {
526 let value = match &f.value {
527 EventPatternValue::Literal { value, .. } => {
528 EventPatternValueIr::Const(match value {
529 LiteralValue::Int(n) => ConstVal::Int(*n),
530 LiteralValue::Str(s) => ConstVal::Str(s.clone()),
531 LiteralValue::Bool(b) => ConstVal::Bool(*b),
532 })
533 }
534 EventPatternValue::Variant { variant, .. } => EventPatternValueIr::Variant {
535 tag: variant.name.clone(),
536 },
537 };
538 (f.name.name.clone(), value)
539 })
540 .collect(),
541 }
542}
543
544/// P6.11 ([DECISION A], #1171): lower a service's own `from <protocol>`
545/// header into a real [`ProtocolIr`] — standalone, takes the sub-node
546/// rather than the owning `ServiceDecl` (mirrors
547/// [`lower_store_field_shape_ir`]), so a `from websocket`/`from Events`
548/// fixture can pin the descriptor by itself.
549///
550/// `WebSocket`/`Events`'s own type refs resolve through the `Ty::Unit`
551/// fallback, not an ADR 0334 panic — deliberately: `resolver.rs` skips
552/// `CommonsItem::Service` in every one of its own type-ref-resolution
553/// passes (`resolver.rs:303-304`/`493-494`/`577-578`), and the one checker
554/// site that does resolve a WebSocket frame type itself falls back to
555/// `Ty::Unit` on a miss rather than erroring (`context_checks.rs:775-778`).
556/// Panicking here would make this an ADR-0334 site asserting a guarantee
557/// the checker doesn't actually give (the agent `key_ty` site that once did,
558/// review of #1169, went with the item assembly in Slice D1 of `#1542`).
559pub fn lower_protocol_ir(protocol: &ServiceProtocol, program: &CheckedProgram) -> ProtocolIr {
560 lower_protocol_ir_from_commons(protocol, program.program())
561}
562
563/// P6.24a: a `TypedCommons`-only sibling of [`lower_protocol_ir`], the same
564/// split `lower_op_sig_ir`/`lower_op_sig_ir_from_commons` already
565/// established — for a call site holding only a unit's own `TypedCommons`,
566/// never a `&CheckedProgram` (`emitter.rs`'s `emit_project_imports`, a
567/// header-import-collection pass that runs well outside the per-declaration
568/// emission loop any `CheckedProgram` is threaded through). Sound for the
569/// identical reason: nothing here reads a per-expression type, the one
570/// lookup whose `.expect()`-panic needed a genuinely certified program.
571pub fn lower_protocol_ir_from_commons(
572 protocol: &ServiceProtocol,
573 commons: &TypedCommons,
574) -> ProtocolIr {
575 let cx = LowerIrCtx::from_commons(commons, HashSet::new());
576 match protocol {
577 ServiceProtocol::Call => ProtocolIr::Call,
578 ServiceProtocol::Http => ProtocolIr::Http,
579 ServiceProtocol::Cron => ProtocolIr::Cron,
580 ServiceProtocol::Queue { name } => ProtocolIr::Queue { name: name.clone() },
581 ServiceProtocol::WebSocket { in_type, out_type } => ProtocolIr::WebSocket {
582 in_ty: cx.resolve_type_ref(in_type).unwrap_or_else(|| cx.unit_ty()),
583 out_ty: cx
584 .resolve_type_ref(out_type)
585 .unwrap_or_else(|| cx.unit_ty()),
586 },
587 ServiceProtocol::Events {
588 event_type,
589 pattern,
590 schema_dispatch,
591 } => ProtocolIr::Events {
592 event: cx
593 .resolve_type_ref(event_type)
594 .unwrap_or_else(|| cx.unit_ty()),
595 pattern: pattern.as_ref().map(lower_event_pattern_ir),
596 schema_dispatch: schema_dispatch.as_ref().map(|d| {
597 let bynk_syntax::ast::SchemaVersionPattern::Literal(version) = d.pattern;
598 version
599 }),
600 },
601 }
602}
603
604/// #1228: a GET handler's own `@cache(maxAge:, scope:)` freshness policy —
605/// [`bynk_ir::CacheIr`]'s own doc comment has the full grounding for why
606/// this is a standalone per-route reader. Field-for-
607/// field the same extraction `emitter/workers_entry.rs`'s own (now
608/// superseded) `cache_policy_for` did: only a `GET` yields a policy;
609/// project validation (`bynk.http.cache_*`) has already rejected a
610/// `@cache` anywhere else, and a malformed `maxAge` there, so a missing or
611/// ill-formed annotation here simply yields `None` — no `&CheckedProgram`
612/// needed, the same posture `lower_policy_ir`'s own doc comment already
613/// argues for: `maxAge`/`scope` are already-resolved syntactic literals
614/// (`ExprKind::DurationLit`/`Ident`), not a type this pass would ever need
615/// to resolve.
616pub fn lower_route_cache_ir(h: &Handler) -> Option<CacheIr> {
617 if !matches!(
618 h.kind,
619 HandlerKind::Http {
620 method: HttpMethod::Get,
621 ..
622 }
623 ) {
624 return None;
625 }
626 let ann = h.annotations.iter().find(|a| a.name.name == "cache")?;
627 let mut max_age_millis: Option<i64> = None;
628 let mut scope = "private";
629 let mut immutable = false;
630 for arg in &ann.args {
631 match arg.label.as_ref().map(|l| l.name.as_str()) {
632 Some("maxAge") => {
633 if let ExprKind::DurationLit { millis, .. } = &arg.value.kind {
634 max_age_millis = Some(*millis);
635 }
636 }
637 Some("scope") => {
638 if let ExprKind::Ident(id) = &arg.value.kind
639 && id.name == "public"
640 {
641 scope = "public";
642 }
643 }
644 Some("immutable") => {
645 if let ExprKind::BoolLit(b) = arg.value.kind {
646 immutable = b;
647 }
648 }
649 _ => {}
650 }
651 }
652 Some(CacheIr {
653 max_age_secs: max_age_millis? / 1000,
654 scope,
655 immutable,
656 })
657}
658
659/// #1228: a route's own `@limit(maxBody:)` annotation, if present — the
660/// override half of `emitter/workers_entry.rs`'s own (now superseded)
661/// `effective_max_body`; the service-wide `limits { maxBody }` fallback
662/// stays that function's own concern (read from a *service*'s `limits {}`
663/// block, not a per-route `Handler`, so it does not move here). Project validation
664/// (`bynk.http.limit_*`/`limits_*`) has already rejected a malformed or
665/// misplaced `@limit`, so an absent/ill-formed annotation here simply
666/// yields `None` — the caller's own service-default fallback still
667/// applies. No `&CheckedProgram` needed, same reasoning as
668/// [`lower_route_cache_ir`]: `maxBody` is an already-resolved
669/// `ExprKind::IntLit`, not a type.
670pub fn lower_route_limit_ir(h: &Handler) -> Option<i64> {
671 h.limit_max_body()
672}
673
674/// Every `from Events(E)` service in `program`'s own unit, captured as an
675/// [`bynk_ir::EventSubscriberShape`] keyed by service name — see that
676/// struct's own doc comment for why this is captured now rather than
677/// re-derived cross-unit at compose time (P6.47, `#1254`).
678///
679/// Slice D0 of `#1542` (the IR cutover, `design/archive/retired-tracks.md`; `#1574`):
680/// reads the two facts it returns from the shape-only helpers that own them —
681/// [`lower_protocol_ir`] for `schema_dispatch`, and [`lower_handler_kind_ir`]
682/// plus [`lower_service_handler_signature_ir`] for the `Event` handler's
683/// parameter count. Before D0 this function went through
684/// `lower_service_item_ir`, which lowers every handler's *body* to `IrExpr`
685/// through the expression lowerer and then discards it — the one production
686/// route into that lowerer, and the detour §10.2 of the track doc names (the
687/// body lowering's own `unreachable!()` safety argument covered
688/// `lower_fn_body_ir`'s callers only, never this path). The values are
689/// identical by construction: `lower_service_handler_ir` itself took its
690/// `params` from [`lower_service_handler_signature_ir`] and its `kind` from
691/// [`lower_handler_kind_ir`], and `lower_service_item_ir` its `protocol` from
692/// [`lower_protocol_ir`] — this function now calls those three directly and
693/// skips the body.
694///
695/// The `ServiceProtocol::Events` pre-filter stays first: a cheap, structural
696/// "which services even have a shape to capture" check (the same match
697/// [`lower_protocol_ir`] performs), not a raw-AST *read* of anything the IR
698/// side owns.
699pub fn lower_event_subscriber_shapes_ir(
700 program: &CheckedProgram,
701) -> HashMap<String, EventSubscriberShape> {
702 let mut out = HashMap::new();
703 for item in &program.program().commons.items {
704 if let CommonsItem::Service(s) = item
705 && matches!(&s.protocol, ServiceProtocol::Events { .. })
706 {
707 let ProtocolIr::Events {
708 schema_dispatch, ..
709 } = lower_protocol_ir(&s.protocol, program)
710 else {
711 panic!(
712 "bynk internal error: lower_protocol_ir did not return \
713 ProtocolIr::Events for a service whose own AST protocol is \
714 ServiceProtocol::Events"
715 )
716 };
717 let two_param_handler = s
718 .handlers
719 .iter()
720 .find(|h| matches!(lower_handler_kind_ir(&h.kind), IrHandlerKind::Event))
721 .is_some_and(|h| lower_service_handler_signature_ir(h, program).0.len() == 2);
722 out.insert(
723 s.name.name.clone(),
724 EventSubscriberShape {
725 two_param_handler,
726 schema_dispatch: schema_dispatch.is_some(),
727 },
728 );
729 }
730 }
731 out
732}
733
734/// A capability declaration's resolved op signatures, in declaration order
735/// — what `emitter.rs`'s own capability-item loop reads (the caller already
736/// holds the capability's name from the AST declaration). Slice 1 of
737/// `#1542` split this out of a full `IrItem::Capability` constructor to end
738/// a build-then-discard-`def` round-trip; Slice D1 deleted that constructor.
739pub fn lower_capability_ops_ir(cap: &CapabilityDecl, program: &CheckedProgram) -> Vec<OpSig> {
740 cap.ops
741 .iter()
742 .map(|op| lower_op_sig_ir(op, program))
743 .collect()
744}
745
746/// P6.29 (design/tracks/the-ir.md §6a): the `TypedCommons`-only counterpart to
747/// [`lower_capability_ops_ir`], for call sites (`emitter/lower.rs`'s
748/// `cap_op_param_names`) that have a `TypedCommons` in hand but no
749/// `CheckedProgram` — `LowerCtx`/`ModuleCtx` never carry one (see
750/// `lower_op_sig_ir_from_commons`, this function's own single-op sibling,
751/// for the identical reason it exists as a separate entry point rather than a
752/// thin wrapper over the `CheckedProgram`-driven `lower_op_sig_ir`).
753///
754/// Resolves one capability operation's signature by name — "find the op
755/// named `op` on the capability named `cap`" has no IR-native replacement
756/// (nothing indexes capabilities by name once lowered), so this still walks
757/// `TypedCommons::commons.items` the same way the code it replaces did.
758/// First match in item order; `None` on no match, mirroring the caller's own
759/// prior fallthrough-to-empty behaviour exactly.
760pub fn capability_op_sig_from_commons(
761 commons: &TypedCommons,
762 cap: &str,
763 op: &str,
764) -> Option<OpSig> {
765 commons.commons.items.iter().find_map(|item| {
766 let CommonsItem::Capability(c) = item else {
767 return None;
768 };
769 if c.name.name != cap {
770 return None;
771 }
772 c.ops
773 .iter()
774 .find(|o| o.name.name == op)
775 .map(|o| lower_op_sig_ir_from_commons(o, commons))
776 })
777}
778
779/// P6.12 (#1173): lower one capability operation's own signature into a real
780/// [`bynk_ir::OpSig`] — the reference's own capability-item sketch
781/// names this type (`ops: Vec<OpSig>`) but never defines it. Resolves
782/// `params`/`return_ty` in the scope `op.type_params` names, the same
783/// per-op rigid-variable seeding `context_checks::build_capability_op_info`
784/// (`bynk-check/src/context_checks.rs`) already gives a generic op for the
785/// checker-facing `CapabilityOpInfo` — an op's own `[T, …]` list is scoped to
786/// the op itself, not the capability (`CapabilityDecl` carries no
787/// `type_params` of its own), so this seeds a fresh [`LowerIrCtx`] per op
788/// rather than reusing `fn_rigid_type_vars`'s fn/method-shaped
789/// receiver-widening, which does not apply here.
790///
791/// **Not an ADR 0334 `.expect()`-style panic on a resolve miss,
792/// deliberately** — the same posture [`lower_agent_item_ir`]'s own `key_ty`
793/// doc comment already argues for `agent.key_type`, and for the identical
794/// reason: a capability op's own `params`/`return_type` are never actually
795/// resolution-checked by the checker at all. The resolver skips
796/// `CommonsItem::Capability` outright (`resolver.rs:301/491/575`, "v0.5
797/// items are resolved via a separate context-level pass"); the context-level
798/// pass that replaces it, `check_capability_decls`, only calls
799/// `checker::record_type_refs`, which silently does nothing on a name absent
800/// from `types` rather than erroring (`checker.rs:2593-2597`); and
801/// `build_capability_op_info` itself, the checker-facing constructor this
802/// pass mirrors, degrades to `Ty::Unit` on the same miss rather than
803/// treating it as impossible (`context_checks.rs:36,40`). `capability Store
804/// { fn get(k: Bogus) -> Effect[Int] }` certifies today, silently. Panicking
805/// here on a state the checker itself accepts would make this the first ADR
806/// 0334 site in this module to assert a guarantee that does not actually
807/// hold — mirror the checker's own fallback instead (review of #1182).
808fn lower_op_sig_ir(op: &CapabilityOp, program: &CheckedProgram) -> OpSig {
809 lower_op_sig_ir_from_commons(op, program.program())
810}
811
812/// #1187's own closing scoping pass: a `TypedCommons`-only sibling of
813/// `lower_op_sig_ir`, for the one real call site that never has a
814/// `&CheckedProgram` — `emitter/lower.rs`'s `cap_op_param_names`, feeding
815/// `trace(Cap.op)`/`with`-predicate observation lowering
816/// (`bynk.test`'s DSL). That call path's own `TypedCommons` is a synthetic,
817/// hand-assembled project-wide view (`project/tests_emit.rs`'s
818/// `synthetic_typed_commons_for_target`, merging every consumed unit's own
819/// `capability` declarations into one scratch commons for lookup) — never
820/// itself the output of `certify`, so wrapping it as a `CheckedProgram`
821/// here would misrepresent an uncertified value as certified
822/// (`CheckedProgram`'s own doc comment, `bynk-check/src/checker.rs`, warns
823/// against exactly this). Splitting this out is sound precisely because
824/// this function never reads a per-expression type — the one lookup whose
825/// `.expect()`-panic needed a genuinely certified program, and the reason
826/// this module's own file-level doc comment gives for taking
827/// `&CheckedProgram` by default elsewhere. `resolve_type_ref`/`unit_ty()` (below) both degrade via
828/// `.unwrap_or_else` and read nothing `TypedCommons` doesn't already expose
829/// directly.
830fn lower_op_sig_ir_from_commons(op: &CapabilityOp, commons: &TypedCommons) -> OpSig {
831 let type_vars: HashSet<String> = op
832 .type_params
833 .iter()
834 .map(|tp| tp.name.name.clone())
835 .collect();
836 let cx = LowerIrCtx::from_commons(commons, type_vars);
837 let params: Vec<(String, TyId)> = op
838 .params
839 .iter()
840 .map(|p| {
841 let ty = cx
842 .resolve_type_ref(&p.type_ref)
843 .unwrap_or_else(|| cx.unit_ty());
844 (p.name.name.clone(), ty)
845 })
846 .collect();
847 let return_ty = cx
848 .resolve_type_ref(&op.return_type)
849 .unwrap_or_else(|| cx.unit_ty());
850 OpSig {
851 name: op.name.name.clone(),
852 type_params: op
853 .type_params
854 .iter()
855 .map(|tp| tp.name.name.clone())
856 .collect(),
857 params,
858 return_ty,
859 }
860}
861
862/// P6.18: [`bynk_ir::FnSig`]'s own constructor — a `fn`'s own resolved
863/// signature, for a call site holding only that fn's *declaring* unit's own
864/// combined types (`bynk_check::symbols::combined_types_for`'s return shape),
865/// never a `CheckedProgram`. The one real call site
866/// (`bynk-emit/src/project.rs`'s `build_emit_unit_ctx`) reads a `uses`-
867/// imported *foreign* unit's own attached methods, whose own `CheckedProgram`
868/// does not survive past that unit's own `check_unit_files` iteration — the
869/// same "dropped before any later, project-wide pass runs" shape
870/// `unit_callees` (#1202)/`EventSubscriberShape` (#1232) both work around,
871/// except here no project-wide accumulator is needed at all: unlike a
872/// `Callee`/event-subscriber-shape classification (checker-only facts, never
873/// re-derivable from raw declarations alone), a fn signature's own
874/// `params`/`return_type` are ordinary type references, resolvable from that
875/// unit's own declared types the same way [`lower_op_sig_ir_from_commons`]
876/// already resolves a capability op's — so a bare types map is sufficient,
877/// the same non-`CheckedProgram` scope that function already established.
878///
879/// Only the method's own `[T, …]` list seeds the rigid-variable scope, not
880/// its generic receiver's — the one real caller (`emit_forwarded_methods`)
881/// never renders `self`'s own type through this value at all (it takes the
882/// *consumer* context's own rebranded type name directly), so resolving
883/// `fn_receiver_ty` here would be dead work.
884fn lower_fn_sig_ir_from_types(
885 f: &FnDecl,
886 types: &HashMap<String, Arc<TypeDecl>>,
887 tys: &Types,
888) -> FnSig {
889 let type_vars: HashSet<String> = f
890 .type_params
891 .iter()
892 .map(|tp| tp.name.name.clone())
893 .collect();
894 let unit_ty = || tys.intern(Ty::Unit);
895 let params: Vec<(String, TyId)> = f
896 .params
897 .iter()
898 .map(|p| {
899 let ty = checker::resolve_type_ref_in(&p.type_ref, types, &type_vars, tys)
900 .unwrap_or_else(unit_ty);
901 (p.name.name.clone(), ty)
902 })
903 .collect();
904 let return_ty = checker::resolve_type_ref_in(&f.return_type, types, &type_vars, tys)
905 .unwrap_or_else(unit_ty);
906 let name = match &f.name {
907 FnName::Method { method_name, .. } => method_name.name.clone(),
908 FnName::Free(id) => id.name.clone(),
909 };
910 FnSig {
911 name,
912 has_self: f.has_self,
913 params,
914 return_ty,
915 }
916}
917
918/// P6.x (#1137): `lower_fn_sig_ir_from_types` over an entire
919/// [`MethodTable`]'s own instance + static entries — the attached-method
920/// gathering [`bynk-emit`'s `build_emit_unit_ctx`] needs for a `uses`-imported
921/// type. Filters to [`FnName::Method`] before lowering: `ResolverMethodTable`
922/// only ever collects attached methods in practice (`bynk-check/src/resolver.rs`'s
923/// own doc comment on [`MethodTable`]), but the filter stays as a defensive
924/// match rather than an assumption, matching the caller's own pre-existing
925/// posture one step earlier — this just moves that posture in front of the
926/// lowering call instead of behind it, so the `FnName` read (and the filter
927/// itself) never has to leave this module.
928pub fn lower_attached_fn_sig_ir_from_types(
929 mt: &MethodTable,
930 types: &HashMap<String, Arc<TypeDecl>>,
931 tys: &Types,
932) -> Vec<FnSig> {
933 mt.instance
934 .values()
935 .chain(mt.statics.values())
936 .filter(|f| matches!(f.name, FnName::Method { .. }))
937 .map(|f| lower_fn_sig_ir_from_types(f, types, tys))
938 .collect()
939}
940
941/// P6.24a: pure, unconditional [`HandlerKind`] → [`IrHandlerKind`]
942/// conversion — every field is already fully resolved at parse time, so
943/// unlike almost every other function in this module this one takes no
944/// `&CheckedProgram`/`&TypedCommons` at all and can never miss.
945pub fn lower_handler_kind_ir(k: &HandlerKind) -> IrHandlerKind {
946 match k {
947 HandlerKind::Call => IrHandlerKind::Call,
948 HandlerKind::Http { method, path } => IrHandlerKind::Http {
949 method: lower_http_method_ir(*method),
950 path: path.clone(),
951 },
952 HandlerKind::Cron { expr } => IrHandlerKind::Cron { expr: expr.clone() },
953 HandlerKind::Message => IrHandlerKind::Message,
954 HandlerKind::Open => IrHandlerKind::Open,
955 HandlerKind::Close => IrHandlerKind::Close,
956 HandlerKind::Event => IrHandlerKind::Event,
957 HandlerKind::Rejected => IrHandlerKind::Rejected,
958 }
959}
960
961/// [`lower_handler_kind_ir`]'s own `HttpMethod` half.
962fn lower_http_method_ir(m: HttpMethod) -> IrHttpMethod {
963 match m {
964 HttpMethod::Get => IrHttpMethod::Get,
965 HttpMethod::Post => IrHttpMethod::Post,
966 HttpMethod::Put => IrHttpMethod::Put,
967 HttpMethod::Patch => IrHttpMethod::Patch,
968 HttpMethod::Delete => IrHttpMethod::Delete,
969 }
970}
971
972/// A provider's own `given` clause, resolved standalone — the entry point
973/// `bynk-emit/src/project.rs`'s `instantiate_provider_ts_expr` actually
974/// calls. This never touches the provider's `ops` or their bodies; the
975/// full-provider assembly that did (and lowered every `Bynk` op body through
976/// the expression lowerer) had no caller and went with Slice D1 of `#1542`.
977pub fn lower_provider_given_ir(provider: &ProviderDecl) -> Vec<CapRefIr> {
978 provider.given.iter().map(lower_cap_ref_ir).collect()
979}
980
981/// #1187's slice 6 plumbing (sibling of [`lower_provider_given_ir`]): a
982/// handler's own `given` clause, resolved standalone — the entry point for
983/// `project.rs`'s `plan_agent_given_deps`, `EmitProjectCtx::
984/// agent_method_givens`, and `emitter/workers.rs`'s own `given` collection.
985/// Reuses `lower_cap_ref_ir` verbatim; a handler's `given` is syntactically
986/// identical to a provider's (`bynk_syntax::ast::CapRef`), so this is the
987/// same one-line adapter, not a new design.
988pub fn lower_handler_given_ir(h: &Handler) -> Vec<CapRefIr> {
989 h.given.iter().map(lower_cap_ref_ir).collect()
990}
991
992/// #1187's slice 3: a handler's resolved actor-verification seam — the same
993/// "narrow, standalone reader of already-resolved data" precedent
994/// [`body_writes_state`]/[`lower_service_handler_signature_ir`] established,
995/// applied to `bynk-check`'s own five actor-seam resolvers
996/// (`bynk-check/src/actors.rs`) instead of a full handler assembly.
997/// [`ActorSeamIr`]'s own doc comment has the full grounding for the
998/// priority order and for the deliberately-missing `Signature` variant.
999///
1000/// Replaces the hand-duplicated "try N resolvers, branch on which
1001/// returned `Some`" call sites this slice converts: `emit_service`
1002/// (`emitter/emit.rs`) and `emit_worker_compose`'s HTTP-dispatch match
1003/// (`emitter/workers.rs`). Deliberately does **not** yet replace every
1004/// caller of the five resolvers — `secrets.rs`'s `declared_secrets` unions
1005/// *all* matching seams' secrets rather than picking one (a different
1006/// shape this enum doesn't model), and the remaining call sites
1007/// (`emitter/workers_entry.rs`, `emitter/workers.rs`'s other two sites,
1008/// `emitter/emit.rs`'s `any_service_binds_caller`/`emit_make_surface`/
1009/// `ws_open_hosts_for`, `project/tests_emit.rs`) each call exactly one
1010/// resolver with nothing to collapse against — converting them to build a
1011/// five-variant enum just to immediately match out one arm would add
1012/// indirection without removing any real duplication.
1013pub fn lower_actor_seam_ir(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> ActorSeamIr {
1014 if let Some(members) = bynk_check::actors::sum_members_for(handler, actors) {
1015 return ActorSeamIr::Sum(members);
1016 }
1017 if let Some(members) =
1018 bynk_check::actors::call_admission_for(handler, actors).and_then(|a| a.sum)
1019 {
1020 return ActorSeamIr::CallSum(members);
1021 }
1022 if let Some(seam) = bynk_check::actors::bearer_seam_for(handler, actors) {
1023 return ActorSeamIr::Bearer(seam);
1024 }
1025 if let Some(seam) = bynk_check::actors::oidc_seam_for(handler, actors) {
1026 return ActorSeamIr::Oidc(seam);
1027 }
1028 if let Some(binder) = bynk_check::actors::caller_binder_for(handler, actors) {
1029 return ActorSeamIr::Caller(binder);
1030 }
1031 ActorSeamIr::None
1032}
1033
1034/// P6.14 (#1174, review of #1186): adapt one `given` entry into a real
1035/// [`bynk_ir::CapRefIr`] — [`CapRefIr`]'s own doc comment has the full
1036/// grounding for the `QualifiedName -> String` flattening and for why a
1037/// `Some` prefix is preserved unresolved.
1038fn lower_cap_ref_ir(cap_ref: &CapRef) -> CapRefIr {
1039 CapRefIr {
1040 context: cap_ref.context.as_ref().map(QualifiedName::joined),
1041 name: cap_ref.name.name.clone(),
1042 }
1043}
1044
1045/// Decision E: targeted minimal fixtures, one per node kind this slice
1046/// covers, staying strictly inside the subset [`lower_expr_ir`]/
1047/// [`lower_stmt_ir`] actually implement — not a walk over the real
1048/// `bynkc/tests/fixtures/positive` corpus, which hits an unimplemented
1049/// `Match`/`Call` arm within a few lines of almost any real fixture.
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053 use bynk_check::checker::CheckedProgram;
1054 use bynk_check::hints::HintSink;
1055 use bynk_check::index::RefSink;
1056 use bynk_check::locals::LocalsSink;
1057 use bynk_check::requirements::RequirementSink;
1058 use bynk_check::{checker, context_checks, resolver, symbols};
1059 use bynk_project::UnitKind;
1060 use bynk_syntax::ast::{AgentDecl, Commons, CommonsItem, HandlerKind, ServiceDecl, SourceUnit};
1061 use bynk_syntax::span::Span;
1062 use bynk_syntax::{lexer, parser};
1063
1064 /// Like [`checked_program`], but for source that declares an `agent`
1065 /// and/or a `service` — both are only legal inside a `context`, not a
1066 /// bare `commons` (`bynk.agent.outside_context`/the service
1067 /// equivalent), so `source` is parsed as a context unit and its items
1068 /// re-wrapped into a [`Commons`] value before re-using the same
1069 /// `resolve`/`check` pipeline `checked_program` does.
1070 /// `resolver::resolve`/`checker::check` both already treat
1071 /// `CommonsItem::Agent`/`Service` as inert (v0.5 declaration kinds "go
1072 /// through the context-level v0.5 path", `resolver.rs`'s own comment)
1073 /// — real agent/service checking (`store` field kinds, handler bodies,
1074 /// `expr_types` for a `Cell` initialiser, actor bindings) only happens
1075 /// via `context_checks::check_context_declarations`, called here by
1076 /// hand with a [`symbols::UnitTable`] built directly from the checked
1077 /// commons' own agent/service/actor and local `capability` items (a
1078 /// handler's `given <Cap>` resolves against `table.capabilities` —
1079 /// populated here so a fixture can declare its own local capability,
1080 /// but still no cross-context `uses`/`consumes` in any fixture this
1081 /// helper is given, so `resolver::CrossContextInfo::default()` is
1082 /// exact, not an approximation — in particular, no `from Events(E)`
1083 /// fixture is possible here, since a real subscription needs
1084 /// `consumes bynk { Events }`).
1085 ///
1086 /// **P6.11 (#1171) adds `services`/`actors` to the table and a
1087 /// pre-`resolve` `inject_service_defaults` pass.** `table.actors`
1088 /// matters even for a fixture using only the prelude (`Caller`,
1089 /// `Visitor`): `actor_identity_ty` resolves a *local* `actor`
1090 /// declaration through `table.actors` and silently falls through to
1091 /// the prelude/`Ty::Unit` otherwise
1092 /// (`context_checks.rs::actor_identity_ty`), so a `by u: Buyer`
1093 /// fixture without this would assert against a wrong `TyId` with no
1094 /// error at all. `inject_service_defaults` stands in for
1095 /// `bynk-check/src/analysis.rs`'s own pipeline-phase-2b call — this
1096 /// reduced harness has no such phase — so a fixture relying on a
1097 /// service-level `by`/`given` default (rather than declaring one per
1098 /// handler) would otherwise silently see it un-inherited, pinning the
1099 /// wrong fact with no failure to signal it.
1100 fn checked_context_program(source: &str) -> CheckedProgram {
1101 let tokens = lexer::tokenize(source).expect("lex");
1102 let unit = parser::parse_unit(&tokens, source).expect("parse");
1103 let SourceUnit::Context(mut ctx) = unit else {
1104 panic!("expected a context unit, got {unit:?}")
1105 };
1106 for item in &mut ctx.items {
1107 if let CommonsItem::Service(svc) = item {
1108 bynk_check::project_model::inject_service_defaults(svc);
1109 }
1110 }
1111 let commons = Commons {
1112 name: ctx.name,
1113 items: ctx.items,
1114 uses: ctx.uses,
1115 documentation: ctx.documentation,
1116 form: ctx.form,
1117 span: ctx.span,
1118 trivia: ctx.trivia,
1119 trailing_comments: ctx.trailing_comments,
1120 };
1121 let resolved = resolver::resolve(commons).expect("resolve");
1122 let mut typed = checker::check(resolved).expect("check");
1123 let agents: HashMap<String, AgentDecl> = typed
1124 .commons
1125 .items
1126 .iter()
1127 .filter_map(|item| match item {
1128 CommonsItem::Agent(a) => Some((a.name.name.clone(), a.clone())),
1129 _ => None,
1130 })
1131 .collect();
1132 let services: HashMap<String, ServiceDecl> = typed
1133 .commons
1134 .items
1135 .iter()
1136 .filter_map(|item| match item {
1137 CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
1138 _ => None,
1139 })
1140 .collect();
1141 let actors: HashMap<String, bynk_syntax::ast::ActorDecl> = typed
1142 .commons
1143 .items
1144 .iter()
1145 .filter_map(|item| match item {
1146 CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
1147 _ => None,
1148 })
1149 .collect();
1150 // A `given <Cap>` clause on a handler resolves against
1151 // `table.capabilities` (`context_checks.rs`'s own `capability_info_map`
1152 // construction) — populated here from this fixture's own local
1153 // `capability` declarations so a handler can legitimately declare one
1154 // (e.g. `Log.append`'s own `given Clock` requirement), the same
1155 // "no cross-context uses/consumes" scope this helper's own doc
1156 // comment already names for `agents`/`types`.
1157 let capabilities: HashMap<String, bynk_syntax::ast::CapabilityDecl> = typed
1158 .commons
1159 .items
1160 .iter()
1161 .filter_map(|item| match item {
1162 CommonsItem::Capability(c) => Some((c.name.name.clone(), c.clone())),
1163 _ => None,
1164 })
1165 .collect();
1166 // P6.14 (#1174): `check_provider_decls` (the pass that actually
1167 // type-checks a `ProviderOp`'s own body via `check_handler_body`)
1168 // reads `table.providers`, keyed by capability name — same "one
1169 // provider per capability in v0.5" convention
1170 // `symbols::UnitTable::providers`'s own doc comment names. Populated
1171 // here for the same reason `capabilities` is (above): without it, a
1172 // fixture's own `provides` declaration is silently never checked at
1173 // all, not merely under-checked (feedback memory
1174 // "bynk-emit test harness scope").
1175 let providers: HashMap<String, ProviderDecl> = typed
1176 .commons
1177 .items
1178 .iter()
1179 .filter_map(|item| match item {
1180 CommonsItem::Provider(p) => Some((p.capability.name.clone(), p.clone())),
1181 _ => None,
1182 })
1183 .collect();
1184 let table = symbols::UnitTable {
1185 kind: Some(UnitKind::Context),
1186 types: typed.types.clone(),
1187 agents,
1188 services,
1189 actors,
1190 capabilities,
1191 providers,
1192 ..symbols::UnitTable::default()
1193 };
1194 let tys = typed.ty_intern.clone();
1195 let errors = context_checks::check_context_declarations(
1196 &mut typed,
1197 &table,
1198 &resolver::CrossContextInfo::default(),
1199 true,
1200 &HashSet::new(),
1201 &HashMap::new(),
1202 &mut RefSink::new(),
1203 &mut HintSink::new(),
1204 &mut LocalsSink::new(),
1205 &mut RequirementSink::new(),
1206 &tys,
1207 );
1208 checker::certify(typed, errors).expect("certify")
1209 }
1210
1211 fn find_agent<'a>(program: &'a CheckedProgram, name: &str) -> &'a AgentDecl {
1212 program
1213 .program()
1214 .commons
1215 .items
1216 .iter()
1217 .find_map(|item| match item {
1218 CommonsItem::Agent(a) if a.name.name == name => Some(a),
1219 _ => None,
1220 })
1221 .unwrap_or_else(|| panic!("no agent named `{name}` in this fixture"))
1222 }
1223
1224 /// `lower_actor_seam_ir`'s own `actors` map — rebuilt the same way
1225 /// `checked_context_program` builds its own throwaway `table.actors`
1226 /// (not itself exposed on `CheckedProgram`), since the resolvers it
1227 /// wraps take the same `HashMap<String, ActorDecl>` shape the real
1228 /// `table.actors`/`ctx.actors` emitter-side callers already carry.
1229 fn actors_map(program: &CheckedProgram) -> HashMap<String, ActorDecl> {
1230 program
1231 .program()
1232 .commons
1233 .items
1234 .iter()
1235 .filter_map(|item| match item {
1236 CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
1237 _ => None,
1238 })
1239 .collect()
1240 }
1241
1242 fn find_service<'a>(program: &'a CheckedProgram, name: &str) -> &'a ServiceDecl {
1243 program
1244 .program()
1245 .commons
1246 .items
1247 .iter()
1248 .find_map(|item| match item {
1249 CommonsItem::Service(s) if s.name.name == name => Some(s),
1250 _ => None,
1251 })
1252 .unwrap_or_else(|| panic!("no service named `{name}` in this fixture"))
1253 }
1254
1255 /// `find_handler` (below) matches on `method_name`, which is always
1256 /// `None` for a service handler — useless here. Matches on
1257 /// `HandlerKind` equality instead; not a unique identity on its own (a
1258 /// service may declare several handlers sharing one `HandlerKind`, all
1259 /// `on call`), so a fixture with more than one same-kind handler must
1260 /// index `service.handlers`/the lowered `handlers` slice directly
1261 /// instead of calling this twice.
1262 fn find_service_handler<'a>(service: &'a ServiceDecl, kind: &HandlerKind) -> &'a Handler {
1263 service
1264 .handlers
1265 .iter()
1266 .find(|h| &h.kind == kind)
1267 .unwrap_or_else(|| {
1268 panic!(
1269 "no handler of kind {kind:?} on service `{}`",
1270 service.name.name
1271 )
1272 })
1273 }
1274
1275 fn find_store_field<'a>(agent: &'a AgentDecl, name: &str) -> &'a StoreField {
1276 agent
1277 .store_fields
1278 .iter()
1279 .find(|f| f.name.name == name)
1280 .unwrap_or_else(|| {
1281 panic!(
1282 "no store field named `{name}` on agent `{}`",
1283 agent.name.name
1284 )
1285 })
1286 }
1287
1288 /// [`lower_store_field_shape_ir`] reads a store field's shape and never
1289 /// its initialiser — pinned against the two initialiser forms that once
1290 /// tripped the (since deleted) `init`-lowering sibling: `= None`
1291 /// (`223_store_cell_agent`'s `store paymentRef: Cell[Option[AuthId]] =
1292 /// None`) and an `is`-expression (`1029_agent_static_init_hoist`'s
1293 /// `store active: Cell[Bool] = if true { 5 is PositiveInt } else {
1294 /// false }`). Both are real, certified fixtures; the shape reader lowers
1295 /// both fields cleanly because it does not touch `init` at all.
1296 #[test]
1297 fn store_field_shape_ir_does_not_panic_on_none_or_is_initialisers() {
1298 let program = checked_context_program(
1299 r#"
1300context demo
1301
1302type AuthId = String where NonEmpty
1303
1304agent Order {
1305 key id: String
1306 store paymentRef: Cell[Option[AuthId]] = None
1307
1308 on call touch() -> Effect[()] {
1309 Effect.pure(())
1310 }
1311}
1312"#,
1313 );
1314 let agent = find_agent(&program, "Order");
1315 let payment_ref = find_store_field(agent, "paymentRef");
1316 let ir = lower_store_field_shape_ir(payment_ref, &program);
1317 assert_eq!(ir.field, "paymentRef");
1318 assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
1319
1320 let program = checked_context_program(
1321 r#"
1322context demo
1323
1324type PositiveInt = Int where Positive
1325
1326agent Meter {
1327 key id: String
1328 store active: Cell[Bool] = if true { 5 is PositiveInt } else { false }
1329
1330 on call touch() -> Effect[()] {
1331 Effect.pure(())
1332 }
1333}
1334"#,
1335 );
1336 let agent = find_agent(&program, "Meter");
1337 let active = find_store_field(agent, "active");
1338 let ir = lower_store_field_shape_ir(active, &program);
1339 assert_eq!(ir.field, "active");
1340 assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
1341 }
1342
1343 /// Review of #1209: pins the one load-bearing ordering decision
1344 /// `ActorSeamIr`'s own doc comment argues for — `sum_members_for`
1345 /// ahead of `bearer_seam_for` — at `lower_actor_seam_ir` itself, not
1346 /// only four fixture-hops away via a full `emit_service`/`bless` run.
1347 /// `bearer_seam_for` has no `by.is_sum()` guard of its own and resolves
1348 /// off `by.primary()`, so a Bearer-first sum (`by who: User | Visitor`
1349 /// with `User`'s own scheme `Bearer`) would resolve as `ActorSeamIr::
1350 /// Bearer` instead of `ActorSeamIr::Sum` if the two resolvers were ever
1351 /// tried in the other order.
1352 #[test]
1353 fn lower_actor_seam_ir_tries_sum_ahead_of_bearer_for_a_bearer_first_sum() {
1354 let program = checked_context_program(
1355 r#"
1356context demo
1357
1358type UserId = String
1359
1360actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
1361
1362service Api from http {
1363 on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
1364 Effect.pure(Ok("ok"))
1365 }
1366}
1367"#,
1368 );
1369 let service = find_service(&program, "Api");
1370 let handler = &service.handlers[0];
1371 let actors = actors_map(&program);
1372 let seam = lower_actor_seam_ir(handler, &actors);
1373 let ActorSeamIr::Sum(members) = &seam else {
1374 panic!("expected ActorSeamIr::Sum for a Bearer-first sum `by` clause, got {seam:?}");
1375 };
1376 assert_eq!(members.len(), 2);
1377 assert_eq!(members[0].actor_name, "User");
1378 assert_eq!(members[1].actor_name, "Visitor");
1379 }
1380
1381 /// [`lower_service_handler_signature_ir`] is `emit_service`'s entry
1382 /// point for a handler's resolved signature (#1187's slice 5, review of
1383 /// #1196) and never reads the body. Pinned against the shape that
1384 /// motivated it: an ordinary `from http` handler body constructing
1385 /// `Ok(...)` directly, not routed through the `fn ok(s) ->
1386 /// HttpResult[String] { Ok(s) }` indirection other fixtures in this
1387 /// module use — historically the body shape a full handler lowering
1388 /// panicked on, and still the clearest demonstration that this reader is
1389 /// signature-only.
1390 #[test]
1391 fn service_handler_signature_lowers_without_touching_a_body_that_constructs_ok() {
1392 let program = checked_context_program(
1393 r#"
1394context demo
1395
1396service Api from http {
1397 on GET("/ping") () -> Effect[HttpResult[String]] by v: Visitor {
1398 Effect.pure(Ok("pong"))
1399 }
1400}
1401"#,
1402 );
1403 let service = find_service(&program, "Api");
1404 let handler = find_service_handler(
1405 service,
1406 &HandlerKind::Http {
1407 method: bynk_syntax::ast::HttpMethod::Get,
1408 path: "/ping".to_string(),
1409 },
1410 );
1411 let (params, _given, ret, effectful) =
1412 lower_service_handler_signature_ir(handler, &program);
1413 assert!(params.is_empty(), "`() -> ...` declares no parameters");
1414 assert!(effectful, "an `Effect[...]` return type");
1415 assert!(matches!(
1416 &*program.program().ty_intern.get(ret),
1417 Ty::Effect(_)
1418 ));
1419 }
1420
1421 /// Mirrors `bynkc/tests/fixtures/positive/236_websocket_chatroom` in
1422 /// full — `on open`/`on message`/`on close` all present, the same
1423 /// shape the real fixture uses — so this fixture's own tests can cover
1424 /// both the owned (`on open`) and borrowed (`on message`/`on close`,
1425 /// P6.13, #1179) `connection` cases. A held `Connection` needs real
1426 /// disposal to certify (the linearity pass), so `on open` transfers it
1427 /// into a trivial `Room` agent rather than dropping it.
1428 fn websocket_service_fixture() -> CheckedProgram {
1429 checked_context_program(
1430 r#"
1431context demo
1432
1433type RoomId = String
1434type UserId = String
1435type ServerFrame = { text: String }
1436type ClientFrame = { text: String }
1437
1438actor Participant { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
1439
1440service ChatGateway from websocket(in: ClientFrame, out: ServerFrame) {
1441 on open (roomId: RoomId) -> Effect[()] by user: Participant {
1442 let _ <- connection.send(ServerFrame { text: "welcome" })
1443 let _ <- Room(roomId).join(user.identity, connection)
1444 ()
1445 }
1446
1447 on message (roomId: RoomId, frame: ClientFrame) -> Effect[()] by user: Participant {
1448 let _ <- connection.send(ServerFrame { text: frame.text })
1449 let _ <- Room(roomId).post(user.identity, frame.text)
1450 ()
1451 }
1452
1453 on close (roomId: RoomId) -> Effect[()] by user: Participant {
1454 let _ <- Room(roomId).leave(user.identity)
1455 ()
1456 }
1457}
1458
1459agent Room {
1460 key id: RoomId
1461 store members: Set[UserId]
1462 store conns: Map[UserId, Connection[ServerFrame]]
1463
1464 on call join(u: UserId, conn: Connection[ServerFrame]) -> Effect[()] {
1465 let _ <- members.add(u)
1466 let _ <- conns.put(u, conn)
1467 ()
1468 }
1469
1470 on call leave(u: UserId) -> Effect[()] {
1471 let _ <- members.remove(u)
1472 let _ <- conns.remove(u)
1473 ()
1474 }
1475
1476 on call post(sender: UserId, text: String) -> Effect[()] {
1477 let _ <- conns.parTraverse((c: Connection[ServerFrame]) => c.send(ServerFrame { text: text }))
1478 ()
1479 }
1480}
1481"#,
1482 )
1483 }
1484
1485 #[test]
1486 fn websocket_protocol_descriptor_lowers_its_frame_types() {
1487 let program = websocket_service_fixture();
1488 let service = find_service(&program, "ChatGateway");
1489 let ir = lower_protocol_ir(&service.protocol, &program);
1490 let ProtocolIr::WebSocket { in_ty, out_ty } = ir else {
1491 panic!("expected ProtocolIr::WebSocket, got {:?}", ir)
1492 };
1493 let tys = &program.program().ty_intern;
1494 assert_eq!(in_ty.display(tys), "ClientFrame");
1495 assert_eq!(out_ty.display(tys), "ServerFrame");
1496 }
1497
1498 #[test]
1499 fn lower_event_pattern_ir_reshapes_literal_and_variant_fields() {
1500 // Review of #1180: the `Events` protocol path had zero coverage.
1501 // `lower_protocol_ir`'s own `Events` arm genuinely can't be driven
1502 // through `checked_context_program` — a real `from Events(E)`
1503 // subscription needs `consumes bynk { Events }`, which this
1504 // reduced harness's `CrossContextInfo::default()` doesn't support
1505 // (the same limitation named in `checked_context_program`'s own
1506 // doc comment). `lower_event_pattern_ir` itself has no such
1507 // excuse: it takes no `&CheckedProgram`, resolves nothing, and
1508 // cannot panic — pure AST reshaping — so it's pinned directly
1509 // against a parsed (not checked or certified) `EventPattern`.
1510 let source = r#"
1511context demo
1512
1513type Status = | Active | Inactive
1514
1515event OrderPlaced = {
1516 status: Status,
1517 count: Int,
1518}
1519
1520service Subscriber from Events(OrderPlaced { status: Active, count: 3, .. }) {
1521 on event(o: OrderPlaced) -> Effect[()] {
1522 Effect.pure(())
1523 }
1524}
1525"#;
1526 let tokens = lexer::tokenize(source).expect("lex");
1527 let unit = parser::parse_unit(&tokens, source).expect("parse");
1528 let SourceUnit::Context(ctx) = unit else {
1529 panic!("expected a context unit, got {unit:?}")
1530 };
1531 let service = ctx
1532 .items
1533 .iter()
1534 .find_map(|item| match item {
1535 CommonsItem::Service(s) if s.name.name == "Subscriber" => Some(s),
1536 _ => None,
1537 })
1538 .expect("no service named `Subscriber` in this fixture");
1539 let ServiceProtocol::Events { pattern, .. } = &service.protocol else {
1540 panic!(
1541 "expected ServiceProtocol::Events, got {:?}",
1542 service.protocol
1543 )
1544 };
1545 let pattern = pattern
1546 .as_ref()
1547 .expect("expected a structural pattern on this Events subscription");
1548
1549 let ir = lower_event_pattern_ir(pattern);
1550 assert_eq!(ir.fields.len(), 2, "declaration order preserved");
1551 assert_eq!(ir.fields[0].0, "status");
1552 assert!(
1553 matches!(&ir.fields[0].1, EventPatternValueIr::Variant { tag } if tag == "Active"),
1554 "expected a bare nullary variant tag (the AST's own optional qualifying type_name \
1555 dropped), got {:?}",
1556 ir.fields[0].1
1557 );
1558 assert_eq!(ir.fields[1].0, "count");
1559 assert!(
1560 matches!(
1561 &ir.fields[1].1,
1562 EventPatternValueIr::Const(ConstVal::Int(3))
1563 ),
1564 "expected a literal Int constant, got {:?}",
1565 ir.fields[1].1
1566 );
1567 }
1568
1569 /// P6.29 (design/tracks/the-ir.md §6a): pins `capability_op_sig_from_commons`
1570 /// against the same fixture as its `CheckedProgram`-driven sibling above —
1571 /// same param names/order, found by name alone from `TypedCommons`, no
1572 /// `CheckedProgram` needed at the call site (`emitter/lower.rs`'s
1573 /// `cap_op_param_names` only ever had one).
1574 #[test]
1575 fn capability_op_sig_from_commons_finds_the_named_op() {
1576 let program = checked_context_program(
1577 r#"
1578context demo
1579
1580capability Store {
1581 fn get(key: String) -> Effect[Int]
1582 fn put(key: String, value: Int) -> Effect[()]
1583}
1584"#,
1585 );
1586 let commons = program.program();
1587
1588 let get = capability_op_sig_from_commons(commons, "Store", "get")
1589 .expect("Store.get should resolve");
1590 assert_eq!(get.params.len(), 1);
1591 assert_eq!(get.params[0].0, "key");
1592
1593 let put = capability_op_sig_from_commons(commons, "Store", "put")
1594 .expect("Store.put should resolve");
1595 assert_eq!(put.params.len(), 2);
1596 assert_eq!(put.params[0].0, "key");
1597 assert_eq!(put.params[1].0, "value");
1598
1599 // Same fallthrough-to-`None` behaviour the by-hand AST walk it
1600 // replaces had, for both an unknown capability and a known
1601 // capability's unknown op — mirrors `cap_op_param_names`'s own prior
1602 // fallthrough-to-empty-`Vec` at the caller.
1603 assert!(capability_op_sig_from_commons(commons, "NoSuchCap", "get").is_none());
1604 assert!(capability_op_sig_from_commons(commons, "Store", "no_such_op").is_none());
1605 }
1606
1607 #[test]
1608 fn lower_cap_ref_ir_local_capability_has_no_context() {
1609 let cap_ref = CapRef {
1610 context: None,
1611 name: bynk_syntax::ast::Ident {
1612 name: "Clock".to_string(),
1613 span: Span::default(),
1614 },
1615 span: Span::default(),
1616 };
1617 let ir = lower_cap_ref_ir(&cap_ref);
1618 assert_eq!(ir.context, None);
1619 assert_eq!(ir.name, "Clock");
1620 }
1621
1622 #[test]
1623 fn lower_cap_ref_ir_preserves_a_cross_context_prefix() {
1624 // `given B.Cap` (v0.15) is out of `checked_context_program`'s own
1625 // fixture scope (no cross-context `uses`/`consumes`, feedback
1626 // memory "bynk-emit test harness scope") — pins `lower_cap_ref_ir`'s
1627 // own `QualifiedName -> String` flattening directly against a
1628 // hand-built `CapRef`, the same posture the external-provider test
1629 // above already takes for a branch the fixture cannot reach.
1630 let cap_ref = CapRef {
1631 context: Some(QualifiedName {
1632 parts: vec![bynk_syntax::ast::Ident {
1633 name: "Billing".to_string(),
1634 span: Span::default(),
1635 }],
1636 span: Span::default(),
1637 }),
1638 name: bynk_syntax::ast::Ident {
1639 name: "Ledger".to_string(),
1640 span: Span::default(),
1641 },
1642 span: Span::default(),
1643 };
1644 let ir = lower_cap_ref_ir(&cap_ref);
1645 assert_eq!(ir.context.as_deref(), Some("Billing"));
1646 assert_eq!(ir.name, "Ledger");
1647 }
1648
1649 /// Review of #1229 (#1228): `lower_route_cache_ir`/`lower_route_limit_ir`
1650 /// take no `&CheckedProgram`, resolve nothing, and cannot panic — the same
1651 /// posture `lower_event_pattern_ir`'s own test above already established a
1652 /// parsed-not-checked fixture for, and for the identical reason here: their
1653 /// defensive branches (a non-`GET` handler, a `maxAge`-less `@cache`, a
1654 /// non-positive `maxBody`) are exactly the shapes `bynk-check`'s
1655 /// `bynk.http.cache_on_non_get`/`cache_bad_max_age`/`limit_bad_max_body`
1656 /// (`bynk-check/src/context_checks.rs`) already reject, so a real checked
1657 /// program can never reach them and the fixture bless run never exercises
1658 /// them either.
1659 fn parsed_only_context(source: &str) -> bynk_syntax::ast::Context {
1660 let tokens = lexer::tokenize(source).expect("lex");
1661 let unit = parser::parse_unit(&tokens, source).expect("parse");
1662 let SourceUnit::Context(ctx) = unit else {
1663 panic!("expected a context unit, got {unit:?}")
1664 };
1665 ctx
1666 }
1667
1668 fn parsed_handler<'a>(
1669 ctx: &'a bynk_syntax::ast::Context,
1670 service: &str,
1671 index: usize,
1672 ) -> &'a Handler {
1673 let service = ctx
1674 .items
1675 .iter()
1676 .find_map(|item| match item {
1677 CommonsItem::Service(s) if s.name.name == service => Some(s),
1678 _ => None,
1679 })
1680 .unwrap_or_else(|| panic!("no service named `{service}` in this fixture"));
1681 &service.handlers[index]
1682 }
1683
1684 #[test]
1685 fn lower_route_cache_ir_reads_maxage_and_scope_off_a_get_handler() {
1686 let ctx = parsed_only_context(
1687 r#"
1688context demo
1689
1690service Api from http {
1691 @cache(maxAge: 5.minutes, scope: public)
1692 on GET("/config") () -> Effect[HttpResult[String]] by v: Visitor {
1693 Ok("cfg")
1694 }
1695
1696 @cache(maxAge: 30.seconds)
1697 on GET("/private") () -> Effect[HttpResult[String]] by v: Visitor {
1698 Ok("priv")
1699 }
1700
1701 on GET("/plain") () -> Effect[HttpResult[String]] by v: Visitor {
1702 Ok("plain")
1703 }
1704}
1705"#,
1706 );
1707 let public_cache = lower_route_cache_ir(parsed_handler(&ctx, "Api", 0))
1708 .unwrap_or_else(|| panic!("expected Some(CacheIr) for a well-formed @cache"));
1709 assert_eq!(public_cache.max_age_secs, 300, "5.minutes in whole seconds");
1710 assert_eq!(public_cache.scope, "public");
1711
1712 let default_scope_cache = lower_route_cache_ir(parsed_handler(&ctx, "Api", 1))
1713 .unwrap_or_else(|| panic!("expected Some(CacheIr) with no explicit scope:"));
1714 assert_eq!(default_scope_cache.max_age_secs, 30);
1715 assert_eq!(
1716 default_scope_cache.scope, "private",
1717 "no scope: argument written — must default to private"
1718 );
1719
1720 assert!(
1721 lower_route_cache_ir(parsed_handler(&ctx, "Api", 2)).is_none(),
1722 "no @cache annotation at all must yield None"
1723 );
1724 }
1725
1726 #[test]
1727 fn lower_route_cache_ir_returns_none_for_a_non_get_handler_even_with_a_cache_annotation() {
1728 // `bynk.http.cache_on_non_get` already rejects this at check time — this
1729 // pins the lowering function's own independent guard, not reachable
1730 // through a real certified program.
1731 let ctx = parsed_only_context(
1732 r#"
1733context demo
1734
1735service Api from http {
1736 @cache(maxAge: 5.minutes)
1737 on POST("/items") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1738 Created(body)
1739 }
1740}
1741"#,
1742 );
1743 assert!(
1744 lower_route_cache_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
1745 "a @cache on a non-GET handler must not construct a CacheIr"
1746 );
1747 }
1748
1749 #[test]
1750 fn lower_route_cache_ir_discards_an_otherwise_well_formed_scope_when_maxage_is_missing() {
1751 // `bynk.http.cache_bad_max_age` already rejects a maxAge-less @cache at
1752 // check time — this pins that `scope`'s own well-formedness does not
1753 // rescue a missing `maxAge` into a partial CacheIr.
1754 let ctx = parsed_only_context(
1755 r#"
1756context demo
1757
1758service Api from http {
1759 @cache(scope: public)
1760 on GET("/broken") () -> Effect[HttpResult[String]] by v: Visitor {
1761 Ok("x")
1762 }
1763}
1764"#,
1765 );
1766 assert!(
1767 lower_route_cache_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
1768 "a well-formed scope: must not survive a missing maxAge:"
1769 );
1770 }
1771
1772 #[test]
1773 fn lower_route_limit_ir_reads_maxbody_off_a_route_annotation() {
1774 let ctx = parsed_only_context(
1775 r#"
1776context demo
1777
1778service Api from http {
1779 @limit(maxBody: 26_214_400)
1780 on POST("/bulk") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1781 Created(body)
1782 }
1783
1784 on POST("/upload") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1785 Created(body)
1786 }
1787}
1788"#,
1789 );
1790 assert_eq!(
1791 lower_route_limit_ir(parsed_handler(&ctx, "Api", 0)),
1792 Some(26_214_400)
1793 );
1794 assert!(
1795 lower_route_limit_ir(parsed_handler(&ctx, "Api", 1)).is_none(),
1796 "no @limit annotation at all must yield None — the caller applies the \
1797 service-wide default, this function does not know it"
1798 );
1799 }
1800
1801 #[test]
1802 fn lower_route_limit_ir_returns_none_for_a_non_positive_maxbody() {
1803 // `bynk.http.limit_bad_max_body` already rejects a non-positive maxBody
1804 // at check time — this pins the lowering function's own independent
1805 // guard. `None` here matters specifically because the caller's own
1806 // fallback composition (`effective_max_body`) treats it as "no
1807 // route-level override," falling through to the service-wide default —
1808 // not as "an explicit zero-byte cap."
1809 let ctx = parsed_only_context(
1810 r#"
1811context demo
1812
1813service Api from http {
1814 @limit(maxBody: 0)
1815 on POST("/zero") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1816 Created(body)
1817 }
1818}
1819"#,
1820 );
1821 assert!(
1822 lower_route_limit_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
1823 "a non-positive maxBody must not construct Some(0)"
1824 );
1825 }
1826
1827 /// Every `body_writes_state` classification (#1165's [DECISION B]/
1828 /// [DECISION C], the shipped `emit_agent` implicit-commit decision) lives
1829 /// on one agent — each handler exercises exactly one case so a failing
1830 /// assertion names its own scenario unambiguously. Until Slice D1 of
1831 /// `#1542` these pinned the same function through the deleted IR-side
1832 /// `CommitShape` constructor; they now pin it directly.
1833 fn store_write_fixture() -> CheckedProgram {
1834 checked_context_program(
1835 r#"
1836context demo
1837
1838type Box = { n: Int }
1839
1840fn Box.put(self, x: Int) -> Effect[()] {
1841 Effect.pure(())
1842}
1843
1844capability Clock {
1845 fn now() -> Effect[Int]
1846}
1847
1848provides Clock = FixedClock {
1849 fn now() -> Effect[Int] {
1850 42
1851 }
1852}
1853
1854agent Widget {
1855 key id: String
1856 store items: Map[String, Int]
1857 store active: Cell[Bool] = true
1858 store tags: Set[String]
1859 store history: Log[String]
1860
1861 on call readOnlyPlain() -> Effect[()] {
1862 Effect.pure(())
1863 }
1864
1865 on call readOnlyQuery() -> Effect[Int] {
1866 items.size()
1867 }
1868
1869 on call nestedMutation(xs: List[String], flag: Bool) -> Effect[()] {
1870 if flag {
1871 match flag {
1872 true => xs.forEach((x) => items.put(x, 1))
1873 false => Effect.pure(())
1874 }
1875 } else {
1876 Effect.pure(())
1877 }
1878 }
1879
1880 on call bareAssign(v: Bool) -> Effect[()] {
1881 active := v
1882 Effect.pure(())
1883 }
1884
1885 on call shadowedName(items: Box, x: Int) -> Effect[()] {
1886 let _ <- items.put(x)
1887 Effect.pure(())
1888 }
1889
1890 on call cellUpdate() -> Effect[()] {
1891 let _ <- active.update((b) => !b)
1892 Effect.pure(())
1893 }
1894
1895 on call setAdd(t: String) -> Effect[()] {
1896 let _ <- tags.add(t)
1897 Effect.pure(())
1898 }
1899
1900 on call logAppend(t: String) -> Effect[()] given Clock {
1901 let _ <- history.append(t)
1902 Effect.pure(())
1903 }
1904}
1905"#,
1906 )
1907 }
1908
1909 /// #2220: the agent state model recomputes the emitter's commit condition
1910 /// syntactically (`bynk_check::agent_state::handler_commits`), because
1911 /// the state view runs on a re-parse with no resolved callees. Pin the two
1912 /// together on every handler of a fixture built to probe where they could
1913 /// part: each store kind's mutating ops, a non-mutating query, writes
1914 /// nested in `if`/`match`/lambdas, a tail write, a query before a write, and a
1915 /// parameter, a `let`,
1916 /// a match binding, a lambda parameter and a `let` in a block-bodied lambda
1917 /// that shadow a store field.
1918 #[test]
1919 fn the_state_models_commit_condition_agrees_with_body_writes_state() {
1920 let extra = checked_context_program(
1921 r#"
1922context demo
1923
1924type Box = { n: Int }
1925
1926fn Box.put(self, x: Int) -> Effect[()] {
1927 Effect.pure(())
1928}
1929
1930agent Gadget {
1931 key id: String
1932 store items: Map[String, Int]
1933 store tags: Set[String]
1934
1935 on call letShadow() -> Effect[()] {
1936 let items = Box { n: 1 }
1937 let _ <- items.put(2)
1938 Effect.pure(())
1939 }
1940
1941 on call matchShadow(b: Option[Box]) -> Effect[()] {
1942 match b {
1943 Some(items) => items.put(1)
1944 None => Effect.pure(())
1945 }
1946 }
1947
1948 on call lambdaShadow(bs: List[Box]) -> Effect[()] {
1949 bs.forEach((items) => items.put(1))
1950 }
1951
1952 on call blockLambdaShadow(bs: List[Box]) -> Effect[()] {
1953 bs.forEach((b) => {
1954 let items = b
1955 items.put(1)
1956 })
1957 }
1958
1959 on call tailWrite(t: String) -> Effect[()] {
1960 tags.add(t)
1961 }
1962
1963 on call queryThenWrite(t: String) -> Effect[Bool] {
1964 let present <- tags.contains(t)
1965 if present { true } else {
1966 let _ <- tags.add(t)
1967 false
1968 }
1969 }
1970}
1971"#,
1972 );
1973 let mut compared = 0;
1974 for (program, agent) in [(store_write_fixture(), "Widget"), (extra, "Gadget")] {
1975 let a = find_agent(&program, agent);
1976 for h in &a.handlers {
1977 let name = h.method_name.as_ref().map_or("call", |m| m.name.as_str());
1978 assert_eq!(
1979 bynk_check::agent_state::handler_commits(a, h),
1980 body_writes_state(&h.body, program.program()),
1981 "`{agent}.{name}`: the state model and the emitter disagree on whether it commits"
1982 );
1983 compared += 1;
1984 }
1985 }
1986 assert_eq!(compared, 14, "every fixture handler was compared");
1987 }
1988
1989 fn find_handler<'a>(agent: &'a AgentDecl, name: &str) -> &'a Handler {
1990 agent
1991 .handlers
1992 .iter()
1993 .find(|h| h.method_name.as_ref().is_some_and(|m| m.name == name))
1994 .unwrap_or_else(|| panic!("no handler named `{name}` on agent `{}`", agent.name.name))
1995 }
1996
1997 /// A queue consumer's own body is the one shape this module's own
1998 /// pre-existing gaps make genuinely unlowerable today, not just
1999 /// awkward to fixture around: `Effect[QueueResult]` is mandatory
2000 /// (`bynk.queue.return_not_https`-adjacent gate, `context_checks.rs:
2001 /// 3730-3744`), and every `QueueResult` value — `Ack`, `NotFound`,
2002 /// `Retry(reason)` — is a bare or qualified built-in-sum variant
2003 /// reference (a contextual, `expected`-type-driven disambiguation).
2004 /// Unlike `HttpResult`'s `Ok`/`Err`, `QueueResult`'s
2005 /// own variants also don't resolve inside an ordinary free `fn` body at
2006 /// all (confirmed empirically: `bynk.resolve.unknown_name`) — the
2007 /// checker's own special-case for them (`checker.rs:3507`) is reached
2008 /// only via a real handler body's own `Ctx::return_ty`, which the
2009 /// resolver's eager pass over an ordinary `fn` never sets up — so the
2010 /// `fn ok(s) -> HttpResult[String] { Ok(s) }` indirection every other
2011 /// HTTP/cron fixture in this module uses has no queue-shaped
2012 /// equivalent. Two tests, not one, cover what's actually true here.
2013 fn queue_service_fixture() -> CheckedProgram {
2014 checked_context_program(
2015 r#"
2016context demo
2017
2018type EmailJob = { to: String }
2019
2020service Outbox from queue("orders") {
2021 on message(m: EmailJob) -> Effect[QueueResult] {
2022 Ack
2023 }
2024}
2025"#,
2026 )
2027 }
2028
2029 #[test]
2030 fn a_queue_services_protocol_and_handler_signature_lower_correctly() {
2031 // The protocol descriptor (standalone, mirroring `lower_protocol_ir`'s
2032 // own precedent for `from websocket`) and the handler's own
2033 // `params`/`given`/`effectful` via `lower_service_handler_signature_ir`
2034 // — the two shape readers `bynk-emit` consumes for a queue service.
2035 let program = queue_service_fixture();
2036 let service = find_service(&program, "Outbox");
2037 assert!(matches!(
2038 lower_protocol_ir(&service.protocol, &program),
2039 ProtocolIr::Queue { name } if name == "orders"
2040 ));
2041 let handler = find_service_handler(service, &HandlerKind::Message);
2042 let (params, given, _ret, effectful) =
2043 lower_service_handler_signature_ir(handler, &program);
2044 assert_eq!(params.len(), 1);
2045 assert_eq!(params[0].0, "m");
2046 assert!(given.is_empty());
2047 assert!(effectful, "every service handler returns Effect[T]");
2048 }
2049
2050 #[test]
2051 fn store_field_with_an_unresolvable_type_is_rejected_before_lowering() {
2052 // #1187's Agent state-field slice, step 0, found that no checker pass
2053 // validated a store field's own type reference, so `store x:
2054 // Cell[Bogus]` certified and reached `resolve_store_field_ty`, whose
2055 // `Ty::Unit` fallback this test used to pin. #1679 closed that gap: the
2056 // resolver now walks agent store field types, so the program is
2057 // rejected with `bynk.resolve.unknown_type` before lowering, and the
2058 // fallback is defensive only.
2059 let source = r#"
2060context demo
2061
2062agent Widget {
2063 key id: String
2064 store x: Cell[Bogus] = "hello"
2065
2066 on call touch() -> Effect[()] {
2067 Effect.pure(())
2068 }
2069}
2070"#;
2071 let tokens = lexer::tokenize(source).expect("lex");
2072 let unit = parser::parse_unit(&tokens, source).expect("parse");
2073 let SourceUnit::Context(ctx) = unit else {
2074 panic!("expected a context unit, got {unit:?}")
2075 };
2076 let commons = Commons {
2077 name: ctx.name,
2078 items: ctx.items,
2079 uses: ctx.uses,
2080 documentation: ctx.documentation,
2081 form: ctx.form,
2082 span: ctx.span,
2083 trivia: ctx.trivia,
2084 trailing_comments: ctx.trailing_comments,
2085 };
2086 let Err(errors) = resolver::resolve(commons) else {
2087 panic!("an unknown store type must be rejected by the resolver")
2088 };
2089 assert!(
2090 errors
2091 .iter()
2092 .any(|e| e.category == "bynk.resolve.unknown_type" && e.message.contains("Bogus")),
2093 "{errors:?}"
2094 );
2095 }
2096
2097 #[test]
2098 fn body_writes_state_is_false_for_a_plain_body() {
2099 let program = store_write_fixture();
2100 let handler = find_handler(find_agent(&program, "Widget"), "readOnlyPlain");
2101 assert!(!body_writes_state(&handler.body, program.program()));
2102 }
2103
2104 #[test]
2105 fn body_writes_state_is_false_for_a_non_mutating_store_read() {
2106 let program = store_write_fixture();
2107 let handler = find_handler(find_agent(&program, "Widget"), "readOnlyQuery");
2108 assert!(!body_writes_state(&handler.body, program.program()));
2109 }
2110
2111 #[test]
2112 fn body_writes_state_is_false_for_a_locally_shadowed_store_field_name() {
2113 let program = store_write_fixture();
2114 let handler = find_handler(find_agent(&program, "Widget"), "shadowedName");
2115 assert!(!body_writes_state(&handler.body, program.program()));
2116 }
2117
2118 #[test]
2119 fn body_writes_state_is_true_for_a_write_nested_in_if_match_lambda() {
2120 let program = store_write_fixture();
2121 let handler = find_handler(find_agent(&program, "Widget"), "nestedMutation");
2122 assert!(body_writes_state(&handler.body, program.program()));
2123 }
2124
2125 #[test]
2126 fn body_writes_state_is_true_for_a_bare_cell_assign() {
2127 let program = store_write_fixture();
2128 let handler = find_handler(find_agent(&program, "Widget"), "bareAssign");
2129 assert!(body_writes_state(&handler.body, program.program()));
2130 }
2131
2132 #[test]
2133 fn body_writes_state_is_true_for_a_cell_update_method_call() {
2134 let program = store_write_fixture();
2135 let handler = find_handler(find_agent(&program, "Widget"), "cellUpdate");
2136 assert!(body_writes_state(&handler.body, program.program()));
2137 }
2138
2139 #[test]
2140 fn body_writes_state_is_true_for_a_set_add_method_call() {
2141 let program = store_write_fixture();
2142 let handler = find_handler(find_agent(&program, "Widget"), "setAdd");
2143 assert!(body_writes_state(&handler.body, program.program()));
2144 }
2145
2146 #[test]
2147 fn body_writes_state_is_true_for_a_log_append_method_call() {
2148 let program = store_write_fixture();
2149 let handler = find_handler(find_agent(&program, "Widget"), "logAppend");
2150 assert!(body_writes_state(&handler.body, program.program()));
2151 }
2152
2153 fn checked_program(source: &str) -> CheckedProgram {
2154 let tokens = lexer::tokenize(source).expect("lex");
2155 let (commons, warnings) = parser::parse_with_warnings(&tokens, source).expect("parse");
2156 let resolved = resolver::resolve(commons).expect("resolve");
2157 let typed = checker::check(resolved).expect("check");
2158 checker::certify(typed, warnings).expect("certify")
2159 }
2160
2161 fn find_type<'a>(program: &'a CheckedProgram, name: &str) -> &'a Arc<TypeDecl> {
2162 program
2163 .program()
2164 .types
2165 .get(name)
2166 .unwrap_or_else(|| panic!("no type named `{name}` in this fixture"))
2167 }
2168
2169 fn find_capability<'a>(program: &'a CheckedProgram, name: &str) -> &'a CapabilityDecl {
2170 program
2171 .program()
2172 .commons
2173 .items
2174 .iter()
2175 .find_map(|item| match item {
2176 CommonsItem::Capability(c) if c.name.name == name => Some(c),
2177 _ => None,
2178 })
2179 .unwrap_or_else(|| panic!("no capability named `{name}` in this fixture"))
2180 }
2181
2182 fn find_provider<'a>(program: &'a CheckedProgram, name: &str) -> &'a ProviderDecl {
2183 program
2184 .program()
2185 .commons
2186 .items
2187 .iter()
2188 .find_map(|item| match item {
2189 CommonsItem::Provider(p) if p.provider_name.name == name => Some(p),
2190 _ => None,
2191 })
2192 .unwrap_or_else(|| panic!("no provider named `{name}` in this fixture"))
2193 }
2194
2195 // The six `type_shape_*` tests below, the capability/provider/handler-kind
2196 // tests after them, and the `body_writes_state_*` tests above were all
2197 // re-created by Slice D1 of `#1542`: each pinned a kept helper only
2198 // through a deleted item constructor (`IrItem::Type`/`Capability`/
2199 // `Provider`/`Service`) and now calls the helper directly, asserting the
2200 // same facts minus the constructor's own wrapper field.
2201
2202 #[test]
2203 fn type_shape_record_resolves_fields_and_generic_rigid_vars() {
2204 let program = checked_program(
2205 r#"
2206commons demo {
2207 type Box[T] = { value: T }
2208}
2209"#,
2210 );
2211 let shape = lower_type_shape_ir(find_type(&program, "Box"), &program);
2212 let TypeShape::Record { fields } = &shape else {
2213 panic!("expected TypeShape::Record, got {shape:?}")
2214 };
2215 assert_eq!(fields.len(), 1);
2216 assert_eq!(fields[0].0, "value");
2217 assert!(matches!(
2218 &*program.program().ty_intern.get(fields[0].1),
2219 Ty::Var(name) if name == "T"
2220 ));
2221 }
2222
2223 #[test]
2224 fn type_shape_sum_resolves_variant_payloads_and_embeds() {
2225 let program = checked_program(
2226 r#"
2227commons demo {
2228 type PaymentError = enum { Declined, InsufficientFunds }
2229
2230 type OrderError =
2231 | OutOfStock(sku: String, qty: Int)
2232 | Payment(reason: PaymentError)
2233 embeds PaymentError as Payment
2234}
2235"#,
2236 );
2237 let shape = lower_type_shape_ir(find_type(&program, "OrderError"), &program);
2238 let TypeShape::Sum { variants, embeds } = &shape else {
2239 panic!("expected TypeShape::Sum, got {shape:?}")
2240 };
2241 assert_eq!(variants.len(), 2);
2242 assert_eq!(variants[0].0, "OutOfStock");
2243 assert_eq!(variants[0].1.len(), 2);
2244 assert_eq!(variants[0].1[0].0, "sku");
2245 assert!(matches!(
2246 &*program.program().ty_intern.get(variants[0].1[0].1),
2247 Ty::Base(bynk_syntax::ast::BaseType::String)
2248 ));
2249 assert_eq!(variants[0].1[1].0, "qty");
2250 assert!(matches!(
2251 &*program.program().ty_intern.get(variants[0].1[1].1),
2252 Ty::Base(bynk_syntax::ast::BaseType::Int)
2253 ));
2254 assert_eq!(variants[1].0, "Payment");
2255 assert_eq!(variants[1].1.len(), 1);
2256 assert_eq!(variants[1].1[0].0, "reason");
2257
2258 assert_eq!(embeds.len(), 1);
2259 let (source, tag) = &embeds[0];
2260 assert_eq!(tag, "Payment");
2261 let Ty::Named { name, .. } = &*program.program().ty_intern.get(*source) else {
2262 panic!("expected embeds source to resolve to a named type")
2263 };
2264 assert_eq!(name, "PaymentError");
2265 }
2266
2267 #[test]
2268 fn type_shape_refined_and_opaque_cover_bare_and_predicated_and_opaque_forms() {
2269 let program = checked_program(
2270 r#"
2271commons demo {
2272 type Age = Int where Positive
2273 type UserId = opaque Int
2274 type Bare = Int
2275}
2276"#,
2277 );
2278 let TypeShape::Refined {
2279 base,
2280 refinement,
2281 opaque,
2282 } = lower_type_shape_ir(find_type(&program, "Age"), &program)
2283 else {
2284 panic!("expected TypeShape::Refined for Age")
2285 };
2286 assert_eq!(base, bynk_syntax::ast::BaseType::Int);
2287 assert!(refinement.is_some());
2288 assert!(!opaque);
2289
2290 let TypeShape::Refined {
2291 refinement, opaque, ..
2292 } = lower_type_shape_ir(find_type(&program, "UserId"), &program)
2293 else {
2294 panic!("expected TypeShape::Refined for UserId")
2295 };
2296 assert!(refinement.is_none());
2297 assert!(opaque);
2298
2299 let TypeShape::Refined {
2300 refinement, opaque, ..
2301 } = lower_type_shape_ir(find_type(&program, "Bare"), &program)
2302 else {
2303 panic!("expected TypeShape::Refined for Bare")
2304 };
2305 assert!(refinement.is_none());
2306 assert!(!opaque);
2307 }
2308
2309 #[test]
2310 fn type_shape_sum_covers_a_payload_less_variant() {
2311 let program = checked_program(
2312 r#"
2313commons demo {
2314 type PaymentError = enum { Declined, InsufficientFunds }
2315}
2316"#,
2317 );
2318 let shape = lower_type_shape_ir(find_type(&program, "PaymentError"), &program);
2319 let TypeShape::Sum { variants, embeds } = &shape else {
2320 panic!("expected TypeShape::Sum, got {shape:?}")
2321 };
2322 assert_eq!(variants.len(), 2);
2323 assert_eq!(variants[0].0, "Declined");
2324 assert!(
2325 variants[0].1.is_empty(),
2326 "a bare variant carries no payload"
2327 );
2328 assert_eq!(variants[1].0, "InsufficientFunds");
2329 assert!(variants[1].1.is_empty());
2330 assert!(embeds.is_empty());
2331 }
2332
2333 #[test]
2334 fn type_shape_record_drops_a_fields_own_inline_refinement() {
2335 // Decision B extension, `bynk-ir`'s own `TypeShape::Record` doc
2336 // comment: a field's inline `where` clause is a construction-time
2337 // constraint the checker already enforces, not part of the emitted
2338 // shape — pin that the field still lowers to `(name, ty)` with the
2339 // refinement silently absent, not that lowering rejects it.
2340 let program = checked_program(
2341 r#"
2342commons demo {
2343 type Account = { balance: Int where NonNegative }
2344}
2345"#,
2346 );
2347 let shape = lower_type_shape_ir(find_type(&program, "Account"), &program);
2348 let TypeShape::Record { fields } = &shape else {
2349 panic!("expected TypeShape::Record, got {shape:?}")
2350 };
2351 assert_eq!(fields.len(), 1);
2352 assert_eq!(fields[0].0, "balance");
2353 assert!(matches!(
2354 &*program.program().ty_intern.get(fields[0].1),
2355 Ty::Base(bynk_syntax::ast::BaseType::Int)
2356 ));
2357 }
2358
2359 #[test]
2360 fn type_shape_record_resolves_a_generic_type_application_field() {
2361 // The `TypeRef::App` arm of `resolve_type_ref_in` — the one arm
2362 // that returns `None` for an unknown/unapplied name, and so the one
2363 // most likely to silently hit this pass's own ADR 0334 panic if the
2364 // field's resolution were ever wired up wrong.
2365 let program = checked_program(
2366 r#"
2367commons demo {
2368 type Box[T] = { value: T }
2369 type Wrapper = { boxed: Box[Int] }
2370}
2371"#,
2372 );
2373 let shape = lower_type_shape_ir(find_type(&program, "Wrapper"), &program);
2374 let TypeShape::Record { fields } = &shape else {
2375 panic!("expected TypeShape::Record, got {shape:?}")
2376 };
2377 assert_eq!(fields.len(), 1);
2378 assert_eq!(fields[0].0, "boxed");
2379 let Ty::Named { name, args, .. } = &*program.program().ty_intern.get(fields[0].1) else {
2380 panic!("expected `boxed` to resolve to a named type")
2381 };
2382 assert_eq!(name, "Box");
2383 assert_eq!(args.len(), 1);
2384 assert!(matches!(
2385 &*program.program().ty_intern.get(args[0]),
2386 Ty::Base(bynk_syntax::ast::BaseType::Int)
2387 ));
2388 }
2389
2390 #[test]
2391 fn lower_capability_ops_ir_assembles_ops_in_declaration_order() {
2392 let program = checked_context_program(
2393 r#"
2394context demo
2395
2396capability Store {
2397 fn get(key: String) -> Effect[Int]
2398 fn put(key: String, value: Int) -> Effect[()]
2399}
2400"#,
2401 );
2402 let ops = lower_capability_ops_ir(find_capability(&program, "Store"), &program);
2403 assert_eq!(ops.len(), 2, "declaration order preserved");
2404
2405 assert_eq!(ops[0].name, "get");
2406 assert!(ops[0].type_params.is_empty());
2407 assert_eq!(ops[0].params.len(), 1);
2408 assert_eq!(ops[0].params[0].0, "key");
2409 assert!(matches!(
2410 &*program.program().ty_intern.get(ops[0].params[0].1),
2411 Ty::Base(bynk_syntax::ast::BaseType::String)
2412 ));
2413 // `return_ty` is Effect-wrapped, not peeled — `get`'s declared
2414 // `Effect[Int]` resolves whole, the same convention `FnSig::ret`
2415 // uses.
2416 assert!(matches!(
2417 &*program.program().ty_intern.get(ops[0].return_ty),
2418 Ty::Effect(inner) if matches!(
2419 &*program.program().ty_intern.get(*inner),
2420 Ty::Base(bynk_syntax::ast::BaseType::Int)
2421 )
2422 ));
2423
2424 assert_eq!(ops[1].name, "put");
2425 assert_eq!(ops[1].params.len(), 2);
2426 assert_eq!(ops[1].params[0].0, "key");
2427 assert_eq!(ops[1].params[1].0, "value");
2428 assert!(matches!(
2429 &*program.program().ty_intern.get(ops[1].params[1].1),
2430 Ty::Base(bynk_syntax::ast::BaseType::Int)
2431 ));
2432 }
2433
2434 #[test]
2435 fn lower_provider_given_ir_preserves_declaration_order_including_unused_entries() {
2436 // `given Random, Clock` (reverse-alphabetical, deliberately) pins
2437 // that `given`'s own declaration order survives — neither op body
2438 // below calls either capability, pinning review of #1186's point
2439 // that an *unused* `given` entry must still appear (it feeds R8.1's
2440 // own `deps` constructor, not anything the op bodies reference).
2441 let program = checked_context_program(
2442 r#"
2443context demo
2444
2445capability Clock {
2446 fn now() -> Effect[Int]
2447}
2448
2449capability Random {
2450 fn next() -> Effect[Int]
2451}
2452
2453capability Store {
2454 fn get(key: String) -> Effect[Int]
2455 fn put(key: String, value: Int) -> Effect[()]
2456}
2457
2458provides Store = MemStore given Random, Clock {
2459 fn get(key: String) -> Effect[Int] {
2460 Effect.pure(0)
2461 }
2462 fn put(key: String, value: Int) -> Effect[()] {
2463 Effect.pure(())
2464 }
2465}
2466"#,
2467 );
2468 let given = lower_provider_given_ir(find_provider(&program, "MemStore"));
2469 assert_eq!(
2470 given.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(),
2471 vec!["Random", "Clock"],
2472 "given's own declaration order preserved, unused entries included"
2473 );
2474 assert!(given.iter().all(|g| g.context.is_none()));
2475 }
2476
2477 #[test]
2478 fn lower_provider_given_ir_reads_an_external_providers_given_too() {
2479 // v0.17: an external (bodiless) provider is only legal inside an
2480 // `adapter` unit (`bynk-check/src/symbols.rs`), which this test
2481 // harness's own `checked_context_program` cannot build (it only
2482 // ever parses a `context`). `lower_provider_given_ir` never reads
2483 // `program` at all, so this hand-constructs the `ProviderDecl` the
2484 // parser would produce for `provides Store = ExternalStore given
2485 // Clock` inside an adapter. Nothing in the grammar or checker gates
2486 // `given` on `external`, so an external provider's `given` must come
2487 // through the same way a Bynk one's does (review of #1187's own
2488 // Provider given/deps-wiring slice).
2489 let provider = ProviderDecl {
2490 capability: bynk_syntax::ast::Ident {
2491 name: "Store".to_string(),
2492 span: Span::default(),
2493 },
2494 provider_name: bynk_syntax::ast::Ident {
2495 name: "ExternalStore".to_string(),
2496 span: Span::default(),
2497 },
2498 given: vec![CapRef {
2499 context: None,
2500 name: bynk_syntax::ast::Ident {
2501 name: "Clock".to_string(),
2502 span: Span::default(),
2503 },
2504 span: Span::default(),
2505 }],
2506 ops: Vec::new(),
2507 external: true,
2508 documentation: None,
2509 span: Span::default(),
2510 trivia: Default::default(),
2511 };
2512 let given = lower_provider_given_ir(&provider);
2513 assert_eq!(given.len(), 1);
2514 assert_eq!(given[0].context, None);
2515 assert_eq!(given[0].name, "Clock");
2516 }
2517
2518 #[test]
2519 fn an_http_service_lowers_its_protocol_and_per_handler_route_kind() {
2520 let program = checked_context_program(
2521 r#"
2522context demo
2523
2524fn ok(s: String) -> HttpResult[String] { Ok(s) }
2525
2526service Api from http {
2527 on GET("/ping") () -> Effect[HttpResult[String]] by v: Visitor {
2528 Effect.pure(ok("pong"))
2529 }
2530}
2531"#,
2532 );
2533 let service = find_service(&program, "Api");
2534 assert!(matches!(
2535 lower_protocol_ir(&service.protocol, &program),
2536 ProtocolIr::Http
2537 ));
2538 assert_eq!(
2539 lower_handler_kind_ir(&service.handlers[0].kind),
2540 IrHandlerKind::Http {
2541 method: IrHttpMethod::Get,
2542 path: "/ping".to_string(),
2543 },
2544 "the route binding lives per-handler — this is why ProtocolIr::Http itself \
2545 carries no payload"
2546 );
2547 }
2548
2549 #[test]
2550 fn a_cron_service_lowers_its_schedule_from_the_handler_not_the_protocol() {
2551 let program = checked_context_program(
2552 r#"
2553context demo
2554
2555fn done() -> Result[(), String] { Ok(()) }
2556
2557service Sweeper from cron {
2558 on schedule("*/5 * * * *") () -> Effect[Result[(), String]] {
2559 Effect.pure(done())
2560 }
2561}
2562"#,
2563 );
2564 let service = find_service(&program, "Sweeper");
2565 assert!(matches!(
2566 lower_protocol_ir(&service.protocol, &program),
2567 ProtocolIr::Cron
2568 ));
2569 assert_eq!(
2570 lower_handler_kind_ir(&service.handlers[0].kind),
2571 IrHandlerKind::Cron {
2572 expr: "*/5 * * * *".to_string()
2573 }
2574 );
2575 }
2576}