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