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