Skip to main content

bynk_check/
checker.rs

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