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