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