bynk_check/checker.rs
1//! Type checker and refinement validator (spec §§5–6, v0.1 §4.2, v0.2 §4.2).
2//!
3//! Operates on a [`ResolvedCommons`]. Walks declarations, validates each
4//! refinement against the spec's predicate-base compatibility and combination
5//! rules, then type-checks every function and method body.
6//!
7//! v0.2 extensions:
8//! - Record types (compatibility, field access, construction).
9//! - Sum types and variant construction (qualified and unqualified).
10//! - Methods (instance and static) with UFCS-style call resolution.
11//! - Pattern matching with exhaustiveness checking.
12//! - The `is` operator with binding flow into truthy contexts.
13//! - The built-in generic `Option[T]`.
14
15use std::collections::{HashMap, HashSet};
16#[cfg(debug_assertions)]
17use std::sync::atomic::{AtomicU32, Ordering};
18use std::sync::{Arc, Mutex};
19
20use crate::builtin_names::map_query;
21use crate::builtin_names::methods::*;
22use crate::builtin_names::types::*;
23use crate::hints::HintSink;
24use crate::index::{RefSink, SymbolKind};
25use crate::locals::LocalsSink;
26use crate::requirements::{
27 Materialize, Requirement, RequirementSink, RequirementSource, StoreKind,
28};
29use crate::resolver::{MethodTable, ResolvedCommons};
30use bynk_syntax::ast::*;
31use bynk_syntax::error::{Applicability, CompileError};
32use bynk_syntax::span::Span;
33
34/// P6.27 (design/tracks/the-ir.md §6a): re-exported so a checker consumer keying
35/// off `expr_types`/`Callee` (both `HashMap<ExprId, _>`, Q2's own settled totality
36/// story) can name `ExprId` through `bynk-check` alone, without also depending on
37/// `bynk_syntax::ast` directly just to spell this one identity type — the same
38/// public-dependency-already-exists shape as `Ty`/`TyId` below.
39pub use bynk_syntax::ast::ExprId;
40
41mod calls;
42mod equality;
43mod expressions;
44mod kernels;
45mod linearity;
46mod refinements;
47mod regex_ambiguity;
48
49use calls::*;
50use expressions::*;
51use kernels::*;
52use refinements::*;
53
54pub use calls::{check_event_field_default, check_state_initialiser};
55pub use refinements::{locale_tag_accepts, locale_tag_pattern, zero_value_ts};
56
57// ==== Type representation ====
58
59/// T3.6b (R4.1): the intern table `TyId` is minted from. Owned per
60/// `check_record` invocation (design settled in the identity-and-totality
61/// track doc §9 before this slice started): created fresh at `check_record`'s
62/// entry, threaded through `Ctx`, carried out on `TypedCommons`/`RecordCheck`
63/// alongside `expr_types`, and forwarded across the `bynk-check`→`bynk-emit`
64/// boundary on `CheckedProgram` (T3.7a/T3.7b already built that seam).
65/// Confirmed safe by checking how cross-unit type references actually flow:
66/// `compose_unit_symbols` merges `TypeDecl` (immutable AST declarations)
67/// across units, never an already-interned `Ty`/`TyId` — every unit
68/// re-interns its own `Ty` graph from shared declarations, so `TyId`s are
69/// never compared across two different `check_record` invocations.
70///
71/// **Why [`intern`](Self::intern) takes `&self`, not `&mut self`.** The table
72/// is reached from `Ctx`, whose other fields (`expr_types`, `errors`, the
73/// sinks) are themselves `&mut` and are routinely live across an interning
74/// call — `ctx.tys.intern(…)` inside a loop over `ctx.scopes` is the common
75/// shape, not the exception. A `&mut Types` would make the borrow checker,
76/// not the type system, the thing every one of the ~200 minting sites is
77/// written around. Interior mutability keeps `&'a Types` `Copy`, so a
78/// function that needs the table just reads `ctx.tys` once and is done.
79///
80/// **Why a `Mutex` and `Arc`, not a `RefCell` and `Rc`.** The compiler itself
81/// is single-threaded, so a cell would do for `bynk-check` and `bynk-emit` —
82/// but the table rides out on `TypedCommons`/`ProjectAnalysis` into
83/// `bynk-lsp`, whose `tower-lsp` handlers are `async` and therefore require
84/// `Send`. A non-atomic refcount is exactly what `Send` forbids, so the
85/// choice is made by the consumer, not by the compiler's own threading. The
86/// lock is uncontended in every current caller.
87pub struct Types {
88 inner: Mutex<TypesInner>,
89 /// Which table this is, so [`Types::get`] can reject a foreign `TyId`
90 /// whose index happens to be in range — see [`TyId`]'s own note.
91 #[cfg(debug_assertions)]
92 tag: u32,
93}
94
95/// Hands each [`Types`] a distinct [`Types::tag`]. Wrapping is not a
96/// correctness problem: it would take 2^32 tables in one process for two to
97/// collide, and the guard is a debug-build aid, not a soundness argument.
98#[cfg(debug_assertions)]
99static NEXT_TABLE_TAG: AtomicU32 = AtomicU32::new(0);
100
101impl Default for Types {
102 fn default() -> Self {
103 Self {
104 inner: Mutex::default(),
105 #[cfg(debug_assertions)]
106 tag: NEXT_TABLE_TAG.fetch_add(1, Ordering::Relaxed),
107 }
108 }
109}
110
111#[derive(Debug, Default)]
112struct TypesInner {
113 /// `TyId(i)` resolves to `table[i]`. `Arc` so [`Types::get`] hands back a
114 /// handle by refcount bump rather than cloning the node, and so the same
115 /// allocation backs both `table` and `index` without storing it twice.
116 table: Vec<Arc<Ty>>,
117 index: HashMap<Arc<Ty>, TyId>,
118}
119
120impl Types {
121 pub fn new() -> Self {
122 Self::default()
123 }
124
125 /// Intern `ty`, returning its `TyId`. The same `Ty` value (by `Eq`)
126 /// always yields the same `TyId` — the property `ty_hash_eq_ord_tests`
127 /// (T3.6b's own settling-review prerequisite) pins directly. Dedup is by
128 /// the *shallow* `Ty`, which is sound precisely because every recursive
129 /// field is already a `TyId`: two structurally-equal types have equal
130 /// children ids by induction, so they hash and compare equal here.
131 pub fn intern(&self, ty: Ty) -> TyId {
132 let mut inner = self.lock();
133 if let Some(&id) = inner.index.get(&ty) {
134 return id;
135 }
136 let node = Arc::new(ty);
137 let id = TyId {
138 idx: inner.table.len() as u32,
139 #[cfg(debug_assertions)]
140 tag: self.tag,
141 };
142 inner.table.push(Arc::clone(&node));
143 inner.index.insert(node, id);
144 id
145 }
146
147 /// The node `id` was interned from.
148 ///
149 /// Panics on a `TyId` minted by a *different* table. That is the one new
150 /// failure mode interning introduces, and it is a wiring bug in the
151 /// compiler, never something a Bynk program can provoke — so it fails
152 /// loudly and by name rather than as a bare index-out-of-bounds. It was
153 /// worth the message: this fired twice while T3.6b was being built, both
154 /// times a synthesised `TypedCommons` that had been given a table of its
155 /// own while its `expr_types` was filled in from another.
156 ///
157 /// Both of those were the *shorter*-table shape, where a bounds check
158 /// alone catches it. The dangerous shape is the other one: a foreign id
159 /// that happens to be in range resolves to an unrelated `Ty` and the
160 /// caller mis-diagnoses or mis-emits in silence. So in debug builds the
161 /// check is identity, not length — [`TyId`] carries its table's tag and
162 /// this compares it. Release builds keep the bounds check only, which is
163 /// what indexing would have cost anyway.
164 pub fn get(&self, id: TyId) -> Arc<Ty> {
165 #[cfg(debug_assertions)]
166 assert!(
167 id.tag == self.tag,
168 "bynk internal error (T3.6b, R4.1): {id:?} resolved against a table it was not \
169 interned into (this is table {}). A `TyId` is only meaningful in its own `Types` — \
170 check that whatever produced this id and whatever is reading it share one table",
171 self.tag
172 );
173 let inner = self.lock();
174 match inner.table.get(id.idx as usize) {
175 Some(node) => Arc::clone(node),
176 None => panic!(
177 "bynk internal error (T3.6b, R4.1): {id:?} resolved against a table it was not \
178 interned into (this table holds {}). A `TyId` is only meaningful in its own \
179 `Types` — check that whatever produced this id and whatever is reading it share \
180 one table",
181 inner.table.len()
182 ),
183 }
184 }
185
186 /// [`Ty::display`] for an already-interned type.
187 pub fn display(&self, id: TyId) -> String {
188 self.get(id).display(self)
189 }
190
191 /// Number of distinct types interned so far. Exposed for the interner's
192 /// own tests (dedup is observable only as "the table did not grow").
193 pub fn len(&self) -> usize {
194 self.lock().table.len()
195 }
196
197 /// The lock, recovered from poisoning. `intern` never panics while
198 /// holding it (it only pushes to a `Vec` and a `HashMap`), so a poisoned
199 /// lock can only mean an unrelated panic unwound past a live guard —
200 /// where the table is still structurally sound.
201 fn lock(&self) -> std::sync::MutexGuard<'_, TypesInner> {
202 self.inner.lock().unwrap_or_else(|e| e.into_inner())
203 }
204
205 pub fn is_empty(&self) -> bool {
206 self.len() == 0
207 }
208}
209
210impl std::fmt::Debug for Types {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 f.debug_struct("Types")
213 .field("len", &self.len())
214 .finish_non_exhaustive()
215 }
216}
217
218/// T3.6b (R4.1/R4.2): a `Ty`'s identity above the intern table — `Copy`,
219/// `Hash`, `Ord`, cheap to pass and compare. Resolved back to a `Ty` only
220/// via the [`Types`] table it was interned into (see that type's own doc).
221///
222/// In debug builds it also carries the tag of the table it came from, so
223/// [`Types::get`] can make good on its "interned into another table" promise
224/// for a foreign id whose index is merely *in range* — the case a bounds
225/// check cannot see, and the one that would otherwise resolve to an
226/// unrelated `Ty` in silence. `idx` is declared first so the derived `Ord`
227/// still orders by insertion within a table, exactly as it does in release.
228#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
229pub struct TyId {
230 idx: u32,
231 #[cfg(debug_assertions)]
232 tag: u32,
233}
234
235impl std::fmt::Debug for TyId {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 #[cfg(debug_assertions)]
238 return write!(f, "TyId({} of table {})", self.idx, self.tag);
239 #[cfg(not(debug_assertions))]
240 return write!(f, "TyId({})", self.idx);
241 }
242}
243
244impl TyId {
245 /// The interned node, for the (many) sites that need to look at the
246 /// type's shape. Sugar for [`Types::get`], so a `TyId` reads like the
247 /// `&Ty` it replaced.
248 pub fn get(self, tys: &Types) -> Arc<Ty> {
249 tys.get(self)
250 }
251
252 /// [`Ty::display`] for this id — the form nearly every diagnostic uses.
253 pub fn display(self, tys: &Types) -> String {
254 tys.display(self)
255 }
256
257 /// True if this type is `Effect[_]` (v0.5).
258 pub fn is_effect(self, tys: &Types) -> bool {
259 tys.get(self).is_effect()
260 }
261
262 /// v0.102: true if this type belongs to the closed `Held` kind.
263 pub fn is_held(self, tys: &Types) -> bool {
264 tys.get(self).is_held()
265 }
266
267 /// The underlying base type, if this type widens to one.
268 pub fn base(self, tys: &Types) -> Option<BaseType> {
269 tys.get(self).base()
270 }
271}
272
273/// A resolved type.
274#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
275pub enum Ty {
276 /// R4.3 measurement probe (not shipped): a real Error variant.
277 Error,
278 /// A base type (`Int`, `String`, `Bool`).
279 Base(BaseType),
280 /// A user-declared named type. `kind` records the declaration's shape
281 /// for compatibility / dispatch decisions. `args` holds the applied type
282 /// arguments of a generic type (`Paginated[String]` → `args = [String]`);
283 /// it is empty for a non-generic type (v0.157, ADR 0183). Substitution,
284 /// unification, and display recurse into `args`.
285 Named {
286 name: String,
287 kind: NamedKind,
288 args: Vec<TyId>,
289 },
290 /// `Result[T, E]`.
291 Result(TyId, TyId),
292 /// `Option[T]`.
293 Option(TyId),
294 /// `Effect[T]` (v0.5).
295 Effect(TyId),
296 /// `HttpResult[T]` (v0.9).
297 HttpResult(TyId),
298 /// `QueueResult` — the built-in queue verdict sum (v0.44). Non-generic.
299 QueueResult,
300 /// `List[T]` — built-in immutable list (v0.20b).
301 List(TyId),
302 /// `Map[K, V]` — built-in immutable map (v0.20b). The key type is
303 /// confined to value-keyable types at TypeRef resolution.
304 Map(TyId, TyId),
305 /// `Query[T]` — a lazy, by-reference description of a read over agent-local
306 /// storage (v0.91, ADR 0115). The inner type is the element a terminal
307 /// yields. Built by the lazy combinator vocabulary over a `store` field,
308 /// executed by a terminal (`-> Effect[…]`). Non-storable, non-boundary, and
309 /// not value-comparable — like `Effect`/`Fn` (ADRs 0031/0030).
310 Query(TyId),
311 /// `Stream[T]` — a lazy, pull-shaped sequence of values produced over time
312 /// (v0.100, real-time track slice 0). The inner type is the element a
313 /// terminal yields. Built from a runtime source (`Stream.of` at v1),
314 /// transformed by lazy builders (`map`/`take`), drained by a terminal
315 /// (`collect -> Effect[List[T]]`). Non-storable, non-boundary, and not
316 /// value-comparable — like `Query`/`Effect`/`Fn` (ADRs 0031/0030).
317 Stream(TyId),
318 /// `Connection[F]` — a held WebSocket connection (v0.102, real-time track
319 /// slice 2). `F` is the server→client frame type. The one concrete instance
320 /// of the closed `Held` kind (`is_held`). Governed by the linearity
321 /// discipline (§2.9): single-owner, mandatory disposal. Non-serialisable,
322 /// non-boundary, non-comparable; storable only in `Cell[Option[Connection]]`
323 /// / `Map[K, Connection]`.
324 Connection(TyId),
325 /// `ValidationError` — built-in error type.
326 ValidationError,
327 /// `JsonError` — built-in JSON-decode error type (v0.22b). A uniform
328 /// record: `kind`/`path`/`message`, all `String`.
329 JsonError,
330 /// `()` — the unit type (v0.5).
331 Unit,
332 /// v0.45: a verified actor binding (`by name: Actor`). The inner type is
333 /// the actor's identity, read as `name.identity`. A boundary-minted, sealed
334 /// value — only ever `.identity`-accessed, never constructed or passed.
335 Actor(TyId),
336 /// v0.52: a resolved multi-actor binding (`by who: A | B`) — an ordered sum
337 /// of peer actors. Each member is `(actor name, identity ty)`; the body
338 /// `match`es on the resolved actor, each non-unit member binding its
339 /// identity directly. Like `Actor`, a sealed boundary value — only ever
340 /// matched, never constructed or passed.
341 ActorSum(Vec<(String, TyId)>),
342 /// `A -> B` — a function type (v0.20a). Effectful iff `ret` is
343 /// `Effect[_]` (the structural rule); no separate flag, so there is a
344 /// single source of truth.
345 Fn { params: Vec<TyId>, ret: TyId },
346 /// A function type parameter (v0.20a). Two lives: *rigid* while checking
347 /// a generic function's own body (name-equality in `compatible`), and
348 /// *flexible* during call-site instantiation, where it is matched by
349 /// `unify` and fully eliminated by `substitute` before any `compatible`
350 /// runs against argument types. Vars never escape call checking into the
351 /// caller's expression types.
352 Var(String),
353}
354
355/// The shape of a named type — what its declaration looks like.
356///
357/// `Refined` widens to its base type when used in arithmetic, comparisons,
358/// and other operations on the base. `Opaque` does NOT widen — its identity
359/// is nominal and the base type is hidden outside the defining commons.
360#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
361pub enum NamedKind {
362 /// Refined-base type: widens to the recorded base.
363 Refined(BaseType),
364 /// Record type.
365 Record,
366 /// Sum type.
367 Sum,
368 /// Opaque base type. The base is hidden; identity is purely nominal.
369 /// The recorded base is used by the type checker (for `.raw`, `.of`,
370 /// `.unsafe`) and by the emitter, but not for compatibility widening.
371 Opaque(BaseType),
372}
373
374impl Ty {
375 /// Display name for diagnostics. Takes the table the type was interned
376 /// into (T3.6b): every recursive field is a `TyId` now, so rendering a
377 /// nested type is a table read rather than a pointer chase.
378 pub fn display(&self, types: &Types) -> String {
379 match self {
380 // R4.3: a resolution failure already has its own diagnostic at the
381 // site that produced this; this string exists only so a *second*
382 // diagnostic that happens to mention the type (e.g. a mismatch one
383 // level up) reads as "type error" rather than a blank or `unknown`.
384 Ty::Error => "<type error>".to_string(),
385 Ty::Base(b) => b.name().to_string(),
386 Ty::Named { name, args, .. } if args.is_empty() => name.clone(),
387 Ty::Named { name, args, .. } => format!(
388 "{}[{}]",
389 name,
390 args.iter()
391 .map(|a| types.display(*a))
392 .collect::<Vec<_>>()
393 .join(", ")
394 ),
395 Ty::Result(t, e) => {
396 format!("Result[{}, {}]", types.display(*t), types.display(*e))
397 }
398 Ty::Option(t) => format!("Option[{}]", types.display(*t)),
399 Ty::Effect(t) => format!("Effect[{}]", types.display(*t)),
400 Ty::HttpResult(t) => format!("HttpResult[{}]", types.display(*t)),
401 Ty::QueueResult => "QueueResult".to_string(),
402 Ty::List(t) => format!("List[{}]", types.display(*t)),
403 Ty::Map(k, v) => format!("Map[{}, {}]", types.display(*k), types.display(*v)),
404 Ty::Query(t) => format!("Query[{}]", types.display(*t)),
405 Ty::Stream(t) => format!("Stream[{}]", types.display(*t)),
406 Ty::Connection(t) => format!("Connection[{}]", types.display(*t)),
407 Ty::ValidationError => "ValidationError".to_string(),
408 Ty::JsonError => "JsonError".to_string(),
409 Ty::Unit => "()".to_string(),
410 Ty::Actor(id) => format!("actor[{}]", types.display(*id)),
411 Ty::ActorSum(members) => members
412 .iter()
413 .map(|(name, _)| name.clone())
414 .collect::<Vec<_>>()
415 .join(" | "),
416 Ty::Fn { params, ret } => {
417 let params = match params.len() {
418 0 => "()".to_string(),
419 // A single Fn-typed param needs parens to stay readable
420 // under right-associativity.
421 1 if !matches!(&*types.get(params[0]), Ty::Fn { .. }) => {
422 types.display(params[0])
423 }
424 _ => format!(
425 "({})",
426 params
427 .iter()
428 .map(|p| types.display(*p))
429 .collect::<Vec<_>>()
430 .join(", ")
431 ),
432 };
433 format!("{params} -> {}", types.display(*ret))
434 }
435 Ty::Var(name) => name.clone(),
436 }
437 }
438
439 /// True if this type is `Effect[_]`.
440 pub fn is_effect(&self) -> bool {
441 matches!(self, Ty::Effect(_))
442 }
443
444 /// v0.102: true if this type belongs to the closed `Held` kind — a
445 /// runtime-managed resource governed by the linearity discipline (§2.9).
446 /// The one instance at v1 is `Connection[F]`; the single extension point
447 /// for future held types (file handles, DB connections).
448 pub fn is_held(&self) -> bool {
449 matches!(self, Ty::Connection(_))
450 }
451
452 /// v0.102: for a `Held` type, the held element it wraps (the frame type of a
453 /// `Connection[F]`). Used by the storage-admission rules to look through an
454 /// `Option[Connection]` value.
455 pub fn held_inner(&self) -> Option<TyId> {
456 match self {
457 Ty::Connection(t) => Some(*t),
458 _ => None,
459 }
460 }
461
462 /// The underlying base type, if this type widens to a base type.
463 /// Opaque types deliberately do NOT widen — that's the whole point of
464 /// the opacity — so `Ty::Named { kind: Opaque(_), .. }` returns None.
465 pub fn base(&self) -> Option<BaseType> {
466 match self {
467 Ty::Base(b) => Some(*b),
468 Ty::Named {
469 kind: NamedKind::Refined(b),
470 ..
471 } => Some(*b),
472 _ => None,
473 }
474 }
475}
476
477/// P6.0 (design/tracks/the-ir.md §6, #1139): a resolved classification of a
478/// call-shaped expression, recorded once by the checker's own dispatch
479/// (`checker::calls`) rather than re-derived by each later consumer —
480/// closing R6.10's duplicated-classification gap between `bynk-check` and
481/// `bynk-emit`'s `lower_method_call`/`lower_call`.
482///
483/// Adapted to the identity handles this checker already has (Decision A,
484/// ADR 0333, `the-ir-callee-in-bynk-check`) rather than the reference
485/// document's `DefId`/`LocalId`/`VariantId`/`OpId` arena — none of which
486/// exists here, since the `Resolve` phase that would mint them was never
487/// built (`project-model.md` §3.4 deferred it to phase 8).
488/// `Arc<FnDecl>`/`Arc<TypeDecl>` are already-cheap resolved handles
489/// (`ResolvedCommons::fns`/`types`); every other variant's identity is a
490/// name, exactly as the checker already keys capabilities, store fields,
491/// units, and agents.
492///
493/// Recorded at each dispatch decision as soon as it is known — including on
494/// an error sub-branch (an arity mismatch, an undeclared capability) — since
495/// the *kind* of call is fixed by dispatch, not by whether it went on to
496/// type-check cleanly.
497#[derive(Debug, Clone)]
498pub enum Callee {
499 /// A free function call.
500 Fn(Arc<FnDecl>),
501 /// Applying a function-typed local or parameter (`f(x)` where `f` is in
502 /// scope, not declared). No stable id exists for a local beyond its
503 /// name — the reference's `LocalId` presumes the same `Resolve` phase
504 /// Decision A declines to build here.
505 Value(String),
506 /// Sum-variant construction, bare (`Some(x)`) or qualified
507 /// (`Opt.Some(x)`).
508 Ctor { sum: Arc<TypeDecl>, tag: String },
509 /// `T.of(value)` — the refined/opaque runtime constructor.
510 Refine(Arc<TypeDecl>),
511 /// `T.unsafe(value)` — the opaque constructor, defining-unit only.
512 Unsafe(Arc<TypeDecl>),
513 /// A user-declared static method (`Type.method(...)`).
514 Static(Arc<FnDecl>),
515 /// A user-declared instance method (UFCS), generic or not.
516 Method(Arc<FnDecl>),
517 /// A built-in method on a value — the collection/query/stream/
518 /// connection/numeric/duration/instant/bytes/string/option/result/
519 /// effect kernels, including the refined-receiver fallback (ADR 0168).
520 /// `recv` is the receiver's own checked type; no `KernelOp` enum exists
521 /// yet in this crate (R6.11), so the operation is named, not typed.
522 Kernel { recv: TyId, op: String },
523 /// A built-in static constructor with no declaring type — `List.empty`,
524 /// `Map.empty`, `Int.parse`/`Float.parse`, `Duration.millis`,
525 /// `Instant.fromEpochMillis`, `Bytes.fromUtf8`/`fromBase64`/`empty`,
526 /// `Json.decode`/`encode`, `Stream.of`.
527 Intrinsic { ns: &'static str, op: String },
528 /// A same-context capability operation call (`Cap.op(...)`).
529 Capability { cap: String, op: String },
530 /// A cross-context capability operation call (`B.Cap.op(...)` /
531 /// `Alias.Cap.op(...)`).
532 CrossCap {
533 unit: String,
534 cap: String,
535 op: String,
536 },
537 /// A cross-context service call (`B.service(...)` / `Alias.service(...)`).
538 Cross { unit: String, service: String },
539 /// `AgentName(key)` — agent instance construction. No slot exists for
540 /// this in the reference's own `Callee` taxonomy (Part 6.5 only names
541 /// handler dispatch); added here since this slice covers every call
542 /// shape `check_call` dispatches, not only the ones the reference
543 /// anticipated.
544 AgentInit(String),
545 /// `agent.handler(args)` — agent handler dispatch.
546 Agent { agent: String, handler: String },
547 /// A test-body service address (`svc.call`/`svc.<VERB>("/path", …)`/
548 /// `svc.schedule(...)`/`svc.message(...)`). `check_test_service_address`
549 /// always returns `None` by design (the runner recovers the outcome
550 /// type at runtime) — this classification exists purely for a later
551 /// consumer (e.g. go-to-definition on the address), not typing.
552 TestService { service: String, address: String },
553 /// An effectful `<field>.<op>(…)` storage operation on a `store`
554 /// `Map`/`Set`/`Cache`/`Log`/`Cell` field — R6.5's own named target
555 /// (P6.2, #1143): a mutation detector keyed on this variant, not a
556 /// receiver's bare name, cannot miss a mutation reached through a
557 /// non-`Ident` receiver or false-negative on a shadowed local, the
558 /// defect class `block_writes_state`'s `mutating_op` still carries.
559 /// `field` is the store field's own name (no `FieldId` arena exists —
560 /// same adaptation `check_store_*_op`'s own lookups already use). Note
561 /// this is recorded *outside* `calls.rs`'s six functions — the
562 /// store-field ladder lives directly in `checker.rs`'s own `type_of`,
563 /// never reaching any of them — extending P6.0's own recording surface
564 /// past the boundary its "Done when" deliberately drew.
565 Store { field: String, op: String },
566 /// A query builder/terminal call that *lifts* a bare `store` `Map`/`Log`
567 /// field into a lazy `Query[V]` (`is_query_op`'s own gate,
568 /// `checker.rs`'s `type_of`) — R6.12's own named target (P6.2, #1143).
569 /// `field` names the store field being lifted — without it, a chain
570 /// rooted at this call (`orders.filter(p).count()`) would carry no
571 /// identity for `orders` anywhere in the classification, the same
572 /// information loss R6.5 exists to close on the write side. `role` is
573 /// read back from the checker's own typing decision for this exact call
574 /// (`Ty::Query(_)` result ⇒ `Builder`, anything else ⇒ `Terminal`), not
575 /// a second name-list classifier alongside `is_query_op`'s. A *chained*
576 /// builder/terminal call on an already-`Query`-typed receiver
577 /// (`.filter(p).count()`'s own `.count()`) is not this variant — it
578 /// reaches `check_method_call`'s ordinary kernel dispatch and is
579 /// `Callee::Kernel` already (P6.0); `Query` here exists only because the
580 /// lift call's own outer expression never passes through any of
581 /// `calls.rs`'s six functions to get one.
582 Query {
583 field: String,
584 op: String,
585 role: QueryRole,
586 },
587}
588
589/// Whether a [`Callee::Query`] call returns another `Query[T]` (chainable)
590/// or executes and returns `Effect[T]` — R6.12: "the builder/terminal split
591/// is a field on the callee, not a name list." Primarily read back from
592/// `check_query_kernel_method`'s own return type at the recording site
593/// (`query_role`, below) — falling back to `is_query_builder_name` only
594/// when the call didn't type at all (an arity mismatch, or a type error
595/// deeper inside the call — `map`'s own lambda body, say — both return
596/// `None` too, not just an arity failure), so a best-effort reader of an
597/// uncertified/erroring unit still gets the right role.
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub enum QueryRole {
600 Builder,
601 Terminal,
602}
603
604/// The fallback `Callee::Query::role` classifier for when
605/// `check_query_kernel_method`'s own return type doesn't settle it (see
606/// `QueryRole`'s doc comment). Kept in sync by hand with
607/// `check_query_kernel_method`'s own match arms
608/// (`bynk-check/src/checker/kernels.rs:861-1118`) — the same "kept in sync
609/// by hand" risk R6.11 already names for `kernel_methods.rs`'s own
610/// registries, not a new class of drift this slice introduces.
611fn is_query_builder_name(name: &str) -> bool {
612 matches!(
613 name,
614 "map"
615 | "filter"
616 | "flatMap"
617 | "sortBy"
618 | "take"
619 | "skip"
620 | "distinct"
621 | "distinctBy"
622 | "joinOn"
623 | "leftJoin"
624 | "join"
625 | "groupBy"
626 )
627}
628
629/// P6.2 (#1143): `Callee::Query`'s `role` for a call whose op name is `op`
630/// and whose checked result (from `check_query_kernel_method`/
631/// `check_store_log_op`) is `result`.
632fn query_role(result: Option<TyId>, op: &str, tys: &Types) -> QueryRole {
633 match result.map(|t| tys.get(t)).as_deref() {
634 Some(Ty::Query(_)) => QueryRole::Builder,
635 Some(_) => QueryRole::Terminal,
636 None if is_query_builder_name(op) => QueryRole::Builder,
637 None => QueryRole::Terminal,
638 }
639}
640
641/// Output of type checking.
642pub struct TypedCommons {
643 pub commons: Commons,
644 pub types: HashMap<String, Arc<TypeDecl>>,
645 pub fns: HashMap<String, Arc<FnDecl>>,
646 pub methods: HashMap<String, MethodTable>,
647 /// T3.4 (R2.4/R2.5): keyed by [`ExprId`] — a node's identity, not its
648 /// position. The value carries its own `span` alongside `ty`, so
649 /// LSP-facing consumers that need "type at this cursor offset" (a
650 /// position-shaped question, asked at the editor boundary, not the
651 /// checker's own identity) can still answer it without a second map.
652 pub expr_types: HashMap<ExprId, TypedExpr>,
653 /// P6.0 (#1139): the call-shaped expressions this unit's checker
654 /// dispatched, classified once here rather than re-derived by
655 /// `bynk-emit`'s lowering (P6.2) or any other later consumer. Mirrors
656 /// `expr_types` exactly — same key, same "recorded during checking, read
657 /// afterward" shape.
658 pub callees: HashMap<ExprId, Callee>,
659 /// v0.89 (ADR 0117): non-failing warnings produced while checking this unit
660 /// — surfaced but not gating. Empty unless a warning-category diagnostic
661 /// (e.g. `bynk.given.unused_capability`) fired on an otherwise-clean check.
662 pub warnings: Vec<CompileError>,
663 /// T3.6b (R4.1): the intern table every `TyId` on this unit — in
664 /// `expr_types`, in a `Ty` node's own recursive fields — was minted from.
665 /// Named `ty_intern` rather than `types` only because `types` above is
666 /// already this struct's *declaration* table (`TypeDecl` by name); the two
667 /// are unrelated. `Rc` so [`RecordCheck`] can hand the same table out
668 /// alongside `partial_expr_types` on the error path, where no
669 /// `TypedCommons` is built to own it.
670 pub ty_intern: Arc<Types>,
671 /// #1170: a service handler's own resolved `by <binder>: <Actor>` actor
672 /// binding — `handler_actor_binding`'s own return value
673 /// (`context_checks.rs`), persisted here rather than discarded once
674 /// `check_service_decls`'s own per-handler loop moves on, the same
675 /// "recorded during checking, read afterward" shape `callees` (above)
676 /// already established. Keyed by the handler's own `span`: a `Handler`
677 /// has no arena identity of its own (no `DefId`/`ExprId` — it is a
678 /// declaration, not an expression), and `Span` is already this
679 /// codebase's established "no arena" substitute for exactly this kind
680 /// of identity (`Copy`/`Eq`/`Hash`, already used as a diagnostic anchor
681 /// throughout `context_checks.rs`). No entry for a handler
682 /// `handler_actor_binding` itself resolves to `None` for: a
683 /// binder-less `by <Actor>` clause, or no `by` clause at all —
684 /// including every agent handler, which cannot carry one
685 /// (`bynk.actor.by_on_agent`). Read back through [`Self::actor_binding`];
686 /// its one IR-side consumer (P6.11, #1171, the service-handler
687 /// actor-binder constructor in `bynk-lower`) was deleted by Slice D1 of
688 /// #1542, so as of that slice the only readers are this crate's own
689 /// `context_checks` tests — the table is kept because the binding is a
690 /// checked fact about the handler, not because of who reads it.
691 ///
692 /// **Unit-wide, not per-file** (review of #1170, unlike `callees`/
693 /// `expr_types`, which are genuinely per-file — keyed by `ExprId`s this
694 /// file's own checking pass minted): `check_service_decls` walks
695 /// `table.services`, the whole unit's own `UnitTable`, not just this
696 /// file's declarations, so every file of a multi-file `context` ends up
697 /// with the *entire unit's* bindings in its own `TypedCommons`. Harmless
698 /// for a by-span lookup (a span is only ever looked up in the file that
699 /// actually owns it), but a future consumer that *iterates* this map
700 /// rather than looking up one known `span` would see sibling files'
701 /// handlers too — worth knowing before writing that consumer, not
702 /// discovering it by surprise.
703 pub actor_bindings: HashMap<Span, (String, TyId)>,
704}
705
706impl TypedCommons {
707 /// A `TypedCommons` with no declarations — a pure-data test fixture, so a
708 /// consuming crate's own test exercising only the `TypedCommons`-shaped
709 /// part of some function doesn't need to hand-construct a
710 /// `Commons`/`QualifiedName` itself (`bynk_syntax::ast` types, invisible
711 /// to `bynk-check` but not to a consumer whose own probe tracks that
712 /// dependency — P6.38, `design/tracks/the-ir.md` §6a).
713 pub fn empty() -> Self {
714 TypedCommons {
715 commons: Commons {
716 name: QualifiedName {
717 parts: Vec::new(),
718 span: Default::default(),
719 },
720 items: Vec::new(),
721 uses: Vec::new(),
722 documentation: None,
723 form: CommonsForm::Fragment,
724 span: Default::default(),
725 trivia: Default::default(),
726 trailing_comments: Vec::new(),
727 },
728 types: HashMap::new(),
729 fns: HashMap::new(),
730 methods: HashMap::new(),
731 expr_types: HashMap::new(),
732 callees: HashMap::new(),
733 warnings: Vec::new(),
734 ty_intern: Arc::new(Types::new()),
735 actor_bindings: HashMap::new(),
736 }
737 }
738
739 /// T3.6b (R4.1): this unit's intern table — what every `TyId` reachable
740 /// from `expr_types` resolves against.
741 /// Returns the `Rc` handle rather than a bare `&Types` so a caller that
742 /// needs to *share* the table (the project path, which checks many units
743 /// into one `ExprTypeSink`) can clone it; `&Arc<Types>` deref-coerces to
744 /// `&Types` everywhere a plain borrow is wanted.
745 pub fn tys(&self) -> &Arc<Types> {
746 &self.ty_intern
747 }
748
749 /// The interned node an expression was typed to, resolved in one step.
750 /// The reader-side shape `bynk-emit`/the LSP want: they ask "what shape is
751 /// this expression?", never "which id is it?". `Rc` so the resolve is a
752 /// refcount bump, and `.as_deref()` gives back the `&Ty` these call sites
753 /// read before T3.6b.
754 pub fn expr_ty(&self, id: ExprId) -> Option<Arc<Ty>> {
755 self.expr_types.get(&id).map(|te| self.ty_intern.get(te.ty))
756 }
757
758 /// P6.0 (#1139): the resolved [`Callee`] classification for a
759 /// call-shaped expression, if this unit's checker dispatched one at
760 /// `id`. Mirrors [`Self::expr_ty`]'s shape.
761 pub fn callee(&self, id: ExprId) -> Option<&Callee> {
762 self.callees.get(&id)
763 }
764
765 /// #1170: a service handler's own resolved actor binding, if
766 /// `handler_actor_binding` (`context_checks.rs`) resolved one for the
767 /// handler at `span`. Mirrors [`Self::callee`]'s shape — the single
768 /// documented read point for `actor_bindings`, kept symmetric with
769 /// `expr_ty`/`callee` rather than leaving every future consumer to
770 /// reach into the `HashMap` directly. Its IR-side reader (P6.11, #1171)
771 /// went with Slice D1 of #1542; see `actor_bindings`'s own doc comment.
772 pub fn actor_binding(&self, span: Span) -> Option<&(String, TyId)> {
773 self.actor_bindings.get(&span)
774 }
775}
776
777/// T3.4: an `expr_types` entry — the checked type, plus the span of the node
778/// it was computed for. `Deref`-free by design (`.ty`/`.span`, not `.0`/`.1`)
779/// so call sites read the same as they did against a bare `Ty` before this.
780///
781/// T3.6b (R4.1/R4.2): `ty` is a `TyId`, not a `Ty` — the whole entry is
782/// `Copy`-cheap, and resolving it needs the unit's `ty_intern` table.
783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub struct TypedExpr {
785 pub span: Span,
786 pub ty: TyId,
787}
788
789/// The outcome of [`check_record`]: the typed model (`Err` if the file had any
790/// error) and, on the error path, the best-effort partial `expr_types` the
791/// checker computed before bailing. Analyse mode surfaces that partial map for
792/// `.`-member completion and signature help even on a broken buffer (ADR 0094);
793/// on the Ok path the types live in the `TypedCommons`, so this is empty.
794pub struct RecordCheck {
795 pub result: Result<TypedCommons, Vec<CompileError>>,
796 pub partial_expr_types: HashMap<ExprId, TypedExpr>,
797 /// T3.6b (R4.1): the table `partial_expr_types`' `TyId`s resolve against.
798 /// The same `Rc` the `Ok` path's `TypedCommons::ty_intern` carries, so a
799 /// caller that reads either map has the table either way.
800 pub ty_intern: Arc<Types>,
801 /// #1663 (Decision A): on the `Err` path, the program as checked so far —
802 /// every declaration was still checked, so a later stage (context
803 /// declarations, handler bodies) can go on checking the *other*
804 /// declarations. Never certifiable: the errors in `result` still fail
805 /// the unit. `None` on the `Ok` path.
806 pub typed_despite_errors: Option<TypedCommons>,
807}
808
809/// T3.7 (R3.10): the gate between analysis and emission, as a type rather
810/// than a control-flow decision — constructible only by [`certify`], so no
811/// unchecked or error-carrying `TypedCommons` can reach the emitter by
812/// construction (previously enforced only by every caller happening to check
813/// a `Result` first). `certify` rejects on any error-severity diagnostic;
814/// T3.3a's `Ty::Error` is what a diagnosed checker failure records into
815/// `expr_types`, so in practice a `Ty::Error` never reaches a `CheckedProgram`
816/// either — R4.3's "rejected by certify" already holds today via the same
817/// diagnostic-severity gate `certify` makes structural.
818///
819/// Scoped to the single-file compile path for now (`bynk-emit`'s
820/// `compile_with_warnings`). The project/batch path's per-unit `emit_project`
821/// call happens *before* that unit's build-wide gate is finally decided
822/// (cross-unit validation can still fail the whole build afterward), so
823/// wrapping it in `CheckedProgram` at today's call site would misrepresent an
824/// unfinished decision as a certified one — that path needs its own slice,
825/// not forced into this one.
826pub struct CheckedProgram(TypedCommons);
827
828impl CheckedProgram {
829 /// The certified program. No accessor exists that goes the other
830 /// direction — a `TypedCommons` is never recoverable-then-rewrapped
831 /// without going through `certify` again.
832 pub fn program(&self) -> &TypedCommons {
833 &self.0
834 }
835}
836
837/// The single place "may we emit?" is asked (R3.10). Rejects — returning
838/// every diagnostic, not just the error-severity ones, matching
839/// `check_record`'s own error-path convention — if `diagnostics` contains an
840/// error-severity entry; otherwise wraps `program` as certified.
841pub fn certify(
842 program: TypedCommons,
843 diagnostics: Vec<CompileError>,
844) -> Result<CheckedProgram, Vec<CompileError>> {
845 let (hard_errors, warnings) = bynk_syntax::partition_by_severity(diagnostics);
846 if hard_errors.is_empty() {
847 Ok(CheckedProgram(program))
848 } else {
849 let mut all = hard_errors;
850 all.extend(warnings);
851 Err(all)
852 }
853}
854
855// ==== Entry points ====
856
857/// #1688: clear the cache of generic functions' compared type parameters.
858/// A project check calls this once before its per-unit loop; a single-file
859/// [`check`] calls it itself.
860pub fn reset_compared_cache() {
861 equality::reset_compared_cache();
862}
863
864pub fn check(input: ResolvedCommons) -> Result<TypedCommons, Vec<CompileError>> {
865 reset_compared_cache();
866 check_record(
867 input,
868 &mut RefSink::new(),
869 &mut HintSink::new(),
870 &mut LocalsSink::new(),
871 &mut RequirementSink::new(),
872 )
873 .result
874}
875
876/// [`check`], recording binding edges into `refs` at the checker's
877/// resolution sites (v0.25). A fresh sink records nothing.
878pub fn check_record(
879 input: ResolvedCommons,
880 refs: &mut RefSink,
881 hints: &mut HintSink,
882 locals: &mut LocalsSink,
883 requirements: &mut RequirementSink,
884) -> RecordCheck {
885 check_record_in(
886 input,
887 &Arc::new(Types::new()),
888 refs,
889 hints,
890 locals,
891 requirements,
892 )
893}
894
895/// [`check_record`] against a **caller-supplied** intern table (T3.6b, R4.1).
896///
897/// The per-invocation table `check_record` mints is the right default: one
898/// unit, one table, ids that never escape it. A *project* check is the case
899/// that needs more — it runs `check_record` once per unit but funnels every
900/// unit's `expr_types` into one `ExprTypeSink`, so a `TyId` recorded there
901/// would be ambiguous if each unit interned into a table of its own. Sharing
902/// one table across the whole analysis makes those ids mean one thing, and is
903/// strictly safer than the per-unit case the track doc argued for: ids are
904/// still only ever compared against ids from the same table.
905pub fn check_record_in(
906 input: ResolvedCommons,
907 ty_intern: &Arc<Types>,
908 refs: &mut RefSink,
909 hints: &mut HintSink,
910 locals: &mut LocalsSink,
911 requirements: &mut RequirementSink,
912) -> RecordCheck {
913 let ty_intern = Arc::clone(ty_intern);
914 let mut errors = Vec::new();
915 let mut expr_types: HashMap<ExprId, TypedExpr> = HashMap::new();
916 let mut callees: HashMap<ExprId, Callee> = HashMap::new();
917 // 1. Validate each type declaration.
918 for item in &input.commons.items {
919 if let CommonsItem::Type(t) = item {
920 check_type_decl(t, &input.types, &ty_intern, &mut errors);
921 }
922 }
923
924 // 2. Type-check each function and method body.
925 for item in &input.commons.items {
926 if let CommonsItem::Fn(f) = item {
927 refs.set_owner(f.name.display());
928 check_fn(
929 f,
930 &input,
931 &mut expr_types,
932 &mut callees,
933 &mut errors,
934 refs,
935 hints,
936 locals,
937 requirements,
938 &ty_intern,
939 );
940 refs.clear_owner();
941 }
942 }
943 // #1688: record this unit's generics' compared type parameters, in this
944 // unit's environment, for the units that import them.
945 equality::scan_local_generics(&input, &ty_intern);
946
947 // v0.89 (ADR 0117): split diagnostics by severity. A unit with no
948 // error-severity diagnostic *checks* — its warnings ride on `TypedCommons`,
949 // surfaced but non-gating. Only error-severity diagnostics fail the check;
950 // on that path the warnings are appended so a failed build still renders
951 // them.
952 // Finding #28 (debug-only): `Span` is `expr_types`'s key, but nothing
953 // enforces that no two AST nodes needing a type share one — bug #844 and
954 // the else-less-`if` synthesis both did, silently, before either was
955 // caught. Walk every checked function/method body with the total child
956 // iterator `ast::expr_children` and assert no two nodes recorded here
957 // collide; a release build doesn't pay for the walk. Scoped per item,
958 // not across the whole commons: a multi-file commons's merged item list
959 // legitimately re-walks the same function more than once (its own,
960 // separate redundancy, outside this finding's scope), and both known
961 // collisions (#844, the else-less-`if` synthesis) are contained within a
962 // single function/handler body regardless.
963 #[cfg(debug_assertions)]
964 for item in &input.commons.items {
965 if let CommonsItem::Fn(f) = item {
966 let mut seen: HashSet<ExprId> = HashSet::new();
967 assert_expr_types_disjoint_in_block(&f.body, &expr_types, &mut seen);
968 }
969 }
970
971 let (hard_errors, warnings) = bynk_syntax::partition_by_severity(errors);
972 if hard_errors.is_empty() {
973 RecordCheck {
974 result: Ok(TypedCommons {
975 commons: input.commons,
976 types: input.types,
977 fns: input.fns,
978 methods: input.methods,
979 expr_types,
980 callees,
981 warnings,
982 ty_intern: Arc::clone(&ty_intern),
983 actor_bindings: HashMap::new(),
984 }),
985 partial_expr_types: HashMap::new(),
986 ty_intern,
987 typed_despite_errors: None,
988 }
989 } else {
990 // Keep the best-effort types the checker already computed; Analyse mode
991 // surfaces them for `.`-member completion on a broken buffer (ADR 0094).
992 let mut all = hard_errors;
993 all.extend(warnings);
994 RecordCheck {
995 result: Err(all),
996 partial_expr_types: expr_types.clone(),
997 typed_despite_errors: Some(TypedCommons {
998 commons: input.commons,
999 types: input.types,
1000 fns: input.fns,
1001 methods: input.methods,
1002 expr_types,
1003 callees,
1004 warnings: Vec::new(),
1005 ty_intern: Arc::clone(&ty_intern),
1006 actor_bindings: HashMap::new(),
1007 }),
1008 ty_intern,
1009 }
1010 }
1011}
1012
1013/// Finding #28 (debug-only), T3.4: the block-level half of the `expr_types`
1014/// identity-uniqueness walk — visits every statement expression and the tail.
1015/// `ExprId` uniqueness is guaranteed by construction (`Parser::alloc_expr_id`
1016/// is the sole allocation point), so this can no longer catch a *parser*
1017/// collision the way it caught #844 on `Span`; it stays as the loud check
1018/// that a synthetic node (`ExprId::SYNTHETIC`) never reaches the checker's
1019/// own `expr_types` — the one way two entries could still collide.
1020#[cfg(debug_assertions)]
1021fn assert_expr_types_disjoint_in_block(
1022 block: &Block,
1023 expr_types: &HashMap<ExprId, TypedExpr>,
1024 seen: &mut HashSet<ExprId>,
1025) {
1026 let mut roots: Vec<&Expr> = Vec::new();
1027 for s in &block.statements {
1028 bynk_syntax::ast::statement_exprs(s, &mut roots);
1029 }
1030 roots.push(&block.tail);
1031 for e in roots {
1032 assert_expr_types_disjoint(e, expr_types, seen);
1033 }
1034}
1035
1036/// Finding #28 (debug-only), T3.4: recurses over an expression with the
1037/// total child iterator `ast::expr_children`, asserting no two nodes
1038/// recorded into `expr_types` share an [`ExprId`] — a collision means one
1039/// node's recorded type silently clobbered another's (bug #844's class of
1040/// bug, before `ExprId` made position-derived collisions structurally
1041/// impossible for parser-allocated nodes).
1042#[cfg(debug_assertions)]
1043fn assert_expr_types_disjoint(
1044 e: &Expr,
1045 expr_types: &HashMap<ExprId, TypedExpr>,
1046 seen: &mut HashSet<ExprId>,
1047) {
1048 assert!(
1049 e.id != ExprId::SYNTHETIC || !expr_types.contains_key(&e.id),
1050 "bynk internal error (finding #28): a synthetic node (ExprId::SYNTHETIC) reached the \
1051 checker's own `expr_types` at {:?} — synthetic nodes are built after checking and must \
1052 never be inserted here",
1053 e.span
1054 );
1055 if expr_types.contains_key(&e.id) {
1056 assert!(
1057 seen.insert(e.id),
1058 "bynk internal error (finding #28): two typed AST nodes share id {:?} (span {:?}) in \
1059 `expr_types` — one node's recorded type silently clobbered another's",
1060 e.id,
1061 e.span
1062 );
1063 }
1064 for child in bynk_syntax::ast::expr_children(e) {
1065 assert_expr_types_disjoint(child, expr_types, seen);
1066 }
1067}
1068
1069/// #522: the six output sinks a handler-body check writes into. One struct at
1070/// each call site instead of six positional `&mut` arguments.
1071pub struct CheckSinks<'a> {
1072 /// T3.6b (R4.1): the intern table the `TyId`s written into `expr_types`
1073 /// (and carried on [`HandlerBodyCheck`]) resolve against. Belongs with the
1074 /// sinks rather than the signature: it is the thing a body check *writes*
1075 /// types into, and a caller holding a `TypedCommons` passes its
1076 /// `ty_intern` straight through.
1077 pub tys: &'a Types,
1078 pub expr_types: &'a mut HashMap<ExprId, TypedExpr>,
1079 pub errors: &'a mut Vec<CompileError>,
1080 pub refs: &'a mut RefSink,
1081 pub hints: &'a mut HintSink,
1082 pub locals: &'a mut LocalsSink,
1083 pub requirements: &'a mut RequirementSink,
1084 /// P6.0 (#1139): the `Callee` classification sink — see [`Callee`].
1085 pub callees: &'a mut HashMap<ExprId, Callee>,
1086}
1087
1088/// #522: everything [`check_handler_body`] needs to know about the handler —
1089/// signature, capability scope, agent state, and held bindings. Replaces what
1090/// was 17 positional parameters (of a 24-parameter signature); [`Self::new`]
1091/// fills the agent/actor/store extras with empties, so a simple site
1092/// (provider op, test body) sets only the fields it actually uses.
1093pub struct HandlerBodyCheck<'a> {
1094 pub body: &'a Block,
1095 pub return_type: &'a TypeRef,
1096 pub params: &'a [Param],
1097 /// The capabilities the body may call (the handler's resolved `given`).
1098 pub capabilities: HashMap<String, CapabilityInfo>,
1099 /// Every declared capability, for "declared but not given" diagnostics.
1100 pub declared_capabilities: HashMap<String, CapabilityInfo>,
1101 pub given: &'a [CapRef],
1102 pub given_anchor: Option<Span>,
1103 pub report_unused: bool,
1104 /// An agent handler's synthetic state-record type, when one is in scope.
1105 pub agent_state_ty: Option<TyId>,
1106 pub agent_self_scope: Option<HashMap<String, TyId>>,
1107 /// v0.45/v0.52: the `by <binder>: <Actor(s)>` binding — the binder name and
1108 /// its fully-formed sealed type: `Ty::Actor(identity)` for a single actor
1109 /// (so `binder.identity` type-checks), or `Ty::ActorSum(members)` for a sum
1110 /// (so the body `match`es on it). `None` for handlers without a `by` binder.
1111 pub actor_binding: Option<(String, TyId)>,
1112 /// The agent's `store` fields, by name (finding #36 — see [`StoreField`]),
1113 /// so the `:=` write form, `<field>.<op>(…)`, and the map query accessors
1114 /// can resolve their target. Empty for service/test bodies and `state {
1115 /// }` agents.
1116 pub store_fields: HashMap<String, StoreField>,
1117 /// v0.106 (slice 3b-iii): held params that are **borrowed**, not owned —
1118 /// the firing `connection` of a `from websocket` `on message`/`on close`.
1119 /// Borrowed bindings admit non-consuming ops (`send`) but carry no disposal
1120 /// obligation. Empty for every other handler (including `on open`, whose
1121 /// connection is owned).
1122 pub borrowed_held: HashSet<String>,
1123}
1124
1125impl<'a> HandlerBodyCheck<'a> {
1126 /// A check of `body` against `return_type` with everything optional empty:
1127 /// no capabilities, no agent state, no actor binding, no store fields.
1128 pub fn new(
1129 body: &'a Block,
1130 return_type: &'a TypeRef,
1131 params: &'a [Param],
1132 given: &'a [CapRef],
1133 ) -> Self {
1134 Self {
1135 body,
1136 return_type,
1137 params,
1138 capabilities: HashMap::new(),
1139 declared_capabilities: HashMap::new(),
1140 given,
1141 given_anchor: None,
1142 report_unused: false,
1143 agent_state_ty: None,
1144 agent_self_scope: None,
1145 actor_binding: None,
1146 store_fields: HashMap::new(),
1147 borrowed_held: HashSet::new(),
1148 }
1149 }
1150}
1151
1152/// Check a single handler body (used for service and agent handlers).
1153pub fn check_handler_body(
1154 input: &ResolvedCommons,
1155 check: HandlerBodyCheck<'_>,
1156 sinks: CheckSinks<'_>,
1157) {
1158 let HandlerBodyCheck {
1159 body,
1160 return_type,
1161 params,
1162 capabilities,
1163 declared_capabilities,
1164 given,
1165 given_anchor,
1166 report_unused,
1167 agent_state_ty,
1168 agent_self_scope,
1169 actor_binding,
1170 store_fields,
1171 borrowed_held,
1172 } = check;
1173 let CheckSinks {
1174 tys,
1175 expr_types,
1176 errors,
1177 refs,
1178 hints,
1179 locals,
1180 requirements,
1181 callees,
1182 } = sinks;
1183 let return_ty_span = return_type.span();
1184 let Some(return_ty) = resolve_type_ref(return_type, &input.types, tys) else {
1185 return;
1186 };
1187 let no_vars = HashSet::new();
1188 record_type_refs(return_type, &input.types, &no_vars, refs);
1189 // Build the parameter scope.
1190 let mut param_scope: HashMap<String, TyId> = HashMap::new();
1191 for p in params {
1192 if let Some(t) = resolve_type_ref(&p.type_ref, &input.types, tys) {
1193 record_type_refs(&p.type_ref, &input.types, &no_vars, refs);
1194 // v0.31: a handler/op parameter is in scope over the whole body.
1195 if p.name.name != "_" {
1196 locals.record(
1197 p.name.name.clone(),
1198 p.name.span,
1199 crate::locals::LocalKind::Param,
1200 t.display(tys),
1201 body.span,
1202 );
1203 }
1204 param_scope.insert(p.name.name.clone(), t);
1205 }
1206 }
1207 if let Some((binder, binder_ty)) = actor_binding {
1208 if binder != "_" {
1209 locals.record(
1210 binder.clone(),
1211 body.span,
1212 crate::locals::LocalKind::Param,
1213 "actor".to_string(),
1214 body.span,
1215 );
1216 }
1217 param_scope.insert(binder, binder_ty);
1218 }
1219 if let Some(self_scope) = agent_self_scope {
1220 param_scope.extend(self_scope);
1221 }
1222 let effectful = return_ty.is_effect(tys);
1223 let given_entries: Vec<(String, Span)> = given
1224 .iter()
1225 .map(|c| (c.key().to_string(), c.span))
1226 .collect();
1227 let given_remaining: HashSet<String> = given_entries.iter().map(|(k, _)| k.clone()).collect();
1228 let mut ctx = Ctx {
1229 input,
1230 tys,
1231 expr_types,
1232 errors,
1233 refs,
1234 hints,
1235 locals,
1236 requirements,
1237 callees,
1238 scopes: vec![param_scope],
1239 is_binding_cache: HashMap::new(),
1240 pattern_binding_types: HashMap::new(),
1241 return_ty,
1242 return_ty_span,
1243 effectful,
1244 agent_state_ty,
1245 commit_seen: false,
1246 caps: CapabilityCtx {
1247 capabilities,
1248 declared_capabilities,
1249 given_remaining,
1250 given_used: HashSet::new(),
1251 given_entries: given_entries.clone(),
1252 given_anchor,
1253 },
1254 in_test_body: false,
1255 test_services: HashMap::new(),
1256 test_actors: HashMap::new(),
1257 type_vars: HashSet::new(),
1258 store_fields,
1259 };
1260 // Check the body and validate it matches the return type.
1261 let Some(body_ty) = type_of_block(body, Some(return_ty), &mut ctx) else {
1262 return;
1263 };
1264 // v0.102 (§3 step 11): the held-resource linearity pass, now that
1265 // `expr_types` is fully populated by the body walk above.
1266 linearity::check(
1267 body,
1268 params,
1269 &input.types,
1270 ctx.expr_types,
1271 &ctx.pattern_binding_types,
1272 &borrowed_held,
1273 ctx.errors,
1274 tys,
1275 );
1276 // Finding #28 (debug-only), extended: `check_record`'s per-function walk
1277 // (43abc242) never reaches a handler body — `check_handler_body` is
1278 // `bynk-emit`'s own entry point for service/agent handlers, called
1279 // directly from `validate.rs`, not from `check_record`'s
1280 // `CommonsItem::Fn` loop. A fresh `seen` set per call, matching the
1281 // per-item (not per-commons) granularity 43abc242 chose, so a
1282 // multi-file commons re-checking the same handler doesn't false-positive.
1283 #[cfg(debug_assertions)]
1284 {
1285 let mut seen: HashSet<ExprId> = HashSet::new();
1286 assert_expr_types_disjoint_in_block(body, ctx.expr_types, &mut seen);
1287 }
1288 if !compatible(body_ty, return_ty, tys) {
1289 ctx.errors.push(
1290 CompileError::new(
1291 "bynk.types.return_mismatch",
1292 body.tail.span,
1293 format!(
1294 "handler body has type `{}`, but the declared return type is `{}`",
1295 body_ty.display(tys),
1296 return_ty.display(tys)
1297 ),
1298 )
1299 .with_label(return_ty_span, "declared return type"),
1300 );
1301 }
1302 // Bidirectional `given` check.
1303 // 1) Every used capability is declared. (Handled in capability-call site.)
1304 // 2) Every declared capability is used — anything left in given_remaining
1305 // minus given_used is unused. Emit as a warning-category error so the
1306 // test harness can match it. Entries are walked in declaration order
1307 // (deduplicated by key) so diagnostics and their fixes are stable.
1308 let mut reported: HashSet<&str> = HashSet::new();
1309 for (i, (c, _)) in given_entries.iter().enumerate() {
1310 if !report_unused {
1311 break;
1312 }
1313 if ctx.caps.given_used.contains(c) || !reported.insert(c) {
1314 continue;
1315 }
1316 ctx.errors.push(
1317 CompileError::new(
1318 "bynk.given.unused_capability",
1319 return_ty_span,
1320 format!("capability `{c}` is declared in `given` but never used in the body"),
1321 )
1322 // Finding #49: the CLI now renders `.with_suggestion` below, so
1323 // this note carries only the alternative fix the suggestion
1324 // doesn't (removing the capability from `given`).
1325 .with_note("alternatively, use the capability in the handler body")
1326 // v0.26 (ADR 0054): the removal is list-aware — only `report_unused`
1327 // sites are handlers, where the clause follows the return type, so
1328 // `return_ty_span` anchors the only-entry case.
1329 .with_suggestion(
1330 format!("remove `{c}` from the `given` clause"),
1331 vec![(
1332 given_removal_span(&given_entries, i, return_ty_span),
1333 String::new(),
1334 )],
1335 Applicability::MachineApplicable,
1336 ),
1337 );
1338 }
1339}
1340
1341/// Type-check a bare body against `return_ty` in `scope`, with `caps`
1342/// available as both in-scope and declared capabilities and (if non-empty)
1343/// `test_services`/`test_actors` in scope for a test-case body's `svc.call`/
1344/// `by <Actor>(...)` resolution (§32/#33: the one shape every hand-rolled
1345/// `Ctx` outside this crate needed, letting `Ctx` itself stay `pub(crate)`).
1346/// `where_pred`, if present, is checked first against `Bool` (a property's
1347/// optional `for all ... where` filter — `bynk.property.where_not_bool` on
1348/// mismatch), sharing `ctx` with the main body so both populate the same
1349/// `expr_types`/`errors` sinks. Unlike [`check_handler_body`], this skips
1350/// the linearity pass, the return-type-mismatch diagnostic, and the
1351/// unused-`given` diagnostic — nothing outside this crate that built its
1352/// own `Ctx` ran those either, and adding them here would be a behaviour
1353/// change, not a refactor.
1354#[allow(clippy::too_many_arguments)]
1355pub fn check_body(
1356 input: &ResolvedCommons,
1357 body: &Block,
1358 return_ty: TyId,
1359 return_ty_span: Span,
1360 scope: HashMap<String, TyId>,
1361 caps: CapabilityCtx,
1362 test_services: HashMap<String, TestServiceSig>,
1363 test_actors: HashMap<String, bynk_syntax::ast::ActorDecl>,
1364 where_pred: Option<&Expr>,
1365 sinks: CheckSinks<'_>,
1366) -> Option<TyId> {
1367 let CheckSinks {
1368 tys,
1369 expr_types,
1370 errors,
1371 refs,
1372 hints,
1373 locals,
1374 requirements,
1375 callees,
1376 } = sinks;
1377 let mut ctx = Ctx {
1378 input,
1379 tys,
1380 expr_types,
1381 errors,
1382 refs,
1383 hints,
1384 locals,
1385 requirements,
1386 callees,
1387 scopes: vec![scope],
1388 is_binding_cache: HashMap::new(),
1389 pattern_binding_types: HashMap::new(),
1390 return_ty,
1391 return_ty_span,
1392 effectful: return_ty.is_effect(tys),
1393 agent_state_ty: None,
1394 commit_seen: false,
1395 caps,
1396 in_test_body: true,
1397 test_services,
1398 test_actors,
1399 type_vars: HashSet::new(),
1400 store_fields: HashMap::new(),
1401 };
1402 if let Some(w) = where_pred {
1403 let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1404 if let Some(actual) = type_of(w, Some(bool_ty), &mut ctx)
1405 && actual.base(tys) != Some(BaseType::Bool)
1406 {
1407 ctx.errors.push(CompileError::new(
1408 "bynk.property.where_not_bool",
1409 w.span,
1410 format!(
1411 "a `for all ... where` filter has type `{}`, but a `Bool` is required",
1412 actual.display(tys)
1413 ),
1414 ));
1415 }
1416 }
1417 let result = type_of_block(body, Some(return_ty), &mut ctx);
1418 // Finding #28 (debug-only), extended: see the identical note in
1419 // `check_handler_body` — `check_body`'s test-case/property callers bypass
1420 // `check_record`'s walk the same way handler bodies do.
1421 #[cfg(debug_assertions)]
1422 {
1423 let mut seen: HashSet<ExprId> = HashSet::new();
1424 assert_expr_types_disjoint_in_block(body, ctx.expr_types, &mut seen);
1425 }
1426 result
1427}
1428
1429/// Check an agent's invariant declarations (v0.80 §14). Each predicate is a pure
1430/// `Bool`-typed expression over the agent's state fields (referenced by bare
1431/// name), plus `implies`/`is`. The pass enforces:
1432///
1433/// - `bynk.invariant.duplicate_name` — two invariants share a name.
1434/// - `bynk.invariant.cross_agent_reference` — a predicate names another agent
1435/// (§14 closes that door; sagas/scenarios are the cross-agent tools).
1436/// - `bynk.invariant.impure_predicate` — a predicate uses an effectful or
1437/// test-only construct (Effect, `?` propagation, `expect`, `Val`).
1438/// - `bynk.invariant.not_bool` — the predicate does not type to `Bool`.
1439///
1440/// Store `Cell` fields are placed in scope as the predicate's locals; invariants
1441/// read fields directly by bare name, mirroring the design-notes worked examples.
1442#[allow(clippy::too_many_arguments)]
1443pub fn check_invariants(
1444 invariants: &[Invariant],
1445 // A `store`-bearing agent's invariants reference its `Cell` fields by bare
1446 // name (a pure read of the staged value), so they form the predicate scope.
1447 store_cells: &HashMap<String, TyId>,
1448 agent_name: &str,
1449 input: &ResolvedCommons,
1450 tys: &Types,
1451 expr_types: &mut HashMap<ExprId, TypedExpr>,
1452 errors: &mut Vec<CompileError>,
1453 refs: &mut RefSink,
1454 hints: &mut HintSink,
1455 locals: &mut LocalsSink,
1456 requirements: &mut RequirementSink,
1457 callees: &mut HashMap<ExprId, Callee>,
1458) {
1459 // Duplicate-name check across the agent's invariants.
1460 let mut seen: HashMap<&str, ()> = HashMap::new();
1461 for inv in invariants {
1462 if seen.insert(inv.name.name.as_str(), ()).is_some() {
1463 errors.push(
1464 CompileError::new(
1465 "bynk.invariant.duplicate_name",
1466 inv.name.span,
1467 format!(
1468 "agent `{agent_name}` declares more than one invariant named `{}`",
1469 inv.name.name
1470 ),
1471 )
1472 .with_note("give each invariant a distinct name"),
1473 );
1474 }
1475 }
1476
1477 // Build the predicate scope once: each `store` `Cell` is in scope by bare
1478 // name (a `Cell` reads as its element type).
1479 let mut field_scope: HashMap<String, TyId> = HashMap::new();
1480 for (name, ty) in store_cells {
1481 field_scope.insert(name.clone(), *ty);
1482 }
1483
1484 for inv in invariants {
1485 // Reject cross-agent references and impure/effectful constructs before
1486 // type-checking, so the bespoke diagnostics win over any cascade.
1487 if let Some(span) = predicate_cross_agent_ref(&inv.predicate, input) {
1488 errors.push(
1489 CompileError::new(
1490 "bynk.invariant.cross_agent_reference",
1491 span,
1492 format!(
1493 "invariant `{}` references another agent; invariants constrain a \
1494 single agent's reachable states",
1495 inv.name.name
1496 ),
1497 )
1498 .with_note(
1499 "a property that genuinely spans agents belongs in a saga or a scenario, \
1500 not an invariant — see §14",
1501 ),
1502 );
1503 continue;
1504 }
1505 if let Some(span) = predicate_impure_construct(&inv.predicate) {
1506 errors.push(
1507 CompileError::new(
1508 "bynk.invariant.impure_predicate",
1509 span,
1510 format!(
1511 "invariant `{}` uses an effectful or test-only construct; invariant \
1512 predicates must be pure",
1513 inv.name.name
1514 ),
1515 )
1516 .with_note(
1517 "an invariant predicate may read state fields and call pure value methods, \
1518 but not perform effects",
1519 ),
1520 );
1521 continue;
1522 }
1523
1524 let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1525 let mut ctx = Ctx {
1526 input,
1527 tys,
1528 expr_types,
1529 errors,
1530 refs,
1531 hints,
1532 locals,
1533 requirements,
1534 callees,
1535 scopes: vec![field_scope.clone()],
1536 is_binding_cache: HashMap::new(),
1537 pattern_binding_types: HashMap::new(),
1538 return_ty: bool_ty,
1539 return_ty_span: inv.predicate.span,
1540 // A predicate is a pure expression — effectful operations (capability
1541 // calls, `<-`) are not permitted and are rejected as type errors.
1542 effectful: false,
1543 agent_state_ty: None,
1544 commit_seen: false,
1545 caps: CapabilityCtx {
1546 capabilities: HashMap::new(),
1547 declared_capabilities: HashMap::new(),
1548 given_remaining: HashSet::new(),
1549 given_used: HashSet::new(),
1550 given_entries: Vec::new(),
1551 given_anchor: None,
1552 },
1553 in_test_body: false,
1554 test_services: HashMap::new(),
1555 test_actors: HashMap::new(),
1556 type_vars: HashSet::new(),
1557 store_fields: HashMap::new(),
1558 };
1559 let pred_ty = type_of(&inv.predicate, Some(bool_ty), &mut ctx);
1560 if let Some(t) = pred_ty
1561 && t.base(tys) != Some(BaseType::Bool)
1562 {
1563 ctx.errors.push(
1564 CompileError::new(
1565 "bynk.invariant.not_bool",
1566 inv.predicate.span,
1567 format!(
1568 "invariant `{}` predicate has type `{}`, but an invariant must be `Bool`",
1569 inv.name.name,
1570 t.display(tys)
1571 ),
1572 )
1573 .with_note("an invariant predicate is a `Bool`-valued property of the state"),
1574 );
1575 }
1576 }
1577}
1578
1579/// Check a function's contract clauses (v0.115 §, testing track slice 3). A
1580/// contract is the invariant predicate attached to a function (ADR 0144 — one
1581/// predicate surface): each `requires`/`ensures` is a pure `Bool`-typed
1582/// expression, `requires` over the parameters and `ensures` over the parameters
1583/// plus `result` (the return value; the awaited element for an `Effect`). The
1584/// pass enforces, mirroring [`check_invariants`]:
1585///
1586/// - `bynk.contract.duplicate_name` — two clauses (across `requires`/`ensures`)
1587/// share a name; the name rides the failure report and dedup.
1588/// - `bynk.contract.result_in_requires` — a precondition references `result`
1589/// (the return value is not yet bound on entry).
1590/// - `bynk.contract.impure_predicate` — a clause uses an effectful or test-only
1591/// construct (Effect, `?` propagation, `expect`, `Val`).
1592/// - `bynk.contract.not_bool` — a clause does not type to `Bool`.
1593///
1594/// Distinct from ADR 0127's capability `@requires` annotation.
1595#[allow(clippy::too_many_arguments)]
1596pub fn check_contracts(
1597 requires: &[Contract],
1598 ensures: &[Contract],
1599 // The function's parameters in scope by bare name (plus `self` for a
1600 // method), the shared predicate scope for both clause kinds.
1601 param_scope: &HashMap<String, TyId>,
1602 // The declared return type, awaited for an `Effect` — the type of `result`
1603 // inside an `ensures` predicate.
1604 result_ty: TyId,
1605 // True when a parameter is literally named `result`; then `result` in a
1606 // `requires` is that parameter, not the (unbound) return value.
1607 has_result_param: bool,
1608 fn_label: &str,
1609 input: &ResolvedCommons,
1610 expr_types: &mut HashMap<ExprId, TypedExpr>,
1611 errors: &mut Vec<CompileError>,
1612 refs: &mut RefSink,
1613 hints: &mut HintSink,
1614 locals: &mut LocalsSink,
1615 requirements: &mut RequirementSink,
1616 callees: &mut HashMap<ExprId, Callee>,
1617 type_vars: &HashSet<String>,
1618 tys: &Types,
1619) {
1620 // Duplicate-name check across *all* clauses — the name is the dedup key for
1621 // the failure report and the redundant-test flag, so it is unique per fn.
1622 let mut seen: HashMap<&str, ()> = HashMap::new();
1623 for c in requires.iter().chain(ensures.iter()) {
1624 if seen.insert(c.name.name.as_str(), ()).is_some() {
1625 errors.push(
1626 CompileError::new(
1627 "bynk.contract.duplicate_name",
1628 c.name.span,
1629 format!(
1630 "{fn_label} declares more than one contract clause named `{}`",
1631 c.name.name
1632 ),
1633 )
1634 .with_note("give each `requires`/`ensures` clause a distinct name"),
1635 );
1636 }
1637 }
1638
1639 // Type-check one clause predicate in the given scope, emitting the shared
1640 // impurity / non-`Bool` diagnostics.
1641 let check_clause = |c: &Contract,
1642 scope: HashMap<String, TyId>,
1643 expr_types: &mut HashMap<ExprId, TypedExpr>,
1644 errors: &mut Vec<CompileError>,
1645 refs: &mut RefSink,
1646 hints: &mut HintSink,
1647 locals: &mut LocalsSink,
1648 requirements: &mut RequirementSink,
1649 callees: &mut HashMap<ExprId, Callee>| {
1650 if let Some(span) = predicate_impure_construct(&c.predicate) {
1651 errors.push(
1652 CompileError::new(
1653 "bynk.contract.impure_predicate",
1654 span,
1655 format!(
1656 "contract clause `{}` uses an effectful or test-only construct; a \
1657 contract predicate must be pure",
1658 c.name.name
1659 ),
1660 )
1661 .with_note(
1662 "a contract predicate may read the parameters (and `result`) and call \
1663 pure value methods, but not perform effects",
1664 ),
1665 );
1666 return;
1667 }
1668 let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1669 let mut ctx = Ctx {
1670 input,
1671 tys,
1672 expr_types,
1673 errors,
1674 refs,
1675 hints,
1676 locals,
1677 requirements,
1678 callees,
1679 scopes: vec![scope],
1680 is_binding_cache: HashMap::new(),
1681 pattern_binding_types: HashMap::new(),
1682 return_ty: bool_ty,
1683 return_ty_span: c.predicate.span,
1684 effectful: false,
1685 agent_state_ty: None,
1686 commit_seen: false,
1687 caps: CapabilityCtx {
1688 capabilities: HashMap::new(),
1689 declared_capabilities: HashMap::new(),
1690 given_remaining: HashSet::new(),
1691 given_used: HashSet::new(),
1692 given_entries: Vec::new(),
1693 given_anchor: None,
1694 },
1695 in_test_body: false,
1696 test_services: HashMap::new(),
1697 test_actors: HashMap::new(),
1698 type_vars: type_vars.clone(),
1699 store_fields: HashMap::new(),
1700 };
1701 let pred_ty = type_of(&c.predicate, Some(bool_ty), &mut ctx);
1702 if let Some(t) = pred_ty
1703 && t.base(tys) != Some(BaseType::Bool)
1704 {
1705 ctx.errors.push(
1706 CompileError::new(
1707 "bynk.contract.not_bool",
1708 c.predicate.span,
1709 format!(
1710 "contract clause `{}` predicate has type `{}`, but a contract clause \
1711 must be `Bool`",
1712 c.name.name,
1713 t.display(tys)
1714 ),
1715 )
1716 .with_note("a contract predicate is a `Bool`-valued claim over the arguments"),
1717 );
1718 }
1719 };
1720
1721 for c in requires {
1722 // `result` is the *return value* — not in scope on entry. A `requires`
1723 // that names it is a scope error with a bespoke diagnostic (unless a
1724 // parameter is literally named `result`, in which case it is that param).
1725 if !has_result_param && let Some(span) = predicate_references_result(&c.predicate) {
1726 errors.push(
1727 CompileError::new(
1728 "bynk.contract.result_in_requires",
1729 span,
1730 format!(
1731 "precondition `{}` references `result`, but the return value is not bound \
1732 until the function returns",
1733 c.name.name
1734 ),
1735 )
1736 .with_note("`result` is only in scope inside an `ensures` clause"),
1737 );
1738 continue;
1739 }
1740 check_clause(
1741 c,
1742 param_scope.clone(),
1743 expr_types,
1744 errors,
1745 refs,
1746 hints,
1747 locals,
1748 requirements,
1749 callees,
1750 );
1751 }
1752
1753 for c in ensures {
1754 // `ensures` scope = parameters + `result` (the return value; awaited for
1755 // an `Effect`). A parameter named `result` is shadowed by the binding.
1756 let mut scope = param_scope.clone();
1757 scope.insert("result".to_string(), result_ty);
1758 check_clause(
1759 c,
1760 scope,
1761 expr_types,
1762 errors,
1763 refs,
1764 hints,
1765 locals,
1766 requirements,
1767 callees,
1768 );
1769 }
1770}
1771
1772/// Check an agent's step invariants (v0.116 §, testing track slice 4). A
1773/// `transition` is the invariant predicate widened to the *step* (ADR 0144 — one
1774/// predicate surface): a pure `Bool` predicate over the `old`/`new` state pair,
1775/// each bound to the agent's synthetic state record (`state_ty`), so `old.status`
1776/// / `new.status` resolve like any record field. The pass enforces, mirroring
1777/// [`check_invariants`]:
1778///
1779/// - `bynk.transition.duplicate_name` — two transitions share a name (the name
1780/// rides the `InvariantViolation` failure report).
1781/// - `bynk.transition.impure_predicate` — a predicate uses an effectful or
1782/// test-only construct.
1783/// - `bynk.transition.no_step_reference` — a predicate references neither `old`
1784/// nor `new`; it is a snapshot claim misfiled as a step (use `invariant`).
1785/// - `bynk.transition.not_bool` — a predicate does not type to `Bool`.
1786///
1787/// Placement is enforced structurally by the grammar (a `transition` is an
1788/// agent-body-only declaration), so there is no "transition on a non-agent"
1789/// diagnostic to raise here.
1790#[allow(clippy::too_many_arguments)]
1791pub fn check_transitions(
1792 transitions: &[Transition],
1793 // The agent's synthetic state record type — both `old` and `new` are bound to
1794 // it, so `old.field` / `new.field` read as the field's element type.
1795 state_ty: TyId,
1796 agent_name: &str,
1797 // Resolved commons carrying the synthetic `<Agent>State` record so field
1798 // access on `old`/`new` resolves.
1799 input: &ResolvedCommons,
1800 expr_types: &mut HashMap<ExprId, TypedExpr>,
1801 errors: &mut Vec<CompileError>,
1802 refs: &mut RefSink,
1803 hints: &mut HintSink,
1804 locals: &mut LocalsSink,
1805 requirements: &mut RequirementSink,
1806 callees: &mut HashMap<ExprId, Callee>,
1807 tys: &Types,
1808) {
1809 // Duplicate-name check across the agent's transitions.
1810 let mut seen: HashMap<&str, ()> = HashMap::new();
1811 for tr in transitions {
1812 if seen.insert(tr.name.name.as_str(), ()).is_some() {
1813 errors.push(
1814 CompileError::new(
1815 "bynk.transition.duplicate_name",
1816 tr.name.span,
1817 format!(
1818 "agent `{agent_name}` declares more than one transition named `{}`",
1819 tr.name.name
1820 ),
1821 )
1822 .with_note("give each transition a distinct name"),
1823 );
1824 }
1825 }
1826
1827 // Both `old` and `new` are in scope as the state record.
1828 let mut scope: HashMap<String, TyId> = HashMap::new();
1829 scope.insert("old".to_string(), state_ty);
1830 scope.insert("new".to_string(), state_ty);
1831
1832 for tr in transitions {
1833 // Reject cross-agent references and impure constructs before type-checking,
1834 // so the bespoke diagnostics win over any cascade.
1835 if let Some(span) = predicate_cross_agent_ref(&tr.predicate, input) {
1836 errors.push(
1837 CompileError::new(
1838 "bynk.transition.cross_agent_reference",
1839 span,
1840 format!(
1841 "transition `{}` references another agent; a step invariant \
1842 constrains a single agent's own state move",
1843 tr.name.name
1844 ),
1845 )
1846 .with_note(
1847 "a property that genuinely spans agents belongs in a saga or a scenario, \
1848 not a transition",
1849 ),
1850 );
1851 continue;
1852 }
1853 if let Some(span) = predicate_impure_construct(&tr.predicate) {
1854 errors.push(
1855 CompileError::new(
1856 "bynk.transition.impure_predicate",
1857 span,
1858 format!(
1859 "transition `{}` uses an effectful or test-only construct; a step \
1860 invariant predicate must be pure",
1861 tr.name.name
1862 ),
1863 )
1864 .with_note(
1865 "a transition predicate may read the `old`/`new` state and call pure value \
1866 methods, but not perform effects",
1867 ),
1868 );
1869 continue;
1870 }
1871 // A transition that mentions neither `old` nor `new` is not a step claim —
1872 // it is a snapshot invariant misfiled. Flag it conservatively.
1873 if predicate_references_old_or_new(&tr.predicate).is_none() {
1874 errors.push(
1875 CompileError::new(
1876 "bynk.transition.no_step_reference",
1877 tr.predicate.span,
1878 format!(
1879 "transition `{}` references neither `old` nor `new`, so it constrains a \
1880 single state, not a step",
1881 tr.name.name
1882 ),
1883 )
1884 .with_note(
1885 "a claim about one committed state is an `invariant`, not a `transition`",
1886 ),
1887 );
1888 continue;
1889 }
1890
1891 let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1892 let mut ctx = Ctx {
1893 input,
1894 tys,
1895 expr_types,
1896 errors,
1897 refs,
1898 hints,
1899 locals,
1900 requirements,
1901 callees,
1902 scopes: vec![scope.clone()],
1903 is_binding_cache: HashMap::new(),
1904 pattern_binding_types: HashMap::new(),
1905 return_ty: bool_ty,
1906 return_ty_span: tr.predicate.span,
1907 effectful: false,
1908 agent_state_ty: None,
1909 commit_seen: false,
1910 caps: CapabilityCtx {
1911 capabilities: HashMap::new(),
1912 declared_capabilities: HashMap::new(),
1913 given_remaining: HashSet::new(),
1914 given_used: HashSet::new(),
1915 given_entries: Vec::new(),
1916 given_anchor: None,
1917 },
1918 in_test_body: false,
1919 test_services: HashMap::new(),
1920 test_actors: HashMap::new(),
1921 type_vars: HashSet::new(),
1922 store_fields: HashMap::new(),
1923 };
1924 let pred_ty = type_of(&tr.predicate, Some(bool_ty), &mut ctx);
1925 if let Some(t) = pred_ty
1926 && t.base(tys) != Some(BaseType::Bool)
1927 {
1928 ctx.errors.push(
1929 CompileError::new(
1930 "bynk.transition.not_bool",
1931 tr.predicate.span,
1932 format!(
1933 "transition `{}` predicate has type `{}`, but a transition must be `Bool`",
1934 tr.name.name,
1935 t.display(tys)
1936 ),
1937 )
1938 .with_note("a transition predicate is a `Bool`-valued property of the state move"),
1939 );
1940 }
1941 }
1942}
1943
1944/// If the predicate references `old` or `new` (a bare identifier) anywhere,
1945/// return the span of the first such reference. Used to flag a `transition` that
1946/// makes no step claim.
1947fn predicate_references_old_or_new(e: &Expr) -> Option<Span> {
1948 match &e.kind {
1949 ExprKind::Ident(id) if id.name == "old" || id.name == "new" => Some(id.span),
1950 _ => bynk_syntax::ast::expr_children(e)
1951 .into_iter()
1952 .find_map(predicate_references_old_or_new),
1953 }
1954}
1955
1956/// If the predicate references `result` (a bare identifier) anywhere, return the
1957/// span of the first such reference. Used to reject `result` in a `requires`.
1958fn predicate_references_result(e: &Expr) -> Option<Span> {
1959 match &e.kind {
1960 ExprKind::Ident(id) if id.name == "result" => Some(id.span),
1961 _ => bynk_syntax::ast::expr_children(e)
1962 .into_iter()
1963 .find_map(predicate_references_result),
1964 }
1965}
1966
1967/// If the predicate references another agent (by bare name, call, or qualified
1968/// constructor), return the span of the first such reference. Used by the
1969/// invariant well-formedness pass to forbid cross-agent predicates.
1970fn predicate_cross_agent_ref(e: &Expr, input: &ResolvedCommons) -> Option<Span> {
1971 let is_agent = |name: &str| input.agents.contains_key(name);
1972 match &e.kind {
1973 ExprKind::Ident(id) if is_agent(&id.name) => Some(id.span),
1974 ExprKind::Call { name, .. } if is_agent(&name.name) => Some(name.span),
1975 ExprKind::ConstructorCall { type_name, .. } if is_agent(&type_name.name) => {
1976 Some(type_name.span)
1977 }
1978 ExprKind::RecordConstruction { type_name, .. } if is_agent(&type_name.name) => {
1979 Some(type_name.span)
1980 }
1981 _ => bynk_syntax::ast::expr_children(e)
1982 .into_iter()
1983 .find_map(|c| predicate_cross_agent_ref(c, input)),
1984 }
1985}
1986
1987/// If the predicate contains an effectful or test-only construct, return its
1988/// span. Capability misuse (an effect operation in a pure context) is left to
1989/// the type checker; this catches the syntactically-impure surface.
1990pub(crate) fn predicate_impure_construct(e: &Expr) -> Option<Span> {
1991 match &e.kind {
1992 ExprKind::EffectPure(_)
1993 | ExprKind::Question(_)
1994 | ExprKind::Expect(_)
1995 | ExprKind::Val { .. }
1996 | ExprKind::Observation(_)
1997 | ExprKind::Faults(_)
1998 | ExprKind::Trace { .. } => Some(e.span),
1999 _ => bynk_syntax::ast::expr_children(e)
2000 .into_iter()
2001 .find_map(predicate_impure_construct),
2002 }
2003}
2004
2005/// Whether `e` reads the identifier `name` anywhere — used by the `:=`
2006/// read-modify-write rule (a cell write whose RHS reads its own LHS).
2007fn expr_reads_ident(e: &Expr, name: &str) -> bool {
2008 match &e.kind {
2009 ExprKind::Ident(id) => id.name == name,
2010 _ => bynk_syntax::ast::expr_children(e)
2011 .into_iter()
2012 .any(|c| expr_reads_ident(c, name)),
2013 }
2014}
2015
2016// ==== Checking context and capability metadata ====
2017
2018/// v0.9.4: a compile-time-constant literal usable for static refinement
2019/// discharge during `T.of(...)` construction.
2020enum ConstLit {
2021 Int(i64),
2022 Float(f64),
2023 Str(String),
2024 Bool(bool),
2025 Unit,
2026}
2027
2028impl ConstLit {
2029 fn display(&self) -> String {
2030 match self {
2031 ConstLit::Int(n) => n.to_string(),
2032 ConstLit::Float(v) => v.to_string(),
2033 ConstLit::Str(s) => format!("{s:?}"),
2034 ConstLit::Bool(b) => b.to_string(),
2035 ConstLit::Unit => "()".to_string(),
2036 }
2037 }
2038}
2039
2040/// Mutable per-function context.
2041/// Capability bookkeeping for the checker — the `given`-clause lifecycle and
2042/// capability dispatch, grouped out of the checker's working context
2043/// (v0.29.10). Empty (`Default`) for pure functions / non-context code.
2044#[derive(Default)]
2045pub struct CapabilityCtx {
2046 /// Capabilities in scope for the current handler, as a name → CapabilityInfo
2047 /// map. Empty for pure functions and non-context code.
2048 pub capabilities: HashMap<String, CapabilityInfo>,
2049 /// All capabilities declared in the surrounding context (for diagnostic
2050 /// purposes — used to detect `<Cap>.op(...)` calls where the capability is
2051 /// declared in the context but not listed in `given`).
2052 pub declared_capabilities: HashMap<String, CapabilityInfo>,
2053 /// Names of capabilities the user listed in `given`, but haven't yet
2054 /// observed used. After checking the body, anything left here is
2055 /// unused — a warning.
2056 pub given_remaining: HashSet<String>,
2057 /// Names of capabilities actually used in the body so far.
2058 pub given_used: HashSet<String>,
2059 /// v0.26 (ADR 0054): the `given` clause's entries in declaration order —
2060 /// (deps key, source span) — so the `given` quick-fixes can author
2061 /// list-aware edits at the diagnosis site. Empty where no `given` clause
2062 /// applies (fns, mock ops, state initialisers).
2063 pub given_entries: Vec<(String, Span)>,
2064 /// v0.26: where the add-capability fix synthesises an *absent* `given`
2065 /// clause — the handler's return type (the clause follows it). `None`
2066 /// where the clause lives elsewhere (a provider's `provides … given`
2067 /// line); the fix is then offered only when entries already exist.
2068 pub given_anchor: Option<Span>,
2069}
2070
2071/// v0.178 (Slice 0, #662) / v0.182 (Slice A, #664): the shape a test body needs
2072/// to resolve a service invocation. Built by the project test pass from the
2073/// target unit's service declarations, so the checker can resolve the addressed
2074/// handler (`svc.call(...)` on an `on call` service, or — Slice A —
2075/// `svc.GET("/x")` / `svc.schedule("…")` / `svc.message(m)` on a `from http` /
2076/// `cron` / `queue` service) and check its arity, argument types, and principal.
2077#[derive(Debug, Clone)]
2078pub struct TestServiceSig {
2079 /// The service's protocol as an author-facing word (`"http"`, `"cron"`,
2080 /// `"queue"`, `"websocket"`), or `None` for a plain `service X { on call }`.
2081 pub protocol: Option<String>,
2082 /// Every handler the service declares, so the branch can resolve any address
2083 /// form. Slice 0 only reads the `on call` entry.
2084 pub handlers: Vec<TestHandler>,
2085}
2086
2087/// One service handler, as a test body sees it (v0.178 / v0.182).
2088#[derive(Debug, Clone)]
2089pub struct TestHandler {
2090 pub kind: bynk_syntax::ast::HandlerKind,
2091 pub params: Vec<bynk_syntax::ast::Param>,
2092 /// The handler's declared `by <Actor>` clause, if any — the actor a call-site
2093 /// principal is checked against. `None` inherits the protocol default actor.
2094 pub by_clause: Option<bynk_syntax::ast::ByClause>,
2095 pub span: Span,
2096}
2097
2098impl TestServiceSig {
2099 /// The `on call` handler, if the service declares one.
2100 pub fn call_handler(&self) -> Option<&TestHandler> {
2101 self.handlers
2102 .iter()
2103 .find(|h| matches!(h.kind, bynk_syntax::ast::HandlerKind::Call))
2104 }
2105}
2106
2107/// One agent `store` field's kind and shape (finding #36) — the checker's
2108/// dispatch keys off this instead of five separate per-kind maps, so a new
2109/// storage kind is one new variant rather than a sixth map threaded through
2110/// every constructor and lookup site.
2111#[derive(Debug, Clone, Copy)]
2112pub enum StoreField {
2113 /// `store <name>: Cell[T]` — element type.
2114 Cell(TyId),
2115 /// `store <name>: Map[K, V]` — key, value.
2116 Map(TyId, TyId),
2117 /// `store <name>: Set[T]` — element type.
2118 Set(TyId),
2119 /// `store <name>: Cache[K, V] @ttl(...)` — key, value, TTL in milliseconds.
2120 Cache(TyId, TyId, i64),
2121 /// `store <name>: Log[T]` — element type.
2122 Log(TyId),
2123}
2124
2125/// The checker's working context. `pub(crate)`: every caller outside this
2126/// crate goes through [`check_handler_body`] or [`check_body`] instead of
2127/// hand-building one — adding a field no longer needs auditing every
2128/// external construction site.
2129pub(crate) struct Ctx<'a> {
2130 pub input: &'a ResolvedCommons,
2131 /// T3.6b (R4.1): the unit's intern table. A shared `&` (the table is
2132 /// interior-mutable, see [`Types`]) so it stays `Copy` — a function that
2133 /// needs it reads `ctx.tys` once and is then free of the `ctx` borrow.
2134 /// Spelled `tys`, not `types`, because `input.types` next door is the
2135 /// unrelated `TypeDecl`-by-name declaration map.
2136 pub tys: &'a Types,
2137 pub expr_types: &'a mut HashMap<ExprId, TypedExpr>,
2138 pub errors: &'a mut Vec<CompileError>,
2139 /// v0.25 (ADR 0053): binding edges recorded at the checker's own
2140 /// resolution sites — capability/service dispatch, typed call dispatch,
2141 /// annotation resolution. Handler/test/provider bodies never pass
2142 /// through the resolver's reference walk, so the checker is their only
2143 /// recording point.
2144 pub refs: &'a mut RefSink,
2145 /// v0.27 (ADR 0056): inferred-type inlay hints recorded at the
2146 /// annotation-absent binding sites (`let` / `let <-` / lambda params)
2147 /// as the binding's final type is computed.
2148 pub hints: &'a mut HintSink,
2149 /// v0.31 (ADR 0064): local bindings recorded with their scope ranges at
2150 /// every binding site (`let`/`let <-`, params, match patterns), for the
2151 /// LSP's scope-at-offset query.
2152 pub locals: &'a mut LocalsSink,
2153 /// v0.99: the capability-requirement ledger — every capability-consuming
2154 /// site (direct call, store op), covered or not, recorded so the editor
2155 /// surfaces (the ghost `given` inlay hint, hover) can read it.
2156 pub requirements: &'a mut RequirementSink,
2157 /// P6.0 (#1139): the `Callee` classification sink — see [`Callee`].
2158 pub callees: &'a mut HashMap<ExprId, Callee>,
2159 /// Stack of in-scope name → type frames.
2160 pub scopes: Vec<HashMap<String, TyId>>,
2161 /// Memoised `is`-pattern bindings, keyed by the condition sub-expression's
2162 /// span. `collect_is_bindings` runs at every `&&`/`implies` node and, for a
2163 /// left-nested `&&` chain, would otherwise re-walk each lhs subtree once per
2164 /// enclosing node — O(N²) for an N-term chain. Because the collector is a
2165 /// pure read over `expr_types` (already populated by the time it runs) and
2166 /// spans are unique per body, caching each node's result collapses the walk
2167 /// to a single pass.
2168 pub is_binding_cache: HashMap<(ExprId, bool), Vec<(String, TyId)>>,
2169 /// T3.4: a pattern-bound name's resolved type, keyed by the binding
2170 /// `Ident`'s own span. Deliberately **not** `ExprId`-keyed and not
2171 /// folded into `expr_types` — a `Pattern::Binding` is not an `Expr` and
2172 /// giving `Ident` an id of its own would touch every identifier
2173 /// construction site in the workspace (field names, type names, params,
2174 /// …), not just the handful that bind. This is exactly the `PatId`
2175 /// reference draws as a *separate* identity from `ExprId` (Part 2) —
2176 /// out of this slice's scope on purpose, not overlooked.
2177 pub pattern_binding_types: HashMap<Span, TyId>,
2178 pub return_ty: TyId,
2179 pub return_ty_span: Span,
2180 /// True if the enclosing function/handler returns `Effect[T]` (v0.5).
2181 /// Determines whether `<-` and capability calls are permitted.
2182 pub effectful: bool,
2183 /// If inside an agent handler, the agent's state type and the agent's
2184 /// name. Used to validate `commit` statements.
2185 pub agent_state_ty: Option<TyId>,
2186 /// True if a `commit` has been seen on the current control-flow path.
2187 /// Used to detect "two reachable commits".
2188 pub commit_seen: bool,
2189 /// Capability bookkeeping — the `given`-clause lifecycle + dispatch,
2190 /// grouped (v0.29.10). Empty for pure functions / non-context code.
2191 pub caps: CapabilityCtx,
2192 /// True when the body being checked is a test case body. Permits
2193 /// `expect` statements (v0.7; renamed from `assert` in v0.112).
2194 pub in_test_body: bool,
2195 /// The target unit's services, populated for test case bodies (v0.25).
2196 /// `svc.call(args)` in a test invokes the target's service; the checker
2197 /// resolves the service's `on call` handler here to check the call's
2198 /// arity and argument types, and records the binding edge so test-file
2199 /// references index. A service with no `on call` handler (a `from http`
2200 /// / `cron` / `queue` service) carries `None` for `call_handler`, which
2201 /// makes `svc.call(...)` a diagnostic rather than a silent runtime crash.
2202 pub test_services: HashMap<String, TestServiceSig>,
2203 /// v0.182 (Slice A, #664): the target unit's actor declarations, so a
2204 /// call-site `by <Actor>(<identity>)` can resolve the actor and type the
2205 /// identity value against its declared identity type. Prelude actors
2206 /// (`Visitor`, `Caller`, …) are resolved separately. Empty outside test
2207 /// bodies.
2208 pub test_actors: HashMap<String, bynk_syntax::ast::ActorDecl>,
2209 /// v0.20a: the enclosing function's type parameters (rigid vars), so
2210 /// nested explicit type arguments (`identity[A](x)` inside a generic
2211 /// body) resolve. Empty outside generic fn bodies.
2212 pub type_vars: HashSet<String>,
2213 /// The agent's `store` fields, by name (finding #36: collapses the five
2214 /// former per-kind maps — `store_cells`/`store_maps`/`store_sets`/
2215 /// `store_caches`/`store_logs` — into one, since a field name can only
2216 /// ever be one kind). A `:=` write, a `<field>.<op>(…)` call, and the
2217 /// `.entries`/`.keys`/`.values` map accessors all resolve their target
2218 /// here, by receiver provenance. Empty outside `store`-bearing agent
2219 /// handlers.
2220 pub store_fields: HashMap<String, StoreField>,
2221}
2222
2223/// Per-capability info for checker dispatch within a handler body.
2224#[derive(Debug, Clone)]
2225pub struct CapabilityInfo {
2226 pub name: String,
2227 pub ops: Vec<CapabilityOpInfo>,
2228}
2229
2230#[derive(Debug, Clone)]
2231pub struct CapabilityOpInfo {
2232 pub name: String,
2233 /// #926: the op's own type parameters (empty for a non-generic op).
2234 /// `params`/`return_ty` below are *pattern* types resolved with these in
2235 /// scope, so a declared `T` survives as `Ty::Var("T")` rather than
2236 /// collapsing to `Ty::Unit` — a call site substitutes a concrete `Ty` for
2237 /// each before checking arguments/return.
2238 pub type_params: Vec<String>,
2239 pub params: Vec<TyId>,
2240 /// The operation's parameter names, positionally aligned with `params`
2241 /// (v0.117). Needed for observation: the `with <pred>` scope binds them by
2242 /// name and `trace(Cap.op)` yields records with these fields.
2243 pub param_names: Vec<String>,
2244 pub return_ty: TyId,
2245}
2246
2247/// The synthetic record type name for `trace(Cap.op)`'s call records (v0.117):
2248/// one record per capability operation, its fields the operation's parameters.
2249pub fn call_record_type_name(cap: &str, op: &str) -> String {
2250 format!("__{cap}_{op}_Call")
2251}
2252
2253impl<'a> Ctx<'a> {
2254 pub fn lookup(&self, name: &str) -> Option<TyId> {
2255 for scope in self.scopes.iter().rev() {
2256 if let Some(t) = scope.get(name) {
2257 return Some(*t);
2258 }
2259 }
2260 None
2261 }
2262
2263 /// Returns the type of an expression's "root identifier" — for `a.b.c`
2264 /// that's `a`; for a bare `a` it's `a`. Used to detect whether a chain's
2265 /// outermost name shadows an alias / consumed-context prefix.
2266 pub fn lookup_root_ident(&self, expr: &Expr) -> Option<TyId> {
2267 match &expr.kind {
2268 ExprKind::Ident(id) => self.lookup(&id.name),
2269 ExprKind::FieldAccess { receiver, .. } => self.lookup_root_ident(receiver),
2270 ExprKind::MethodCall { receiver, .. } => self.lookup_root_ident(receiver),
2271 _ => None,
2272 }
2273 }
2274
2275 /// v0.158 (ADR 0184): whether an expression's root ident names an agent
2276 /// `store` field. A store field is not in the value scope (so
2277 /// [`lookup_root_ident`](Self::lookup_root_ident) returns `None` for it),
2278 /// yet `<map>.entries.…` / `<map>.values.…` chains root in one — this
2279 /// distinguishes them from an un-consumed cross-context prefix so the
2280 /// `map.entries` query accessor is not mistaken for a service call.
2281 pub fn root_ident_is_store_field(&self, expr: &Expr) -> bool {
2282 match &expr.kind {
2283 ExprKind::Ident(id) => self.store_fields.contains_key(&id.name),
2284 ExprKind::FieldAccess { receiver, .. } | ExprKind::MethodCall { receiver, .. } => {
2285 self.root_ident_is_store_field(receiver)
2286 }
2287 _ => false,
2288 }
2289 }
2290
2291 pub fn push_scope(&mut self) {
2292 self.scopes.push(HashMap::new());
2293 }
2294 pub fn pop_scope(&mut self) {
2295 self.scopes.pop();
2296 }
2297 pub fn bind(&mut self, name: String, ty: TyId) {
2298 self.scopes.last_mut().unwrap().insert(name, ty);
2299 }
2300}
2301
2302// ==== Type-system core (resolution, unification, compatibility, inference) ====
2303
2304/// Build a `Ty` from a TypeDecl name reference.
2305pub fn type_from_decl(
2306 id: &Ident,
2307 types: &HashMap<String, Arc<TypeDecl>>,
2308 tys: &Types,
2309) -> Option<TyId> {
2310 let decl = types.get(&id.name)?;
2311 Some(named_ty(decl, tys))
2312}
2313
2314/// Build a `Ty::Named` for the given declaration with the given applied type
2315/// arguments (empty for a non-generic reference).
2316pub fn named_ty_with_args(decl: &TypeDecl, args: Vec<TyId>, tys: &Types) -> TyId {
2317 let kind = match &decl.body {
2318 TypeBody::Refined { base, .. } => NamedKind::Refined(*base),
2319 TypeBody::Record(_) => NamedKind::Record,
2320 TypeBody::Sum(_) => NamedKind::Sum,
2321 TypeBody::Opaque { base, .. } => NamedKind::Opaque(*base),
2322 };
2323 tys.intern(Ty::Named {
2324 name: decl.name.name.clone(),
2325 kind,
2326 args,
2327 })
2328}
2329
2330/// Build a `Ty::Named` for the given declaration (no applied type arguments).
2331pub fn named_ty(decl: &TypeDecl, tys: &Types) -> TyId {
2332 named_ty_with_args(decl, Vec::new(), tys)
2333}
2334
2335/// v0.158 (ADR 0184): the compiler-known `MapEntry[K, V]` record — the element
2336/// a `store Map[K, V]`'s `.entries` query yields. A nominal generic record
2337/// (`{ key: K, value: V }`), so it flows through `unify`/`compatible`/`display`
2338/// and the ADR 0183 non-boundary rule like any generic-record instantiation;
2339/// its fields are resolved by name in `check_field_access` (it has no
2340/// user-visible `TypeDecl`, like `JsonError`).
2341pub fn map_entry_ty(k: TyId, v: TyId, tys: &Types) -> TyId {
2342 tys.intern(Ty::Named {
2343 name: MAP_ENTRY.to_string(),
2344 kind: NamedKind::Record,
2345 args: vec![k, v],
2346 })
2347}
2348
2349/// v0.157 (ADR 0183): the substitution mapping a generic record's declared
2350/// type parameters onto a concrete instantiation's arguments. Empty when the
2351/// type is non-generic or `args` is empty (an under-applied reference — the
2352/// resolver reports that separately).
2353pub fn type_param_subst(decl: &TypeDecl, args: &[TyId]) -> HashMap<String, TyId> {
2354 decl.type_params
2355 .iter()
2356 .map(|p| p.name.name.clone())
2357 .zip(args.iter().copied())
2358 .collect()
2359}
2360
2361/// v0.157 (ADR 0183): the type of a generic record's field at a concrete
2362/// instantiation. The field's declared type is resolved with the declaration's
2363/// type parameters in scope as rigid vars, then those vars are replaced by the
2364/// instantiation's `args`. For a non-generic record this is a plain resolve.
2365pub fn instantiate_field_ty(
2366 decl: &TypeDecl,
2367 args: &[TyId],
2368 field_ref: &TypeRef,
2369 types: &HashMap<String, Arc<TypeDecl>>,
2370 tys: &Types,
2371) -> Option<TyId> {
2372 if decl.type_params.is_empty() {
2373 return resolve_type_ref(field_ref, types, tys);
2374 }
2375 // Without a full argument set the substitution is partial and would leave a
2376 // rigid `Ty::Var` in the field type; an under-/over-applied reference is an
2377 // error the resolver reports, so field access yields no type here.
2378 if decl.type_params.len() != args.len() {
2379 return None;
2380 }
2381 let vars: HashSet<String> = decl
2382 .type_params
2383 .iter()
2384 .map(|p| p.name.name.clone())
2385 .collect();
2386 let field_ty = resolve_type_ref_in(field_ref, types, &vars, tys)?;
2387 Some(substitute(field_ty, &type_param_subst(decl, args), tys))
2388}
2389
2390/// v0.20a: like [`resolve_type_ref`], with a set of in-scope **type
2391/// parameters**: a `Named` reference matching one resolves to [`Ty::Var`]
2392/// (checked before the type-table lookup — a type parameter shadows a
2393/// same-named declaration; the collision is diagnosed at the declaration).
2394pub fn resolve_type_ref_in(
2395 r: &TypeRef,
2396 types: &HashMap<String, Arc<TypeDecl>>,
2397 vars: &HashSet<String>,
2398 tys: &Types,
2399) -> Option<TyId> {
2400 let ty = match r {
2401 TypeRef::Named(id) if vars.contains(&id.name) => Ty::Var(id.name.clone()),
2402 TypeRef::Result(t, e, _) => Ty::Result(
2403 resolve_type_ref_in(t, types, vars, tys)?,
2404 resolve_type_ref_in(e, types, vars, tys)?,
2405 ),
2406 TypeRef::Option(t, _) => Ty::Option(resolve_type_ref_in(t, types, vars, tys)?),
2407 TypeRef::Effect(t, _) => Ty::Effect(resolve_type_ref_in(t, types, vars, tys)?),
2408 TypeRef::HttpResult(t, _) => Ty::HttpResult(resolve_type_ref_in(t, types, vars, tys)?),
2409 TypeRef::List(t, _) => Ty::List(resolve_type_ref_in(t, types, vars, tys)?),
2410 TypeRef::Query(t, _) => Ty::Query(resolve_type_ref_in(t, types, vars, tys)?),
2411 TypeRef::Stream(t, _) => Ty::Stream(resolve_type_ref_in(t, types, vars, tys)?),
2412 TypeRef::Connection(t, _) => Ty::Connection(resolve_type_ref_in(t, types, vars, tys)?),
2413 TypeRef::Map(k, v, _) => Ty::Map(
2414 resolve_type_ref_in(k, types, vars, tys)?,
2415 resolve_type_ref_in(v, types, vars, tys)?,
2416 ),
2417 TypeRef::Fn(params, ret, _) => {
2418 let params: Option<Vec<TyId>> = params
2419 .iter()
2420 .map(|p| resolve_type_ref_in(p, types, vars, tys))
2421 .collect();
2422 Ty::Fn {
2423 params: params?,
2424 ret: resolve_type_ref_in(ret, types, vars, tys)?,
2425 }
2426 }
2427 // v0.157 (ADR 0183): `Name[Arg, …]` — application of a user generic
2428 // type. Arguments resolve with the enclosing type parameters in scope;
2429 // existence/arity are validated in the resolver, so an unknown or
2430 // mis-applied name simply produces no type here.
2431 TypeRef::App { name, args, .. } => {
2432 let decl = types.get(&name.name)?;
2433 let args: Option<Vec<TyId>> = args
2434 .iter()
2435 .map(|a| resolve_type_ref_in(a, types, vars, tys))
2436 .collect();
2437 return Some(named_ty_with_args(decl, args?, tys));
2438 }
2439 _ => return resolve_type_ref(r, types, tys),
2440 };
2441 Some(tys.intern(ty))
2442}
2443
2444/// v0.20a: substitute type variables in `t` per `subst`. Must be total when
2445/// instantiating a call (the uninferable check runs first); an unbound Var
2446/// passes through unchanged for partial substitution during inference.
2447pub(crate) fn substitute(t: TyId, subst: &HashMap<String, TyId>, tys: &Types) -> TyId {
2448 let node = tys.get(t);
2449 let substituted = match &*node {
2450 Ty::Var(n) => return subst.get(n).copied().unwrap_or(t),
2451 Ty::Result(a, b) => Ty::Result(substitute(*a, subst, tys), substitute(*b, subst, tys)),
2452 Ty::Option(a) => Ty::Option(substitute(*a, subst, tys)),
2453 Ty::Effect(a) => Ty::Effect(substitute(*a, subst, tys)),
2454 Ty::HttpResult(a) => Ty::HttpResult(substitute(*a, subst, tys)),
2455 Ty::List(a) => Ty::List(substitute(*a, subst, tys)),
2456 Ty::Query(a) => Ty::Query(substitute(*a, subst, tys)),
2457 Ty::Stream(a) => Ty::Stream(substitute(*a, subst, tys)),
2458 Ty::Connection(a) => Ty::Connection(substitute(*a, subst, tys)),
2459 Ty::Map(k, v) => Ty::Map(substitute(*k, subst, tys), substitute(*v, subst, tys)),
2460 Ty::Fn { params, ret } => Ty::Fn {
2461 params: params.iter().map(|p| substitute(*p, subst, tys)).collect(),
2462 ret: substitute(*ret, subst, tys),
2463 },
2464 // v0.157 (ADR 0183): a generic named type's arguments may carry vars —
2465 // substitution recurses into them (an under-applied bare reference has
2466 // empty `args`, so this is a no-op there).
2467 Ty::Named { name, kind, args } => Ty::Named {
2468 name: name.clone(),
2469 kind: kind.clone(),
2470 args: args.iter().map(|a| substitute(*a, subst, tys)).collect(),
2471 },
2472 // Leaves (no inner type to ground) and the sealed actor bindings
2473 // (boundary-minted, never Var-bearing). Enumerated — no `_` — so a
2474 // new `Ty` variant must state whether substitution recurses into it.
2475 // `Ty::Error` is a leaf by construction (R4.3): it never carries a
2476 // `Var` to ground. T3.6b: a leaf substitutes to itself, and its `TyId`
2477 // is already that value — return it rather than re-interning.
2478 Ty::Error
2479 | Ty::Base(_)
2480 | Ty::QueueResult
2481 | Ty::ValidationError
2482 | Ty::JsonError
2483 | Ty::Unit
2484 | Ty::Actor(_)
2485 | Ty::ActorSum(_) => return t,
2486 };
2487 tys.intern(substituted)
2488}
2489
2490/// v0.20a: does `t` still contain a type variable?
2491pub(crate) fn contains_var(t: TyId, tys: &Types) -> bool {
2492 match &*tys.get(t) {
2493 Ty::Var(_) => true,
2494 // R4.3: `Ty::Error` is a leaf; it never carries a `Var`.
2495 Ty::Error => false,
2496 Ty::Result(a, b) | Ty::Map(a, b) => contains_var(*a, tys) || contains_var(*b, tys),
2497 Ty::Option(a)
2498 | Ty::Effect(a)
2499 | Ty::HttpResult(a)
2500 | Ty::List(a)
2501 | Ty::Query(a)
2502 | Ty::Stream(a)
2503 | Ty::Connection(a) => contains_var(*a, tys),
2504 Ty::Fn { params, ret } => {
2505 params.iter().any(|p| contains_var(*p, tys)) || contains_var(*ret, tys)
2506 }
2507 // v0.157 (ADR 0183): a generic named type's arguments may carry vars.
2508 Ty::Named { args, .. } => args.iter().any(|a| contains_var(*a, tys)),
2509 Ty::Base(_)
2510 | Ty::QueueResult
2511 | Ty::ValidationError
2512 | Ty::JsonError
2513 | Ty::Unit
2514 | Ty::Actor(_)
2515 | Ty::ActorSum(_) => false,
2516 }
2517}
2518
2519/// v0.20b: does `t` contain a type variable that is NOT one of the enclosing
2520/// function's rigid type parameters? Rigid vars are fully constrained inside
2521/// the body; only flexible (call-site instantiation) vars mean "still being
2522/// inferred".
2523fn contains_flexible_var(t: TyId, rigid: &HashSet<String>, tys: &Types) -> bool {
2524 match &*tys.get(t) {
2525 Ty::Var(n) => !rigid.contains(n),
2526 // R4.3: `Ty::Error` is a leaf; it never carries a `Var`.
2527 Ty::Error => false,
2528 Ty::Result(a, b) | Ty::Map(a, b) => {
2529 contains_flexible_var(*a, rigid, tys) || contains_flexible_var(*b, rigid, tys)
2530 }
2531 Ty::Option(a)
2532 | Ty::Effect(a)
2533 | Ty::HttpResult(a)
2534 | Ty::List(a)
2535 | Ty::Query(a)
2536 | Ty::Stream(a)
2537 | Ty::Connection(a) => contains_flexible_var(*a, rigid, tys),
2538 Ty::Fn { params, ret } => {
2539 params.iter().any(|p| contains_flexible_var(*p, rigid, tys))
2540 || contains_flexible_var(*ret, rigid, tys)
2541 }
2542 // v0.157 (ADR 0183): a generic named type's arguments may carry vars.
2543 Ty::Named { args, .. } => args.iter().any(|a| contains_flexible_var(*a, rigid, tys)),
2544 Ty::Base(_)
2545 | Ty::QueueResult
2546 | Ty::ValidationError
2547 | Ty::JsonError
2548 | Ty::Unit
2549 | Ty::Actor(_)
2550 | Ty::ActorSum(_) => false,
2551 }
2552}
2553
2554/// v0.20a: argument-directed unification. Walks `pattern` (possibly
2555/// Var-bearing) against the ground `actual`; a Var binds on first sight and
2556/// must match its prior binding **exactly** afterwards (keep inference dumb
2557/// and predictable — the explicit `name[T](…)` form is the pressure valve).
2558/// Returns false on a conflict; structural mismatches are NOT reported here —
2559/// the post-substitution `compatible` check owns those diagnostics.
2560pub(crate) fn unify(
2561 pattern: TyId,
2562 actual: TyId,
2563 subst: &mut HashMap<String, TyId>,
2564 tys: &Types,
2565) -> bool {
2566 // T3.6b: bind the two nodes first — the `Rc`s must outlive the `match`
2567 // they are destructured by, and a `TyId` pair is not itself matchable.
2568 let (p_node, a_node) = (tys.get(pattern), tys.get(actual));
2569 match (&*p_node, &*a_node) {
2570 (Ty::Var(n), _) => match subst.get(n) {
2571 // T3.6b (R4.1): "matches its prior binding exactly" is now a
2572 // `TyId` comparison — one `u32` equality, where it used to be a
2573 // recursive structural walk. Interning is what makes the two
2574 // equivalent.
2575 Some(bound) => *bound == actual,
2576 None => {
2577 subst.insert(n.clone(), actual);
2578 true
2579 }
2580 },
2581 (Ty::Result(a1, b1), Ty::Result(a2, b2)) | (Ty::Map(a1, b1), Ty::Map(a2, b2)) => {
2582 unify(*a1, *a2, subst, tys) && unify(*b1, *b2, subst, tys)
2583 }
2584 (Ty::Option(a1), Ty::Option(a2))
2585 | (Ty::Effect(a1), Ty::Effect(a2))
2586 | (Ty::HttpResult(a1), Ty::HttpResult(a2))
2587 | (Ty::List(a1), Ty::List(a2))
2588 | (Ty::Query(a1), Ty::Query(a2))
2589 | (Ty::Stream(a1), Ty::Stream(a2))
2590 | (Ty::Connection(a1), Ty::Connection(a2)) => unify(*a1, *a2, subst, tys),
2591 (
2592 Ty::Fn {
2593 params: p1,
2594 ret: r1,
2595 },
2596 Ty::Fn {
2597 params: p2,
2598 ret: r2,
2599 },
2600 ) => {
2601 p1.len() == p2.len()
2602 && p1
2603 .iter()
2604 .zip(p2)
2605 .all(|(a, b)| unify(*a, *b, subst, tys))
2606 && unify(*r1, *r2, subst, tys)
2607 }
2608 // v0.157 (ADR 0183): a generic named type binds vars through its
2609 // arguments — `Paginated[T]` against `Paginated[User]` binds `T=User`.
2610 (
2611 Ty::Named {
2612 name: n1, args: a1, ..
2613 },
2614 Ty::Named {
2615 name: n2, args: a2, ..
2616 },
2617 ) if n1 == n2 && a1.len() == a2.len() && !a1.is_empty() => {
2618 a1.iter().zip(a2).all(|(x, y)| unify(*x, *y, subst, tys))
2619 }
2620 // Ground-vs-ground: any pair is fine here; `compatible` owns the
2621 // real check after substitution. The left side is enumerated — no
2622 // `_` — so a new inner-type-bearing `Ty` variant must add its
2623 // recursion arm above instead of silently skipping unification.
2624 (
2625 Ty::Base(_)
2626 | Ty::Named { .. }
2627 | Ty::Result(..)
2628 | Ty::Option(_)
2629 | Ty::Effect(_)
2630 | Ty::HttpResult(_)
2631 | Ty::QueueResult
2632 | Ty::List(_)
2633 | Ty::Map(..)
2634 | Ty::Query(_)
2635 | Ty::Stream(_)
2636 | Ty::Connection(_)
2637 | Ty::ValidationError
2638 | Ty::JsonError
2639 | Ty::Unit
2640 | Ty::Actor(_)
2641 | Ty::ActorSum(_)
2642 | Ty::Fn { .. }
2643 // R4.3: an already-diagnosed subtree unifies with anything —
2644 // the failure was reported once, at the site that produced
2645 // `Ty::Error`; unification is not where a second one belongs.
2646 | Ty::Error,
2647 _,
2648 ) => true,
2649 }
2650}
2651
2652/// v0.25 (ADR 0053): record a binding edge for every `Named` reference
2653/// inside a type-ref that resolved. Called alongside the `resolve_type_ref*`
2654/// annotation sites; `skip` holds the enclosing fn's type parameters (rigid
2655/// vars are not type symbols). Handler signatures and body annotations never
2656/// pass through the resolver's reference walk, so these sites are their only
2657/// recording point; where both passes run, assembly dedupes.
2658pub fn record_type_refs(
2659 r: &TypeRef,
2660 types: &HashMap<String, Arc<TypeDecl>>,
2661 skip: &HashSet<String>,
2662 refs: &mut RefSink,
2663) {
2664 match r {
2665 TypeRef::Named(id) => {
2666 if types.contains_key(&id.name) && !skip.contains(&id.name) {
2667 refs.record(id.span, SymbolKind::Type, &id.name);
2668 }
2669 }
2670 TypeRef::Fn(params, ret, _) => {
2671 for p in params {
2672 record_type_refs(p, types, skip, refs);
2673 }
2674 record_type_refs(ret, types, skip, refs);
2675 }
2676 TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
2677 record_type_refs(a, types, skip, refs);
2678 record_type_refs(b, types, skip, refs);
2679 }
2680 TypeRef::Option(t, _)
2681 | TypeRef::Effect(t, _)
2682 | TypeRef::HttpResult(t, _)
2683 | TypeRef::Query(t, _)
2684 | TypeRef::Stream(t, _)
2685 | TypeRef::Connection(t, _)
2686 | TypeRef::History(t, _)
2687 | TypeRef::List(t, _) => record_type_refs(t, types, skip, refs),
2688 // v0.157 (ADR 0183): a `Name[Arg, …]` application records the generic
2689 // type's name plus every argument.
2690 TypeRef::App { name, args, .. } => {
2691 if types.contains_key(&name.name) && !skip.contains(&name.name) {
2692 refs.record(name.span, SymbolKind::Type, &name.name);
2693 }
2694 for a in args {
2695 record_type_refs(a, types, skip, refs);
2696 }
2697 }
2698 TypeRef::Base(..)
2699 | TypeRef::QueueResult(_)
2700 | TypeRef::ValidationError(_)
2701 | TypeRef::JsonError(_)
2702 | TypeRef::Unit(_) => {}
2703 }
2704}
2705
2706/// #712: resolve a type reference that appears in an *expression* position the
2707/// resolver does not walk for handler bodies — explicit call type arguments
2708/// (`identity[T](x)`), `Json.decode[T]`, and lambda parameter annotations
2709/// (`(x: T) => …`). On failure the reference is silently dropped by the bare
2710/// `resolve_type_ref_in`, so an unknown type in a handler body would compile
2711/// clean; this reports `bynk.resolve.unknown_type` instead, and records the
2712/// resolved type's references for the IDE on success. The resolver still covers
2713/// `fn`/method bodies, and the checker runs only after the resolver returns Ok
2714/// (`bynk-emit`'s pipeline sequences `resolve(..)?` then `check(..)`), so this
2715/// never double-reports.
2716pub(crate) fn resolve_expr_type_ref(r: &TypeRef, ctx: &mut Ctx) -> Option<TyId> {
2717 let tys = ctx.tys;
2718 match resolve_type_ref_in(r, &ctx.input.types, &ctx.type_vars, tys) {
2719 Some(ty) => {
2720 record_type_refs(r, &ctx.input.types, &ctx.type_vars, ctx.refs);
2721 Some(ty)
2722 }
2723 None => {
2724 ctx.errors.push(unresolved_type_ref_error(
2725 r,
2726 &ctx.input.types,
2727 &ctx.type_vars,
2728 ));
2729 None
2730 }
2731 }
2732}
2733
2734/// #712: the diagnostic for a type reference that fails to resolve. Points at
2735/// the exact offending name when one can be identified (`identity[Missing](5)`
2736/// → the `Missing` span), falling back to the whole reference otherwise.
2737fn unresolved_type_ref_error(
2738 r: &TypeRef,
2739 types: &HashMap<String, Arc<TypeDecl>>,
2740 vars: &HashSet<String>,
2741) -> CompileError {
2742 match first_unresolved_type_name(r, types, vars) {
2743 Some(id) => CompileError::new(
2744 "bynk.resolve.unknown_type",
2745 id.span,
2746 format!("unknown type `{}`", id.name),
2747 )
2748 .with_note(
2749 "in scope are the base types (`Int`, `Float`, `String`, `Bool`, `Duration`, \
2750 `Instant`, `Bytes`), the built-in generics (`List`, `Map`, `Option`, `Result`, …), \
2751 `ValidationError`, and the types this unit declares or imports",
2752 ),
2753 None => CompileError::new(
2754 "bynk.resolve.unknown_type",
2755 r.span(),
2756 "this type does not resolve",
2757 ),
2758 }
2759}
2760
2761/// #712: the first type name in `r` that names neither a declared type nor an
2762/// in-scope type variable — the reason `resolve_type_ref_in` returned `None`.
2763fn first_unresolved_type_name<'a>(
2764 r: &'a TypeRef,
2765 types: &HashMap<String, Arc<TypeDecl>>,
2766 vars: &HashSet<String>,
2767) -> Option<&'a Ident> {
2768 match r {
2769 TypeRef::Named(id) => {
2770 (!types.contains_key(&id.name) && !vars.contains(&id.name)).then_some(id)
2771 }
2772 TypeRef::App { name, args, .. } => {
2773 if !types.contains_key(&name.name) && !vars.contains(&name.name) {
2774 return Some(name);
2775 }
2776 args.iter()
2777 .find_map(|a| first_unresolved_type_name(a, types, vars))
2778 }
2779 TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
2780 first_unresolved_type_name(a, types, vars)
2781 .or_else(|| first_unresolved_type_name(b, types, vars))
2782 }
2783 TypeRef::Option(t, _)
2784 | TypeRef::Effect(t, _)
2785 | TypeRef::HttpResult(t, _)
2786 | TypeRef::List(t, _)
2787 | TypeRef::Query(t, _)
2788 | TypeRef::Stream(t, _)
2789 | TypeRef::Connection(t, _)
2790 | TypeRef::History(t, _) => first_unresolved_type_name(t, types, vars),
2791 TypeRef::Fn(params, ret, _) => params
2792 .iter()
2793 .find_map(|p| first_unresolved_type_name(p, types, vars))
2794 .or_else(|| first_unresolved_type_name(ret, types, vars)),
2795 TypeRef::Base(..)
2796 | TypeRef::QueueResult(_)
2797 | TypeRef::ValidationError(_)
2798 | TypeRef::JsonError(_)
2799 | TypeRef::Unit(_) => None,
2800 }
2801}
2802
2803/// v0.154 (ADR 0178): the declared error embedding that converts `source_err`
2804/// into `target_err`, if one exists. When `target_err` is a sum declaring
2805/// `embeds E as V` with `E` compatible with `source_err`, returns
2806/// `(sum_type_name, variant_name)` — the variant a value of `source_err`
2807/// auto-wraps into. One level only: the source must match a declared embedding
2808/// directly. Used by `?` in the checker (to accept the conversion) and the
2809/// emitter (to lower the `Err`-wrap) from the **same** rule, so the two cannot
2810/// diverge.
2811pub fn embedding_for(
2812 target_err: TyId,
2813 source_err: TyId,
2814 types: &HashMap<String, Arc<TypeDecl>>,
2815 tys: &Types,
2816) -> Option<(String, String)> {
2817 let target_node = tys.get(target_err);
2818 let Ty::Named { name, .. } = &*target_node else {
2819 return None;
2820 };
2821 let decl = types.get(name)?;
2822 let TypeBody::Sum(sum) = &decl.body else {
2823 return None;
2824 };
2825 for clause in &sum.embeds {
2826 if let Some(src) = resolve_type_ref(&clause.source_type, types, tys)
2827 && compatible(source_err, src, tys)
2828 {
2829 return Some((name.clone(), clause.variant.name.clone()));
2830 }
2831 }
2832 None
2833}
2834
2835pub fn resolve_type_ref(
2836 r: &TypeRef,
2837 types: &HashMap<String, Arc<TypeDecl>>,
2838 tys: &Types,
2839) -> Option<TyId> {
2840 let ty = match r {
2841 TypeRef::Base(b, _) => Ty::Base(*b),
2842 TypeRef::Named(id) => return type_from_decl(id, types, tys),
2843 // v0.20a: a function type. Effectfulness is structural (ret is
2844 // Effect[_]); nothing extra to record.
2845 TypeRef::Fn(params, ret, _) => {
2846 let params: Option<Vec<TyId>> = params
2847 .iter()
2848 .map(|p| resolve_type_ref(p, types, tys))
2849 .collect();
2850 Ty::Fn {
2851 params: params?,
2852 ret: resolve_type_ref(ret, types, tys)?,
2853 }
2854 }
2855 TypeRef::Result(t, e, _) => Ty::Result(
2856 resolve_type_ref(t, types, tys)?,
2857 resolve_type_ref(e, types, tys)?,
2858 ),
2859 TypeRef::Option(t, _) => Ty::Option(resolve_type_ref(t, types, tys)?),
2860 TypeRef::Effect(t, _) => Ty::Effect(resolve_type_ref(t, types, tys)?),
2861 TypeRef::HttpResult(t, _) => Ty::HttpResult(resolve_type_ref(t, types, tys)?),
2862 TypeRef::List(t, _) => Ty::List(resolve_type_ref(t, types, tys)?),
2863 TypeRef::Query(t, _) => Ty::Query(resolve_type_ref(t, types, tys)?),
2864 TypeRef::Stream(t, _) => Ty::Stream(resolve_type_ref(t, types, tys)?),
2865 TypeRef::Connection(t, _) => Ty::Connection(resolve_type_ref(t, types, tys)?),
2866 TypeRef::Map(k, v, _) => Ty::Map(
2867 resolve_type_ref(k, types, tys)?,
2868 resolve_type_ref(v, types, tys)?,
2869 ),
2870 TypeRef::QueueResult(_) => Ty::QueueResult,
2871 // v0.119 (ADR 0155): `History[Agent]` is not a value type — it is a
2872 // test-only generator handled directly in `check_property_body`. It never
2873 // resolves as an ordinary type, so a stray `History[…]` in a value
2874 // position fails to resolve (the resolver reports `outside_property`).
2875 TypeRef::History(_, _) => return None,
2876 // v0.157 (ADR 0183): `Name[Arg, …]` — a user generic-type application.
2877 TypeRef::App { name, args, .. } => {
2878 let decl = types.get(&name.name)?;
2879 let args: Option<Vec<TyId>> = args
2880 .iter()
2881 .map(|a| resolve_type_ref(a, types, tys))
2882 .collect();
2883 return Some(named_ty_with_args(decl, args?, tys));
2884 }
2885 TypeRef::ValidationError(_) => Ty::ValidationError,
2886 TypeRef::JsonError(_) => Ty::JsonError,
2887 TypeRef::Unit(_) => Ty::Unit,
2888 };
2889 Some(tys.intern(ty))
2890}
2891
2892/// `t` is usable where `u` is expected.
2893///
2894/// T3.6b: deliberately **no** `t == u` fast path, tempting as interning makes
2895/// one. `compatible` is not reflexive — `Actor`/`ActorSum` are sealed boundary
2896/// values that fall through to the `false` arm below even against themselves
2897/// (they are matched, never assigned), so short-circuiting on id equality
2898/// would silently make them assignable.
2899pub fn compatible(t: TyId, u: TyId, tys: &Types) -> bool {
2900 let (t_node, u_node) = (tys.get(t), tys.get(u));
2901 match (&*t_node, &*u_node) {
2902 // R4.3: `Ty::Error` is compatible with everything, in both positions
2903 // — the failure that produced it was already diagnosed at its own
2904 // site, and a mismatch diagnostic naming it here would be a second
2905 // report of the same failure, not a new one. Ordered first so it
2906 // takes priority over the more specific arms below.
2907 (Ty::Error, _) | (_, Ty::Error) => true,
2908 (Ty::Base(a), Ty::Base(b)) => a == b,
2909 // v0.157 (ADR 0183): two named types are compatible when they share a
2910 // name and kind and their applied type arguments are pairwise
2911 // compatible. Records are immutable (`readonly` fields), so the
2912 // arguments are covariant — like `List`/`Option`.
2913 (
2914 Ty::Named {
2915 name: a,
2916 kind: ka,
2917 args: aa,
2918 },
2919 Ty::Named {
2920 name: b,
2921 kind: kb,
2922 args: ba,
2923 },
2924 ) => {
2925 a == b
2926 && ka == kb
2927 && aa.len() == ba.len()
2928 && aa.iter().zip(ba).all(|(x, y)| compatible(*x, *y, tys))
2929 }
2930 // Refined → base (widening).
2931 (
2932 Ty::Named {
2933 kind: NamedKind::Refined(b),
2934 ..
2935 },
2936 Ty::Base(target),
2937 ) => b == target,
2938 (Ty::Base(_), Ty::Named { .. }) => false,
2939 (Ty::Result(t1, e1), Ty::Result(t2, e2)) => {
2940 compatible(*t1, *t2, tys) && compatible(*e1, *e2, tys)
2941 }
2942 (Ty::Option(a), Ty::Option(b)) => compatible(*a, *b, tys),
2943 (Ty::Effect(a), Ty::Effect(b)) => compatible(*a, *b, tys),
2944 (Ty::HttpResult(a), Ty::HttpResult(b)) => compatible(*a, *b, tys),
2945 // v0.20b: collections are covariant in their element/value types;
2946 // Map keys must match exactly — key-position widening would split a
2947 // map's keys across refined/base identities at lookup time.
2948 (Ty::List(a), Ty::List(b)) => compatible(*a, *b, tys),
2949 // v0.100: `Stream[T]` is covariant in its element, like `List`/`Effect`.
2950 // (Assignability only — streams are not value-comparable for `==`.)
2951 (Ty::Stream(a), Ty::Stream(b)) => compatible(*a, *b, tys),
2952 // v0.91: `Query[T]` is covariant in its element, like `List`/`Stream`.
2953 // (Assignability only — queries are not value-comparable for `==`.)
2954 (Ty::Query(a), Ty::Query(b)) => compatible(*a, *b, tys),
2955 // v0.102: a `Connection[F]` is assignable to itself (the linearity pass
2956 // governs the move). Held values have identity, not value-equality, so
2957 // they are not `==`-comparable (guarded in the `Eq`/`NotEq` arm).
2958 (Ty::Connection(a), Ty::Connection(b)) => compatible(*a, *b, tys),
2959 (Ty::Map(k1, v1), Ty::Map(k2, v2)) => k1 == k2 && compatible(*v1, *v2, tys),
2960 (Ty::QueueResult, Ty::QueueResult) => true,
2961 (Ty::ValidationError, Ty::ValidationError) => true,
2962 (Ty::JsonError, Ty::JsonError) => true,
2963 (Ty::Unit, Ty::Unit) => true,
2964 // v0.20a: function types — **contravariant** in parameters, covariant
2965 // in the return type. `compatible(t, u, tys)` is "t usable where u is
2966 // expected" and is already asymmetric (refined → base widening), so
2967 // the per-position argument order flips for params: a function
2968 // expecting the *wider* param type is usable where one expecting the
2969 // narrower is required — and crucially, the covariant direction would
2970 // let unvalidated base values flow into a refined-typed body.
2971 (Ty::Fn { params: p, ret: r }, Ty::Fn { params: q, ret: s }) => {
2972 p.len() == q.len()
2973 && p.iter().zip(q).all(|(a, b)| compatible(*b, *a, tys))
2974 && compatible(*r, *s, tys)
2975 }
2976 // v0.20a: rigid type variables (a generic fn's own body) match by
2977 // name. Flexible vars never reach `compatible` — they are eliminated
2978 // by substitution during call-site instantiation.
2979 (Ty::Var(a), Ty::Var(b)) => a == b,
2980 // Everything else is incompatible: cross-variant pairs, and the
2981 // sealed boundary values (`Actor`/`ActorSum` are only ever matched,
2982 // never assigned). The left side is enumerated — no `_` — so adding
2983 // a `Ty` variant fails to compile here instead of silently making
2984 // the new type incompatible with itself (the trap `Query` fell into).
2985 (
2986 Ty::Base(_)
2987 | Ty::Named { .. }
2988 | Ty::Result(..)
2989 | Ty::Option(_)
2990 | Ty::Effect(_)
2991 | Ty::HttpResult(_)
2992 | Ty::QueueResult
2993 | Ty::List(_)
2994 | Ty::Map(..)
2995 | Ty::Query(_)
2996 | Ty::Stream(_)
2997 | Ty::Connection(_)
2998 | Ty::ValidationError
2999 | Ty::JsonError
3000 | Ty::Unit
3001 | Ty::Actor(_)
3002 | Ty::ActorSum(_)
3003 | Ty::Fn { .. }
3004 | Ty::Var(_),
3005 _,
3006 ) => false,
3007 }
3008}
3009
3010pub(crate) fn type_of_block(block: &Block, expected: Option<TyId>, ctx: &mut Ctx) -> Option<TyId> {
3011 let tys = ctx.tys;
3012 ctx.push_scope();
3013 for stmt in &block.statements {
3014 match stmt {
3015 Statement::Let(l) => {
3016 let annot_ty = l.type_annot.as_ref().and_then(|a| {
3017 // v0.20b: the enclosing fn's type parameters are legal
3018 // in body annotations (`let init: List[B] = …`).
3019 let r = resolve_type_ref_in(a, &ctx.input.types, &ctx.type_vars, tys);
3020 if r.is_none() {
3021 ctx.errors.push(CompileError::new(
3022 "bynk.resolve.unknown_type",
3023 a.span(),
3024 "type in `let` annotation does not resolve",
3025 ));
3026 } else {
3027 record_type_refs(a, &ctx.input.types, &ctx.type_vars, ctx.refs);
3028 }
3029 r
3030 });
3031 let rhs_ty = type_of(&l.value, annot_ty, ctx);
3032 check_unbound_effects(&l.value, false, ctx);
3033 let final_ty = match (annot_ty, rhs_ty) {
3034 (Some(annot), Some(rhs)) => {
3035 if !compatible(rhs, annot, tys) {
3036 ctx.errors.push(
3037 CompileError::new(
3038 "bynk.types.let_annotation_mismatch",
3039 l.value.span,
3040 format!(
3041 "let binding's value has type `{}`, but the annotation declares `{}`",
3042 rhs.display(tys),
3043 annot.display(tys)
3044 ),
3045 )
3046 .with_label(
3047 l.type_annot.as_ref().unwrap().span(),
3048 "declared type annotation",
3049 ),
3050 );
3051 }
3052 annot
3053 }
3054 (Some(annot), None) => annot,
3055 (None, Some(rhs)) => rhs,
3056 // #1663: the value failed to type (already reported).
3057 // Bind the name to the error type anyway, so its uses
3058 // absorb instead of echoing as unknown names.
3059 (None, None) => {
3060 if l.name.name != "_" {
3061 ctx.bind(l.name.name.clone(), tys.intern(Ty::Error));
3062 }
3063 continue;
3064 }
3065 };
3066 if l.name.name != "_" {
3067 // v0.27 (ADR 0056): an annotation-absent binding gets an
3068 // inferred-type inlay hint at the binding name.
3069 if l.type_annot.is_none() {
3070 ctx.hints
3071 .record(l.name.span, format!(": {}", final_ty.display(tys)));
3072 }
3073 // v0.31: in scope from after this statement to block end.
3074 ctx.locals.record(
3075 l.name.name.clone(),
3076 l.name.span,
3077 crate::locals::LocalKind::Let,
3078 final_ty.display(tys),
3079 Span {
3080 file: l.span.file,
3081 start: l.span.end,
3082 end: block.span.end,
3083 },
3084 );
3085 ctx.bind(l.name.name.clone(), final_ty);
3086 }
3087 }
3088 Statement::EffectLet(l) => {
3089 if !ctx.effectful {
3090 ctx.errors.push(
3091 CompileError::new(
3092 "bynk.effect.bind_in_pure_context",
3093 l.span,
3094 "the `<-` operator can only be used inside an effectful body (one returning `Effect[T]`)",
3095 )
3096 .with_label(
3097 ctx.return_ty_span,
3098 format!("enclosing return type is `{}`", ctx.return_ty.display(tys)),
3099 )
3100 .with_note(
3101 "change the enclosing function/handler's return type to `Effect[...]`, or use `let ... =` for a pure binding",
3102 ),
3103 );
3104 }
3105 // Determine the inner Effect[T] payload type for the binding.
3106 let annot_ty = l.type_annot.as_ref().and_then(|a| {
3107 // v0.20b: the enclosing fn's type parameters are legal
3108 // in body annotations (`let init: List[B] = …`).
3109 let r = resolve_type_ref_in(a, &ctx.input.types, &ctx.type_vars, tys);
3110 if r.is_none() {
3111 ctx.errors.push(CompileError::new(
3112 "bynk.resolve.unknown_type",
3113 a.span(),
3114 "type in `let` annotation does not resolve",
3115 ));
3116 } else {
3117 record_type_refs(a, &ctx.input.types, &ctx.type_vars, ctx.refs);
3118 }
3119 r
3120 });
3121 // The expected type for the RHS is `Effect[annot]` if annot present.
3122 let rhs_expected = annot_ty.map(|t| tys.intern(Ty::Effect(t)));
3123 let rhs_ty = type_of(&l.value, rhs_expected, ctx);
3124 check_unbound_effects(&l.value, true, ctx);
3125 // v0.182 (#664): validate the call-site principal against the
3126 // addressed handler — including the *absent* case, where an
3127 // identity-carrying handler driven with no `by` would silently
3128 // drop the identity.
3129 calls::check_effect_let_principal(&l.value, l.principal.as_ref(), ctx);
3130 let inner_ty = match rhs_ty.map(|t| tys.get(t)).as_deref() {
3131 Some(Ty::Effect(t)) => Some(*t),
3132 Some(_) => {
3133 ctx.errors.push(
3134 CompileError::new(
3135 "bynk.effect.bind_on_non_effect",
3136 l.value.span,
3137 format!(
3138 "the `<-` operator requires an `Effect[T]` value, but got `{}`",
3139 rhs_ty.expect("matched Some").display(tys)
3140 ),
3141 )
3142 .with_note(
3143 "use `let ... =` for a pure binding, or wrap the value with `Effect.pure(...)`",
3144 ),
3145 );
3146 None
3147 }
3148 None => None,
3149 };
3150 let final_ty = match (annot_ty, inner_ty) {
3151 (Some(annot), Some(rhs)) => {
3152 if !compatible(rhs, annot, tys) {
3153 ctx.errors.push(CompileError::new(
3154 "bynk.types.let_annotation_mismatch",
3155 l.value.span,
3156 format!(
3157 "let-binding's value has type `Effect[{}]`, but the annotation declares `Effect[{}]`",
3158 rhs.display(tys),
3159 annot.display(tys)
3160 ),
3161 ));
3162 }
3163 annot
3164 }
3165 (Some(annot), None) => annot,
3166 (None, Some(rhs)) => rhs,
3167 // #1663: the value failed to type (already reported).
3168 // Bind the name to the error type anyway, so its uses
3169 // absorb instead of echoing as unknown names.
3170 (None, None) => {
3171 if l.name.name != "_" {
3172 ctx.bind(l.name.name.clone(), tys.intern(Ty::Error));
3173 }
3174 continue;
3175 }
3176 };
3177 if l.name.name != "_" {
3178 // v0.27 (ADR 0056): as for `let =`, but `final_ty` here
3179 // is the peeled `Effect[T]` payload — the binding's
3180 // actual type, which is what the hint must show.
3181 if l.type_annot.is_none() {
3182 ctx.hints
3183 .record(l.name.span, format!(": {}", final_ty.display(tys)));
3184 }
3185 ctx.locals.record(
3186 l.name.name.clone(),
3187 l.name.span,
3188 crate::locals::LocalKind::Let,
3189 final_ty.display(tys),
3190 Span {
3191 file: l.span.file,
3192 start: l.span.end,
3193 end: block.span.end,
3194 },
3195 );
3196 ctx.bind(l.name.name.clone(), final_ty);
3197 }
3198 }
3199 Statement::Expect(a) => {
3200 if !ctx.in_test_body {
3201 ctx.errors.push(
3202 CompileError::new(
3203 "bynk.expect.outside_case",
3204 a.span,
3205 "`expect` is only valid inside a `case` body",
3206 )
3207 .with_note(
3208 "expectations verify predicates at test runtime; use them only inside `case \"...\" { ... }` blocks",
3209 ),
3210 );
3211 }
3212 let val_ty = type_of(&a.value, Some(tys.intern(Ty::Base(BaseType::Bool))), ctx);
3213 if let Some(actual) = val_ty
3214 && !compatible(actual, tys.intern(Ty::Base(BaseType::Bool)), tys)
3215 {
3216 ctx.errors.push(CompileError::new(
3217 "bynk.expect.not_bool",
3218 a.value.span,
3219 format!(
3220 "`expect` predicate has type `{}`, but a `Bool` is required",
3221 actual.display(tys),
3222 ),
3223 ));
3224 }
3225 }
3226 Statement::Send(s) => {
3227 // v0.79: `~> e` — fire-and-forget. Effectful context only, like
3228 // `<-`; the reply is never awaited, so nothing is bound.
3229 if !ctx.effectful {
3230 ctx.errors.push(
3231 CompileError::new(
3232 "bynk.send.in_pure_context",
3233 s.span,
3234 "the `~>` send can only be used inside an effectful body (one returning `Effect[T]`)",
3235 )
3236 .with_label(
3237 ctx.return_ty_span,
3238 format!("enclosing return type is `{}`", ctx.return_ty.display(tys)),
3239 )
3240 .with_note(
3241 "change the enclosing function/handler's return type to `Effect[...]`",
3242 ),
3243 );
3244 }
3245 // The reply must be `Effect[()]`. A real payload (value or error)
3246 // would be silently dropped by a fire-and-forget send — the error
3247 // gate ([DECISION C/D]). `let _ <- e` is the honest spelling for
3248 // "await and discard".
3249 let unit = tys.intern(Ty::Unit);
3250 let expected = tys.intern(Ty::Effect(unit));
3251 let rhs_ty = type_of(&s.value, Some(expected), ctx);
3252 match rhs_ty.map(|t| tys.get(t)).as_deref() {
3253 Some(Ty::Effect(inner)) if *inner == unit => {}
3254 Some(Ty::Effect(inner)) => {
3255 ctx.errors.push(
3256 CompileError::new(
3257 "bynk.send.requires_unit",
3258 s.value.span,
3259 format!(
3260 "`~>` requires an `Effect[()]` reply, but this send returns `Effect[{}]` — its result would be silently dropped",
3261 inner.display(tys)
3262 ),
3263 )
3264 .with_note(
3265 "a `~>` send never awaits a reply, so it is reserved for empty replies; to await and discard a real result, write `let _ <- ...` instead",
3266 ),
3267 );
3268 }
3269 Some(other) => {
3270 ctx.errors.push(
3271 CompileError::new(
3272 "bynk.send.non_effect",
3273 s.value.span,
3274 format!(
3275 "the `~>` send requires an `Effect[()]` value, but got `{}`",
3276 other.display(tys)
3277 ),
3278 )
3279 .with_note("`~>` sends an effectful call; the target must be a call returning `Effect[()]`"),
3280 );
3281 }
3282 None => {}
3283 }
3284 }
3285 Statement::Do(d) => {
3286 // v0.146 (ADR 0170): `do e` — perform a unit effect as a
3287 // statement. Effectful context only, like `<-`; nothing is bound,
3288 // so the operand MUST be `Effect[()]`. A valued reply is rejected
3289 // (`bynk.effect.do_requires_unit`): throwing away a real result
3290 // stays explicit with `let _ <- e`.
3291 if !ctx.effectful {
3292 ctx.errors.push(
3293 CompileError::new(
3294 "bynk.effect.do_in_pure_context",
3295 d.span,
3296 "the `do` statement can only be used inside an effectful body (one returning `Effect[T]`)",
3297 )
3298 .with_label(
3299 ctx.return_ty_span,
3300 format!("enclosing return type is `{}`", ctx.return_ty.display(tys)),
3301 )
3302 .with_note(
3303 "change the enclosing function/handler's return type to `Effect[...]`",
3304 ),
3305 );
3306 }
3307 let unit = tys.intern(Ty::Unit);
3308 let expected = tys.intern(Ty::Effect(unit));
3309 let rhs_ty = type_of(&d.value, Some(expected), ctx);
3310 check_unbound_effects(&d.value, true, ctx);
3311 match rhs_ty.map(|t| tys.get(t)).as_deref() {
3312 Some(Ty::Effect(inner)) if *inner == unit => {}
3313 Some(Ty::Effect(inner)) => {
3314 ctx.errors.push(
3315 CompileError::new(
3316 "bynk.effect.do_requires_unit",
3317 d.value.span,
3318 format!(
3319 "a `do` statement requires an `Effect[()]`, but this is `Effect[{}]` — its result would be silently dropped",
3320 inner.display(tys)
3321 ),
3322 )
3323 .with_note(
3324 "`do e` performs a unit effect; to await and discard a real result, write `let _ <- e` instead",
3325 ),
3326 );
3327 }
3328 Some(other) => {
3329 ctx.errors.push(
3330 CompileError::new(
3331 "bynk.effect.do_on_non_effect",
3332 d.value.span,
3333 format!(
3334 "a `do` statement requires an `Effect[()]` value, but got `{}`",
3335 other.display(tys)
3336 ),
3337 )
3338 .with_note("`do` performs an effect; its operand must be a call returning `Effect[()]`"),
3339 );
3340 }
3341 None => {}
3342 }
3343 }
3344 Statement::Assign(a) => {
3345 // v0.81 (storage track): `cell := expr` — the unconditional `Cell`
3346 // write. The target must be a `store Cell` field; the value must
3347 // match the cell's element type; and (the §10 read-modify-write
3348 // rule) the RHS must not read the cell being written.
3349 match ctx.store_fields.get(&a.target.name).cloned() {
3350 // A name that isn't a store field at all, or is one of a
3351 // different kind, is the same "not a Cell" diagnostic.
3352 None
3353 | Some(
3354 StoreField::Map(..)
3355 | StoreField::Set(_)
3356 | StoreField::Cache(..)
3357 | StoreField::Log(_),
3358 ) => {
3359 ctx.errors.push(
3360 CompileError::new(
3361 "bynk.cell.invalid_target",
3362 a.target.span,
3363 format!(
3364 "`:=` writes a `Cell` store field, but `{}` is not one",
3365 a.target.name
3366 ),
3367 )
3368 .with_note(
3369 "the `:=` write form applies only to a `store <name>: Cell[T]` field",
3370 ),
3371 );
3372 type_of(&a.value, None, ctx);
3373 }
3374 Some(StoreField::Cell(elem_ty)) => {
3375 // §10: a `:=` whose RHS reads its own LHS is a hidden
3376 // read-modify-write — require `.update(fn)` instead, so the
3377 // dependency is visible (and retry-safe).
3378 if expr_reads_ident(&a.value, &a.target.name) {
3379 ctx.errors.push(
3380 CompileError::new(
3381 "bynk.cell.self_reference",
3382 a.span,
3383 format!(
3384 "the `:=` right-hand side reads `{0}`, the cell being \
3385 written — this is a read-modify-write",
3386 a.target.name
3387 ),
3388 )
3389 .with_note(
3390 "use `<cell>.update(fn)` for a read-modify-write so the \
3391 dependency on the prior value is explicit",
3392 ),
3393 );
3394 }
3395 if let Some(vt) = type_of(&a.value, Some(elem_ty), ctx)
3396 && !compatible(vt, elem_ty, tys)
3397 {
3398 ctx.errors.push(CompileError::new(
3399 "bynk.types.type_mismatch",
3400 a.value.span,
3401 format!(
3402 "this `:=` writes `{}`, but the cell `{}` holds `{}`",
3403 vt.display(tys),
3404 a.target.name,
3405 elem_ty.display(tys)
3406 ),
3407 ));
3408 }
3409 }
3410 }
3411 }
3412 }
3413 }
3414 let ty = type_of(&block.tail, expected, ctx);
3415 check_unbound_effects(&block.tail, true, ctx);
3416 let ty = maybe_auto_lift(ty, expected, tys);
3417 // T3.4: this block previously wrote its own auto-lifted type into
3418 // `expr_types` at `block.span` (bug #844's era — recording it only when
3419 // `block.span != block.tail.span`, to avoid clobbering a synthetic
3420 // single-expression block's more specific tail entry). `Block` has no
3421 // `ExprId` of its own to key that write with now, and — checked, not
3422 // assumed — nothing in the workspace ever read it: the only caller that
3423 // has a real enclosing expression to attribute it to (`ExprKind::Block`,
3424 // `checker.rs`'s own `type_of` dispatch) already gets an identical entry
3425 // for free from `type_of`'s own choke-point write on the way back out,
3426 // since the parser sets that expression's span to `block.span` exactly.
3427 // The other eight callers (function/handler bodies, `if` branches,
3428 // `match` arm bodies) never had a real position to attribute it to
3429 // either, span-keyed or not. Dropped rather than worked around.
3430 ctx.pop_scope();
3431 ty
3432}
3433
3434/// v0.7.1 tail-position auto-lift. If the expected type is `Effect[T]` and
3435/// the computed type is `T` (not itself an `Effect[_]`), lift it to
3436/// `Effect[T]`. Otherwise leave the type alone — the surrounding compatibility
3437/// check will report any genuine mismatch.
3438fn maybe_auto_lift(ty: Option<TyId>, expected: Option<TyId>, tys: &Types) -> Option<TyId> {
3439 if let Some(actual) = ty
3440 && let Some(exp) = expected
3441 && let Ty::Effect(et) = &*tys.get(exp)
3442 && !actual.is_effect(tys)
3443 && compatible(actual, *et, tys)
3444 {
3445 return Some(tys.intern(Ty::Effect(actual)));
3446 }
3447 ty
3448}
3449
3450/// Whether a value of type `ty` may fill an interpolation hole (v0.43, ADR
3451/// 0075): a base scalar, or a refinement of one (which widens to its base for
3452/// display). Opaque types are excluded — their base is hidden, so a value must
3453/// be `.raw`-ed out first.
3454fn interpolable(ty: TyId, tys: &Types) -> bool {
3455 matches!(
3456 &*tys.get(ty),
3457 Ty::Base(_)
3458 | Ty::Named {
3459 kind: NamedKind::Refined(_),
3460 ..
3461 }
3462 )
3463}
3464
3465pub(crate) fn type_of(expr: &Expr, expected: Option<TyId>, ctx: &mut Ctx) -> Option<TyId> {
3466 let tys = ctx.tys;
3467 let ty = match &expr.kind {
3468 // v0.9.4: a literal in a refined-expected position takes the refined
3469 // type (validated now); otherwise it keeps its base type.
3470 // v0.20a: a lambda. With an expected function type, params type
3471 // contextually and the body checks against the expected return; in an
3472 // unconstrained position, every param must be annotated and
3473 // effectfulness is inferred bottom-up by a syntactic pre-scan.
3474 ExprKind::Lambda(lambda) => check_lambda(lambda, expected, ctx),
3475 ExprKind::IntLit { .. } => {
3476 admit_refined_literal(expr, expected, ctx).or(Some(tys.intern(Ty::Base(BaseType::Int))))
3477 }
3478 ExprKind::FloatLit { .. } => admit_refined_literal(expr, expected, ctx)
3479 .or(Some(tys.intern(Ty::Base(BaseType::Float)))),
3480 // v0.86 (ADR 0112): a `Duration` literal always takes the base
3481 // `Duration` (no refined `Duration` types exist).
3482 ExprKind::DurationLit { .. } => Some(tys.intern(Ty::Base(BaseType::Duration))),
3483 ExprKind::StrLit(_) => admit_refined_literal(expr, expected, ctx)
3484 .or(Some(tys.intern(Ty::Base(BaseType::String)))),
3485 // An interpolated string (v0.43, ADR 0075). Each hole must type to a
3486 // base scalar (String/Int/Float/Bool) or a *refinement* of one — those
3487 // have a well-defined display form (Int/Float via the ADR 0074
3488 // `toString` contract, Bool as `true`/`false`; a refined value widens
3489 // to its base, e.g. `Subject` displays as its `String`). Records,
3490 // sums, opaque types (whose base is deliberately hidden — `.raw` it
3491 // first), and other types are rejected, foreclosing JS's
3492 // `[object Object]` footgun. The result is always a `String`.
3493 ExprKind::InterpStr(parts) => {
3494 for part in parts {
3495 let InterpPart::Hole(hole) = part else {
3496 continue;
3497 };
3498 match type_of(hole, None, ctx) {
3499 Some(ty) if interpolable(ty, tys) => {}
3500 Some(other) => ctx.errors.push(
3501 CompileError::new(
3502 "bynk.types.interpolation_non_scalar",
3503 hole.span,
3504 format!("type `{}` has no string form here", other.display(tys)),
3505 )
3506 .with_note(
3507 "interpolation holes accept the base scalar types (String, Int, Float, Bool) or a refinement of one; map other values to a String first",
3508 ),
3509 ),
3510 // The hole already produced its own error — don't pile on.
3511 None => {}
3512 }
3513 }
3514 Some(tys.intern(Ty::Base(BaseType::String)))
3515 }
3516 ExprKind::BoolLit(_) => Some(tys.intern(Ty::Base(BaseType::Bool))),
3517 // v0.20b: a list literal. Elements check against the expected
3518 // element type when one is supplied (so refined literals admit,
3519 // v0.9.4); an empty `[]` has no inferable element type without one.
3520 ExprKind::ListLit(elems) => {
3521 let expected_elem = expected.and_then(|t| peel_to_list(t, tys));
3522 if elems.is_empty() {
3523 match expected_elem {
3524 Some(t) => Some(tys.intern(Ty::List(t))),
3525 None => {
3526 ctx.errors.push(
3527 CompileError::new(
3528 "bynk.types.uninferable_element_type",
3529 expr.span,
3530 "an empty `[]` has no inferable element type",
3531 )
3532 .with_note(
3533 "annotate the binding (`let xs: List[T] = []`) or use the empty list where a `List[T]` is expected",
3534 ),
3535 );
3536 None
3537 }
3538 }
3539 } else {
3540 let mut elem_ty: Option<TyId> = expected_elem;
3541 for e in elems {
3542 let Some(t) = type_of(e, elem_ty, ctx) else {
3543 continue;
3544 };
3545 match &elem_ty {
3546 Some(et) => {
3547 if !compatible(t, *et, tys) {
3548 ctx.errors.push(CompileError::new(
3549 "bynk.types.list_element_mismatch",
3550 e.span,
3551 format!(
3552 "list element has type `{}`, but the list's element type is `{}`",
3553 t.display(tys),
3554 et.display(tys)
3555 ),
3556 ));
3557 }
3558 }
3559 None => elem_ty = Some(t),
3560 }
3561 }
3562 elem_ty.map(|t| tys.intern(Ty::List(t)))
3563 }
3564 }
3565 ExprKind::Ident(id) => {
3566 // v0.94 (ADR 0120): a bare `store Map` ident used as a **value** — not
3567 // a method receiver, which the `MethodCall` arm dispatches — is a lazy
3568 // `Query[V]` over the whole map (e.g. the `other` side of a join). It
3569 // is not in the value scope, so it never shadows a local.
3570 if ctx.lookup(id.name.as_str()).is_none()
3571 && let Some(StoreField::Map(_, v)) = ctx.store_fields.get(&id.name).cloned()
3572 {
3573 Some(tys.intern(Ty::Query(v)))
3574 }
3575 // v0.9: a bare ident may name an HttpResult variant. Resolve to
3576 // HttpResult only when (a) the surrounding type implies it, or
3577 // (b) no user sum-type variant of the same name exists. This
3578 // keeps `NotFound` resolving to a user `StockError` variant
3579 // when the caller expects a domain Result.
3580 else if ctx.lookup(id.name.as_str()).is_none()
3581 && let Some(v) = http_variant(&id.name)
3582 {
3583 let user_owns = ctx.input.types.values().any(|t| {
3584 matches!(&t.body, TypeBody::Sum(s)
3585 if s.variants.iter().any(|var| var.name.name == id.name))
3586 });
3587 let http_implied = expected
3588 .map(|t| peel_to_http_result(t, tys).is_some())
3589 .unwrap_or(false)
3590 || peel_to_http_result(ctx.return_ty, tys).is_some();
3591 if http_implied || !user_owns {
3592 // P6.21/P6.23 (review of #1244/#1247): `Callee::Intrinsic`
3593 // recorded here — a namespace-qualified built-in-sum
3594 // variant reference, the shape P6.1's Decision C had
3595 // excluded from bare-global resolution until a sink like
3596 // this existed for it.
3597 ctx.callees.insert(
3598 expr.id,
3599 Callee::Intrinsic {
3600 ns: HTTP_RESULT,
3601 op: v.name.to_string(),
3602 },
3603 );
3604 check_http_variant(id.span, v, &[], expected, ctx)
3605 } else {
3606 check_ident(id, expected, ctx)
3607 }
3608 } else if ctx.lookup(id.name.as_str()).is_none()
3609 && let Some(qv) = queue_variant(&id.name)
3610 && (expected.is_some_and(|t| peel_to_queue_result(t, tys))
3611 || peel_to_queue_result(ctx.return_ty, tys))
3612 {
3613 // v0.44: a bare QueueResult variant (`Ack`) in a queue handler.
3614 ctx.callees.insert(
3615 expr.id,
3616 Callee::Intrinsic {
3617 ns: QUEUE_RESULT,
3618 op: qv.name.to_string(),
3619 },
3620 );
3621 check_queue_variant(id.span, qv, &[], ctx)
3622 } else {
3623 check_ident(id, expected, ctx)
3624 }
3625 }
3626 ExprKind::Paren(inner) => type_of(inner, expected, ctx),
3627 ExprKind::Call {
3628 name,
3629 type_args,
3630 args,
3631 } => {
3632 // v0.9: HttpResult variant call. Prefer HttpResult when the
3633 // surrounding type implies it; otherwise defer to fn/user-variant
3634 // resolution and only fall back to HttpResult when nothing else
3635 // owns the name.
3636 //
3637 // `http_variant`/`queue_variant` are cheap keyword lookups; gate
3638 // the expensive context peel and — above all — the O(types×variants)
3639 // scan for user sum-variant owners behind them. The common case is
3640 // an ordinary function call whose name is neither keyword, so it
3641 // must not pay for either. The owner scan is further deferred behind
3642 // `http_implied`, since `unowned` only matters when the surrounding
3643 // type does not already imply HttpResult.
3644 if let Some(v) = http_variant(&name.name) {
3645 let http_implied = expected
3646 .map(|t| peel_to_http_result(t, tys).is_some())
3647 .unwrap_or(false)
3648 || peel_to_http_result(ctx.return_ty, tys).is_some();
3649 let owned_elsewhere = || {
3650 ctx.input.fns.contains_key(&name.name)
3651 || ctx.input.types.values().any(|t| {
3652 matches!(&t.body, TypeBody::Sum(s)
3653 if s.variants.iter().any(|var| var.name.name == name.name))
3654 })
3655 };
3656 if http_implied || !owned_elsewhere() {
3657 ctx.callees.insert(
3658 expr.id,
3659 Callee::Intrinsic {
3660 ns: HTTP_RESULT,
3661 op: v.name.to_string(),
3662 },
3663 );
3664 check_http_variant(expr.span, v, args, expected, ctx)
3665 } else {
3666 // Falling straight to `check_call` (rather than the
3667 // `queue_variant` else-if below) relies on the http and
3668 // queue variant keyword sets being disjoint, so an http
3669 // name could never have taken the queue branch anyway.
3670 check_call(name, type_args, args, expr.span, expected, expr.id, ctx)
3671 }
3672 } else if let Some(qv) = queue_variant(&name.name)
3673 && (expected.is_some_and(|t| peel_to_queue_result(t, tys))
3674 || peel_to_queue_result(ctx.return_ty, tys))
3675 {
3676 // v0.44: a QueueResult variant call (`Retry(reason)`).
3677 ctx.callees.insert(
3678 expr.id,
3679 Callee::Intrinsic {
3680 ns: QUEUE_RESULT,
3681 op: qv.name.to_string(),
3682 },
3683 );
3684 check_queue_variant(expr.span, qv, args, ctx)
3685 } else {
3686 check_call(name, type_args, args, expr.span, expected, expr.id, ctx)
3687 }
3688 }
3689 ExprKind::UnaryOp(op, inner) => check_unary(*op, inner, expr.span, ctx),
3690 ExprKind::BinOp(op, lhs, rhs) => check_binop(*op, lhs, rhs, ctx),
3691 ExprKind::Block(b) => type_of_block(b, expected, ctx),
3692 ExprKind::If {
3693 cond,
3694 then_block,
3695 else_block,
3696 } => check_if(cond, then_block, else_block, expr.span, expected, ctx),
3697 ExprKind::Ok(inner) => check_ok(inner, expr.span, expected, ctx),
3698 ExprKind::Err(inner) => check_err(inner, expr.span, expected, ctx),
3699 ExprKind::Some(inner) => check_some(inner, expr.span, expected, ctx),
3700 ExprKind::None => check_none(expr.span, expected, ctx),
3701 ExprKind::Question(inner) => check_question(inner, expr.span, ctx),
3702 ExprKind::ConstructorCall {
3703 type_name,
3704 method,
3705 args,
3706 } => {
3707 if type_name.name == HTTP_RESULT {
3708 if let Some(v) = http_variant(&method.name) {
3709 ctx.callees.insert(
3710 expr.id,
3711 Callee::Intrinsic {
3712 ns: HTTP_RESULT,
3713 op: v.name.to_string(),
3714 },
3715 );
3716 check_http_variant(expr.span, v, args, expected, ctx)
3717 } else {
3718 ctx.errors.push(CompileError::new(
3719 "bynk.types.unknown_static_member",
3720 method.span,
3721 format!("`HttpResult` has no variant named `{}`", method.name),
3722 ));
3723 None
3724 }
3725 } else if type_name.name == QUEUE_RESULT {
3726 if let Some(qv) = queue_variant(&method.name) {
3727 ctx.callees.insert(
3728 expr.id,
3729 Callee::Intrinsic {
3730 ns: QUEUE_RESULT,
3731 op: qv.name.to_string(),
3732 },
3733 );
3734 check_queue_variant(expr.span, qv, args, ctx)
3735 } else {
3736 ctx.errors.push(CompileError::new(
3737 "bynk.types.unknown_static_member",
3738 method.span,
3739 format!("`QueueResult` has no variant named `{}`", method.name),
3740 ));
3741 None
3742 }
3743 } else {
3744 // `ConstructorCall` has no type-argument slot — qualified
3745 // variant construction (`Opt.Some(x)`), never a capability
3746 // call, so `type_args` is always empty here.
3747 check_static_call(
3748 type_name,
3749 method,
3750 &[],
3751 args,
3752 expr.span,
3753 expected,
3754 expr.id,
3755 ctx,
3756 )
3757 }
3758 }
3759 ExprKind::RecordConstruction { type_name, fields } => {
3760 check_record_construction(type_name, fields, expected, expr.span, ctx)
3761 }
3762 ExprKind::FieldAccess { receiver, field } => {
3763 // v0.9: `HttpResult.Variant` qualified nullary variant access.
3764 if let ExprKind::Ident(id) = &receiver.kind
3765 && ctx.lookup(id.name.as_str()).is_none()
3766 && id.name == HTTP_RESULT
3767 {
3768 if let Some(v) = http_variant(&field.name) {
3769 if !matches!(v.payload, HttpVariantPayload::None) {
3770 ctx.errors.push(CompileError::new(
3771 "bynk.types.variant_missing_payload",
3772 field.span,
3773 format!(
3774 "`HttpResult.{}` has a payload — call it with an argument",
3775 v.name
3776 ),
3777 ));
3778 return None;
3779 }
3780 ctx.callees.insert(
3781 expr.id,
3782 Callee::Intrinsic {
3783 ns: HTTP_RESULT,
3784 op: v.name.to_string(),
3785 },
3786 );
3787 check_http_variant(field.span, v, &[], expected, ctx)
3788 } else {
3789 ctx.errors.push(CompileError::new(
3790 "bynk.types.unknown_static_member",
3791 field.span,
3792 format!("`HttpResult` has no variant named `{}`", field.name),
3793 ));
3794 None
3795 }
3796 } else {
3797 check_field_access(receiver, field, expected, ctx)
3798 }
3799 }
3800 ExprKind::MethodCall {
3801 receiver,
3802 method,
3803 type_args,
3804 args,
3805 } => {
3806 // `<field>.<op>(…)` on a `store` field — effectful storage
3807 // operations, dispatched by receiver provenance (a bare ident
3808 // naming a store field). Finding #36: one lookup into the unified
3809 // `store_fields` map, then dispatch by kind, instead of five
3810 // sequential per-kind lookups.
3811 //
3812 // Note: unlike the other store kinds, a `Cell` field is
3813 // deliberately bound into scope by `self_scope` (v0.81: "each
3814 // `Cell` store field is a bare local of its element type") so a
3815 // bare read derefs it — so `ctx.lookup` legitimately finds it and
3816 // no `is_none()` guard belongs there; a local sharing a cell's
3817 // name is a scope-construction question, not a dispatch-order
3818 // one. Every other kind requires `ctx.lookup(...).is_none()` so a
3819 // local that happens to share a store field's name is not
3820 // shadowed by the store dispatch.
3821 if let ExprKind::Ident(id) = &receiver.kind
3822 && let Some(field) = ctx.store_fields.get(&id.name).cloned()
3823 && (matches!(field, StoreField::Cell(_)) || ctx.lookup(id.name.as_str()).is_none())
3824 {
3825 match field {
3826 // v0.82 (ADR 0110): `<map>.<op>(…)` on a `store Map[K, V]`
3827 // field — effectful storage-map operations.
3828 StoreField::Map(k, v) => {
3829 // v0.91 (ADR 0115): a query builder/terminal lifts the
3830 // store map into a lazy `Query[V]` over its values; an
3831 // entry op (`put`/`get`/…) stays the effectful map
3832 // operation.
3833 if is_query_op(&method.name) {
3834 // v0.107 (slice 4): record the receiver's lifted
3835 // `Query[V]` type (otherwise unrecorded — the
3836 // dispatch keys off the store field, not a typed
3837 // receiver). This is the receiver's true type for
3838 // any query op; its load-bearing use is the
3839 // linearity pass, which now sees a held-bearing
3840 // collection and lends the closure parameter of
3841 // `forEach`/`parTraverse` as borrowed —
3842 // otherwise `ty_of(receiver)` is `None` and the
3843 // no-consume-in-a-broadcast rule is silently
3844 // unenforced.
3845 ctx.expr_types.insert(
3846 receiver.id,
3847 TypedExpr {
3848 span: receiver.span,
3849 ty: tys.intern(Ty::Query(v)),
3850 },
3851 );
3852 let result = check_query_kernel_method(method, args, v, expr.span, ctx);
3853 // P6.2 (#1143, R6.12): role is read back from
3854 // this call's own resolved type where possible —
3855 // `is_query_op` above only decides "lift or
3856 // not", never "builder or terminal" (`query_role`
3857 // itself, not this call site).
3858 let role = query_role(result, &method.name, tys);
3859 ctx.callees.insert(
3860 expr.id,
3861 Callee::Query {
3862 field: id.name.clone(),
3863 op: method.name.clone(),
3864 role,
3865 },
3866 );
3867 result
3868 } else {
3869 ctx.callees.insert(
3870 expr.id,
3871 Callee::Store {
3872 field: id.name.clone(),
3873 op: method.name.clone(),
3874 },
3875 );
3876 check_store_map_op(method, args, k, v, expr.span, ctx)
3877 }
3878 }
3879 // v0.83: `<set>.<op>(…)` on a `store Set[T]` field —
3880 // effectful storage-set ops.
3881 StoreField::Set(t) => {
3882 ctx.callees.insert(
3883 expr.id,
3884 Callee::Store {
3885 field: id.name.clone(),
3886 op: method.name.clone(),
3887 },
3888 );
3889 check_store_set_op(method, args, t, expr.span, ctx)
3890 }
3891 // v0.87 (ADR 0113): `<cache>.<op>(…)` on a `store
3892 // Cache[K, V]` field — the storage-map ops plus a `given
3893 // Clock` requirement (eviction).
3894 StoreField::Cache(k, v, _ttl) => {
3895 ctx.callees.insert(
3896 expr.id,
3897 Callee::Store {
3898 field: id.name.clone(),
3899 op: method.name.clone(),
3900 },
3901 );
3902 check_store_cache_op(method, args, k, v, expr.span, ctx)
3903 }
3904 // v0.95 (ADR 0121): `<log>.<op>(…)` on a `store Log[T]`
3905 // field — `append` is the effectful non-idempotent write
3906 // (`given Clock`); the time-window roots and general
3907 // builders lift the log into a lazy `Query[T]` over its
3908 // entry values. Unlike `Map`, the store-vs-query split
3909 // lives *inside* `check_store_log_op` itself (the
3910 // window-root vocabulary `since`/`before`/`between`/
3911 // `recent`/`reversed` plus its own `is_query_op`
3912 // fallthrough, `calls.rs:1879-1913`) — mirrored here by
3913 // name so `Callee::Query` is recorded only for the same
3914 // vocabulary `check_store_log_op` itself treats as a
3915 // query op, not for every non-`append` name (an unknown
3916 // op — `check_store_log_op`'s own `other =>` arm reports
3917 // `bynk.store.unknown_op` — gets neither `Callee`, since
3918 // dispatch's own conclusion is that it is not a valid
3919 // call at all).
3920 StoreField::Log(t) => match method.name.as_str() {
3921 "append" => {
3922 ctx.callees.insert(
3923 expr.id,
3924 Callee::Store {
3925 field: id.name.clone(),
3926 op: method.name.clone(),
3927 },
3928 );
3929 check_store_log_op(method, args, t, expr.span, ctx)
3930 }
3931 name if matches!(
3932 name,
3933 "since" | "before" | "between" | "recent" | "reversed"
3934 ) || is_query_op(name) =>
3935 {
3936 let result = check_store_log_op(method, args, t, expr.span, ctx);
3937 let role = query_role(result, &method.name, tys);
3938 ctx.callees.insert(
3939 expr.id,
3940 Callee::Query {
3941 field: id.name.clone(),
3942 op: method.name.clone(),
3943 role,
3944 },
3945 );
3946 result
3947 }
3948 _ => check_store_log_op(method, args, t, expr.span, ctx),
3949 },
3950 // v0.98 (ADR 0125): `<cell>.update(f)` on a `store
3951 // Cell[T]` field — the one method-shaped cell op (read is
3952 // the bare name, write is `:=`).
3953 StoreField::Cell(t) => {
3954 ctx.callees.insert(
3955 expr.id,
3956 Callee::Store {
3957 field: id.name.clone(),
3958 op: method.name.clone(),
3959 },
3960 );
3961 check_store_cell_op(method, args, t, expr.span, ctx)
3962 }
3963 }
3964 }
3965 // v0.9: `HttpResult.Variant(args)` — explicit HttpResult construction.
3966 else if let ExprKind::Ident(id) = &receiver.kind
3967 && ctx.lookup(id.name.as_str()).is_none()
3968 && id.name == HTTP_RESULT
3969 {
3970 if let Some(v) = http_variant(&method.name) {
3971 ctx.callees.insert(
3972 expr.id,
3973 Callee::Intrinsic {
3974 ns: HTTP_RESULT,
3975 op: v.name.to_string(),
3976 },
3977 );
3978 check_http_variant(expr.span, v, args, expected, ctx)
3979 } else {
3980 ctx.errors.push(CompileError::new(
3981 "bynk.types.unknown_static_member",
3982 method.span,
3983 format!("`HttpResult` has no variant named `{}`", method.name),
3984 ));
3985 None
3986 }
3987 } else {
3988 check_method_call(
3989 receiver, method, type_args, args, expr.span, expected, expr.id, ctx,
3990 )
3991 }
3992 }
3993 ExprKind::Match { discriminant, arms } => {
3994 check_match(discriminant, arms, expr.span, expected, ctx)
3995 }
3996 ExprKind::Is { value, pattern } => check_is(value, pattern, expr.span, ctx),
3997 ExprKind::UnitLit => Some(tys.intern(Ty::Unit)),
3998 ExprKind::EffectPure(inner) => {
3999 let expected_inner = match expected.map(|e| tys.get(e)).as_deref() {
4000 Some(Ty::Effect(t)) => Some(*t),
4001 _ => None,
4002 };
4003 let inner_ty = type_of(inner, expected_inner, ctx)?;
4004 Some(tys.intern(Ty::Effect(inner_ty)))
4005 }
4006 ExprKind::RecordSpread {
4007 type_name,
4008 base,
4009 overrides,
4010 } => check_record_spread(
4011 type_name.as_ref(),
4012 base,
4013 overrides,
4014 expr.span,
4015 expected,
4016 ctx,
4017 ),
4018 ExprKind::Expect(inner) => check_expect(inner, expr.span, ctx),
4019 ExprKind::Val { type_ref, args } => check_val(type_ref, args, expr.span, ctx),
4020 ExprKind::Observation(o) => check_observation(o, expr.span, ctx),
4021 ExprKind::Faults(call) => check_faults(call, expr.span, ctx),
4022 ExprKind::Trace { cap, op } => check_trace(cap, op, expr.span, ctx),
4023 // Slice C: a `Wire(<String>)` reached through the ordinary expression
4024 // checker is *misplaced* — a valid `Wire` is intercepted by the service-
4025 // address argument checker (`check_address_args`), which validates the
4026 // inner and the `system` tier. Anywhere else it is an error. The inner is
4027 // still typed so a mistake inside it is reported too.
4028 ExprKind::Wire(inner) => {
4029 let _ = type_of(inner, Some(tys.intern(Ty::Base(BaseType::String))), ctx);
4030 ctx.errors.push(
4031 CompileError::new(
4032 "bynk.test.wire_needs_system",
4033 expr.span,
4034 "`Wire(...)` may only be passed as an argument to a service address in a `system`-tier case",
4035 )
4036 .with_note(
4037 "`Wire` hands raw, pre-validation input to the boundary; there is no wire to be raw about at `unit`, and it is meaningless outside a service address",
4038 ),
4039 );
4040 None
4041 }
4042 };
4043 // T3.3b (R4.3, R2.5, R4.9): `expr_types` is total for every expression
4044 // `type_of` is called on — a `None` result (whether from a diagnosed
4045 // failure or a deliberate, undiagnosed non-type such as an untyped
4046 // test-body binding) records `Ty::Error` rather than leaving the span
4047 // unrecorded. This changes only what gets *written*; every caller of
4048 // `type_of` still sees its actual `Option<TyId>` return value and every
4049 // existing `?`/`.or(...)` control-flow site is unaffected — `Ty::Error`
4050 // only becomes observable to an external reader of `expr_types` (the
4051 // emitter, the LSP), never to internal checker logic.
4052 ctx.expr_types.insert(
4053 expr.id,
4054 TypedExpr {
4055 span: expr.span,
4056 ty: ty.unwrap_or_else(|| tys.intern(Ty::Error)),
4057 },
4058 );
4059 ty
4060}
4061
4062/// #1658 (runtime-semantics track S9): an `Effect[T]` value in an effectful
4063/// body must be bound (`<-`), sequenced (`do`), returned (the block's tail),
4064/// or handed to something that takes an `Effect`. The emitter translates an
4065/// effectful call to an eager `Promise`, so a call that is *built* but not
4066/// bound still runs, unawaited, racing everything after it: `let e =
4067/// Counter("k").bump()` performed the write with no diagnostic. This flags the
4068/// value positions where an `Effect` can only be built and abandoned:
4069/// - the right-hand side of a plain `let` (`allowed == false` at the root),
4070/// including `let _ = …`;
4071/// - a list literal's element. No API takes a `List[Effect[T]]`, and a list's
4072/// element type is inferred from its elements, so it cannot vouch for them;
4073/// - the payload of `Some`/`Ok`/`Err`, a sum-variant constructor, or a record
4074/// field.
4075///
4076/// It does not descend into call arguments, receivers, lambdas or nested
4077/// blocks: an argument is the higher-order use the language keeps (its
4078/// parameter decides), and nested blocks are checked as blocks. Pure bodies
4079/// are untouched: effectful calls are already rejected there.
4080fn check_unbound_effects(e: &Expr, allowed: bool, ctx: &mut Ctx) {
4081 if !ctx.effectful {
4082 return;
4083 }
4084 let tys = ctx.tys;
4085 let ty_of = |e: &Expr, ctx: &Ctx| ctx.expr_types.get(&e.id).map(|t| t.ty);
4086 let is_effect = |t: Option<TyId>| t.is_some_and(|t| matches!(&*tys.get(t), Ty::Effect(_)));
4087 if !allowed && is_effect(ty_of(e, ctx)) {
4088 ctx.errors.push(
4089 CompileError::new(
4090 "bynk.effect.unbound_effect",
4091 e.span,
4092 "this `Effect` is started here and never awaited — it runs eagerly, out of order with everything after it",
4093 )
4094 .with_note(
4095 "bind its result with `let x <- …`, run it for its effect with `do …`, or return it as the body's value",
4096 ),
4097 );
4098 return;
4099 }
4100 match &e.kind {
4101 ExprKind::Paren(inner) => check_unbound_effects(inner, allowed, ctx),
4102 ExprKind::Some(x) | ExprKind::Ok(x) | ExprKind::Err(x) => {
4103 check_unbound_effects(x, false, ctx);
4104 }
4105 ExprKind::ListLit(args) => {
4106 for a in args {
4107 check_unbound_effects(a, false, ctx);
4108 }
4109 }
4110 // A variant constructor in every spelling: `Loaded(x)` is a `Call`;
4111 // the qualified `ApiResult.Loaded(x)` parses as a `MethodCall` on the
4112 // type name, or a `ConstructorCall`. All resolve to `Callee::Ctor` on
4113 // their own expression (review of #1694).
4114 ExprKind::Call { args, .. }
4115 | ExprKind::MethodCall { args, .. }
4116 | ExprKind::ConstructorCall { args, .. }
4117 if matches!(ctx.callees.get(&e.id), Some(Callee::Ctor { .. })) =>
4118 {
4119 for a in args {
4120 check_unbound_effects(a, false, ctx);
4121 }
4122 }
4123 ExprKind::RecordConstruction { fields, .. }
4124 | ExprKind::RecordSpread {
4125 overrides: fields, ..
4126 } => {
4127 for f in fields {
4128 if let Some(v) = &f.value {
4129 check_unbound_effects(v, false, ctx);
4130 }
4131 }
4132 }
4133 _ => {}
4134 }
4135}
4136
4137// ==== Peel helpers (unwrap Effect / Result / Option / List / Map) ====
4138
4139/// Peel one optional `Effect[_]` wrapper to expose an underlying `HttpResult[T]`.
4140pub(crate) fn peel_to_http_result(ty: TyId, tys: &Types) -> Option<TyId> {
4141 match &*tys.get(ty) {
4142 Ty::HttpResult(inner) => Some(*inner),
4143 Ty::Effect(inner) => peel_to_http_result(*inner, tys),
4144 _ => None,
4145 }
4146}
4147
4148/// v0.44: peel an optional `Effect[_]` to detect an underlying `QueueResult`.
4149fn peel_to_queue_result(ty: TyId, tys: &Types) -> bool {
4150 match &*tys.get(ty) {
4151 Ty::QueueResult => true,
4152 Ty::Effect(inner) => peel_to_queue_result(*inner, tys),
4153 _ => false,
4154 }
4155}
4156
4157fn surrounding_result(
4158 expected: Option<TyId>,
4159 return_ty: TyId,
4160 tys: &Types,
4161) -> Option<(TyId, TyId)> {
4162 if let Some(t) = expected
4163 && let Some(pair) = peel_to_result(t, tys)
4164 {
4165 return Some(pair);
4166 }
4167 peel_to_result(return_ty, tys)
4168}
4169
4170/// Peel one optional `Effect[_]` wrapper to expose an underlying `Result[T, E]`.
4171/// Used by `Ok` / `Err` checking in v0.7.1 so that bare constructors in
4172/// `Effect[Result[T, E]]` tail positions can pick up the surrounding type's
4173/// parameters via the auto-lift propagation.
4174fn peel_to_result(ty: TyId, tys: &Types) -> Option<(TyId, TyId)> {
4175 match &*tys.get(ty) {
4176 Ty::Result(t, e) => Some((*t, *e)),
4177 Ty::Effect(inner) => peel_to_result(*inner, tys),
4178 _ => None,
4179 }
4180}
4181
4182/// Companion to `peel_to_result` for `Option[T]`.
4183fn peel_to_option(ty: TyId, tys: &Types) -> Option<TyId> {
4184 match &*tys.get(ty) {
4185 Ty::Option(t) => Some(*t),
4186 Ty::Effect(inner) => peel_to_option(*inner, tys),
4187 _ => None,
4188 }
4189}
4190
4191/// Companion to `peel_to_result` for `List[T]` (v0.20b) — the expected
4192/// element type of a list literal, looking through `Effect[_]` so tail
4193/// auto-lift positions still propagate it.
4194fn peel_to_list(ty: TyId, tys: &Types) -> Option<TyId> {
4195 match &*tys.get(ty) {
4196 Ty::List(t) => Some(*t),
4197 Ty::Effect(inner) => peel_to_list(*inner, tys),
4198 _ => None,
4199 }
4200}
4201
4202/// Companion to `peel_to_list` for `Map[K, V]` (v0.20b).
4203fn peel_to_map(ty: TyId, tys: &Types) -> Option<(TyId, TyId)> {
4204 match &*tys.get(ty) {
4205 Ty::Map(k, v) => Some((*k, *v)),
4206 Ty::Effect(inner) => peel_to_map(*inner, tys),
4207 _ => None,
4208 }
4209}
4210
4211// ==== Structural compatibility and variant introspection ====
4212
4213/// A flattened view of a type's variants (name + payload types).
4214///
4215/// `pub` since P6.4 (design/tracks/the-ir.md §6, #1157, Decision A):
4216/// `bynk-emit::ir::lower`'s pattern-lowering needs the exact same uniform
4217/// view this function already gives the checker — a user sum, `Result`,
4218/// `Option`, `ActorSum` and `HttpResult` all flattened into one `name` +
4219/// `payload` shape, with no `Arc<TypeDecl>` required (`Callee::Ctor`'s own
4220/// identity scheme never fires for `Ok`/`Err`/`Some`/`None`, ADR 0333's
4221/// `#1145` Decision B). No behaviour change — a reachability change only,
4222/// the same shape ADR 0333 already gave `Callee`.
4223pub struct VariantInfo {
4224 pub name: String,
4225 pub payload: Vec<(String, TyId)>,
4226}
4227
4228/// Project a return type produced in the consumed context's namespace into
4229/// the caller's namespace by re-resolving named types that exist on both
4230/// sides. The structural shape stays the same; the brand changes.
4231fn rebrand_return_type(
4232 t: TyId,
4233 caller_types: &HashMap<String, Arc<TypeDecl>>,
4234 tys: &Types,
4235) -> TyId {
4236 let node = tys.get(t);
4237 match &*node {
4238 Ty::Named { name, kind, args } => {
4239 // If the caller's namespace has the same name, prefer the caller's
4240 // view (it carries the caller's brand at emission time). Otherwise
4241 // keep the consumed-context name; the caller can hold it opaquely.
4242 // Applied type arguments (a generic record) are preserved either
4243 // way — though a generic record is non-boundary, so this path only
4244 // ever sees the empty-args non-generic case in practice.
4245 if let Some(decl) = caller_types.get(name) {
4246 named_ty_with_args(decl, args.clone(), tys)
4247 } else {
4248 tys.intern(Ty::Named {
4249 name: name.clone(),
4250 kind: kind.clone(),
4251 args: args.clone(),
4252 })
4253 }
4254 }
4255 Ty::Result(t, e) => tys.intern(Ty::Result(
4256 rebrand_return_type(*t, caller_types, tys),
4257 rebrand_return_type(*e, caller_types, tys),
4258 )),
4259 Ty::Option(t) => tys.intern(Ty::Option(rebrand_return_type(*t, caller_types, tys))),
4260 Ty::Effect(t) => tys.intern(Ty::Effect(rebrand_return_type(*t, caller_types, tys))),
4261 Ty::HttpResult(t) => tys.intern(Ty::HttpResult(rebrand_return_type(*t, caller_types, tys))),
4262 Ty::List(t) => tys.intern(Ty::List(rebrand_return_type(*t, caller_types, tys))),
4263 Ty::Query(t) => tys.intern(Ty::Query(rebrand_return_type(*t, caller_types, tys))),
4264 Ty::Stream(t) => tys.intern(Ty::Stream(rebrand_return_type(*t, caller_types, tys))),
4265 Ty::Connection(t) => tys.intern(Ty::Connection(rebrand_return_type(*t, caller_types, tys))),
4266 Ty::Map(k, v) => tys.intern(Ty::Map(
4267 rebrand_return_type(*k, caller_types, tys),
4268 rebrand_return_type(*v, caller_types, tys),
4269 )),
4270 // R4.3: `Ty::Error` carries no name to rebrand — pass it through.
4271 Ty::Error
4272 | Ty::Base(_)
4273 | Ty::QueueResult
4274 | Ty::ValidationError
4275 | Ty::JsonError
4276 | Ty::Unit
4277 | Ty::Actor(_)
4278 | Ty::ActorSum(_) => t,
4279 // v0.20a: function types are confined to non-boundary positions
4280 // (`bynk.types.function_at_boundary`), so a cross-context return can
4281 // never carry one; Vars never escape call checking.
4282 Ty::Fn { .. } | Ty::Var(_) => t,
4283 }
4284}
4285
4286/// Structural compatibility check for values crossing a context boundary
4287/// (v0.6 §4.3). The two types may be expressed in different namespaces
4288/// (caller-side / callee-side type tables), so we walk them in parallel
4289/// against their respective tables.
4290fn structurally_compatible(
4291 arg: TyId,
4292 param: TyId,
4293 arg_types: &HashMap<String, Arc<TypeDecl>>,
4294 param_types: &HashMap<String, Arc<TypeDecl>>,
4295 tys: &Types,
4296) -> bool {
4297 structurally_compatible_inner(arg, param, arg_types, param_types, tys, &mut HashSet::new())
4298}
4299
4300fn structurally_compatible_inner(
4301 arg: TyId,
4302 param: TyId,
4303 arg_types: &HashMap<String, Arc<TypeDecl>>,
4304 param_types: &HashMap<String, Arc<TypeDecl>>,
4305 tys: &Types,
4306 visited: &mut HashSet<(String, String)>,
4307) -> bool {
4308 let (arg_node, param_node) = (tys.get(arg), tys.get(param));
4309 match (&*arg_node, &*param_node) {
4310 // R4.3: as in `compatible` — an already-diagnosed side is compatible
4311 // with anything, so a cross-context signature check doesn't report
4312 // the same failure a second time as a signature mismatch.
4313 (Ty::Error, _) | (_, Ty::Error) => true,
4314 (Ty::Base(a), Ty::Base(b)) => a == b,
4315 (Ty::ValidationError, Ty::ValidationError) => true,
4316 (Ty::JsonError, Ty::JsonError) => true,
4317 (Ty::Unit, Ty::Unit) => true,
4318 (Ty::Result(t1, e1), Ty::Result(t2, e2)) => {
4319 structurally_compatible_inner(*t1, *t2, arg_types, param_types, tys, visited)
4320 && structurally_compatible_inner(*e1, *e2, arg_types, param_types, tys, visited)
4321 }
4322 (Ty::Option(a), Ty::Option(b)) => {
4323 structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4324 }
4325 (Ty::Effect(a), Ty::Effect(b)) => {
4326 structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4327 }
4328 (Ty::HttpResult(a), Ty::HttpResult(b)) => {
4329 structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4330 }
4331 // The boundary-crossing collections walk their element types like
4332 // `Result`/`Option` — without these arms an identical `List[Int]`
4333 // was rejected against itself at a context boundary.
4334 (Ty::List(a), Ty::List(b)) => {
4335 structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4336 }
4337 (Ty::Map(k1, v1), Ty::Map(k2, v2)) => {
4338 structurally_compatible_inner(*k1, *k2, arg_types, param_types, tys, visited)
4339 && structurally_compatible_inner(*v1, *v2, arg_types, param_types, tys, visited)
4340 }
4341 (Ty::QueueResult, Ty::QueueResult) => true,
4342 (
4343 Ty::Named {
4344 name: an, args: aa, ..
4345 },
4346 Ty::Named {
4347 name: bn, args: ba, ..
4348 },
4349 ) => {
4350 // v0.157 (ADR 0183): applied type arguments must match structurally
4351 // too — `Paginated[String]` and `Paginated[Int]` are not the same
4352 // brand. (#1736: live now that a generic type's fields compare at
4353 // these arguments, in `structural_compare_named`.)
4354 if aa.len() != ba.len()
4355 || !aa.iter().zip(ba).all(|(x, y)| {
4356 structurally_compatible_inner(*x, *y, arg_types, param_types, tys, visited)
4357 })
4358 {
4359 return false;
4360 }
4361 // Cycle break: once we've started comparing (an, bn) we trust
4362 // the recursive case to succeed.
4363 let key = (an.clone(), bn.clone());
4364 if !visited.insert(key.clone()) {
4365 return true;
4366 }
4367 let ok =
4368 structural_compare_named((an, aa), (bn, ba), arg_types, param_types, tys, visited);
4369 visited.remove(&key);
4370 ok
4371 }
4372 // Refined-named widens to its base; tolerate one-sided widening only
4373 // when comparing within the same nominal name (handled above) or when
4374 // the param accepts a plain base.
4375 (
4376 Ty::Named {
4377 kind: NamedKind::Refined(b),
4378 ..
4379 },
4380 Ty::Base(target),
4381 ) => b == target,
4382 // Everything else cannot cross a context boundary: cross-variant
4383 // pairs, and the non-boundary types (`Effect` payloads are unwrapped
4384 // before this check; `Query`/`Stream`/`Connection`/`Fn`/`Var` and the
4385 // sealed actor bindings never cross). The left side is enumerated —
4386 // no `_` — so adding a `Ty` variant fails to compile here instead of
4387 // silently rejecting the new type against itself (the trap the
4388 // collections fell into).
4389 (
4390 Ty::Base(_)
4391 | Ty::Named { .. }
4392 | Ty::Result(..)
4393 | Ty::Option(_)
4394 | Ty::Effect(_)
4395 | Ty::HttpResult(_)
4396 | Ty::QueueResult
4397 | Ty::List(_)
4398 | Ty::Map(..)
4399 | Ty::Query(_)
4400 | Ty::Stream(_)
4401 | Ty::Connection(_)
4402 | Ty::ValidationError
4403 | Ty::JsonError
4404 | Ty::Unit
4405 | Ty::Actor(_)
4406 | Ty::ActorSum(_)
4407 | Ty::Fn { .. }
4408 | Ty::Var(_),
4409 _,
4410 ) => false,
4411 }
4412}
4413
4414fn structural_compare_named(
4415 (arg_name, arg_args): (&str, &[TyId]),
4416 (param_name, param_args): (&str, &[TyId]),
4417 arg_types: &HashMap<String, Arc<TypeDecl>>,
4418 param_types: &HashMap<String, Arc<TypeDecl>>,
4419 tys: &Types,
4420 visited: &mut HashSet<(String, String)>,
4421) -> bool {
4422 // The "same nominal name" case is the most common: both sides derive
4423 // the same commons type. Compare their structural shapes.
4424 let Some(arg_decl) = arg_types.get(arg_name) else {
4425 return false;
4426 };
4427 let Some(param_decl) = param_types.get(param_name) else {
4428 return false;
4429 };
4430 // #1736: a generic declaration's fields name its type parameters
4431 // (`item: T`), which resolve against neither side's type table, so every
4432 // generic record or sum used to compare as incompatible, even against
4433 // itself. Each side's fields are resolved at that side's *applied*
4434 // arguments (`Envelope[Int]` → `item: Int`) with [`instantiate_field_ty`],
4435 // whose arity guard turns a mis-applied reference into `None` (rejected),
4436 // so the bodies compare as instantiated. The arguments were already
4437 // compared pairwise by the caller.
4438 match (&arg_decl.body, ¶m_decl.body) {
4439 (
4440 TypeBody::Refined {
4441 base: ab,
4442 refinement: ar,
4443 ..
4444 },
4445 TypeBody::Refined {
4446 base: bb,
4447 refinement: br,
4448 ..
4449 },
4450 ) => {
4451 if ab != bb {
4452 return false;
4453 }
4454 refinements_match(ar.as_ref(), br.as_ref())
4455 }
4456 (
4457 TypeBody::Opaque {
4458 base: ab,
4459 refinement: ar,
4460 ..
4461 },
4462 TypeBody::Opaque {
4463 base: bb,
4464 refinement: br,
4465 ..
4466 },
4467 ) => {
4468 // Opaque types must share a name to be compatible (a context's
4469 // opaque cannot be reinterpreted as a different context's opaque).
4470 if arg_name != param_name {
4471 return false;
4472 }
4473 if ab != bb {
4474 return false;
4475 }
4476 refinements_match(ar.as_ref(), br.as_ref())
4477 }
4478 (TypeBody::Record(a), TypeBody::Record(b)) => {
4479 if a.fields.len() != b.fields.len() {
4480 return false;
4481 }
4482 for af in &a.fields {
4483 let Some(bf) = b.fields.iter().find(|f| f.name.name == af.name.name) else {
4484 return false;
4485 };
4486 let at = instantiate_field_ty(arg_decl, arg_args, &af.type_ref, arg_types, tys);
4487 let bt =
4488 instantiate_field_ty(param_decl, param_args, &bf.type_ref, param_types, tys);
4489 let (Some(at), Some(bt)) = (at, bt) else {
4490 return false;
4491 };
4492 if !structurally_compatible_inner(at, bt, arg_types, param_types, tys, visited) {
4493 return false;
4494 }
4495 }
4496 true
4497 }
4498 (TypeBody::Sum(a), TypeBody::Sum(b)) => {
4499 if a.variants.len() != b.variants.len() {
4500 return false;
4501 }
4502 for av in &a.variants {
4503 let Some(bv) = b.variants.iter().find(|v| v.name.name == av.name.name) else {
4504 return false;
4505 };
4506 if av.payload.len() != bv.payload.len() {
4507 return false;
4508 }
4509 for (af, bf) in av.payload.iter().zip(bv.payload.iter()) {
4510 if af.name.name != bf.name.name {
4511 return false;
4512 }
4513 let at = instantiate_field_ty(arg_decl, arg_args, &af.type_ref, arg_types, tys);
4514 let bt = instantiate_field_ty(
4515 param_decl,
4516 param_args,
4517 &bf.type_ref,
4518 param_types,
4519 tys,
4520 );
4521 let (Some(at), Some(bt)) = (at, bt) else {
4522 return false;
4523 };
4524 if !structurally_compatible_inner(at, bt, arg_types, param_types, tys, visited)
4525 {
4526 return false;
4527 }
4528 }
4529 }
4530 true
4531 }
4532 _ => false,
4533 }
4534}
4535
4536/// v0.177 (#643): two refinements match when their **canonical forms** are
4537/// equal — a *set* comparison, not a positional one.
4538///
4539/// This retires the v0.6 §4.3 foot-gun the status doc named: predicates were
4540/// compared by `zip`, so `String where NonEmpty, MaxLen(10)` and
4541/// `String where MaxLen(10), NonEmpty` — the same type — spuriously failed to
4542/// match. Predicates are conjunctive and side-effect-free, so their order
4543/// carries no meaning and comparing it was always accidental.
4544///
4545/// The comparison routes through `contract::canon_refinement`, the same function
4546/// that feeds the cross-context contract hash, and deliberately so: if the
4547/// matcher and the hash disagreed about what "the same refinement" is, a
4548/// contract could type-check at compile time and 409 at runtime — the worst
4549/// failure available to this increment. One normal form, two consumers.
4550///
4551/// The asymmetry is unchanged: a *more* restrictive sending side is admitted
4552/// into a more permissive receiving one, but not the reverse.
4553///
4554/// One behavioural consequence of sharing the form: it de-duplicates, so
4555/// `where NonEmpty, NonEmpty` now matches `where NonEmpty`. That is correct — a
4556/// conjunction is idempotent, so they are the same type — and it must hold on
4557/// the hash side regardless, or two contexts spelling the same type differently
4558/// would fail closed against each other.
4559fn refinements_match(a: Option<&Refinement>, b: Option<&Refinement>) -> bool {
4560 match (a, b) {
4561 (None, None) => true,
4562 (Some(_), None) => true, // sending side is more restrictive — receiving is more permissive
4563 (None, Some(_)) => false,
4564 (Some(a), Some(b)) => {
4565 crate::contract::canon_refinement(Some(a)) == crate::contract::canon_refinement(Some(b))
4566 }
4567 }
4568}
4569
4570/// `pub` since P6.4 (#1157, Decision A) — see [`VariantInfo`]'s own doc
4571/// comment for why `bynk-emit` needs this exact function rather than a
4572/// re-derived copy (R5.11, for the IR side).
4573pub fn variants_of(
4574 ty: TyId,
4575 types: &HashMap<String, Arc<TypeDecl>>,
4576 tys: &Types,
4577) -> Option<Vec<VariantInfo>> {
4578 match &*tys.get(ty) {
4579 Ty::Named {
4580 kind: NamedKind::Sum,
4581 name,
4582 args,
4583 } => {
4584 let decl = types.get(name)?;
4585 if let TypeBody::Sum(s) = &decl.body {
4586 Some(
4587 s.variants
4588 .iter()
4589 .map(|v| VariantInfo {
4590 name: v.name.name.clone(),
4591 payload: v
4592 .payload
4593 .iter()
4594 .map(|f| {
4595 // #593: for a generic sum, substitute the
4596 // instantiation's arguments into each payload
4597 // type (`Some(v: T)` over `Opt[Int]` ⇒ `Int`),
4598 // exactly as a generic record's fields are read
4599 // at an instantiation. `instantiate_field_ty`
4600 // degrades to a plain resolve for a non-generic
4601 // sum (empty `args`).
4602 let t =
4603 instantiate_field_ty(decl, args, &f.type_ref, types, tys)
4604 .unwrap_or_else(|| tys.intern(Ty::Base(BaseType::Int)));
4605 (f.name.name.clone(), t)
4606 })
4607 .collect(),
4608 })
4609 .collect(),
4610 )
4611 } else {
4612 None
4613 }
4614 }
4615 Ty::Result(t, e) => Some(vec![
4616 VariantInfo {
4617 name: "Ok".to_string(),
4618 payload: vec![("value".to_string(), *t)],
4619 },
4620 VariantInfo {
4621 name: "Err".to_string(),
4622 payload: vec![("error".to_string(), *e)],
4623 },
4624 ]),
4625 Ty::Option(t) => Some(vec![
4626 VariantInfo {
4627 name: "Some".to_string(),
4628 payload: vec![("value".to_string(), *t)],
4629 },
4630 VariantInfo {
4631 name: "None".to_string(),
4632 payload: vec![],
4633 },
4634 ]),
4635 // v0.52: a multi-actor sum matches on the resolved actor. Each member's
4636 // variant is named by the actor and binds that actor's identity
4637 // *directly* (`User(u)` ⇒ `u : UserId` — the arm already names the
4638 // actor, so no `.identity` indirection). A unit-identity member
4639 // (`Visitor`, `Webhook`) binds nothing.
4640 Ty::ActorSum(members) => Some(
4641 members
4642 .iter()
4643 .map(|(name, id)| VariantInfo {
4644 name: name.clone(),
4645 payload: match &*tys.get(*id) {
4646 Ty::Unit => vec![],
4647 _ => vec![("identity".to_string(), *id)],
4648 },
4649 })
4650 .collect(),
4651 ),
4652 Ty::HttpResult(t) => Some(
4653 HTTP_VARIANTS
4654 .iter()
4655 .map(|v| VariantInfo {
4656 name: v.name.to_string(),
4657 payload: match v.payload {
4658 HttpVariantPayload::None => vec![],
4659 HttpVariantPayload::Value => vec![("value".to_string(), *t)],
4660 HttpVariantPayload::Message => {
4661 vec![(
4662 "message".to_string(),
4663 tys.intern(Ty::Base(BaseType::String)),
4664 )]
4665 }
4666 HttpVariantPayload::Location => {
4667 vec![(
4668 "location".to_string(),
4669 tys.intern(Ty::Base(BaseType::String)),
4670 )]
4671 }
4672 HttpVariantPayload::Streamed => {
4673 let elem = tys.intern(Ty::Base(BaseType::String));
4674 vec![("stream".to_string(), tys.intern(Ty::Stream(elem)))]
4675 }
4676 // v0.111: the first two-field payload. Field names are
4677 // kept byte-identical to the runtime union in
4678 // `bynk-emit/runtime/src/http.ts`. An `HttpResult` is
4679 // construct-only in handler position (never scrutinised),
4680 // so this binding exists for exhaustiveness, not a path.
4681 HttpVariantPayload::Raw => vec![
4682 ("body".to_string(), tys.intern(Ty::Base(BaseType::Bytes))),
4683 (
4684 "contentType".to_string(),
4685 tys.intern(Ty::Base(BaseType::String)),
4686 ),
4687 ],
4688 },
4689 })
4690 .collect(),
4691 ),
4692 _ => None,
4693 }
4694}
4695
4696// ── v0.9.2: agent state-field zeroability ──────────────────────────────────
4697//
4698// Fresh agent state is the zero-value record (finding #10): a never-seen key
4699// reads `0` / `false` / `""` / `None` rather than `undefined`. A type is
4700// *zeroable* when it has a defined zero; agent state fields must be zeroable,
4701// since a fresh key has no committed value to load. Non-zeroable fields (a
4702// non-Option sum, an opaque type, or a refined type whose refinement excludes
4703// the underlying zero) are a compile error until explicit-initialiser syntax
4704// lands.
4705
4706#[cfg(test)]
4707mod generics_tests {
4708 use super::*;
4709
4710 fn var(tys: &Types, n: &str) -> TyId {
4711 tys.intern(Ty::Var(n.to_string()))
4712 }
4713 fn int(tys: &Types) -> TyId {
4714 tys.intern(Ty::Base(BaseType::Int))
4715 }
4716 fn string(tys: &Types) -> TyId {
4717 tys.intern(Ty::Base(BaseType::String))
4718 }
4719
4720 #[test]
4721 fn unify_binds_and_holds() {
4722 let tys = &Types::new();
4723 let mut s = HashMap::new();
4724 assert!(unify(var(tys, "A"), int(tys), &mut s, tys));
4725 assert_eq!(s.get("A"), Some(&int(tys)));
4726 // Same binding again: fine. A different one: conflict.
4727 assert!(unify(var(tys, "A"), int(tys), &mut s, tys));
4728 assert!(!unify(var(tys, "A"), string(tys), &mut s, tys));
4729 }
4730
4731 #[test]
4732 fn unify_walks_structure() {
4733 let tys = &Types::new();
4734 let mut s = HashMap::new();
4735 let pattern = tys.intern(Ty::Fn {
4736 params: vec![var(tys, "A")],
4737 ret: tys.intern(Ty::Effect(var(tys, "B"))),
4738 });
4739 let actual = tys.intern(Ty::Fn {
4740 params: vec![int(tys)],
4741 ret: tys.intern(Ty::Effect(string(tys))),
4742 });
4743 assert!(unify(pattern, actual, &mut s, tys));
4744 assert_eq!(s.get("A"), Some(&int(tys)));
4745 assert_eq!(s.get("B"), Some(&string(tys)));
4746 }
4747
4748 #[test]
4749 fn substitute_grounds_fully() {
4750 let tys = &Types::new();
4751 let mut s = HashMap::new();
4752 s.insert("A".to_string(), int(tys));
4753 let inner = tys.intern(Ty::Fn {
4754 params: vec![var(tys, "A")],
4755 ret: var(tys, "A"),
4756 });
4757 let t = tys.intern(Ty::Option(inner));
4758 let g = substitute(t, &s, tys);
4759 assert!(!contains_var(g, tys));
4760 }
4761
4762 /// The §2 invariant (pinned per the plan): every expected-driven feature
4763 /// in `type_of` matches *concrete* `Ty` variants, so a Var-bearing
4764 /// expected imposes no constraint — `compatible` must simply reject
4765 /// Var-vs-ground pairs rather than panic or accept.
4766 #[test]
4767 fn var_bearing_expected_is_benign() {
4768 let tys = &Types::new();
4769 assert!(!compatible(int(tys), var(tys, "A"), tys));
4770 assert!(!compatible(var(tys, "A"), int(tys), tys));
4771 // Rigid vars: name equality only.
4772 assert!(compatible(var(tys, "A"), var(tys, "A"), tys));
4773 assert!(!compatible(var(tys, "A"), var(tys, "B"), tys));
4774 }
4775}
4776
4777/// T3.6b (R4.1/R4.2): the interner's dedup property, and the `Hash`/`Eq`/`Ord`
4778/// it rests on.
4779///
4780/// T3.6a shipped the derives; these tests (added while scoping T3.6b, see the
4781/// identity-and-totality track doc §9) pinned the property `intern` would
4782/// depend on before it existed. Now that it does, they pin it directly: two
4783/// types built through *different construction paths* but structurally
4784/// identical must intern to the **same** `TyId` (or the checker's `TyId`
4785/// equality — which `unify` and `Ty::Map`'s key comparison now rely on —
4786/// would be unsound), and structurally different types must never collide.
4787///
4788/// Dedup being by the *shallow* `Ty` is exactly what makes this work: every
4789/// recursive field is already a `TyId`, so equal children imply equal parents
4790/// by induction. The nested cases below are what test that induction.
4791#[cfg(test)]
4792mod ty_hash_eq_ord_tests {
4793 use super::*;
4794 use std::collections::HashSet;
4795 use std::collections::hash_map::DefaultHasher;
4796 use std::hash::{Hash, Hasher};
4797
4798 fn hash_of(ty: &Ty) -> u64 {
4799 let mut h = DefaultHasher::new();
4800 ty.hash(&mut h);
4801 h.finish()
4802 }
4803
4804 /// `map_entry_ty` (a real constructor) vs. the raw `Ty::Named` literal it
4805 /// builds — two different construction paths for the same type.
4806 #[test]
4807 fn map_entry_ty_matches_its_own_raw_literal() {
4808 let tys = &Types::new();
4809 let int = tys.intern(Ty::Base(BaseType::Int));
4810 let string = tys.intern(Ty::Base(BaseType::String));
4811 let via_constructor = map_entry_ty(int, string, tys);
4812 let via_literal = tys.intern(Ty::Named {
4813 name: MAP_ENTRY.to_string(),
4814 kind: NamedKind::Record,
4815 args: vec![int, string],
4816 });
4817 assert_eq!(via_constructor, via_literal);
4818 assert_eq!(
4819 hash_of(&tys.get(via_constructor)),
4820 hash_of(&tys.get(via_literal))
4821 );
4822 }
4823
4824 /// A deeply nested type built two separate times interns to one `TyId` —
4825 /// the property `unify`'s "matches its prior binding exactly" now rests on.
4826 #[test]
4827 fn structurally_identical_nested_types_intern_to_one_id() {
4828 let tys = &Types::new();
4829 let build = || {
4830 let int = tys.intern(Ty::Base(BaseType::Int));
4831 let opt = tys.intern(Ty::Option(int));
4832 let list = tys.intern(Ty::List(opt));
4833 let string = tys.intern(Ty::Base(BaseType::String));
4834 tys.intern(Ty::Map(string, list))
4835 };
4836 let a = build();
4837 let before = tys.len();
4838 let b = build();
4839 assert_eq!(a, b);
4840 // Re-building it added nothing: every node was already interned.
4841 assert_eq!(tys.len(), before);
4842 assert_eq!(hash_of(&tys.get(a)), hash_of(&tys.get(b)));
4843 }
4844
4845 /// Structurally different types must get distinct ids — the flip side of
4846 /// the dedup property above.
4847 #[test]
4848 fn structurally_different_types_get_distinct_ids() {
4849 let tys = &Types::new();
4850 let int = tys.intern(Ty::Base(BaseType::Int));
4851 let string = tys.intern(Ty::Base(BaseType::String));
4852 let ids = HashSet::from([
4853 tys.intern(Ty::List(int)),
4854 tys.intern(Ty::List(string)),
4855 tys.intern(Ty::Option(int)),
4856 ]);
4857 assert_eq!(ids.len(), 3);
4858 }
4859
4860 /// Interning the same type repeatedly grows the table exactly once.
4861 #[test]
4862 fn repeated_interning_grows_the_table_once() {
4863 let tys = &Types::new();
4864 let int = tys.intern(Ty::Base(BaseType::Int));
4865 let bool_ = tys.intern(Ty::Base(BaseType::Bool));
4866 let before = tys.len();
4867 let ids: HashSet<TyId> = (0..3).map(|_| map_entry_ty(int, bool_, tys)).collect();
4868 assert_eq!(ids.len(), 1);
4869 assert_eq!(tys.len(), before + 1);
4870 }
4871
4872 /// `resolve`-round-trip: an id resolves to the node it was minted from.
4873 #[test]
4874 fn an_id_resolves_to_the_node_it_was_interned_from() {
4875 let tys = &Types::new();
4876 let int = tys.intern(Ty::Base(BaseType::Int));
4877 assert_eq!(&*tys.get(int), &Ty::Base(BaseType::Int));
4878 let list = tys.intern(Ty::List(int));
4879 assert_eq!(&*tys.get(list), &Ty::List(int));
4880 }
4881
4882 /// A `TyId` is only meaningful in the table it was minted from — the one
4883 /// new failure mode interning introduces. It fails loudly and by name;
4884 /// pinned so the diagnosis stays cheap for the next reader who wires two
4885 /// tables together by accident.
4886 ///
4887 /// This is the *shorter*-table shape, which both migration bugs had and
4888 /// which a bounds check alone catches. Its sibling below is the shape
4889 /// that actually needs the tag.
4890 #[test]
4891 #[should_panic(expected = "resolved against a table it was not interned into")]
4892 fn an_id_from_another_table_is_a_named_panic_not_a_silent_wrong_answer() {
4893 let a = Types::new();
4894 let b = Types::new();
4895 let id = a.intern(Ty::Base(BaseType::Int));
4896 let _ = b.get(id);
4897 }
4898
4899 /// The sharp edge of the same guard: a foreign id whose index is merely
4900 /// *in range*. Before `TyId` carried its table's tag this returned an
4901 /// unrelated `Ty` — here, `Bool` for an id minted from `Int` — and the
4902 /// caller went on to mis-diagnose or mis-emit with no panic at all. The
4903 /// bounds check cannot see this one, so it is the case worth pinning.
4904 ///
4905 /// Debug-only, because that is where the tag exists; a release build
4906 /// still has the length check the sibling above covers.
4907 #[cfg(debug_assertions)]
4908 #[test]
4909 #[should_panic(expected = "resolved against a table it was not interned into")]
4910 fn an_in_range_id_from_another_table_panics_rather_than_resolving_wrongly() {
4911 let a = Types::new();
4912 let b = Types::new();
4913 let id = a.intern(Ty::Base(BaseType::Int));
4914 b.intern(Ty::Base(BaseType::Bool));
4915 assert_eq!(a.len(), b.len(), "the index must be in range for b");
4916 let _ = b.get(id);
4917 }
4918
4919 /// T3.6b's own soundness guard: `compatible` is deliberately **not**
4920 /// reflexive for the sealed boundary values, so interning must not be
4921 /// short-circuited on id equality. Pinned here because the fast path is
4922 /// the obvious "optimisation" a later reader would add.
4923 #[test]
4924 fn compatible_is_not_reflexive_for_sealed_actor_types() {
4925 let tys = &Types::new();
4926 let id_ty = tys.intern(Ty::Base(BaseType::String));
4927 let actor = tys.intern(Ty::Actor(id_ty));
4928 assert!(!compatible(actor, actor, tys));
4929 let sum = tys.intern(Ty::ActorSum(vec![("User".to_string(), id_ty)]));
4930 assert!(!compatible(sum, sum, tys));
4931 // …while an ordinary type still is.
4932 assert!(compatible(id_ty, id_ty, tys));
4933 }
4934}
4935
4936/// Characterization pins for `checker.rs`'s pure free functions (v0.29.10
4937/// slice 0). These pin *current* behaviour ahead of the upcoming module split
4938/// so the verbatim moves are verifiable. Any surprising behaviour is pinned
4939/// as-is, flagged with a comment — these are not specifications.
4940#[cfg(test)]
4941mod pure_helper_pins {
4942 use super::*;
4943 use bynk_syntax::ast::{FloatBound, RefinementPred};
4944
4945 // -- small constructors ------------------------------------------------
4946
4947 fn sp() -> Span {
4948 Span::new(0, 0)
4949 }
4950 fn ident(n: &str) -> Ident {
4951 Ident {
4952 name: n.to_string(),
4953 span: sp(),
4954 }
4955 }
4956 fn var(tys: &Types, n: &str) -> TyId {
4957 tys.intern(Ty::Var(n.to_string()))
4958 }
4959 fn int(tys: &Types) -> TyId {
4960 tys.intern(Ty::Base(BaseType::Int))
4961 }
4962 fn string(tys: &Types) -> TyId {
4963 tys.intern(Ty::Base(BaseType::String))
4964 }
4965 fn expr(kind: ExprKind) -> Expr {
4966 Expr {
4967 id: ExprId::SYNTHETIC,
4968 kind,
4969 span: sp(),
4970 }
4971 }
4972 fn pred(kind: PredKind) -> RefinementPred {
4973 RefinementPred { kind, span: sp() }
4974 }
4975 fn refinement(preds: Vec<PredKind>) -> Refinement {
4976 Refinement {
4977 predicates: preds.into_iter().map(pred).collect(),
4978 span: sp(),
4979 }
4980 }
4981 fn fbound(value: f64) -> FloatBound {
4982 FloatBound {
4983 value,
4984 lexeme: value.to_string(),
4985 span: Span::new(0, 0),
4986 }
4987 }
4988 fn ibound(value: i64) -> IntBound {
4989 IntBound {
4990 value,
4991 span: Span::new(0, 0),
4992 }
4993 }
4994 /// An `InRange` predicate from two int values (test convenience).
4995 fn in_range(lo: i64, hi: i64) -> PredKind {
4996 PredKind::InRange(ibound(lo), ibound(hi))
4997 }
4998 fn refined_decl(name: &str, base: BaseType, refinement: Option<Refinement>) -> TypeDecl {
4999 TypeDecl {
5000 name: ident(name),
5001 type_params: Vec::new(),
5002 body: TypeBody::Refined {
5003 base,
5004 base_span: sp(),
5005 refinement,
5006 },
5007 documentation: None,
5008 span: sp(),
5009 trivia: bynk_syntax::ast::Trivia::default(),
5010 }
5011 }
5012 fn record_decl(name: &str) -> TypeDecl {
5013 TypeDecl {
5014 name: ident(name),
5015 type_params: Vec::new(),
5016 body: TypeBody::Record(bynk_syntax::ast::RecordBody {
5017 trailing_comments: Default::default(),
5018 fields: vec![],
5019 span: sp(),
5020 }),
5021 documentation: None,
5022 span: sp(),
5023 trivia: bynk_syntax::ast::Trivia::default(),
5024 }
5025 }
5026
5027 // -- unify -------------------------------------------------------------
5028
5029 #[test]
5030 fn unify_identical_concrete_types() {
5031 let tys = &Types::new();
5032 let mut s = HashMap::new();
5033 assert!(unify(int(tys), int(tys), &mut s, tys));
5034 assert!(s.is_empty());
5035 }
5036
5037 #[test]
5038 fn unify_var_binds_in_subst() {
5039 let tys = &Types::new();
5040 let mut s = HashMap::new();
5041 assert!(unify(var(tys, "A"), string(tys), &mut s, tys));
5042 assert_eq!(s.get("A"), Some(&string(tys)));
5043 }
5044
5045 #[test]
5046 fn unify_nested_generic_binds() {
5047 let tys = &Types::new();
5048 // List[A] vs List[Int] binds A := Int.
5049 let mut s = HashMap::new();
5050 let pat = tys.intern(Ty::List(var(tys, "A")));
5051 let act = tys.intern(Ty::List(int(tys)));
5052 assert!(unify(pat, act, &mut s, tys));
5053 assert_eq!(s.get("A"), Some(&int(tys)));
5054 }
5055
5056 #[test]
5057 fn unify_surprise_concrete_mismatch_returns_true() {
5058 let tys = &Types::new();
5059 // SURPRISING (pinned as-is): `unify`'s catch-all is `_ => true`, so a
5060 // ground-vs-ground mismatch (Int vs String) and a constructor mismatch
5061 // (List vs Option) both *succeed* here — `compatible` owns those
5062 // diagnostics post-substitution, not `unify`.
5063 let mut s = HashMap::new();
5064 assert!(unify(int(tys), string(tys), &mut s, tys));
5065 assert!(unify(
5066 tys.intern(Ty::List(int(tys))),
5067 tys.intern(Ty::Option(int(tys))),
5068 &mut s,
5069 tys
5070 ));
5071 // The only false paths: a Var rebind conflict and an Fn arity mismatch.
5072 let mut s2 = HashMap::new();
5073 assert!(unify(var(tys, "A"), int(tys), &mut s2, tys));
5074 assert!(!unify(var(tys, "A"), string(tys), &mut s2, tys));
5075 let mut s3 = HashMap::new();
5076 let f1 = tys.intern(Ty::Fn {
5077 params: vec![int(tys)],
5078 ret: int(tys),
5079 });
5080 let f2 = tys.intern(Ty::Fn {
5081 params: vec![int(tys), int(tys)],
5082 ret: int(tys),
5083 });
5084 assert!(!unify(f1, f2, &mut s3, tys));
5085 }
5086
5087 // -- substitute --------------------------------------------------------
5088
5089 #[test]
5090 fn substitute_replaces_bound_var() {
5091 let tys = &Types::new();
5092 let mut s = HashMap::new();
5093 s.insert("A".to_string(), int(tys));
5094 assert_eq!(substitute(var(tys, "A"), &s, tys), int(tys));
5095 }
5096
5097 #[test]
5098 fn substitute_recurses_into_nested() {
5099 let tys = &Types::new();
5100 let mut s = HashMap::new();
5101 s.insert("A".to_string(), string(tys));
5102 let t = tys.intern(Ty::Map(var(tys, "A"), int(tys)));
5103 assert_eq!(
5104 substitute(t, &s, tys),
5105 tys.intern(Ty::Map(string(tys), int(tys))),
5106 );
5107 }
5108
5109 #[test]
5110 fn substitute_leaves_unbound_var_alone() {
5111 let tys = &Types::new();
5112 let s = HashMap::new();
5113 assert_eq!(substitute(var(tys, "Z"), &s, tys), var(tys, "Z"));
5114 }
5115
5116 // -- contains_var / contains_flexible_var ------------------------------
5117
5118 #[test]
5119 fn contains_var_positive_and_negative() {
5120 let tys = &Types::new();
5121 assert!(contains_var(tys.intern(Ty::Option(var(tys, "A"))), tys));
5122 assert!(!contains_var(tys.intern(Ty::Option(int(tys))), tys));
5123 assert!(!contains_var(int(tys), tys));
5124 }
5125
5126 #[test]
5127 fn contains_flexible_var_respects_rigid_set() {
5128 let tys = &Types::new();
5129 let mut rigid = HashSet::new();
5130 rigid.insert("A".to_string());
5131 // A is rigid → not flexible.
5132 assert!(!contains_flexible_var(var(tys, "A"), &rigid, tys));
5133 // B is not rigid → flexible.
5134 assert!(contains_flexible_var(var(tys, "B"), &rigid, tys));
5135 // No vars at all → not flexible.
5136 assert!(!contains_flexible_var(int(tys), &rigid, tys));
5137 }
5138
5139 // -- peel_to_* ---------------------------------------------------------
5140
5141 #[test]
5142 fn peel_to_result_matches_and_misses() {
5143 let tys = &Types::new();
5144 let r = tys.intern(Ty::Result(int(tys), string(tys)));
5145 assert_eq!(peel_to_result(r, tys), Some((int(tys), string(tys))));
5146 assert_eq!(peel_to_result(int(tys), tys), None);
5147 // Pinned: peels through Effect[_].
5148 assert_eq!(
5149 peel_to_result(tys.intern(Ty::Effect(r)), tys),
5150 Some((int(tys), string(tys)))
5151 );
5152 }
5153
5154 #[test]
5155 fn peel_to_option_matches_and_misses() {
5156 let tys = &Types::new();
5157 assert_eq!(
5158 peel_to_option(tys.intern(Ty::Option(int(tys))), tys),
5159 Some(int(tys))
5160 );
5161 assert_eq!(peel_to_option(int(tys), tys), None);
5162 }
5163
5164 #[test]
5165 fn peel_to_list_matches_and_misses() {
5166 let tys = &Types::new();
5167 assert_eq!(
5168 peel_to_list(tys.intern(Ty::List(string(tys))), tys),
5169 Some(string(tys))
5170 );
5171 assert_eq!(peel_to_list(int(tys), tys), None);
5172 }
5173
5174 #[test]
5175 fn peel_to_map_matches_and_misses() {
5176 let tys = &Types::new();
5177 let m = tys.intern(Ty::Map(string(tys), int(tys)));
5178 assert_eq!(peel_to_map(m, tys), Some((string(tys), int(tys))));
5179 assert_eq!(peel_to_map(int(tys), tys), None);
5180 }
5181
5182 #[test]
5183 fn peel_to_http_result_matches_and_misses() {
5184 let tys = &Types::new();
5185 assert_eq!(
5186 peel_to_http_result(tys.intern(Ty::HttpResult(int(tys))), tys),
5187 Some(int(tys)),
5188 );
5189 assert_eq!(peel_to_http_result(int(tys), tys), None);
5190 }
5191
5192 // -- maybe_auto_lift ---------------------------------------------------
5193
5194 #[test]
5195 fn maybe_auto_lift_lifts_into_expected_effect() {
5196 let tys = &Types::new();
5197 // T lifts to Effect[T] when expected is Effect[T] and T is not effectful.
5198 let expected = tys.intern(Ty::Effect(int(tys)));
5199 let lifted = maybe_auto_lift(Some(int(tys)), Some(expected), tys);
5200 assert_eq!(lifted, Some(tys.intern(Ty::Effect(int(tys)))));
5201 }
5202
5203 #[test]
5204 fn maybe_auto_lift_leaves_non_matching_alone() {
5205 let tys = &Types::new();
5206 // Already Effect[_]: untouched.
5207 let expected = tys.intern(Ty::Effect(int(tys)));
5208 assert_eq!(
5209 maybe_auto_lift(Some(tys.intern(Ty::Effect(int(tys)))), Some(expected), tys),
5210 Some(tys.intern(Ty::Effect(int(tys)))),
5211 );
5212 // Expected not an Effect: untouched.
5213 assert_eq!(
5214 maybe_auto_lift(Some(int(tys)), Some(int(tys)), tys),
5215 Some(int(tys))
5216 );
5217 // None type: untouched.
5218 assert_eq!(maybe_auto_lift(None, Some(expected), tys), None);
5219 }
5220
5221 // -- const_literal -----------------------------------------------------
5222
5223 #[test]
5224 fn const_literal_extracts_literals() {
5225 assert!(matches!(
5226 const_literal(&expr(ExprKind::int_lit(7))),
5227 Some(ConstLit::Int(7)),
5228 ));
5229 assert!(matches!(
5230 const_literal(&expr(ExprKind::BoolLit(true))),
5231 Some(ConstLit::Bool(true)),
5232 ));
5233 assert!(matches!(
5234 const_literal(&expr(ExprKind::StrLit("hi".into()))),
5235 Some(ConstLit::Str(s)) if s == "hi",
5236 ));
5237 assert!(matches!(
5238 const_literal(&expr(ExprKind::FloatLit {
5239 value: 1.5,
5240 lexeme: "1.5".into(),
5241 })),
5242 Some(ConstLit::Float(_)),
5243 ));
5244 // Unary-neg on an int literal folds.
5245 let neg = expr(ExprKind::UnaryOp(
5246 UnaryOp::Neg,
5247 Box::new(expr(ExprKind::int_lit(3))),
5248 ));
5249 assert!(matches!(const_literal(&neg), Some(ConstLit::Int(-3))));
5250 }
5251
5252 #[test]
5253 fn const_literal_rejects_non_literals() {
5254 assert!(const_literal(&expr(ExprKind::Ident(ident("x")))).is_none());
5255 }
5256
5257 // -- eval_predicate ----------------------------------------------------
5258
5259 #[test]
5260 fn eval_predicate_int_and_float() {
5261 assert!(eval_predicate(&PredKind::NonNegative, &ConstLit::Int(0)));
5262 assert!(!eval_predicate(&PredKind::NonNegative, &ConstLit::Int(-1)));
5263 assert!(eval_predicate(&PredKind::Positive, &ConstLit::Int(1)));
5264 assert!(!eval_predicate(&PredKind::Positive, &ConstLit::Int(0)));
5265 assert!(eval_predicate(&in_range(1, 10), &ConstLit::Int(5),));
5266 assert!(!eval_predicate(&in_range(1, 10), &ConstLit::Int(11),));
5267 }
5268
5269 #[test]
5270 fn eval_predicate_string() {
5271 assert!(eval_predicate(
5272 &PredKind::MinLength(2),
5273 &ConstLit::Str("ab".into()),
5274 ));
5275 assert!(!eval_predicate(
5276 &PredKind::MinLength(3),
5277 &ConstLit::Str("ab".into()),
5278 ));
5279 assert!(eval_predicate(
5280 &PredKind::NonEmpty,
5281 &ConstLit::Str("x".into()),
5282 ));
5283 assert!(!eval_predicate(
5284 &PredKind::NonEmpty,
5285 &ConstLit::Str(String::new()),
5286 ));
5287 assert!(eval_predicate(
5288 &PredKind::Matches("[a-z]+".into()),
5289 &ConstLit::Str("abc".into()),
5290 ));
5291 assert!(!eval_predicate(
5292 &PredKind::Matches("[a-z]+".into()),
5293 &ConstLit::Str("ABC".into()),
5294 ));
5295 }
5296
5297 #[test]
5298 fn eval_predicate_base_mismatch_is_vacuously_true() {
5299 // SURPRISING (pinned as-is): a predicate/literal base mismatch returns
5300 // `true` — base/predicate mismatch is a declaration-time error reported
5301 // elsewhere, not by construction-time eval.
5302 assert!(eval_predicate(&PredKind::MinLength(5), &ConstLit::Int(0),));
5303 }
5304
5305 // -- literal_matches_base ----------------------------------------------
5306
5307 #[test]
5308 fn literal_matches_base_pairs() {
5309 assert!(literal_matches_base(&ConstLit::Int(1), BaseType::Int));
5310 assert!(literal_matches_base(
5311 &ConstLit::Str("x".into()),
5312 BaseType::String,
5313 ));
5314 assert!(!literal_matches_base(&ConstLit::Int(1), BaseType::String));
5315 assert!(!literal_matches_base(&ConstLit::Unit, BaseType::Int));
5316 }
5317
5318 // -- type_decl_base / type_decl_refinement -----------------------------
5319
5320 #[test]
5321 fn type_decl_base_refined_vs_record() {
5322 let refined = refined_decl("Age", BaseType::Int, None);
5323 assert_eq!(type_decl_base(&refined), Some(BaseType::Int));
5324 assert_eq!(type_decl_base(&record_decl("Pt")), None);
5325 }
5326
5327 #[test]
5328 fn type_decl_refinement_present_vs_absent() {
5329 let with = refined_decl(
5330 "Age",
5331 BaseType::Int,
5332 Some(refinement(vec![PredKind::Positive])),
5333 );
5334 assert!(type_decl_refinement(&with).is_some());
5335 let without = refined_decl("Raw", BaseType::Int, None);
5336 assert!(type_decl_refinement(&without).is_none());
5337 assert!(type_decl_refinement(&record_decl("Pt")).is_none());
5338 }
5339
5340 // -- check_*_refinement_consistency ------------------------------------
5341
5342 #[test]
5343 fn int_refinement_consistency() {
5344 // Consistent: 1..=10 with Positive — no error.
5345 let mut errs = vec![];
5346 check_int_refinement_consistency(
5347 &refinement(vec![PredKind::Positive, in_range(1, 10)]),
5348 &mut errs,
5349 );
5350 assert!(errs.is_empty());
5351 // Inconsistent: InRange(10, 1) is empty → exactly one error.
5352 let mut errs = vec![];
5353 check_int_refinement_consistency(&refinement(vec![in_range(10, 1)]), &mut errs);
5354 assert_eq!(errs.len(), 1);
5355 assert_eq!(errs[0].category, "bynk.types.empty_refinement");
5356 }
5357
5358 #[test]
5359 fn float_refinement_consistency() {
5360 // Consistent range.
5361 let mut errs = vec![];
5362 check_float_refinement_consistency(
5363 &refinement(vec![PredKind::InRangeF(fbound(0.0), fbound(1.0))]),
5364 &mut errs,
5365 );
5366 assert!(errs.is_empty());
5367 // Empty: 5.0..=1.0 → one error.
5368 let mut errs = vec![];
5369 check_float_refinement_consistency(
5370 &refinement(vec![PredKind::InRangeF(fbound(5.0), fbound(1.0))]),
5371 &mut errs,
5372 );
5373 assert_eq!(errs.len(), 1);
5374 assert_eq!(errs[0].category, "bynk.types.empty_refinement");
5375 // Degenerate-but-exclusive: Positive with InRangeF(0.0, 0.0) → lo==hi
5376 // and lo_exclusive → one error.
5377 let mut errs = vec![];
5378 check_float_refinement_consistency(
5379 &refinement(vec![
5380 PredKind::Positive,
5381 PredKind::InRangeF(fbound(0.0), fbound(0.0)),
5382 ]),
5383 &mut errs,
5384 );
5385 assert_eq!(errs.len(), 1);
5386 }
5387
5388 #[test]
5389 fn string_refinement_consistency() {
5390 // Consistent: MinLength(1), MaxLength(10).
5391 let mut errs = vec![];
5392 check_string_refinement_consistency(
5393 &refinement(vec![PredKind::MinLength(1), PredKind::MaxLength(10)]),
5394 &mut errs,
5395 );
5396 assert!(errs.is_empty());
5397 // min > max → one error.
5398 let mut errs = vec![];
5399 check_string_refinement_consistency(
5400 &refinement(vec![PredKind::MinLength(10), PredKind::MaxLength(2)]),
5401 &mut errs,
5402 );
5403 assert_eq!(errs.len(), 1);
5404 assert_eq!(errs[0].category, "bynk.types.empty_refinement");
5405 // Conflicting exact lengths → TWO errors (pinned as-is): the explicit
5406 // `Length(3)`/`Length(5)` conflict push, *plus* the subsequent
5407 // min_len(5) > max_len(3) empty-range push (each `Length` clamps both
5408 // bounds to itself).
5409 let mut errs = vec![];
5410 check_string_refinement_consistency(
5411 &refinement(vec![PredKind::Length(3), PredKind::Length(5)]),
5412 &mut errs,
5413 );
5414 assert_eq!(errs.len(), 2);
5415 assert!(
5416 errs.iter()
5417 .all(|e| e.category == "bynk.types.empty_refinement")
5418 );
5419 }
5420
5421 /// R12.2/T1.8: `NonEmpty` folds to `MinLength(1)` in `contract::canon_predicate`,
5422 /// so `refinements_match` — which routes through that same canonical form —
5423 /// must now treat the two spellings as the same refinement. Before the fold
5424 /// this was `false`.
5425 #[test]
5426 fn refinements_match_treats_non_empty_and_min_length_one_as_equal() {
5427 let a = refinement(vec![PredKind::NonEmpty]);
5428 let b = refinement(vec![PredKind::MinLength(1)]);
5429 assert!(refinements_match(Some(&a), Some(&b)));
5430 // …and the fold is exactly `MinLength(1)`, not a subsumption rule: a
5431 // strictly tighter refinement must still fail to match. Without this,
5432 // widening the fold into "`MinLength(2)` implies `MinLength(1)`, so
5433 // admit it" would leave every assertion in T1.8 passing while a
5434 // genuinely tighter callee refinement is accepted across a boundary.
5435 let c = refinement(vec![PredKind::MinLength(2)]);
5436 assert!(!refinements_match(Some(&a), Some(&c)));
5437 assert!(!refinements_match(Some(&c), Some(&a)));
5438 }
5439
5440 // -- numeric_mix -------------------------------------------------------
5441
5442 #[test]
5443 fn numeric_mix_int_float_pairs() {
5444 assert!(numeric_mix(Some(BaseType::Int), Some(BaseType::Float)));
5445 assert!(numeric_mix(Some(BaseType::Float), Some(BaseType::Int)));
5446 assert!(!numeric_mix(Some(BaseType::Int), Some(BaseType::Int)));
5447 assert!(!numeric_mix(Some(BaseType::Float), Some(BaseType::Float)));
5448 assert!(!numeric_mix(None, Some(BaseType::Int)));
5449 }
5450}