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