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