Skip to main content

bynk_check/
checker.rs

1//! Type checker and refinement validator (spec §§5–6, v0.1 §4.2, v0.2 §4.2).
2//!
3//! Operates on a [`ResolvedCommons`]. Walks declarations, validates each
4//! refinement against the spec's predicate-base compatibility and combination
5//! rules, then type-checks every function and method body.
6//!
7//! v0.2 extensions:
8//! - Record types (compatibility, field access, construction).
9//! - Sum types and variant construction (qualified and unqualified).
10//! - Methods (instance and static) with UFCS-style call resolution.
11//! - Pattern matching with exhaustiveness checking.
12//! - The `is` operator with binding flow into truthy contexts.
13//! - The built-in generic `Option[T]`.
14
15use std::collections::{HashMap, HashSet};
16
17use regex::Regex;
18
19use crate::builtin_names::methods::*;
20use crate::builtin_names::types::*;
21use crate::hints::HintSink;
22use crate::index::{RefSink, SymbolKind};
23use crate::locals::LocalsSink;
24use crate::requirements::{
25    Materialize, Requirement, RequirementSink, RequirementSource, StoreKind,
26};
27use crate::resolver::{MethodTable, ResolvedCommons};
28use bynk_syntax::ast::*;
29use bynk_syntax::error::{Applicability, CompileError};
30use bynk_syntax::span::Span;
31
32mod calls;
33mod expressions;
34mod kernels;
35mod linearity;
36mod refinements;
37
38use calls::*;
39use expressions::*;
40use kernels::*;
41use refinements::*;
42
43pub use calls::check_state_initialiser;
44pub use refinements::zero_value_ts;
45
46// ==== Type representation ====
47
48/// A resolved type.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Ty {
51    /// A base type (`Int`, `String`, `Bool`).
52    Base(BaseType),
53    /// A user-declared named type. `kind` records the declaration's shape
54    /// for compatibility / dispatch decisions.
55    Named { name: String, kind: NamedKind },
56    /// `Result[T, E]`.
57    Result(Box<Ty>, Box<Ty>),
58    /// `Option[T]`.
59    Option(Box<Ty>),
60    /// `Effect[T]` (v0.5).
61    Effect(Box<Ty>),
62    /// `HttpResult[T]` (v0.9).
63    HttpResult(Box<Ty>),
64    /// `QueueResult` — the built-in queue verdict sum (v0.44). Non-generic.
65    QueueResult,
66    /// `List[T]` — built-in immutable list (v0.20b).
67    List(Box<Ty>),
68    /// `Map[K, V]` — built-in immutable map (v0.20b). The key type is
69    /// confined to value-keyable types at TypeRef resolution.
70    Map(Box<Ty>, Box<Ty>),
71    /// `Query[T]` — a lazy, by-reference description of a read over agent-local
72    /// storage (v0.91, ADR 0115). The inner type is the element a terminal
73    /// yields. Built by the lazy combinator vocabulary over a `store` field,
74    /// executed by a terminal (`-> Effect[…]`). Non-storable, non-boundary, and
75    /// not value-comparable — like `Effect`/`Fn` (ADRs 0031/0030).
76    Query(Box<Ty>),
77    /// `Stream[T]` — a lazy, pull-shaped sequence of values produced over time
78    /// (v0.100, real-time track slice 0). The inner type is the element a
79    /// terminal yields. Built from a runtime source (`Stream.of` at v1),
80    /// transformed by lazy builders (`map`/`take`), drained by a terminal
81    /// (`collect -> Effect[List[T]]`). Non-storable, non-boundary, and not
82    /// value-comparable — like `Query`/`Effect`/`Fn` (ADRs 0031/0030).
83    Stream(Box<Ty>),
84    /// `Connection[F]` — a held WebSocket connection (v0.102, real-time track
85    /// slice 2). `F` is the server→client frame type. The one concrete instance
86    /// of the closed `Held` kind (`is_held`). Governed by the linearity
87    /// discipline (§2.9): single-owner, mandatory disposal. Non-serialisable,
88    /// non-boundary, non-comparable; storable only in `Cell[Option[Connection]]`
89    /// / `Map[K, Connection]`.
90    Connection(Box<Ty>),
91    /// `ValidationError` — built-in error type.
92    ValidationError,
93    /// `JsonError` — built-in JSON-decode error type (v0.22b). A uniform
94    /// record: `kind`/`path`/`message`, all `String`.
95    JsonError,
96    /// `()` — the unit type (v0.5).
97    Unit,
98    /// v0.45: a verified actor binding (`by name: Actor`). The inner type is
99    /// the actor's identity, read as `name.identity`. A boundary-minted, sealed
100    /// value — only ever `.identity`-accessed, never constructed or passed.
101    Actor(Box<Ty>),
102    /// v0.52: a resolved multi-actor binding (`by who: A | B`) — an ordered sum
103    /// of peer actors. Each member is `(actor name, identity ty)`; the body
104    /// `match`es on the resolved actor, each non-unit member binding its
105    /// identity directly. Like `Actor`, a sealed boundary value — only ever
106    /// matched, never constructed or passed.
107    ActorSum(Vec<(String, Ty)>),
108    /// `A -> B` — a function type (v0.20a). Effectful iff `ret` is
109    /// `Effect[_]` (the structural rule); no separate flag, so there is a
110    /// single source of truth.
111    Fn { params: Vec<Ty>, ret: Box<Ty> },
112    /// A function type parameter (v0.20a). Two lives: *rigid* while checking
113    /// a generic function's own body (name-equality in `compatible`), and
114    /// *flexible* during call-site instantiation, where it is matched by
115    /// `unify` and fully eliminated by `substitute` before any `compatible`
116    /// runs against argument types. Vars never escape call checking into the
117    /// caller's expression types.
118    Var(String),
119}
120
121/// The shape of a named type — what its declaration looks like.
122///
123/// `Refined` widens to its base type when used in arithmetic, comparisons,
124/// and other operations on the base. `Opaque` does NOT widen — its identity
125/// is nominal and the base type is hidden outside the defining commons.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum NamedKind {
128    /// Refined-base type: widens to the recorded base.
129    Refined(BaseType),
130    /// Record type.
131    Record,
132    /// Sum type.
133    Sum,
134    /// Opaque base type. The base is hidden; identity is purely nominal.
135    /// The recorded base is used by the type checker (for `.raw`, `.of`,
136    /// `.unsafe`) and by the emitter, but not for compatibility widening.
137    Opaque(BaseType),
138}
139
140impl Ty {
141    /// Display name for diagnostics.
142    pub fn display(&self) -> String {
143        match self {
144            Ty::Base(b) => b.name().to_string(),
145            Ty::Named { name, .. } => name.clone(),
146            Ty::Result(t, e) => format!("Result[{}, {}]", t.display(), e.display()),
147            Ty::Option(t) => format!("Option[{}]", t.display()),
148            Ty::Effect(t) => format!("Effect[{}]", t.display()),
149            Ty::HttpResult(t) => format!("HttpResult[{}]", t.display()),
150            Ty::QueueResult => "QueueResult".to_string(),
151            Ty::List(t) => format!("List[{}]", t.display()),
152            Ty::Map(k, v) => format!("Map[{}, {}]", k.display(), v.display()),
153            Ty::Query(t) => format!("Query[{}]", t.display()),
154            Ty::Stream(t) => format!("Stream[{}]", t.display()),
155            Ty::Connection(t) => format!("Connection[{}]", t.display()),
156            Ty::ValidationError => "ValidationError".to_string(),
157            Ty::JsonError => "JsonError".to_string(),
158            Ty::Unit => "()".to_string(),
159            Ty::Actor(id) => format!("actor[{}]", id.display()),
160            Ty::ActorSum(members) => members
161                .iter()
162                .map(|(name, _)| name.clone())
163                .collect::<Vec<_>>()
164                .join(" | "),
165            Ty::Fn { params, ret } => {
166                let params = match params.len() {
167                    0 => "()".to_string(),
168                    // A single Fn-typed param needs parens to stay readable
169                    // under right-associativity.
170                    1 if !matches!(params[0], Ty::Fn { .. }) => params[0].display(),
171                    _ => format!(
172                        "({})",
173                        params
174                            .iter()
175                            .map(|p| p.display())
176                            .collect::<Vec<_>>()
177                            .join(", ")
178                    ),
179                };
180                format!("{params} -> {}", ret.display())
181            }
182            Ty::Var(name) => name.clone(),
183        }
184    }
185
186    /// True if this type is `Effect[_]`.
187    pub fn is_effect(&self) -> bool {
188        matches!(self, Ty::Effect(_))
189    }
190
191    /// v0.102: true if this type belongs to the closed `Held` kind — a
192    /// runtime-managed resource governed by the linearity discipline (§2.9).
193    /// The one instance at v1 is `Connection[F]`; the single extension point
194    /// for future held types (file handles, DB connections).
195    pub fn is_held(&self) -> bool {
196        matches!(self, Ty::Connection(_))
197    }
198
199    /// v0.102: for a `Held` type, the held element it wraps (the frame type of a
200    /// `Connection[F]`). Used by the storage-admission rules to look through an
201    /// `Option[Connection]` value.
202    pub fn held_inner(&self) -> Option<&Ty> {
203        match self {
204            Ty::Connection(t) => Some(t),
205            _ => None,
206        }
207    }
208
209    /// The underlying base type, if this type widens to a base type.
210    /// Opaque types deliberately do NOT widen — that's the whole point of
211    /// the opacity — so `Ty::Named { kind: Opaque(_), .. }` returns None.
212    pub fn base(&self) -> Option<BaseType> {
213        match self {
214            Ty::Base(b) => Some(*b),
215            Ty::Named {
216                kind: NamedKind::Refined(b),
217                ..
218            } => Some(*b),
219            _ => None,
220        }
221    }
222}
223
224/// Output of type checking.
225pub struct TypedCommons {
226    pub commons: Commons,
227    pub types: HashMap<String, TypeDecl>,
228    pub fns: HashMap<String, FnDecl>,
229    pub methods: HashMap<String, MethodTable>,
230    pub expr_types: HashMap<Span, Ty>,
231    /// v0.89 (ADR 0117): non-failing warnings produced while checking this unit
232    /// — surfaced but not gating. Empty unless a warning-category diagnostic
233    /// (e.g. `bynk.given.unused_capability`) fired on an otherwise-clean check.
234    pub warnings: Vec<CompileError>,
235}
236
237/// The outcome of [`check_record`]: the typed model (`Err` if the file had any
238/// error) and, on the error path, the best-effort partial `expr_types` the
239/// checker computed before bailing. Analyse mode surfaces that partial map for
240/// `.`-member completion and signature help even on a broken buffer (ADR 0094);
241/// on the Ok path the types live in the `TypedCommons`, so this is empty.
242pub struct RecordCheck {
243    pub result: Result<TypedCommons, Vec<CompileError>>,
244    pub partial_expr_types: HashMap<Span, Ty>,
245}
246
247// ==== Entry points ====
248
249pub fn check(input: ResolvedCommons) -> Result<TypedCommons, Vec<CompileError>> {
250    check_record(
251        input,
252        &mut RefSink::new(),
253        &mut HintSink::new(),
254        &mut LocalsSink::new(),
255        &mut RequirementSink::new(),
256    )
257    .result
258}
259
260/// [`check`], recording binding edges into `refs` at the checker's
261/// resolution sites (v0.25). A fresh sink records nothing.
262pub fn check_record(
263    input: ResolvedCommons,
264    refs: &mut RefSink,
265    hints: &mut HintSink,
266    locals: &mut LocalsSink,
267    requirements: &mut RequirementSink,
268) -> RecordCheck {
269    let mut errors = Vec::new();
270    let mut expr_types: HashMap<Span, Ty> = HashMap::new();
271
272    // 1. Validate each type declaration.
273    for item in &input.commons.items {
274        if let CommonsItem::Type(t) = item {
275            check_type_decl(t, &input.types, &mut errors);
276        }
277    }
278
279    // 2. Type-check each function and method body.
280    for item in &input.commons.items {
281        if let CommonsItem::Fn(f) = item {
282            refs.set_owner(f.name.display());
283            check_fn(
284                f,
285                &input,
286                &mut expr_types,
287                &mut errors,
288                refs,
289                hints,
290                locals,
291                requirements,
292            );
293            refs.clear_owner();
294        }
295    }
296
297    // v0.89 (ADR 0117): split diagnostics by severity. A unit with no
298    // error-severity diagnostic *checks* — its warnings ride on `TypedCommons`,
299    // surfaced but non-gating. Only error-severity diagnostics fail the check;
300    // on that path the warnings are appended so a failed build still renders
301    // them.
302    let (hard_errors, warnings) = bynk_syntax::partition_by_severity(errors);
303    if hard_errors.is_empty() {
304        RecordCheck {
305            result: Ok(TypedCommons {
306                commons: input.commons,
307                types: input.types,
308                fns: input.fns,
309                methods: input.methods,
310                expr_types,
311                warnings,
312            }),
313            partial_expr_types: HashMap::new(),
314        }
315    } else {
316        // Keep the best-effort types the checker already computed; Analyse mode
317        // surfaces them for `.`-member completion on a broken buffer (ADR 0094).
318        let mut all = hard_errors;
319        all.extend(warnings);
320        RecordCheck {
321            result: Err(all),
322            partial_expr_types: expr_types,
323        }
324    }
325}
326
327/// Check a single handler body (used for service and agent handlers).
328///
329/// `capabilities_in_scope` is the set of capabilities the handler may
330/// reference. `agent_state_ty` carries an agent handler's synthetic state-record
331/// type when one is in scope.
332#[allow(clippy::too_many_arguments)]
333pub fn check_handler_body(
334    body: &Block,
335    return_type: &TypeRef,
336    return_ty_span: Span,
337    params: &[Param],
338    input: &ResolvedCommons,
339    expr_types: &mut HashMap<Span, Ty>,
340    errors: &mut Vec<CompileError>,
341    refs: &mut RefSink,
342    hints: &mut HintSink,
343    locals: &mut LocalsSink,
344    requirements: &mut RequirementSink,
345    capabilities: HashMap<String, CapabilityInfo>,
346    declared_capabilities: HashMap<String, CapabilityInfo>,
347    agent_state_ty: Option<Ty>,
348    agent_self_scope: Option<HashMap<String, Ty>>,
349    given: &[CapRef],
350    given_anchor: Option<Span>,
351    report_unused: bool,
352    // v0.45/v0.52: the `by <binder>: <Actor(s)>` binding — the binder name and
353    // its fully-formed sealed type: `Ty::Actor(identity)` for a single actor (so
354    // `binder.identity` type-checks), or `Ty::ActorSum(members)` for a sum (so
355    // the body `match`es on it). `None` for handlers without a `by` binder.
356    actor_binding: Option<(String, Ty)>,
357    // v0.81 (storage track): the agent's `store` `Cell` fields (name → element
358    // type), so the `:=` write form can resolve its target and type its value.
359    // Empty for service/test bodies and `state { }` agents.
360    store_cells: HashMap<String, Ty>,
361    // v0.82 (ADR 0110): the agent's `store` `Map` fields (name → (key, value)),
362    // so `<map>.put/get/update/upsert/remove/contains/size` resolve to the
363    // effectful storage-map ops. Empty outside `store`-map agent handlers.
364    store_maps: HashMap<String, (Ty, Ty)>,
365    // v0.83: the agent's `store` `Set` fields (name → element). `<set>.add/remove/
366    // contains/size` resolve to the effectful storage-set ops.
367    store_sets: HashMap<String, Ty>,
368    // v0.87 (ADR 0113): the agent's `store` `Cache` fields (name → (key, value,
369    // ttl millis)). `<cache>.put/get/update/upsert/remove/contains/size` resolve
370    // to the effectful cache ops, which additionally require `given Clock`.
371    store_caches: HashMap<String, (Ty, Ty, i64)>,
372    // v0.95 (ADR 0121): the agent's `store` `Log` fields (name → element type).
373    // `<log>.append` is the effectful, non-idempotent write (requires `given
374    // Clock`); the time-window roots and general builders lift the log into a
375    // lazy `Query[T]` over its entry values.
376    store_logs: HashMap<String, Ty>,
377    // v0.106 (slice 3b-iii): held params that are **borrowed**, not owned — the
378    // firing `connection` of a `from WebSocket` `on message`/`on close`. Borrowed
379    // bindings admit non-consuming ops (`send`) but carry no disposal obligation.
380    // Empty for every other handler (including `on open`, whose connection is owned).
381    borrowed_held: HashSet<String>,
382) {
383    let Some(return_ty) = resolve_type_ref(return_type, &input.types) else {
384        return;
385    };
386    let no_vars = HashSet::new();
387    record_type_refs(return_type, &input.types, &no_vars, refs);
388    // Build the parameter scope.
389    let mut param_scope: HashMap<String, Ty> = HashMap::new();
390    for p in params {
391        if let Some(t) = resolve_type_ref(&p.type_ref, &input.types) {
392            record_type_refs(&p.type_ref, &input.types, &no_vars, refs);
393            // v0.31: a handler/op parameter is in scope over the whole body.
394            if p.name.name != "_" {
395                locals.record(p.name.name.clone(), p.name.span, t.display(), body.span);
396            }
397            param_scope.insert(p.name.name.clone(), t);
398        }
399    }
400    if let Some((binder, binder_ty)) = actor_binding {
401        if binder != "_" {
402            locals.record(binder.clone(), body.span, "actor".to_string(), body.span);
403        }
404        param_scope.insert(binder, binder_ty);
405    }
406    if let Some(self_scope) = agent_self_scope {
407        param_scope.extend(self_scope);
408    }
409    let effectful = matches!(&return_ty, Ty::Effect(_));
410    let given_entries: Vec<(String, Span)> = given
411        .iter()
412        .map(|c| (c.key().to_string(), c.span))
413        .collect();
414    let given_remaining: HashSet<String> = given_entries.iter().map(|(k, _)| k.clone()).collect();
415    let mut ctx = Ctx {
416        input,
417        expr_types,
418        errors,
419        refs,
420        hints,
421        locals,
422        requirements,
423        scopes: vec![param_scope],
424        return_ty: return_ty.clone(),
425        return_ty_span,
426        effectful,
427        agent_state_ty,
428        commit_seen: false,
429        caps: CapabilityCtx {
430            capabilities,
431            declared_capabilities,
432            given_remaining,
433            given_used: HashSet::new(),
434            given_entries: given_entries.clone(),
435            given_anchor,
436        },
437        in_test_body: false,
438        test_services: HashSet::new(),
439        type_vars: HashSet::new(),
440        store_cells,
441        store_maps,
442        store_sets,
443        store_caches,
444        store_logs,
445    };
446    // Check the body and validate it matches the return type.
447    let Some(body_ty) = type_of_block(body, Some(&return_ty), &mut ctx) else {
448        return;
449    };
450    // v0.102 (§3 step 11): the held-resource linearity pass, now that
451    // `expr_types` is fully populated by the body walk above.
452    linearity::check(
453        body,
454        params,
455        &input.types,
456        ctx.expr_types,
457        &borrowed_held,
458        ctx.errors,
459    );
460    if !compatible(&body_ty, &return_ty) {
461        ctx.errors.push(
462            CompileError::new(
463                "bynk.types.return_mismatch",
464                body.tail.span,
465                format!(
466                    "handler body has type `{}`, but the declared return type is `{}`",
467                    body_ty.display(),
468                    return_ty.display()
469                ),
470            )
471            .with_label(return_ty_span, "declared return type"),
472        );
473    }
474    // Bidirectional `given` check.
475    // 1) Every used capability is declared. (Handled in capability-call site.)
476    // 2) Every declared capability is used — anything left in given_remaining
477    //    minus given_used is unused. Emit as a warning-category error so the
478    //    test harness can match it. Entries are walked in declaration order
479    //    (deduplicated by key) so diagnostics and their fixes are stable.
480    let mut reported: HashSet<&str> = HashSet::new();
481    for (i, (c, _)) in given_entries.iter().enumerate() {
482        if !report_unused {
483            break;
484        }
485        if ctx.caps.given_used.contains(c) || !reported.insert(c) {
486            continue;
487        }
488        ctx.errors.push(
489            CompileError::new(
490                "bynk.given.unused_capability",
491                return_ty_span,
492                format!("capability `{c}` is declared in `given` but never used in the body"),
493            )
494            .with_note(
495                "remove the capability from the `given` clause, or use it in the handler body",
496            )
497            // v0.26 (ADR 0054): the removal is list-aware — only `report_unused`
498            // sites are handlers, where the clause follows the return type, so
499            // `return_ty_span` anchors the only-entry case.
500            .with_suggestion(
501                format!("remove `{c}` from the `given` clause"),
502                vec![(
503                    given_removal_span(&given_entries, i, return_ty_span),
504                    String::new(),
505                )],
506                Applicability::MachineApplicable,
507            ),
508        );
509    }
510}
511
512/// Check an agent's invariant declarations (v0.80 §14). Each predicate is a pure
513/// `Bool`-typed expression over the agent's state fields (referenced by bare
514/// name), plus `implies`/`is`. The pass enforces:
515///
516/// - `bynk.invariant.duplicate_name` — two invariants share a name.
517/// - `bynk.invariant.cross_agent_reference` — a predicate names another agent
518///   (§14 closes that door; sagas/scenarios are the cross-agent tools).
519/// - `bynk.invariant.impure_predicate` — a predicate uses an effectful or
520///   test-only construct (Effect, `?` propagation, `expect`, `Val`).
521/// - `bynk.invariant.not_bool` — the predicate does not type to `Bool`.
522///
523/// Store `Cell` fields are placed in scope as the predicate's locals; invariants
524/// read fields directly by bare name, mirroring the design-notes worked examples.
525#[allow(clippy::too_many_arguments)]
526pub fn check_invariants(
527    invariants: &[Invariant],
528    // A `store`-bearing agent's invariants reference its `Cell` fields by bare
529    // name (a pure read of the staged value), so they form the predicate scope.
530    store_cells: &HashMap<String, Ty>,
531    agent_name: &str,
532    input: &ResolvedCommons,
533    expr_types: &mut HashMap<Span, Ty>,
534    errors: &mut Vec<CompileError>,
535    refs: &mut RefSink,
536    hints: &mut HintSink,
537    locals: &mut LocalsSink,
538    requirements: &mut RequirementSink,
539) {
540    // Duplicate-name check across the agent's invariants.
541    let mut seen: HashMap<&str, ()> = HashMap::new();
542    for inv in invariants {
543        if seen.insert(inv.name.name.as_str(), ()).is_some() {
544            errors.push(
545                CompileError::new(
546                    "bynk.invariant.duplicate_name",
547                    inv.name.span,
548                    format!(
549                        "agent `{agent_name}` declares more than one invariant named `{}`",
550                        inv.name.name
551                    ),
552                )
553                .with_note("give each invariant a distinct name"),
554            );
555        }
556    }
557
558    // Build the predicate scope once: each `store` `Cell` is in scope by bare
559    // name (a `Cell` reads as its element type).
560    let mut field_scope: HashMap<String, Ty> = HashMap::new();
561    for (name, ty) in store_cells {
562        field_scope.insert(name.clone(), ty.clone());
563    }
564
565    for inv in invariants {
566        // Reject cross-agent references and impure/effectful constructs before
567        // type-checking, so the bespoke diagnostics win over any cascade.
568        if let Some(span) = predicate_cross_agent_ref(&inv.predicate, input) {
569            errors.push(
570                CompileError::new(
571                    "bynk.invariant.cross_agent_reference",
572                    span,
573                    format!(
574                        "invariant `{}` references another agent; invariants constrain a \
575                         single agent's reachable states",
576                        inv.name.name
577                    ),
578                )
579                .with_note(
580                    "a property that genuinely spans agents belongs in a saga or a scenario, \
581                     not an invariant — see §14",
582                ),
583            );
584            continue;
585        }
586        if let Some(span) = predicate_impure_construct(&inv.predicate) {
587            errors.push(
588                CompileError::new(
589                    "bynk.invariant.impure_predicate",
590                    span,
591                    format!(
592                        "invariant `{}` uses an effectful or test-only construct; invariant \
593                         predicates must be pure",
594                        inv.name.name
595                    ),
596                )
597                .with_note(
598                    "an invariant predicate may read state fields and call pure value methods, \
599                     but not perform effects",
600                ),
601            );
602            continue;
603        }
604
605        let bool_ty = Ty::Base(BaseType::Bool);
606        let mut ctx = Ctx {
607            input,
608            expr_types,
609            errors,
610            refs,
611            hints,
612            locals,
613            requirements,
614            scopes: vec![field_scope.clone()],
615            return_ty: bool_ty.clone(),
616            return_ty_span: inv.predicate.span,
617            // A predicate is a pure expression — effectful operations (capability
618            // calls, `<-`) are not permitted and are rejected as type errors.
619            effectful: false,
620            agent_state_ty: None,
621            commit_seen: false,
622            caps: CapabilityCtx {
623                capabilities: HashMap::new(),
624                declared_capabilities: HashMap::new(),
625                given_remaining: HashSet::new(),
626                given_used: HashSet::new(),
627                given_entries: Vec::new(),
628                given_anchor: None,
629            },
630            in_test_body: false,
631            test_services: HashSet::new(),
632            type_vars: HashSet::new(),
633            store_cells: HashMap::new(),
634            store_maps: HashMap::new(),
635            store_sets: HashMap::new(),
636            store_caches: HashMap::new(),
637            store_logs: HashMap::new(),
638        };
639        let pred_ty = type_of(&inv.predicate, Some(&bool_ty), &mut ctx);
640        if let Some(t) = pred_ty
641            && t.base() != Some(BaseType::Bool)
642        {
643            ctx.errors.push(
644                CompileError::new(
645                    "bynk.invariant.not_bool",
646                    inv.predicate.span,
647                    format!(
648                        "invariant `{}` predicate has type `{}`, but an invariant must be `Bool`",
649                        inv.name.name,
650                        t.display()
651                    ),
652                )
653                .with_note("an invariant predicate is a `Bool`-valued property of the state"),
654            );
655        }
656    }
657}
658
659/// If the predicate references another agent (by bare name, call, or qualified
660/// constructor), return the span of the first such reference. Used by the
661/// invariant well-formedness pass to forbid cross-agent predicates.
662fn predicate_cross_agent_ref(e: &Expr, input: &ResolvedCommons) -> Option<Span> {
663    let is_agent = |name: &str| input.agents.contains_key(name);
664    match &e.kind {
665        ExprKind::Ident(id) if is_agent(&id.name) => Some(id.span),
666        ExprKind::Call { name, .. } if is_agent(&name.name) => Some(name.span),
667        ExprKind::ConstructorCall { type_name, .. } if is_agent(&type_name.name) => {
668            Some(type_name.span)
669        }
670        ExprKind::RecordConstruction { type_name, .. } if is_agent(&type_name.name) => {
671            Some(type_name.span)
672        }
673        _ => predicate_children(e)
674            .into_iter()
675            .find_map(|c| predicate_cross_agent_ref(c, input)),
676    }
677}
678
679/// If the predicate contains an effectful or test-only construct, return its
680/// span. Capability misuse (an effect operation in a pure context) is left to
681/// the type checker; this catches the syntactically-impure surface.
682fn predicate_impure_construct(e: &Expr) -> Option<Span> {
683    match &e.kind {
684        ExprKind::EffectPure(_)
685        | ExprKind::Question(_)
686        | ExprKind::Expect(_)
687        | ExprKind::Val { .. } => Some(e.span),
688        _ => predicate_children(e)
689            .into_iter()
690            .find_map(predicate_impure_construct),
691    }
692}
693
694/// The directly-nested sub-expressions of `e`, for the invariant predicate
695/// walks. Patterns, blocks, and lambdas do not appear in well-formed
696/// predicates, but are traversed defensively so a malformed predicate is still
697/// fully scanned.
698/// Whether `e` reads the identifier `name` anywhere — used by the `:=`
699/// read-modify-write rule (a cell write whose RHS reads its own LHS).
700fn expr_reads_ident(e: &Expr, name: &str) -> bool {
701    match &e.kind {
702        ExprKind::Ident(id) => id.name == name,
703        _ => predicate_children(e)
704            .into_iter()
705            .any(|c| expr_reads_ident(c, name)),
706    }
707}
708
709fn predicate_children(e: &Expr) -> Vec<&Expr> {
710    match &e.kind {
711        ExprKind::BinOp(_, l, r) => vec![l, r],
712        ExprKind::UnaryOp(_, inner)
713        | ExprKind::Paren(inner)
714        | ExprKind::Ok(inner)
715        | ExprKind::Err(inner)
716        | ExprKind::Question(inner)
717        | ExprKind::Some(inner)
718        | ExprKind::EffectPure(inner)
719        | ExprKind::Expect(inner) => vec![inner],
720        ExprKind::Is { value, .. } => vec![value.as_ref()],
721        ExprKind::FieldAccess { receiver, .. } => vec![receiver.as_ref()],
722        ExprKind::MethodCall { receiver, args, .. } => {
723            let mut v = vec![receiver.as_ref()];
724            v.extend(args.iter());
725            v
726        }
727        ExprKind::Call { args, .. }
728        | ExprKind::ConstructorCall { args, .. }
729        | ExprKind::Val { args, .. }
730        | ExprKind::ListLit(args) => args.iter().collect(),
731        ExprKind::RecordConstruction { fields, .. } => {
732            fields.iter().filter_map(|f| f.value.as_ref()).collect()
733        }
734        ExprKind::RecordSpread {
735            base, overrides, ..
736        } => {
737            let mut v = vec![base.as_ref()];
738            v.extend(overrides.iter().filter_map(|f| f.value.as_ref()));
739            v
740        }
741        ExprKind::InterpStr(parts) => parts
742            .iter()
743            .filter_map(|p| match p {
744                InterpPart::Hole(e) => Some(e.as_ref()),
745                InterpPart::Chunk(_) => None,
746            })
747            .collect(),
748        ExprKind::If {
749            cond,
750            then_block,
751            else_block,
752        } => vec![cond.as_ref(), &then_block.tail, &else_block.tail],
753        ExprKind::Block(b) => vec![&b.tail],
754        ExprKind::Match { discriminant, .. } => vec![discriminant.as_ref()],
755        _ => Vec::new(),
756    }
757}
758
759// ==== Checking context and capability metadata ====
760
761/// v0.9.4: a compile-time-constant literal usable for static refinement
762/// discharge during `T.of(...)` construction.
763enum ConstLit {
764    Int(i64),
765    Float(f64),
766    Str(String),
767    Bool(bool),
768    Unit,
769}
770
771impl ConstLit {
772    fn display(&self) -> String {
773        match self {
774            ConstLit::Int(n) => n.to_string(),
775            ConstLit::Float(v) => v.to_string(),
776            ConstLit::Str(s) => format!("{s:?}"),
777            ConstLit::Bool(b) => b.to_string(),
778            ConstLit::Unit => "()".to_string(),
779        }
780    }
781}
782
783/// Mutable per-function context.
784/// Capability bookkeeping for the checker — the `given`-clause lifecycle and
785/// capability dispatch, grouped out of [`Ctx`] (v0.29.10). Empty (`Default`)
786/// for pure functions / non-context code.
787#[derive(Default)]
788pub struct CapabilityCtx {
789    /// Capabilities in scope for the current handler, as a name → CapabilityInfo
790    /// map. Empty for pure functions and non-context code.
791    pub capabilities: HashMap<String, CapabilityInfo>,
792    /// All capabilities declared in the surrounding context (for diagnostic
793    /// purposes — used to detect `<Cap>.op(...)` calls where the capability is
794    /// declared in the context but not listed in `given`).
795    pub declared_capabilities: HashMap<String, CapabilityInfo>,
796    /// Names of capabilities the user listed in `given`, but haven't yet
797    /// observed used. After checking the body, anything left here is
798    /// unused — a warning.
799    pub given_remaining: HashSet<String>,
800    /// Names of capabilities actually used in the body so far.
801    pub given_used: HashSet<String>,
802    /// v0.26 (ADR 0054): the `given` clause's entries in declaration order —
803    /// (deps key, source span) — so the `given` quick-fixes can author
804    /// list-aware edits at the diagnosis site. Empty where no `given` clause
805    /// applies (fns, mock ops, state initialisers).
806    pub given_entries: Vec<(String, Span)>,
807    /// v0.26: where the add-capability fix synthesises an *absent* `given`
808    /// clause — the handler's return type (the clause follows it). `None`
809    /// where the clause lives elsewhere (a provider's `provides … given`
810    /// line); the fix is then offered only when entries already exist.
811    pub given_anchor: Option<Span>,
812}
813
814pub struct Ctx<'a> {
815    pub input: &'a ResolvedCommons,
816    pub expr_types: &'a mut HashMap<Span, Ty>,
817    pub errors: &'a mut Vec<CompileError>,
818    /// v0.25 (ADR 0053): binding edges recorded at the checker's own
819    /// resolution sites — capability/service dispatch, typed call dispatch,
820    /// annotation resolution. Handler/test/provider bodies never pass
821    /// through the resolver's reference walk, so the checker is their only
822    /// recording point.
823    pub refs: &'a mut RefSink,
824    /// v0.27 (ADR 0056): inferred-type inlay hints recorded at the
825    /// annotation-absent binding sites (`let` / `let <-` / lambda params)
826    /// as the binding's final type is computed.
827    pub hints: &'a mut HintSink,
828    /// v0.31 (ADR 0064): local bindings recorded with their scope ranges at
829    /// every binding site (`let`/`let <-`, params, match patterns), for the
830    /// LSP's scope-at-offset query.
831    pub locals: &'a mut LocalsSink,
832    /// v0.99: the capability-requirement ledger — every capability-consuming
833    /// site (direct call, store op), covered or not, recorded so the editor
834    /// surfaces (the ghost `given` inlay hint, hover) can read it.
835    pub requirements: &'a mut RequirementSink,
836    /// Stack of in-scope name → type frames.
837    pub scopes: Vec<HashMap<String, Ty>>,
838    pub return_ty: Ty,
839    pub return_ty_span: Span,
840    /// True if the enclosing function/handler returns `Effect[T]` (v0.5).
841    /// Determines whether `<-` and capability calls are permitted.
842    pub effectful: bool,
843    /// If inside an agent handler, the agent's state type and the agent's
844    /// name. Used to validate `commit` statements.
845    pub agent_state_ty: Option<Ty>,
846    /// True if a `commit` has been seen on the current control-flow path.
847    /// Used to detect "two reachable commits".
848    pub commit_seen: bool,
849    /// Capability bookkeeping — the `given`-clause lifecycle + dispatch,
850    /// grouped (v0.29.10). Empty for pure functions / non-context code.
851    pub caps: CapabilityCtx,
852    /// True when the body being checked is a test case body. Permits
853    /// `expect` statements (v0.7; renamed from `assert` in v0.112).
854    pub in_test_body: bool,
855    /// The target unit's service names, populated for test case bodies
856    /// (v0.25). `svc.call(args)` in a test invokes the target's service —
857    /// the emitter wires it from the same set; the checker records the
858    /// binding edge here so test-file references index.
859    pub test_services: HashSet<String>,
860    /// v0.20a: the enclosing function's type parameters (rigid vars), so
861    /// nested explicit type arguments (`identity[A](x)` inside a generic
862    /// body) resolve. Empty outside generic fn bodies.
863    pub type_vars: HashSet<String>,
864    /// v0.81 (storage track): the agent's `store` `Cell` fields (name → element
865    /// type). A `:=` write resolves its target here and types its value against
866    /// the element type. Empty outside `store`-bearing agent handlers.
867    pub store_cells: HashMap<String, Ty>,
868    /// v0.82 (ADR 0110): the agent's `store` `Map` fields (name → (key, value)).
869    /// A `<map>.<op>(…)` call resolves against the storage-map op set here, by
870    /// receiver provenance. Empty outside `store`-map agent handlers.
871    pub store_maps: HashMap<String, (Ty, Ty)>,
872    /// v0.83: the agent's `store` `Set` fields (name → element). A `<set>.<op>(…)`
873    /// call resolves against the storage-set op set here, by receiver provenance.
874    pub store_sets: HashMap<String, Ty>,
875    /// v0.87 (ADR 0113): the agent's `store` `Cache` fields (name → (key, value,
876    /// ttl millis)). A `<cache>.<op>(…)` resolves against the cache op set and
877    /// requires `given Clock`.
878    pub store_caches: HashMap<String, (Ty, Ty, i64)>,
879    /// v0.95 (ADR 0121): the agent's `store` `Log` fields (name → element type).
880    /// `<log>.append` is the effectful non-idempotent write; the time-window
881    /// roots / builders lift the log into a lazy `Query[T]` over its values.
882    pub store_logs: HashMap<String, Ty>,
883}
884
885/// Per-capability info for checker dispatch within a handler body.
886#[derive(Debug, Clone)]
887pub struct CapabilityInfo {
888    pub name: String,
889    pub ops: Vec<CapabilityOpInfo>,
890}
891
892#[derive(Debug, Clone)]
893pub struct CapabilityOpInfo {
894    pub name: String,
895    pub params: Vec<Ty>,
896    pub return_ty: Ty,
897}
898
899impl<'a> Ctx<'a> {
900    pub fn lookup(&self, name: &str) -> Option<Ty> {
901        for scope in self.scopes.iter().rev() {
902            if let Some(t) = scope.get(name) {
903                return Some(t.clone());
904            }
905        }
906        None
907    }
908
909    /// Returns the type of an expression's "root identifier" — for `a.b.c`
910    /// that's `a`; for a bare `a` it's `a`. Used to detect whether a chain's
911    /// outermost name shadows an alias / consumed-context prefix.
912    pub fn lookup_root_ident(&self, expr: &Expr) -> Option<Ty> {
913        match &expr.kind {
914            ExprKind::Ident(id) => self.lookup(&id.name),
915            ExprKind::FieldAccess { receiver, .. } => self.lookup_root_ident(receiver),
916            ExprKind::MethodCall { receiver, .. } => self.lookup_root_ident(receiver),
917            _ => None,
918        }
919    }
920
921    pub fn push_scope(&mut self) {
922        self.scopes.push(HashMap::new());
923    }
924    pub fn pop_scope(&mut self) {
925        self.scopes.pop();
926    }
927    pub fn bind(&mut self, name: String, ty: Ty) {
928        self.scopes.last_mut().unwrap().insert(name, ty);
929    }
930}
931
932// ==== Type-system core (resolution, unification, compatibility, inference) ====
933
934/// Build a `Ty` from a TypeDecl name reference.
935pub fn type_from_decl(id: &Ident, types: &HashMap<String, TypeDecl>) -> Option<Ty> {
936    let decl = types.get(&id.name)?;
937    Some(named_ty(decl))
938}
939
940/// Build a `Ty::Named` for the given declaration.
941pub fn named_ty(decl: &TypeDecl) -> Ty {
942    let kind = match &decl.body {
943        TypeBody::Refined { base, .. } => NamedKind::Refined(*base),
944        TypeBody::Record(_) => NamedKind::Record,
945        TypeBody::Sum(_) => NamedKind::Sum,
946        TypeBody::Opaque { base, .. } => NamedKind::Opaque(*base),
947    };
948    Ty::Named {
949        name: decl.name.name.clone(),
950        kind,
951    }
952}
953
954/// v0.20a: like [`resolve_type_ref`], with a set of in-scope **type
955/// parameters**: a `Named` reference matching one resolves to [`Ty::Var`]
956/// (checked before the type-table lookup — a type parameter shadows a
957/// same-named declaration; the collision is diagnosed at the declaration).
958pub fn resolve_type_ref_in(
959    r: &TypeRef,
960    types: &HashMap<String, TypeDecl>,
961    vars: &HashSet<String>,
962) -> Option<Ty> {
963    match r {
964        TypeRef::Named(id) if vars.contains(&id.name) => Some(Ty::Var(id.name.clone())),
965        TypeRef::Result(t, e, _) => Some(Ty::Result(
966            Box::new(resolve_type_ref_in(t, types, vars)?),
967            Box::new(resolve_type_ref_in(e, types, vars)?),
968        )),
969        TypeRef::Option(t, _) => Some(Ty::Option(Box::new(resolve_type_ref_in(t, types, vars)?))),
970        TypeRef::Effect(t, _) => Some(Ty::Effect(Box::new(resolve_type_ref_in(t, types, vars)?))),
971        TypeRef::HttpResult(t, _) => Some(Ty::HttpResult(Box::new(resolve_type_ref_in(
972            t, types, vars,
973        )?))),
974        TypeRef::List(t, _) => Some(Ty::List(Box::new(resolve_type_ref_in(t, types, vars)?))),
975        TypeRef::Query(t, _) => Some(Ty::Query(Box::new(resolve_type_ref_in(t, types, vars)?))),
976        TypeRef::Stream(t, _) => Some(Ty::Stream(Box::new(resolve_type_ref_in(t, types, vars)?))),
977        TypeRef::Connection(t, _) => Some(Ty::Connection(Box::new(resolve_type_ref_in(
978            t, types, vars,
979        )?))),
980        TypeRef::Map(k, v, _) => Some(Ty::Map(
981            Box::new(resolve_type_ref_in(k, types, vars)?),
982            Box::new(resolve_type_ref_in(v, types, vars)?),
983        )),
984        TypeRef::Fn(params, ret, _) => {
985            let params: Option<Vec<Ty>> = params
986                .iter()
987                .map(|p| resolve_type_ref_in(p, types, vars))
988                .collect();
989            Some(Ty::Fn {
990                params: params?,
991                ret: Box::new(resolve_type_ref_in(ret, types, vars)?),
992            })
993        }
994        _ => resolve_type_ref(r, types),
995    }
996}
997
998/// v0.20a: substitute type variables in `t` per `subst`. Must be total when
999/// instantiating a call (the uninferable check runs first); an unbound Var
1000/// passes through unchanged for partial substitution during inference.
1001fn substitute(t: &Ty, subst: &HashMap<String, Ty>) -> Ty {
1002    match t {
1003        Ty::Var(n) => subst.get(n).cloned().unwrap_or_else(|| t.clone()),
1004        Ty::Result(a, b) => Ty::Result(
1005            Box::new(substitute(a, subst)),
1006            Box::new(substitute(b, subst)),
1007        ),
1008        Ty::Option(a) => Ty::Option(Box::new(substitute(a, subst))),
1009        Ty::Effect(a) => Ty::Effect(Box::new(substitute(a, subst))),
1010        Ty::HttpResult(a) => Ty::HttpResult(Box::new(substitute(a, subst))),
1011        Ty::List(a) => Ty::List(Box::new(substitute(a, subst))),
1012        Ty::Stream(a) => Ty::Stream(Box::new(substitute(a, subst))),
1013        Ty::Connection(a) => Ty::Connection(Box::new(substitute(a, subst))),
1014        Ty::Map(k, v) => Ty::Map(
1015            Box::new(substitute(k, subst)),
1016            Box::new(substitute(v, subst)),
1017        ),
1018        Ty::Fn { params, ret } => Ty::Fn {
1019            params: params.iter().map(|p| substitute(p, subst)).collect(),
1020            ret: Box::new(substitute(ret, subst)),
1021        },
1022        _ => t.clone(),
1023    }
1024}
1025
1026/// v0.20a: does `t` still contain a type variable?
1027fn contains_var(t: &Ty) -> bool {
1028    match t {
1029        Ty::Var(_) => true,
1030        Ty::Result(a, b) | Ty::Map(a, b) => contains_var(a) || contains_var(b),
1031        Ty::Option(a)
1032        | Ty::Effect(a)
1033        | Ty::HttpResult(a)
1034        | Ty::List(a)
1035        | Ty::Stream(a)
1036        | Ty::Connection(a) => contains_var(a),
1037        Ty::Fn { params, ret } => params.iter().any(contains_var) || contains_var(ret),
1038        _ => false,
1039    }
1040}
1041
1042/// v0.20b: does `t` contain a type variable that is NOT one of the enclosing
1043/// function's rigid type parameters? Rigid vars are fully constrained inside
1044/// the body; only flexible (call-site instantiation) vars mean "still being
1045/// inferred".
1046fn contains_flexible_var(t: &Ty, rigid: &HashSet<String>) -> bool {
1047    match t {
1048        Ty::Var(n) => !rigid.contains(n),
1049        Ty::Result(a, b) | Ty::Map(a, b) => {
1050            contains_flexible_var(a, rigid) || contains_flexible_var(b, rigid)
1051        }
1052        Ty::Option(a)
1053        | Ty::Effect(a)
1054        | Ty::HttpResult(a)
1055        | Ty::List(a)
1056        | Ty::Stream(a)
1057        | Ty::Connection(a) => contains_flexible_var(a, rigid),
1058        Ty::Fn { params, ret } => {
1059            params.iter().any(|p| contains_flexible_var(p, rigid))
1060                || contains_flexible_var(ret, rigid)
1061        }
1062        _ => false,
1063    }
1064}
1065
1066/// v0.20a: argument-directed unification. Walks `pattern` (possibly
1067/// Var-bearing) against the ground `actual`; a Var binds on first sight and
1068/// must match its prior binding **exactly** afterwards (keep inference dumb
1069/// and predictable — the explicit `name[T](…)` form is the pressure valve).
1070/// Returns false on a conflict; structural mismatches are NOT reported here —
1071/// the post-substitution `compatible` check owns those diagnostics.
1072fn unify(pattern: &Ty, actual: &Ty, subst: &mut HashMap<String, Ty>) -> bool {
1073    match (pattern, actual) {
1074        (Ty::Var(n), a) => match subst.get(n) {
1075            Some(bound) => bound == a,
1076            None => {
1077                subst.insert(n.clone(), a.clone());
1078                true
1079            }
1080        },
1081        (Ty::Result(a1, b1), Ty::Result(a2, b2)) | (Ty::Map(a1, b1), Ty::Map(a2, b2)) => {
1082            unify(a1, a2, subst) && unify(b1, b2, subst)
1083        }
1084        (Ty::Option(a1), Ty::Option(a2))
1085        | (Ty::Effect(a1), Ty::Effect(a2))
1086        | (Ty::HttpResult(a1), Ty::HttpResult(a2))
1087        | (Ty::List(a1), Ty::List(a2))
1088        | (Ty::Stream(a1), Ty::Stream(a2))
1089        | (Ty::Connection(a1), Ty::Connection(a2)) => unify(a1, a2, subst),
1090        (
1091            Ty::Fn {
1092                params: p1,
1093                ret: r1,
1094            },
1095            Ty::Fn {
1096                params: p2,
1097                ret: r2,
1098            },
1099        ) => {
1100            p1.len() == p2.len()
1101                && p1.iter().zip(p2).all(|(a, b)| unify(a, b, subst))
1102                && unify(r1, r2, subst)
1103        }
1104        // Ground-vs-ground: any pair is fine here; `compatible` owns the
1105        // real check after substitution.
1106        _ => true,
1107    }
1108}
1109
1110/// v0.25 (ADR 0053): record a binding edge for every `Named` reference
1111/// inside a type-ref that resolved. Called alongside the `resolve_type_ref*`
1112/// annotation sites; `skip` holds the enclosing fn's type parameters (rigid
1113/// vars are not type symbols). Handler signatures and body annotations never
1114/// pass through the resolver's reference walk, so these sites are their only
1115/// recording point; where both passes run, assembly dedupes.
1116pub fn record_type_refs(
1117    r: &TypeRef,
1118    types: &HashMap<String, TypeDecl>,
1119    skip: &HashSet<String>,
1120    refs: &mut RefSink,
1121) {
1122    match r {
1123        TypeRef::Named(id) => {
1124            if types.contains_key(&id.name) && !skip.contains(&id.name) {
1125                refs.record(id.span, SymbolKind::Type, &id.name);
1126            }
1127        }
1128        TypeRef::Fn(params, ret, _) => {
1129            for p in params {
1130                record_type_refs(p, types, skip, refs);
1131            }
1132            record_type_refs(ret, types, skip, refs);
1133        }
1134        TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
1135            record_type_refs(a, types, skip, refs);
1136            record_type_refs(b, types, skip, refs);
1137        }
1138        TypeRef::Option(t, _)
1139        | TypeRef::Effect(t, _)
1140        | TypeRef::HttpResult(t, _)
1141        | TypeRef::Query(t, _)
1142        | TypeRef::Stream(t, _)
1143        | TypeRef::Connection(t, _)
1144        | TypeRef::List(t, _) => record_type_refs(t, types, skip, refs),
1145        TypeRef::Base(..)
1146        | TypeRef::QueueResult(_)
1147        | TypeRef::ValidationError(_)
1148        | TypeRef::JsonError(_)
1149        | TypeRef::Unit(_) => {}
1150    }
1151}
1152
1153pub fn resolve_type_ref(r: &TypeRef, types: &HashMap<String, TypeDecl>) -> Option<Ty> {
1154    match r {
1155        TypeRef::Base(b, _) => Some(Ty::Base(*b)),
1156        TypeRef::Named(id) => type_from_decl(id, types),
1157        // v0.20a: a function type. Effectfulness is structural (ret is
1158        // Effect[_]); nothing extra to record.
1159        TypeRef::Fn(params, ret, _) => {
1160            let params: Option<Vec<Ty>> =
1161                params.iter().map(|p| resolve_type_ref(p, types)).collect();
1162            Some(Ty::Fn {
1163                params: params?,
1164                ret: Box::new(resolve_type_ref(ret, types)?),
1165            })
1166        }
1167        TypeRef::Result(t, e, _) => {
1168            let t = resolve_type_ref(t, types)?;
1169            let e = resolve_type_ref(e, types)?;
1170            Some(Ty::Result(Box::new(t), Box::new(e)))
1171        }
1172        TypeRef::Option(t, _) => {
1173            let t = resolve_type_ref(t, types)?;
1174            Some(Ty::Option(Box::new(t)))
1175        }
1176        TypeRef::Effect(t, _) => {
1177            let t = resolve_type_ref(t, types)?;
1178            Some(Ty::Effect(Box::new(t)))
1179        }
1180        TypeRef::HttpResult(t, _) => {
1181            let t = resolve_type_ref(t, types)?;
1182            Some(Ty::HttpResult(Box::new(t)))
1183        }
1184        TypeRef::List(t, _) => {
1185            let t = resolve_type_ref(t, types)?;
1186            Some(Ty::List(Box::new(t)))
1187        }
1188        TypeRef::Query(t, _) => {
1189            let t = resolve_type_ref(t, types)?;
1190            Some(Ty::Query(Box::new(t)))
1191        }
1192        TypeRef::Stream(t, _) => {
1193            let t = resolve_type_ref(t, types)?;
1194            Some(Ty::Stream(Box::new(t)))
1195        }
1196        TypeRef::Connection(t, _) => {
1197            let t = resolve_type_ref(t, types)?;
1198            Some(Ty::Connection(Box::new(t)))
1199        }
1200        TypeRef::Map(k, v, _) => {
1201            let k = resolve_type_ref(k, types)?;
1202            let v = resolve_type_ref(v, types)?;
1203            Some(Ty::Map(Box::new(k), Box::new(v)))
1204        }
1205        TypeRef::QueueResult(_) => Some(Ty::QueueResult),
1206        TypeRef::ValidationError(_) => Some(Ty::ValidationError),
1207        TypeRef::JsonError(_) => Some(Ty::JsonError),
1208        TypeRef::Unit(_) => Some(Ty::Unit),
1209    }
1210}
1211
1212/// `t` is usable where `u` is expected.
1213pub fn compatible(t: &Ty, u: &Ty) -> bool {
1214    match (t, u) {
1215        (Ty::Base(a), Ty::Base(b)) => a == b,
1216        (Ty::Named { name: a, kind: ka }, Ty::Named { name: b, kind: kb }) => a == b && ka == kb,
1217        // Refined → base (widening).
1218        (
1219            Ty::Named {
1220                kind: NamedKind::Refined(b),
1221                ..
1222            },
1223            Ty::Base(target),
1224        ) => b == target,
1225        (Ty::Base(_), Ty::Named { .. }) => false,
1226        (Ty::Result(t1, e1), Ty::Result(t2, e2)) => compatible(t1, t2) && compatible(e1, e2),
1227        (Ty::Option(a), Ty::Option(b)) => compatible(a, b),
1228        (Ty::Effect(a), Ty::Effect(b)) => compatible(a, b),
1229        (Ty::HttpResult(a), Ty::HttpResult(b)) => compatible(a, b),
1230        // v0.20b: collections are covariant in their element/value types;
1231        // Map keys must match exactly — key-position widening would split a
1232        // map's keys across refined/base identities at lookup time.
1233        (Ty::List(a), Ty::List(b)) => compatible(a, b),
1234        // v0.100: `Stream[T]` is covariant in its element, like `List`/`Effect`.
1235        // (Assignability only — streams are not value-comparable for `==`.)
1236        (Ty::Stream(a), Ty::Stream(b)) => compatible(a, b),
1237        // v0.102: a `Connection[F]` is assignable to itself (the linearity pass
1238        // governs the move). Held values have identity, not value-equality, so
1239        // they are not `==`-comparable (guarded in the `Eq`/`NotEq` arm).
1240        (Ty::Connection(a), Ty::Connection(b)) => compatible(a, b),
1241        (Ty::Map(k1, v1), Ty::Map(k2, v2)) => k1 == k2 && compatible(v1, v2),
1242        (Ty::QueueResult, Ty::QueueResult) => true,
1243        (Ty::ValidationError, Ty::ValidationError) => true,
1244        (Ty::JsonError, Ty::JsonError) => true,
1245        (Ty::Unit, Ty::Unit) => true,
1246        // v0.20a: function types — **contravariant** in parameters, covariant
1247        // in the return type. `compatible(t, u)` is "t usable where u is
1248        // expected" and is already asymmetric (refined → base widening), so
1249        // the per-position argument order flips for params: a function
1250        // expecting the *wider* param type is usable where one expecting the
1251        // narrower is required — and crucially, the covariant direction would
1252        // let unvalidated base values flow into a refined-typed body.
1253        (Ty::Fn { params: p, ret: r }, Ty::Fn { params: q, ret: s }) => {
1254            p.len() == q.len() && p.iter().zip(q).all(|(a, b)| compatible(b, a)) && compatible(r, s)
1255        }
1256        // v0.20a: rigid type variables (a generic fn's own body) match by
1257        // name. Flexible vars never reach `compatible` — they are eliminated
1258        // by substitution during call-site instantiation.
1259        (Ty::Var(a), Ty::Var(b)) => a == b,
1260        _ => false,
1261    }
1262}
1263
1264pub fn type_of_block(block: &Block, expected: Option<&Ty>, ctx: &mut Ctx) -> Option<Ty> {
1265    ctx.push_scope();
1266    for stmt in &block.statements {
1267        match stmt {
1268            Statement::Let(l) => {
1269                let annot_ty = l.type_annot.as_ref().and_then(|a| {
1270                    // v0.20b: the enclosing fn's type parameters are legal
1271                    // in body annotations (`let init: List[B] = …`).
1272                    let r = resolve_type_ref_in(a, &ctx.input.types, &ctx.type_vars);
1273                    if r.is_none() {
1274                        ctx.errors.push(CompileError::new(
1275                            "bynk.resolve.unknown_type",
1276                            a.span(),
1277                            "type in `let` annotation does not resolve",
1278                        ));
1279                    } else {
1280                        record_type_refs(a, &ctx.input.types, &ctx.type_vars, ctx.refs);
1281                    }
1282                    r
1283                });
1284                let rhs_ty = type_of(&l.value, annot_ty.as_ref(), ctx);
1285                let final_ty = match (annot_ty, rhs_ty) {
1286                    (Some(annot), Some(rhs)) => {
1287                        if !compatible(&rhs, &annot) {
1288                            ctx.errors.push(
1289                                CompileError::new(
1290                                    "bynk.types.let_annotation_mismatch",
1291                                    l.value.span,
1292                                    format!(
1293                                        "let binding's value has type `{}`, but the annotation declares `{}`",
1294                                        rhs.display(),
1295                                        annot.display()
1296                                    ),
1297                                )
1298                                .with_label(
1299                                    l.type_annot.as_ref().unwrap().span(),
1300                                    "declared type annotation",
1301                                ),
1302                            );
1303                        }
1304                        annot
1305                    }
1306                    (Some(annot), None) => annot,
1307                    (None, Some(rhs)) => rhs,
1308                    (None, None) => continue,
1309                };
1310                if l.name.name != "_" {
1311                    // v0.27 (ADR 0056): an annotation-absent binding gets an
1312                    // inferred-type inlay hint at the binding name.
1313                    if l.type_annot.is_none() {
1314                        ctx.hints
1315                            .record(l.name.span, format!(": {}", final_ty.display()));
1316                    }
1317                    // v0.31: in scope from after this statement to block end.
1318                    ctx.locals.record(
1319                        l.name.name.clone(),
1320                        l.name.span,
1321                        final_ty.display(),
1322                        Span {
1323                            start: l.span.end,
1324                            end: block.span.end,
1325                        },
1326                    );
1327                    ctx.bind(l.name.name.clone(), final_ty);
1328                }
1329            }
1330            Statement::EffectLet(l) => {
1331                if !ctx.effectful {
1332                    ctx.errors.push(
1333                        CompileError::new(
1334                            "bynk.effect.bind_in_pure_context",
1335                            l.span,
1336                            "the `<-` operator can only be used inside an effectful body (one returning `Effect[T]`)",
1337                        )
1338                        .with_label(
1339                            ctx.return_ty_span,
1340                            format!("enclosing return type is `{}`", ctx.return_ty.display()),
1341                        )
1342                        .with_note(
1343                            "change the enclosing function/handler's return type to `Effect[...]`, or use `let ... =` for a pure binding",
1344                        ),
1345                    );
1346                }
1347                // Determine the inner Effect[T] payload type for the binding.
1348                let annot_ty = l.type_annot.as_ref().and_then(|a| {
1349                    // v0.20b: the enclosing fn's type parameters are legal
1350                    // in body annotations (`let init: List[B] = …`).
1351                    let r = resolve_type_ref_in(a, &ctx.input.types, &ctx.type_vars);
1352                    if r.is_none() {
1353                        ctx.errors.push(CompileError::new(
1354                            "bynk.resolve.unknown_type",
1355                            a.span(),
1356                            "type in `let` annotation does not resolve",
1357                        ));
1358                    } else {
1359                        record_type_refs(a, &ctx.input.types, &ctx.type_vars, ctx.refs);
1360                    }
1361                    r
1362                });
1363                // The expected type for the RHS is `Effect[annot]` if annot present.
1364                let rhs_expected = annot_ty.as_ref().map(|t| Ty::Effect(Box::new(t.clone())));
1365                let rhs_ty = type_of(&l.value, rhs_expected.as_ref(), ctx);
1366                let inner_ty = match rhs_ty {
1367                    Some(Ty::Effect(t)) => Some((*t).clone()),
1368                    Some(other) => {
1369                        ctx.errors.push(
1370                            CompileError::new(
1371                                "bynk.effect.bind_on_non_effect",
1372                                l.value.span,
1373                                format!(
1374                                    "the `<-` operator requires an `Effect[T]` value, but got `{}`",
1375                                    other.display()
1376                                ),
1377                            )
1378                            .with_note(
1379                                "use `let ... =` for a pure binding, or wrap the value with `Effect.pure(...)`",
1380                            ),
1381                        );
1382                        None
1383                    }
1384                    None => None,
1385                };
1386                let final_ty = match (annot_ty, inner_ty) {
1387                    (Some(annot), Some(rhs)) => {
1388                        if !compatible(&rhs, &annot) {
1389                            ctx.errors.push(CompileError::new(
1390                                "bynk.types.let_annotation_mismatch",
1391                                l.value.span,
1392                                format!(
1393                                    "let-binding's value has type `Effect[{}]`, but the annotation declares `Effect[{}]`",
1394                                    rhs.display(),
1395                                    annot.display()
1396                                ),
1397                            ));
1398                        }
1399                        annot
1400                    }
1401                    (Some(annot), None) => annot,
1402                    (None, Some(rhs)) => rhs,
1403                    (None, None) => continue,
1404                };
1405                if l.name.name != "_" {
1406                    // v0.27 (ADR 0056): as for `let =`, but `final_ty` here
1407                    // is the peeled `Effect[T]` payload — the binding's
1408                    // actual type, which is what the hint must show.
1409                    if l.type_annot.is_none() {
1410                        ctx.hints
1411                            .record(l.name.span, format!(": {}", final_ty.display()));
1412                    }
1413                    ctx.locals.record(
1414                        l.name.name.clone(),
1415                        l.name.span,
1416                        final_ty.display(),
1417                        Span {
1418                            start: l.span.end,
1419                            end: block.span.end,
1420                        },
1421                    );
1422                    ctx.bind(l.name.name.clone(), final_ty);
1423                }
1424            }
1425            Statement::Expect(a) => {
1426                if !ctx.in_test_body {
1427                    ctx.errors.push(
1428                        CompileError::new(
1429                            "bynk.expect.outside_case",
1430                            a.span,
1431                            "`expect` is only valid inside a `case` body",
1432                        )
1433                        .with_note(
1434                            "expectations verify predicates at test runtime; use them only inside `case \"...\" { ... }` blocks",
1435                        ),
1436                    );
1437                }
1438                let val_ty = type_of(&a.value, Some(&Ty::Base(BaseType::Bool)), ctx);
1439                if let Some(actual) = val_ty
1440                    && !compatible(&actual, &Ty::Base(BaseType::Bool))
1441                {
1442                    ctx.errors.push(CompileError::new(
1443                        "bynk.expect.not_bool",
1444                        a.value.span,
1445                        format!(
1446                            "`expect` predicate has type `{}`, but a `Bool` is required",
1447                            actual.display(),
1448                        ),
1449                    ));
1450                }
1451            }
1452            Statement::Send(s) => {
1453                // v0.79: `~> e` — fire-and-forget. Effectful context only, like
1454                // `<-`; the reply is never awaited, so nothing is bound.
1455                if !ctx.effectful {
1456                    ctx.errors.push(
1457                        CompileError::new(
1458                            "bynk.send.in_pure_context",
1459                            s.span,
1460                            "the `~>` send can only be used inside an effectful body (one returning `Effect[T]`)",
1461                        )
1462                        .with_label(
1463                            ctx.return_ty_span,
1464                            format!("enclosing return type is `{}`", ctx.return_ty.display()),
1465                        )
1466                        .with_note(
1467                            "change the enclosing function/handler's return type to `Effect[...]`",
1468                        ),
1469                    );
1470                }
1471                // The reply must be `Effect[()]`. A real payload (value or error)
1472                // would be silently dropped by a fire-and-forget send — the error
1473                // gate ([DECISION C/D]). `let _ <- e` is the honest spelling for
1474                // "await and discard".
1475                let expected = Ty::Effect(Box::new(Ty::Unit));
1476                let rhs_ty = type_of(&s.value, Some(&expected), ctx);
1477                match rhs_ty {
1478                    Some(Ty::Effect(inner)) if matches!(*inner, Ty::Unit) => {}
1479                    Some(Ty::Effect(inner)) => {
1480                        ctx.errors.push(
1481                            CompileError::new(
1482                                "bynk.send.requires_unit",
1483                                s.value.span,
1484                                format!(
1485                                    "`~>` requires an `Effect[()]` reply, but this send returns `Effect[{}]` — its result would be silently dropped",
1486                                    inner.display()
1487                                ),
1488                            )
1489                            .with_note(
1490                                "a `~>` send never awaits a reply, so it is reserved for empty replies; to await and discard a real result, write `let _ <- ...` instead",
1491                            ),
1492                        );
1493                    }
1494                    Some(other) => {
1495                        ctx.errors.push(
1496                            CompileError::new(
1497                                "bynk.send.non_effect",
1498                                s.value.span,
1499                                format!(
1500                                    "the `~>` send requires an `Effect[()]` value, but got `{}`",
1501                                    other.display()
1502                                ),
1503                            )
1504                            .with_note("`~>` sends an effectful call; the target must be a call returning `Effect[()]`"),
1505                        );
1506                    }
1507                    None => {}
1508                }
1509            }
1510            Statement::Assign(a) => {
1511                // v0.81 (storage track): `cell := expr` — the unconditional `Cell`
1512                // write. The target must be a `store Cell` field; the value must
1513                // match the cell's element type; and (the §10 read-modify-write
1514                // rule) the RHS must not read the cell being written.
1515                match ctx.store_cells.get(&a.target.name).cloned() {
1516                    None => {
1517                        ctx.errors.push(
1518                            CompileError::new(
1519                                "bynk.cell.invalid_target",
1520                                a.target.span,
1521                                format!(
1522                                    "`:=` writes a `Cell` store field, but `{}` is not one",
1523                                    a.target.name
1524                                ),
1525                            )
1526                            .with_note(
1527                                "the `:=` write form applies only to a `store <name>: Cell[T]` field",
1528                            ),
1529                        );
1530                        type_of(&a.value, None, ctx);
1531                    }
1532                    Some(elem_ty) => {
1533                        // §10: a `:=` whose RHS reads its own LHS is a hidden
1534                        // read-modify-write — require `.update(fn)` instead, so the
1535                        // dependency is visible (and retry-safe).
1536                        if expr_reads_ident(&a.value, &a.target.name) {
1537                            ctx.errors.push(
1538                                CompileError::new(
1539                                    "bynk.cell.self_reference",
1540                                    a.span,
1541                                    format!(
1542                                        "the `:=` right-hand side reads `{0}`, the cell being \
1543                                         written — this is a read-modify-write",
1544                                        a.target.name
1545                                    ),
1546                                )
1547                                .with_note(
1548                                    "use `<cell>.update(fn)` for a read-modify-write so the \
1549                                     dependency on the prior value is explicit",
1550                                ),
1551                            );
1552                        }
1553                        if let Some(vt) = type_of(&a.value, Some(&elem_ty), ctx)
1554                            && !compatible(&vt, &elem_ty)
1555                        {
1556                            ctx.errors.push(CompileError::new(
1557                                "bynk.types.type_mismatch",
1558                                a.value.span,
1559                                format!(
1560                                    "this `:=` writes `{}`, but the cell `{}` holds `{}`",
1561                                    vt.display(),
1562                                    a.target.name,
1563                                    elem_ty.display()
1564                                ),
1565                            ));
1566                        }
1567                    }
1568                }
1569            }
1570        }
1571    }
1572    let ty = type_of(&block.tail, expected, ctx);
1573    let ty = maybe_auto_lift(ty, expected);
1574    if let Some(ty) = &ty {
1575        ctx.expr_types.insert(block.span, ty.clone());
1576    }
1577    ctx.pop_scope();
1578    ty
1579}
1580
1581/// v0.7.1 tail-position auto-lift. If the expected type is `Effect[T]` and
1582/// the computed type is `T` (not itself an `Effect[_]`), lift it to
1583/// `Effect[T]`. Otherwise leave the type alone — the surrounding compatibility
1584/// check will report any genuine mismatch.
1585fn maybe_auto_lift(ty: Option<Ty>, expected: Option<&Ty>) -> Option<Ty> {
1586    if let Some(actual) = &ty
1587        && let Some(Ty::Effect(et)) = expected
1588        && !actual.is_effect()
1589        && compatible(actual, et)
1590    {
1591        return Some(Ty::Effect(Box::new(actual.clone())));
1592    }
1593    ty
1594}
1595
1596/// Whether a value of type `ty` may fill an interpolation hole (v0.43, ADR
1597/// 0075): a base scalar, or a refinement of one (which widens to its base for
1598/// display). Opaque types are excluded — their base is hidden, so a value must
1599/// be `.raw`-ed out first.
1600fn interpolable(ty: &Ty) -> bool {
1601    matches!(
1602        ty,
1603        Ty::Base(_)
1604            | Ty::Named {
1605                kind: NamedKind::Refined(_),
1606                ..
1607            }
1608    )
1609}
1610
1611pub fn type_of(expr: &Expr, expected: Option<&Ty>, ctx: &mut Ctx) -> Option<Ty> {
1612    let ty = match &expr.kind {
1613        // v0.9.4: a literal in a refined-expected position takes the refined
1614        // type (validated now); otherwise it keeps its base type.
1615        // v0.20a: a lambda. With an expected function type, params type
1616        // contextually and the body checks against the expected return; in an
1617        // unconstrained position, every param must be annotated and
1618        // effectfulness is inferred bottom-up by a syntactic pre-scan.
1619        ExprKind::Lambda(lambda) => check_lambda(lambda, expected, ctx),
1620        ExprKind::IntLit(_) => {
1621            admit_refined_literal(expr, expected, ctx).or(Some(Ty::Base(BaseType::Int)))
1622        }
1623        ExprKind::FloatLit { .. } => {
1624            admit_refined_literal(expr, expected, ctx).or(Some(Ty::Base(BaseType::Float)))
1625        }
1626        // v0.86 (ADR 0112): a `Duration` literal always takes the base
1627        // `Duration` (no refined `Duration` types exist).
1628        ExprKind::DurationLit { .. } => Some(Ty::Base(BaseType::Duration)),
1629        ExprKind::StrLit(_) => {
1630            admit_refined_literal(expr, expected, ctx).or(Some(Ty::Base(BaseType::String)))
1631        }
1632        // An interpolated string (v0.43, ADR 0075). Each hole must type to a
1633        // base scalar (String/Int/Float/Bool) or a *refinement* of one — those
1634        // have a well-defined display form (Int/Float via the ADR 0074
1635        // `toString` contract, Bool as `true`/`false`; a refined value widens
1636        // to its base, e.g. `Subject` displays as its `String`). Records,
1637        // sums, opaque types (whose base is deliberately hidden — `.raw` it
1638        // first), and other types are rejected, foreclosing JS's
1639        // `[object Object]` footgun. The result is always a `String`.
1640        ExprKind::InterpStr(parts) => {
1641            for part in parts {
1642                let InterpPart::Hole(hole) = part else {
1643                    continue;
1644                };
1645                match type_of(hole, None, ctx) {
1646                    Some(ty) if interpolable(&ty) => {}
1647                    Some(other) => ctx.errors.push(
1648                        CompileError::new(
1649                            "bynk.types.interpolation_non_scalar",
1650                            hole.span,
1651                            format!("type `{}` has no string form here", other.display()),
1652                        )
1653                        .with_note(
1654                            "interpolation holes accept the base scalar types (String, Int, Float, Bool) or a refinement of one; map other values to a String first",
1655                        ),
1656                    ),
1657                    // The hole already produced its own error — don't pile on.
1658                    None => {}
1659                }
1660            }
1661            Some(Ty::Base(BaseType::String))
1662        }
1663        ExprKind::BoolLit(_) => Some(Ty::Base(BaseType::Bool)),
1664        // v0.20b: a list literal. Elements check against the expected
1665        // element type when one is supplied (so refined literals admit,
1666        // v0.9.4); an empty `[]` has no inferable element type without one.
1667        ExprKind::ListLit(elems) => {
1668            let expected_elem = expected.and_then(peel_to_list);
1669            if elems.is_empty() {
1670                match expected_elem {
1671                    Some(t) => Some(Ty::List(Box::new(t))),
1672                    None => {
1673                        ctx.errors.push(
1674                            CompileError::new(
1675                                "bynk.types.uninferable_element_type",
1676                                expr.span,
1677                                "an empty `[]` has no inferable element type",
1678                            )
1679                            .with_note(
1680                                "annotate the binding (`let xs: List[T] = []`) or use the empty list where a `List[T]` is expected",
1681                            ),
1682                        );
1683                        None
1684                    }
1685                }
1686            } else {
1687                let mut elem_ty: Option<Ty> = expected_elem;
1688                for e in elems {
1689                    let Some(t) = type_of(e, elem_ty.as_ref(), ctx) else {
1690                        continue;
1691                    };
1692                    match &elem_ty {
1693                        Some(et) => {
1694                            if !compatible(&t, et) {
1695                                ctx.errors.push(CompileError::new(
1696                                    "bynk.types.list_element_mismatch",
1697                                    e.span,
1698                                    format!(
1699                                        "list element has type `{}`, but the list's element type is `{}`",
1700                                        t.display(),
1701                                        et.display()
1702                                    ),
1703                                ));
1704                            }
1705                        }
1706                        None => elem_ty = Some(t),
1707                    }
1708                }
1709                elem_ty.map(|t| Ty::List(Box::new(t)))
1710            }
1711        }
1712        ExprKind::Ident(id) => {
1713            // v0.94 (ADR 0120): a bare `store Map` ident used as a **value** — not
1714            // a method receiver, which the `MethodCall` arm dispatches — is a lazy
1715            // `Query[V]` over the whole map (e.g. the `other` side of a join). It
1716            // is not in the value scope, so it never shadows a local.
1717            if ctx.lookup(id.name.as_str()).is_none()
1718                && let Some((_, v)) = ctx.store_maps.get(&id.name).cloned()
1719            {
1720                Some(Ty::Query(Box::new(v)))
1721            }
1722            // v0.9: a bare ident may name an HttpResult variant. Resolve to
1723            // HttpResult only when (a) the surrounding type implies it, or
1724            // (b) no user sum-type variant of the same name exists. This
1725            // keeps `NotFound` resolving to a user `StockError` variant
1726            // when the caller expects a domain Result.
1727            else if ctx.lookup(id.name.as_str()).is_none()
1728                && let Some(v) = http_variant(&id.name)
1729            {
1730                let user_owns = ctx.input.types.values().any(|t| {
1731                    matches!(&t.body, TypeBody::Sum(s)
1732                        if s.variants.iter().any(|var| var.name.name == id.name))
1733                });
1734                let http_implied = expected
1735                    .map(|t| peel_to_http_result(t).is_some())
1736                    .unwrap_or(false)
1737                    || peel_to_http_result(&ctx.return_ty).is_some();
1738                if http_implied || !user_owns {
1739                    check_http_variant(id.span, v, &[], expected, ctx)
1740                } else {
1741                    check_ident(id, expected, ctx)
1742                }
1743            } else if ctx.lookup(id.name.as_str()).is_none()
1744                && let Some(qv) = queue_variant(&id.name)
1745                && (expected.is_some_and(peel_to_queue_result)
1746                    || peel_to_queue_result(&ctx.return_ty))
1747            {
1748                // v0.44: a bare QueueResult variant (`Ack`) in a queue handler.
1749                check_queue_variant(id.span, qv, &[], ctx)
1750            } else {
1751                check_ident(id, expected, ctx)
1752            }
1753        }
1754        ExprKind::Paren(inner) => type_of(inner, expected, ctx),
1755        ExprKind::Call {
1756            name,
1757            type_args,
1758            args,
1759        } => {
1760            // v0.9: HttpResult variant call. Prefer HttpResult when the
1761            // surrounding type implies it; otherwise defer to fn/user-variant
1762            // resolution and only fall back to HttpResult when nothing else
1763            // owns the name.
1764            let user_owners: usize = ctx
1765                .input
1766                .types
1767                .values()
1768                .filter(|t| {
1769                    matches!(&t.body, TypeBody::Sum(s)
1770                        if s.variants.iter().any(|v| v.name.name == name.name))
1771                })
1772                .count();
1773            let http_implied = expected
1774                .map(|t| peel_to_http_result(t).is_some())
1775                .unwrap_or(false)
1776                || peel_to_http_result(&ctx.return_ty).is_some();
1777            let unowned = !ctx.input.fns.contains_key(&name.name) && user_owners == 0;
1778            if let Some(v) = http_variant(&name.name)
1779                && (http_implied || unowned)
1780            {
1781                check_http_variant(expr.span, v, args, expected, ctx)
1782            } else if let Some(qv) = queue_variant(&name.name)
1783                && (expected.is_some_and(peel_to_queue_result)
1784                    || peel_to_queue_result(&ctx.return_ty))
1785            {
1786                // v0.44: a QueueResult variant call (`Retry(reason)`).
1787                check_queue_variant(expr.span, qv, args, ctx)
1788            } else {
1789                check_call(name, type_args, args, expr.span, ctx)
1790            }
1791        }
1792        ExprKind::UnaryOp(op, inner) => check_unary(*op, inner, expr.span, ctx),
1793        ExprKind::BinOp(op, lhs, rhs) => check_binop(*op, lhs, rhs, ctx),
1794        ExprKind::Block(b) => type_of_block(b, expected, ctx),
1795        ExprKind::If {
1796            cond,
1797            then_block,
1798            else_block,
1799        } => check_if(cond, then_block, else_block, expr.span, expected, ctx),
1800        ExprKind::Ok(inner) => check_ok(inner, expr.span, expected, ctx),
1801        ExprKind::Err(inner) => check_err(inner, expr.span, expected, ctx),
1802        ExprKind::Some(inner) => check_some(inner, expr.span, expected, ctx),
1803        ExprKind::None => check_none(expr.span, expected, ctx),
1804        ExprKind::Question(inner) => check_question(inner, expr.span, ctx),
1805        ExprKind::ConstructorCall {
1806            type_name,
1807            method,
1808            args,
1809        } => {
1810            if type_name.name == HTTP_RESULT {
1811                if let Some(v) = http_variant(&method.name) {
1812                    check_http_variant(expr.span, v, args, expected, ctx)
1813                } else {
1814                    ctx.errors.push(CompileError::new(
1815                        "bynk.types.unknown_static_member",
1816                        method.span,
1817                        format!("`HttpResult` has no variant named `{}`", method.name),
1818                    ));
1819                    None
1820                }
1821            } else if type_name.name == QUEUE_RESULT {
1822                if let Some(qv) = queue_variant(&method.name) {
1823                    check_queue_variant(expr.span, qv, args, ctx)
1824                } else {
1825                    ctx.errors.push(CompileError::new(
1826                        "bynk.types.unknown_static_member",
1827                        method.span,
1828                        format!("`QueueResult` has no variant named `{}`", method.name),
1829                    ));
1830                    None
1831                }
1832            } else {
1833                check_static_call(type_name, method, args, expr.span, ctx)
1834            }
1835        }
1836        ExprKind::RecordConstruction { type_name, fields } => {
1837            check_record_construction(type_name, fields, expr.span, ctx)
1838        }
1839        ExprKind::FieldAccess { receiver, field } => {
1840            // v0.9: `HttpResult.Variant` qualified nullary variant access.
1841            if let ExprKind::Ident(id) = &receiver.kind
1842                && ctx.lookup(id.name.as_str()).is_none()
1843                && id.name == HTTP_RESULT
1844            {
1845                if let Some(v) = http_variant(&field.name) {
1846                    if !matches!(v.payload, HttpVariantPayload::None) {
1847                        ctx.errors.push(CompileError::new(
1848                            "bynk.types.variant_missing_payload",
1849                            field.span,
1850                            format!(
1851                                "`HttpResult.{}` has a payload — call it with an argument",
1852                                v.name
1853                            ),
1854                        ));
1855                        return None;
1856                    }
1857                    check_http_variant(field.span, v, &[], expected, ctx)
1858                } else {
1859                    ctx.errors.push(CompileError::new(
1860                        "bynk.types.unknown_static_member",
1861                        field.span,
1862                        format!("`HttpResult` has no variant named `{}`", field.name),
1863                    ));
1864                    None
1865                }
1866            } else {
1867                check_field_access(receiver, field, ctx)
1868            }
1869        }
1870        ExprKind::MethodCall {
1871            receiver,
1872            method,
1873            type_args,
1874            args,
1875        } => {
1876            // v0.82 (ADR 0110): `<map>.<op>(…)` on a `store Map[K, V]` field —
1877            // effectful storage-map operations, dispatched by receiver provenance
1878            // (a bare ident that names a store map; not in the value scope).
1879            if let ExprKind::Ident(id) = &receiver.kind
1880                && let Some((k, v)) = ctx.store_maps.get(&id.name).cloned()
1881            {
1882                // v0.91 (ADR 0115): a query builder/terminal lifts the store map
1883                // into a lazy `Query[V]` over its values; an entry op
1884                // (`put`/`get`/…) stays the effectful map operation.
1885                if is_query_op(&method.name) {
1886                    // v0.107 (slice 4): record the receiver's lifted `Query[V]` type
1887                    // (otherwise unrecorded — the dispatch keys off the store field,
1888                    // not a typed receiver). This is the receiver's true type for any
1889                    // query op; its load-bearing use is the linearity pass, which now
1890                    // sees a held-bearing collection and lends the closure parameter of
1891                    // `forEach`/`parTraverse` as borrowed — otherwise `ty_of(receiver)`
1892                    // is `None` and the no-consume-in-a-broadcast rule is silently
1893                    // unenforced.
1894                    ctx.expr_types
1895                        .insert(receiver.span, Ty::Query(Box::new(v.clone())));
1896                    check_query_kernel_method(method, args, &v, expr.span, ctx)
1897                } else {
1898                    check_store_map_op(method, args, &k, &v, expr.span, ctx)
1899                }
1900            }
1901            // v0.83: `<set>.<op>(…)` on a `store Set[T]` field — effectful
1902            // storage-set ops, dispatched by receiver provenance.
1903            else if let ExprKind::Ident(id) = &receiver.kind
1904                && let Some(t) = ctx.store_sets.get(&id.name).cloned()
1905            {
1906                check_store_set_op(method, args, &t, expr.span, ctx)
1907            }
1908            // v0.87 (ADR 0113): `<cache>.<op>(…)` on a `store Cache[K, V]` field —
1909            // the storage-map ops plus a `given Clock` requirement (eviction).
1910            else if let ExprKind::Ident(id) = &receiver.kind
1911                && let Some((k, v, _ttl)) = ctx.store_caches.get(&id.name).cloned()
1912            {
1913                check_store_cache_op(method, args, &k, &v, expr.span, ctx)
1914            }
1915            // v0.95 (ADR 0121): `<log>.<op>(…)` on a `store Log[T]` field —
1916            // `append` is the effectful non-idempotent write (`given Clock`); the
1917            // time-window roots and general builders lift the log into a lazy
1918            // `Query[T]` over its entry values.
1919            else if let ExprKind::Ident(id) = &receiver.kind
1920                && let Some(t) = ctx.store_logs.get(&id.name).cloned()
1921            {
1922                check_store_log_op(method, args, &t, expr.span, ctx)
1923            }
1924            // v0.98 (ADR 0125): `<cell>.update(f)` on a `store Cell[T]` field — the
1925            // one method-shaped cell op (read is the bare name, write is `:=`).
1926            // Dispatched by receiver provenance.
1927            else if let ExprKind::Ident(id) = &receiver.kind
1928                && let Some(t) = ctx.store_cells.get(&id.name).cloned()
1929            {
1930                check_store_cell_op(method, args, &t, expr.span, ctx)
1931            }
1932            // v0.9: `HttpResult.Variant(args)` — explicit HttpResult construction.
1933            else if let ExprKind::Ident(id) = &receiver.kind
1934                && ctx.lookup(id.name.as_str()).is_none()
1935                && id.name == HTTP_RESULT
1936            {
1937                if let Some(v) = http_variant(&method.name) {
1938                    check_http_variant(expr.span, v, args, expected, ctx)
1939                } else {
1940                    ctx.errors.push(CompileError::new(
1941                        "bynk.types.unknown_static_member",
1942                        method.span,
1943                        format!("`HttpResult` has no variant named `{}`", method.name),
1944                    ));
1945                    None
1946                }
1947            } else {
1948                check_method_call(receiver, method, type_args, args, expr.span, expected, ctx)
1949            }
1950        }
1951        ExprKind::Match { discriminant, arms } => {
1952            check_match(discriminant, arms, expr.span, expected, ctx)
1953        }
1954        ExprKind::Is { value, pattern } => check_is(value, pattern, expr.span, ctx),
1955        ExprKind::UnitLit => Some(Ty::Unit),
1956        ExprKind::EffectPure(inner) => {
1957            let expected_inner = match expected {
1958                Some(Ty::Effect(t)) => Some((**t).clone()),
1959                _ => None,
1960            };
1961            let inner_ty = type_of(inner, expected_inner.as_ref(), ctx)?;
1962            Some(Ty::Effect(Box::new(inner_ty)))
1963        }
1964        ExprKind::RecordSpread {
1965            type_name,
1966            base,
1967            overrides,
1968        } => check_record_spread(
1969            type_name.as_ref(),
1970            base,
1971            overrides,
1972            expr.span,
1973            expected,
1974            ctx,
1975        ),
1976        ExprKind::Expect(inner) => check_expect(inner, expr.span, ctx),
1977        ExprKind::Val { type_ref, args } => check_val(type_ref, args, expr.span, ctx),
1978    };
1979    if let Some(ty) = &ty {
1980        ctx.expr_types.insert(expr.span, ty.clone());
1981    }
1982    ty
1983}
1984
1985// ==== Peel helpers (unwrap Effect / Result / Option / List / Map) ====
1986
1987/// Peel one optional `Effect[_]` wrapper to expose an underlying `HttpResult[T]`.
1988fn peel_to_http_result(ty: &Ty) -> Option<Ty> {
1989    match ty {
1990        Ty::HttpResult(inner) => Some((**inner).clone()),
1991        Ty::Effect(inner) => peel_to_http_result(inner),
1992        _ => None,
1993    }
1994}
1995
1996/// v0.44: peel an optional `Effect[_]` to detect an underlying `QueueResult`.
1997fn peel_to_queue_result(ty: &Ty) -> bool {
1998    match ty {
1999        Ty::QueueResult => true,
2000        Ty::Effect(inner) => peel_to_queue_result(inner),
2001        _ => false,
2002    }
2003}
2004
2005fn surrounding_result(expected: Option<&Ty>, return_ty: &Ty) -> Option<(Ty, Ty)> {
2006    if let Some(t) = expected
2007        && let Some(pair) = peel_to_result(t)
2008    {
2009        return Some(pair);
2010    }
2011    peel_to_result(return_ty)
2012}
2013
2014/// Peel one optional `Effect[_]` wrapper to expose an underlying `Result[T, E]`.
2015/// Used by `Ok` / `Err` checking in v0.7.1 so that bare constructors in
2016/// `Effect[Result[T, E]]` tail positions can pick up the surrounding type's
2017/// parameters via the auto-lift propagation.
2018fn peel_to_result(ty: &Ty) -> Option<(Ty, Ty)> {
2019    match ty {
2020        Ty::Result(t, e) => Some(((**t).clone(), (**e).clone())),
2021        Ty::Effect(inner) => peel_to_result(inner),
2022        _ => None,
2023    }
2024}
2025
2026/// Companion to `peel_to_result` for `Option[T]`.
2027fn peel_to_option(ty: &Ty) -> Option<Ty> {
2028    match ty {
2029        Ty::Option(t) => Some((**t).clone()),
2030        Ty::Effect(inner) => peel_to_option(inner),
2031        _ => None,
2032    }
2033}
2034
2035/// Companion to `peel_to_result` for `List[T]` (v0.20b) — the expected
2036/// element type of a list literal, looking through `Effect[_]` so tail
2037/// auto-lift positions still propagate it.
2038fn peel_to_list(ty: &Ty) -> Option<Ty> {
2039    match ty {
2040        Ty::List(t) => Some((**t).clone()),
2041        Ty::Effect(inner) => peel_to_list(inner),
2042        _ => None,
2043    }
2044}
2045
2046/// Companion to `peel_to_list` for `Map[K, V]` (v0.20b).
2047fn peel_to_map(ty: &Ty) -> Option<(Ty, Ty)> {
2048    match ty {
2049        Ty::Map(k, v) => Some(((**k).clone(), (**v).clone())),
2050        Ty::Effect(inner) => peel_to_map(inner),
2051        _ => None,
2052    }
2053}
2054
2055// ==== Structural compatibility and variant introspection ====
2056
2057/// A flattened view of a type's variants (name + payload types).
2058struct VariantInfo {
2059    name: String,
2060    payload: Vec<(String, Ty)>,
2061}
2062
2063/// Project a return type produced in the consumed context's namespace into
2064/// the caller's namespace by re-resolving named types that exist on both
2065/// sides. The structural shape stays the same; the brand changes.
2066fn rebrand_return_type(t: &Ty, caller_types: &HashMap<String, TypeDecl>) -> Ty {
2067    match t {
2068        Ty::Named { name, kind } => {
2069            // If the caller's namespace has the same name, prefer the caller's
2070            // view (it carries the caller's brand at emission time). Otherwise
2071            // keep the consumed-context name; the caller can hold it opaquely.
2072            if let Some(decl) = caller_types.get(name) {
2073                named_ty(decl)
2074            } else {
2075                Ty::Named {
2076                    name: name.clone(),
2077                    kind: kind.clone(),
2078                }
2079            }
2080        }
2081        Ty::Result(t, e) => Ty::Result(
2082            Box::new(rebrand_return_type(t, caller_types)),
2083            Box::new(rebrand_return_type(e, caller_types)),
2084        ),
2085        Ty::Option(t) => Ty::Option(Box::new(rebrand_return_type(t, caller_types))),
2086        Ty::Effect(t) => Ty::Effect(Box::new(rebrand_return_type(t, caller_types))),
2087        Ty::HttpResult(t) => Ty::HttpResult(Box::new(rebrand_return_type(t, caller_types))),
2088        Ty::List(t) => Ty::List(Box::new(rebrand_return_type(t, caller_types))),
2089        Ty::Query(t) => Ty::Query(Box::new(rebrand_return_type(t, caller_types))),
2090        Ty::Stream(t) => Ty::Stream(Box::new(rebrand_return_type(t, caller_types))),
2091        Ty::Connection(t) => Ty::Connection(Box::new(rebrand_return_type(t, caller_types))),
2092        Ty::Map(k, v) => Ty::Map(
2093            Box::new(rebrand_return_type(k, caller_types)),
2094            Box::new(rebrand_return_type(v, caller_types)),
2095        ),
2096        Ty::Base(_)
2097        | Ty::QueueResult
2098        | Ty::ValidationError
2099        | Ty::JsonError
2100        | Ty::Unit
2101        | Ty::Actor(_)
2102        | Ty::ActorSum(_) => t.clone(),
2103        // v0.20a: function types are confined to non-boundary positions
2104        // (`bynk.types.function_at_boundary`), so a cross-context return can
2105        // never carry one; Vars never escape call checking.
2106        Ty::Fn { .. } | Ty::Var(_) => t.clone(),
2107    }
2108}
2109
2110/// Structural compatibility check for values crossing a context boundary
2111/// (v0.6 §4.3). The two types may be expressed in different namespaces
2112/// (caller-side / callee-side type tables), so we walk them in parallel
2113/// against their respective tables.
2114fn structurally_compatible(
2115    arg: &Ty,
2116    param: &Ty,
2117    arg_types: &HashMap<String, TypeDecl>,
2118    param_types: &HashMap<String, TypeDecl>,
2119) -> bool {
2120    structurally_compatible_inner(arg, param, arg_types, param_types, &mut HashSet::new())
2121}
2122
2123fn structurally_compatible_inner(
2124    arg: &Ty,
2125    param: &Ty,
2126    arg_types: &HashMap<String, TypeDecl>,
2127    param_types: &HashMap<String, TypeDecl>,
2128    visited: &mut HashSet<(String, String)>,
2129) -> bool {
2130    match (arg, param) {
2131        (Ty::Base(a), Ty::Base(b)) => a == b,
2132        (Ty::ValidationError, Ty::ValidationError) => true,
2133        (Ty::JsonError, Ty::JsonError) => true,
2134        (Ty::Unit, Ty::Unit) => true,
2135        (Ty::Result(t1, e1), Ty::Result(t2, e2)) => {
2136            structurally_compatible_inner(t1, t2, arg_types, param_types, visited)
2137                && structurally_compatible_inner(e1, e2, arg_types, param_types, visited)
2138        }
2139        (Ty::Option(a), Ty::Option(b)) => {
2140            structurally_compatible_inner(a, b, arg_types, param_types, visited)
2141        }
2142        (Ty::Effect(a), Ty::Effect(b)) => {
2143            structurally_compatible_inner(a, b, arg_types, param_types, visited)
2144        }
2145        (Ty::HttpResult(a), Ty::HttpResult(b)) => {
2146            structurally_compatible_inner(a, b, arg_types, param_types, visited)
2147        }
2148        (Ty::Named { name: an, .. }, Ty::Named { name: bn, .. }) => {
2149            // Cycle break: once we've started comparing (an, bn) we trust
2150            // the recursive case to succeed.
2151            let key = (an.clone(), bn.clone());
2152            if !visited.insert(key.clone()) {
2153                return true;
2154            }
2155            let ok = structural_compare_named(an, bn, arg_types, param_types, visited);
2156            visited.remove(&key);
2157            ok
2158        }
2159        // Refined-named widens to its base; tolerate one-sided widening only
2160        // when comparing within the same nominal name (handled above) or when
2161        // the param accepts a plain base.
2162        (
2163            Ty::Named {
2164                kind: NamedKind::Refined(b),
2165                ..
2166            },
2167            Ty::Base(target),
2168        ) => b == target,
2169        _ => false,
2170    }
2171}
2172
2173fn structural_compare_named(
2174    arg_name: &str,
2175    param_name: &str,
2176    arg_types: &HashMap<String, TypeDecl>,
2177    param_types: &HashMap<String, TypeDecl>,
2178    visited: &mut HashSet<(String, String)>,
2179) -> bool {
2180    // The "same nominal name" case is the most common: both sides derive
2181    // the same commons type. Compare their structural shapes.
2182    let Some(arg_decl) = arg_types.get(arg_name) else {
2183        return false;
2184    };
2185    let Some(param_decl) = param_types.get(param_name) else {
2186        return false;
2187    };
2188    match (&arg_decl.body, &param_decl.body) {
2189        (
2190            TypeBody::Refined {
2191                base: ab,
2192                refinement: ar,
2193                ..
2194            },
2195            TypeBody::Refined {
2196                base: bb,
2197                refinement: br,
2198                ..
2199            },
2200        ) => {
2201            if ab != bb {
2202                return false;
2203            }
2204            refinements_match(ar.as_ref(), br.as_ref())
2205        }
2206        (
2207            TypeBody::Opaque {
2208                base: ab,
2209                refinement: ar,
2210                ..
2211            },
2212            TypeBody::Opaque {
2213                base: bb,
2214                refinement: br,
2215                ..
2216            },
2217        ) => {
2218            // Opaque types must share a name to be compatible (a context's
2219            // opaque cannot be reinterpreted as a different context's opaque).
2220            if arg_name != param_name {
2221                return false;
2222            }
2223            if ab != bb {
2224                return false;
2225            }
2226            refinements_match(ar.as_ref(), br.as_ref())
2227        }
2228        (TypeBody::Record(a), TypeBody::Record(b)) => {
2229            if a.fields.len() != b.fields.len() {
2230                return false;
2231            }
2232            for af in &a.fields {
2233                let Some(bf) = b.fields.iter().find(|f| f.name.name == af.name.name) else {
2234                    return false;
2235                };
2236                let at = resolve_type_ref(&af.type_ref, arg_types);
2237                let bt = resolve_type_ref(&bf.type_ref, param_types);
2238                let (Some(at), Some(bt)) = (at, bt) else {
2239                    return false;
2240                };
2241                if !structurally_compatible_inner(&at, &bt, arg_types, param_types, visited) {
2242                    return false;
2243                }
2244            }
2245            true
2246        }
2247        (TypeBody::Sum(a), TypeBody::Sum(b)) => {
2248            if a.variants.len() != b.variants.len() {
2249                return false;
2250            }
2251            for av in &a.variants {
2252                let Some(bv) = b.variants.iter().find(|v| v.name.name == av.name.name) else {
2253                    return false;
2254                };
2255                if av.payload.len() != bv.payload.len() {
2256                    return false;
2257                }
2258                for (af, bf) in av.payload.iter().zip(bv.payload.iter()) {
2259                    if af.name.name != bf.name.name {
2260                        return false;
2261                    }
2262                    let at = resolve_type_ref(&af.type_ref, arg_types);
2263                    let bt = resolve_type_ref(&bf.type_ref, param_types);
2264                    let (Some(at), Some(bt)) = (at, bt) else {
2265                        return false;
2266                    };
2267                    if !structurally_compatible_inner(&at, &bt, arg_types, param_types, visited) {
2268                        return false;
2269                    }
2270                }
2271            }
2272            true
2273        }
2274        _ => false,
2275    }
2276}
2277
2278fn refinements_match(a: Option<&Refinement>, b: Option<&Refinement>) -> bool {
2279    match (a, b) {
2280        (None, None) => true,
2281        (Some(_), None) => true, // sending side is more restrictive — receiving is more permissive
2282        (None, Some(_)) => false,
2283        (Some(a), Some(b)) => {
2284            if a.predicates.len() != b.predicates.len() {
2285                return false;
2286            }
2287            // Exact match required (per spec §4.3 conservative rule).
2288            // Predicate order matters here; refinements are conventionally
2289            // written in a fixed order.
2290            for (pa, pb) in a.predicates.iter().zip(b.predicates.iter()) {
2291                if !predicate_eq(&pa.kind, &pb.kind) {
2292                    return false;
2293                }
2294            }
2295            true
2296        }
2297    }
2298}
2299
2300fn predicate_eq(a: &PredKind, b: &PredKind) -> bool {
2301    match (a, b) {
2302        (PredKind::Matches(x), PredKind::Matches(y)) => x == y,
2303        (PredKind::InRange(a1, a2), PredKind::InRange(b1, b2)) => {
2304            a1.value == b1.value && a2.value == b2.value
2305        }
2306        (PredKind::InRangeF(a1, a2), PredKind::InRangeF(b1, b2)) => {
2307            a1.value == b1.value && a2.value == b2.value
2308        }
2309        (PredKind::MinLength(a), PredKind::MinLength(b)) => a == b,
2310        (PredKind::MaxLength(a), PredKind::MaxLength(b)) => a == b,
2311        (PredKind::Length(a), PredKind::Length(b)) => a == b,
2312        (PredKind::NonNegative, PredKind::NonNegative) => true,
2313        (PredKind::Positive, PredKind::Positive) => true,
2314        (PredKind::NonEmpty, PredKind::NonEmpty) => true,
2315        _ => false,
2316    }
2317}
2318
2319fn variants_of(ty: &Ty, types: &HashMap<String, TypeDecl>) -> Option<Vec<VariantInfo>> {
2320    match ty {
2321        Ty::Named {
2322            kind: NamedKind::Sum,
2323            name,
2324        } => {
2325            let decl = types.get(name)?;
2326            if let TypeBody::Sum(s) = &decl.body {
2327                Some(
2328                    s.variants
2329                        .iter()
2330                        .map(|v| VariantInfo {
2331                            name: v.name.name.clone(),
2332                            payload: v
2333                                .payload
2334                                .iter()
2335                                .map(|f| {
2336                                    let t = resolve_type_ref(&f.type_ref, types)
2337                                        .unwrap_or(Ty::Base(BaseType::Int));
2338                                    (f.name.name.clone(), t)
2339                                })
2340                                .collect(),
2341                        })
2342                        .collect(),
2343                )
2344            } else {
2345                None
2346            }
2347        }
2348        Ty::Result(t, e) => Some(vec![
2349            VariantInfo {
2350                name: "Ok".to_string(),
2351                payload: vec![("value".to_string(), (**t).clone())],
2352            },
2353            VariantInfo {
2354                name: "Err".to_string(),
2355                payload: vec![("error".to_string(), (**e).clone())],
2356            },
2357        ]),
2358        Ty::Option(t) => Some(vec![
2359            VariantInfo {
2360                name: "Some".to_string(),
2361                payload: vec![("value".to_string(), (**t).clone())],
2362            },
2363            VariantInfo {
2364                name: "None".to_string(),
2365                payload: vec![],
2366            },
2367        ]),
2368        // v0.52: a multi-actor sum matches on the resolved actor. Each member's
2369        // variant is named by the actor and binds that actor's identity
2370        // *directly* (`User(u)` ⇒ `u : UserId` — the arm already names the
2371        // actor, so no `.identity` indirection). A unit-identity member
2372        // (`Visitor`, `Webhook`) binds nothing.
2373        Ty::ActorSum(members) => Some(
2374            members
2375                .iter()
2376                .map(|(name, id)| VariantInfo {
2377                    name: name.clone(),
2378                    payload: match id {
2379                        Ty::Unit => vec![],
2380                        _ => vec![("identity".to_string(), id.clone())],
2381                    },
2382                })
2383                .collect(),
2384        ),
2385        Ty::HttpResult(t) => Some(
2386            HTTP_VARIANTS
2387                .iter()
2388                .map(|v| VariantInfo {
2389                    name: v.name.to_string(),
2390                    payload: match v.payload {
2391                        HttpVariantPayload::None => vec![],
2392                        HttpVariantPayload::Value => vec![("value".to_string(), (**t).clone())],
2393                        HttpVariantPayload::Message => {
2394                            vec![("message".to_string(), Ty::Base(BaseType::String))]
2395                        }
2396                        HttpVariantPayload::Location => {
2397                            vec![("location".to_string(), Ty::Base(BaseType::String))]
2398                        }
2399                        HttpVariantPayload::Streamed => vec![(
2400                            "stream".to_string(),
2401                            Ty::Stream(Box::new(Ty::Base(BaseType::String))),
2402                        )],
2403                        // v0.111: the first two-field payload. Field names are
2404                        // kept byte-identical to the runtime union in
2405                        // `bynk-emit/runtime/src/http.ts`. An `HttpResult` is
2406                        // construct-only in handler position (never scrutinised),
2407                        // so this binding exists for exhaustiveness, not a path.
2408                        HttpVariantPayload::Raw => vec![
2409                            ("body".to_string(), Ty::Base(BaseType::Bytes)),
2410                            ("contentType".to_string(), Ty::Base(BaseType::String)),
2411                        ],
2412                    },
2413                })
2414                .collect(),
2415        ),
2416        _ => None,
2417    }
2418}
2419
2420// ── v0.9.2: agent state-field zeroability ──────────────────────────────────
2421//
2422// Fresh agent state is the zero-value record (finding #10): a never-seen key
2423// reads `0` / `false` / `""` / `None` rather than `undefined`. A type is
2424// *zeroable* when it has a defined zero; agent state fields must be zeroable,
2425// since a fresh key has no committed value to load. Non-zeroable fields (a
2426// non-Option sum, an opaque type, or a refined type whose refinement excludes
2427// the underlying zero) are a compile error until explicit-initialiser syntax
2428// lands.
2429
2430#[cfg(test)]
2431mod generics_tests {
2432    use super::*;
2433
2434    fn var(n: &str) -> Ty {
2435        Ty::Var(n.to_string())
2436    }
2437    fn int() -> Ty {
2438        Ty::Base(BaseType::Int)
2439    }
2440
2441    #[test]
2442    fn unify_binds_and_holds() {
2443        let mut s = HashMap::new();
2444        assert!(unify(&var("A"), &int(), &mut s));
2445        assert_eq!(s.get("A"), Some(&int()));
2446        // Same binding again: fine. A different one: conflict.
2447        assert!(unify(&var("A"), &int(), &mut s));
2448        assert!(!unify(&var("A"), &Ty::Base(BaseType::String), &mut s));
2449    }
2450
2451    #[test]
2452    fn unify_walks_structure() {
2453        let mut s = HashMap::new();
2454        let pattern = Ty::Fn {
2455            params: vec![var("A")],
2456            ret: Box::new(Ty::Effect(Box::new(var("B")))),
2457        };
2458        let actual = Ty::Fn {
2459            params: vec![int()],
2460            ret: Box::new(Ty::Effect(Box::new(Ty::Base(BaseType::String)))),
2461        };
2462        assert!(unify(&pattern, &actual, &mut s));
2463        assert_eq!(s.get("A"), Some(&int()));
2464        assert_eq!(s.get("B"), Some(&Ty::Base(BaseType::String)));
2465    }
2466
2467    #[test]
2468    fn substitute_grounds_fully() {
2469        let mut s = HashMap::new();
2470        s.insert("A".to_string(), int());
2471        let t = Ty::Option(Box::new(Ty::Fn {
2472            params: vec![var("A")],
2473            ret: Box::new(var("A")),
2474        }));
2475        let g = substitute(&t, &s);
2476        assert!(!contains_var(&g));
2477    }
2478
2479    /// The §2 invariant (pinned per the plan): every expected-driven feature
2480    /// in `type_of` matches *concrete* `Ty` variants, so a Var-bearing
2481    /// expected imposes no constraint — `compatible` must simply reject
2482    /// Var-vs-ground pairs rather than panic or accept.
2483    #[test]
2484    fn var_bearing_expected_is_benign() {
2485        assert!(!compatible(&int(), &var("A")));
2486        assert!(!compatible(&var("A"), &int()));
2487        // Rigid vars: name equality only.
2488        assert!(compatible(&var("A"), &var("A")));
2489        assert!(!compatible(&var("A"), &var("B")));
2490    }
2491}
2492
2493/// Characterization pins for `checker.rs`'s pure free functions (v0.29.10
2494/// slice 0). These pin *current* behaviour ahead of the upcoming module split
2495/// so the verbatim moves are verifiable. Any surprising behaviour is pinned
2496/// as-is, flagged with a comment — these are not specifications.
2497#[cfg(test)]
2498mod pure_helper_pins {
2499    use super::*;
2500    use bynk_syntax::ast::{FloatBound, RefinementPred};
2501
2502    // -- small constructors ------------------------------------------------
2503
2504    fn sp() -> Span {
2505        Span::new(0, 0)
2506    }
2507    fn ident(n: &str) -> Ident {
2508        Ident {
2509            name: n.to_string(),
2510            span: sp(),
2511        }
2512    }
2513    fn var(n: &str) -> Ty {
2514        Ty::Var(n.to_string())
2515    }
2516    fn int() -> Ty {
2517        Ty::Base(BaseType::Int)
2518    }
2519    fn string() -> Ty {
2520        Ty::Base(BaseType::String)
2521    }
2522    fn expr(kind: ExprKind) -> Expr {
2523        Expr { kind, span: sp() }
2524    }
2525    fn pred(kind: PredKind) -> RefinementPred {
2526        RefinementPred { kind, span: sp() }
2527    }
2528    fn refinement(preds: Vec<PredKind>) -> Refinement {
2529        Refinement {
2530            predicates: preds.into_iter().map(pred).collect(),
2531            span: sp(),
2532        }
2533    }
2534    fn fbound(value: f64) -> FloatBound {
2535        FloatBound {
2536            value,
2537            lexeme: value.to_string(),
2538            span: Span::new(0, 0),
2539        }
2540    }
2541    fn ibound(value: i64) -> IntBound {
2542        IntBound {
2543            value,
2544            span: Span::new(0, 0),
2545        }
2546    }
2547    /// An `InRange` predicate from two int values (test convenience).
2548    fn in_range(lo: i64, hi: i64) -> PredKind {
2549        PredKind::InRange(ibound(lo), ibound(hi))
2550    }
2551    fn refined_decl(name: &str, base: BaseType, refinement: Option<Refinement>) -> TypeDecl {
2552        TypeDecl {
2553            name: ident(name),
2554            body: TypeBody::Refined {
2555                base,
2556                base_span: sp(),
2557                refinement,
2558            },
2559            documentation: None,
2560            span: sp(),
2561            trivia: bynk_syntax::ast::Trivia::default(),
2562        }
2563    }
2564    fn record_decl(name: &str) -> TypeDecl {
2565        TypeDecl {
2566            name: ident(name),
2567            body: TypeBody::Record(bynk_syntax::ast::RecordBody {
2568                fields: vec![],
2569                span: sp(),
2570            }),
2571            documentation: None,
2572            span: sp(),
2573            trivia: bynk_syntax::ast::Trivia::default(),
2574        }
2575    }
2576
2577    // -- unify -------------------------------------------------------------
2578
2579    #[test]
2580    fn unify_identical_concrete_types() {
2581        let mut s = HashMap::new();
2582        assert!(unify(&int(), &int(), &mut s));
2583        assert!(s.is_empty());
2584    }
2585
2586    #[test]
2587    fn unify_var_binds_in_subst() {
2588        let mut s = HashMap::new();
2589        assert!(unify(&var("A"), &string(), &mut s));
2590        assert_eq!(s.get("A"), Some(&string()));
2591    }
2592
2593    #[test]
2594    fn unify_nested_generic_binds() {
2595        // List[A] vs List[Int] binds A := Int.
2596        let mut s = HashMap::new();
2597        let pat = Ty::List(Box::new(var("A")));
2598        let act = Ty::List(Box::new(int()));
2599        assert!(unify(&pat, &act, &mut s));
2600        assert_eq!(s.get("A"), Some(&int()));
2601    }
2602
2603    #[test]
2604    fn unify_surprise_concrete_mismatch_returns_true() {
2605        // SURPRISING (pinned as-is): `unify`'s catch-all is `_ => true`, so a
2606        // ground-vs-ground mismatch (Int vs String) and a constructor mismatch
2607        // (List vs Option) both *succeed* here — `compatible` owns those
2608        // diagnostics post-substitution, not `unify`.
2609        let mut s = HashMap::new();
2610        assert!(unify(&int(), &string(), &mut s));
2611        assert!(unify(
2612            &Ty::List(Box::new(int())),
2613            &Ty::Option(Box::new(int())),
2614            &mut s,
2615        ));
2616        // The only false paths: a Var rebind conflict and an Fn arity mismatch.
2617        let mut s2 = HashMap::new();
2618        assert!(unify(&var("A"), &int(), &mut s2));
2619        assert!(!unify(&var("A"), &string(), &mut s2));
2620        let mut s3 = HashMap::new();
2621        let f1 = Ty::Fn {
2622            params: vec![int()],
2623            ret: Box::new(int()),
2624        };
2625        let f2 = Ty::Fn {
2626            params: vec![int(), int()],
2627            ret: Box::new(int()),
2628        };
2629        assert!(!unify(&f1, &f2, &mut s3));
2630    }
2631
2632    // -- substitute --------------------------------------------------------
2633
2634    #[test]
2635    fn substitute_replaces_bound_var() {
2636        let mut s = HashMap::new();
2637        s.insert("A".to_string(), int());
2638        assert_eq!(substitute(&var("A"), &s), int());
2639    }
2640
2641    #[test]
2642    fn substitute_recurses_into_nested() {
2643        let mut s = HashMap::new();
2644        s.insert("A".to_string(), string());
2645        let t = Ty::Map(Box::new(var("A")), Box::new(int()));
2646        assert_eq!(
2647            substitute(&t, &s),
2648            Ty::Map(Box::new(string()), Box::new(int())),
2649        );
2650    }
2651
2652    #[test]
2653    fn substitute_leaves_unbound_var_alone() {
2654        let s = HashMap::new();
2655        assert_eq!(substitute(&var("Z"), &s), var("Z"));
2656    }
2657
2658    // -- contains_var / contains_flexible_var ------------------------------
2659
2660    #[test]
2661    fn contains_var_positive_and_negative() {
2662        assert!(contains_var(&Ty::Option(Box::new(var("A")))));
2663        assert!(!contains_var(&Ty::Option(Box::new(int()))));
2664        assert!(!contains_var(&int()));
2665    }
2666
2667    #[test]
2668    fn contains_flexible_var_respects_rigid_set() {
2669        let mut rigid = HashSet::new();
2670        rigid.insert("A".to_string());
2671        // A is rigid → not flexible.
2672        assert!(!contains_flexible_var(&var("A"), &rigid));
2673        // B is not rigid → flexible.
2674        assert!(contains_flexible_var(&var("B"), &rigid));
2675        // No vars at all → not flexible.
2676        assert!(!contains_flexible_var(&int(), &rigid));
2677    }
2678
2679    // -- peel_to_* ---------------------------------------------------------
2680
2681    #[test]
2682    fn peel_to_result_matches_and_misses() {
2683        let r = Ty::Result(Box::new(int()), Box::new(string()));
2684        assert_eq!(peel_to_result(&r), Some((int(), string())));
2685        assert_eq!(peel_to_result(&int()), None);
2686        // Pinned: peels through Effect[_].
2687        assert_eq!(
2688            peel_to_result(&Ty::Effect(Box::new(r))),
2689            Some((int(), string()))
2690        );
2691    }
2692
2693    #[test]
2694    fn peel_to_option_matches_and_misses() {
2695        assert_eq!(peel_to_option(&Ty::Option(Box::new(int()))), Some(int()));
2696        assert_eq!(peel_to_option(&int()), None);
2697    }
2698
2699    #[test]
2700    fn peel_to_list_matches_and_misses() {
2701        assert_eq!(peel_to_list(&Ty::List(Box::new(string()))), Some(string()));
2702        assert_eq!(peel_to_list(&int()), None);
2703    }
2704
2705    #[test]
2706    fn peel_to_map_matches_and_misses() {
2707        let m = Ty::Map(Box::new(string()), Box::new(int()));
2708        assert_eq!(peel_to_map(&m), Some((string(), int())));
2709        assert_eq!(peel_to_map(&int()), None);
2710    }
2711
2712    #[test]
2713    fn peel_to_http_result_matches_and_misses() {
2714        assert_eq!(
2715            peel_to_http_result(&Ty::HttpResult(Box::new(int()))),
2716            Some(int()),
2717        );
2718        assert_eq!(peel_to_http_result(&int()), None);
2719    }
2720
2721    // -- maybe_auto_lift ---------------------------------------------------
2722
2723    #[test]
2724    fn maybe_auto_lift_lifts_into_expected_effect() {
2725        // T lifts to Effect[T] when expected is Effect[T] and T is not effectful.
2726        let expected = Ty::Effect(Box::new(int()));
2727        let lifted = maybe_auto_lift(Some(int()), Some(&expected));
2728        assert_eq!(lifted, Some(Ty::Effect(Box::new(int()))));
2729    }
2730
2731    #[test]
2732    fn maybe_auto_lift_leaves_non_matching_alone() {
2733        // Already Effect[_]: untouched.
2734        let expected = Ty::Effect(Box::new(int()));
2735        assert_eq!(
2736            maybe_auto_lift(Some(Ty::Effect(Box::new(int()))), Some(&expected)),
2737            Some(Ty::Effect(Box::new(int()))),
2738        );
2739        // Expected not an Effect: untouched.
2740        assert_eq!(maybe_auto_lift(Some(int()), Some(&int())), Some(int()));
2741        // None type: untouched.
2742        assert_eq!(maybe_auto_lift(None, Some(&expected)), None);
2743    }
2744
2745    // -- const_literal -----------------------------------------------------
2746
2747    #[test]
2748    fn const_literal_extracts_literals() {
2749        assert!(matches!(
2750            const_literal(&expr(ExprKind::IntLit(7))),
2751            Some(ConstLit::Int(7)),
2752        ));
2753        assert!(matches!(
2754            const_literal(&expr(ExprKind::BoolLit(true))),
2755            Some(ConstLit::Bool(true)),
2756        ));
2757        assert!(matches!(
2758            const_literal(&expr(ExprKind::StrLit("hi".into()))),
2759            Some(ConstLit::Str(s)) if s == "hi",
2760        ));
2761        assert!(matches!(
2762            const_literal(&expr(ExprKind::FloatLit {
2763                value: 1.5,
2764                lexeme: "1.5".into(),
2765            })),
2766            Some(ConstLit::Float(_)),
2767        ));
2768        // Unary-neg on an int literal folds.
2769        let neg = expr(ExprKind::UnaryOp(
2770            UnaryOp::Neg,
2771            Box::new(expr(ExprKind::IntLit(3))),
2772        ));
2773        assert!(matches!(const_literal(&neg), Some(ConstLit::Int(-3))));
2774    }
2775
2776    #[test]
2777    fn const_literal_rejects_non_literals() {
2778        assert!(const_literal(&expr(ExprKind::Ident(ident("x")))).is_none());
2779    }
2780
2781    // -- eval_predicate ----------------------------------------------------
2782
2783    #[test]
2784    fn eval_predicate_int_and_float() {
2785        assert!(eval_predicate(&PredKind::NonNegative, &ConstLit::Int(0)));
2786        assert!(!eval_predicate(&PredKind::NonNegative, &ConstLit::Int(-1)));
2787        assert!(eval_predicate(&PredKind::Positive, &ConstLit::Int(1)));
2788        assert!(!eval_predicate(&PredKind::Positive, &ConstLit::Int(0)));
2789        assert!(eval_predicate(&in_range(1, 10), &ConstLit::Int(5),));
2790        assert!(!eval_predicate(&in_range(1, 10), &ConstLit::Int(11),));
2791    }
2792
2793    #[test]
2794    fn eval_predicate_string() {
2795        assert!(eval_predicate(
2796            &PredKind::MinLength(2),
2797            &ConstLit::Str("ab".into()),
2798        ));
2799        assert!(!eval_predicate(
2800            &PredKind::MinLength(3),
2801            &ConstLit::Str("ab".into()),
2802        ));
2803        assert!(eval_predicate(
2804            &PredKind::NonEmpty,
2805            &ConstLit::Str("x".into()),
2806        ));
2807        assert!(!eval_predicate(
2808            &PredKind::NonEmpty,
2809            &ConstLit::Str(String::new()),
2810        ));
2811        assert!(eval_predicate(
2812            &PredKind::Matches("[a-z]+".into()),
2813            &ConstLit::Str("abc".into()),
2814        ));
2815        assert!(!eval_predicate(
2816            &PredKind::Matches("[a-z]+".into()),
2817            &ConstLit::Str("ABC".into()),
2818        ));
2819    }
2820
2821    #[test]
2822    fn eval_predicate_base_mismatch_is_vacuously_true() {
2823        // SURPRISING (pinned as-is): a predicate/literal base mismatch returns
2824        // `true` — base/predicate mismatch is a declaration-time error reported
2825        // elsewhere, not by construction-time eval.
2826        assert!(eval_predicate(&PredKind::MinLength(5), &ConstLit::Int(0),));
2827    }
2828
2829    // -- literal_matches_base ----------------------------------------------
2830
2831    #[test]
2832    fn literal_matches_base_pairs() {
2833        assert!(literal_matches_base(&ConstLit::Int(1), BaseType::Int));
2834        assert!(literal_matches_base(
2835            &ConstLit::Str("x".into()),
2836            BaseType::String,
2837        ));
2838        assert!(!literal_matches_base(&ConstLit::Int(1), BaseType::String));
2839        assert!(!literal_matches_base(&ConstLit::Unit, BaseType::Int));
2840    }
2841
2842    // -- type_decl_base / type_decl_refinement -----------------------------
2843
2844    #[test]
2845    fn type_decl_base_refined_vs_record() {
2846        let refined = refined_decl("Age", BaseType::Int, None);
2847        assert_eq!(type_decl_base(&refined), Some(BaseType::Int));
2848        assert_eq!(type_decl_base(&record_decl("Pt")), None);
2849    }
2850
2851    #[test]
2852    fn type_decl_refinement_present_vs_absent() {
2853        let with = refined_decl(
2854            "Age",
2855            BaseType::Int,
2856            Some(refinement(vec![PredKind::Positive])),
2857        );
2858        assert!(type_decl_refinement(&with).is_some());
2859        let without = refined_decl("Raw", BaseType::Int, None);
2860        assert!(type_decl_refinement(&without).is_none());
2861        assert!(type_decl_refinement(&record_decl("Pt")).is_none());
2862    }
2863
2864    // -- check_*_refinement_consistency ------------------------------------
2865
2866    #[test]
2867    fn int_refinement_consistency() {
2868        // Consistent: 1..=10 with Positive — no error.
2869        let mut errs = vec![];
2870        check_int_refinement_consistency(
2871            &refinement(vec![PredKind::Positive, in_range(1, 10)]),
2872            &mut errs,
2873        );
2874        assert!(errs.is_empty());
2875        // Inconsistent: InRange(10, 1) is empty → exactly one error.
2876        let mut errs = vec![];
2877        check_int_refinement_consistency(&refinement(vec![in_range(10, 1)]), &mut errs);
2878        assert_eq!(errs.len(), 1);
2879        assert_eq!(errs[0].category, "bynk.types.empty_refinement");
2880    }
2881
2882    #[test]
2883    fn float_refinement_consistency() {
2884        // Consistent range.
2885        let mut errs = vec![];
2886        check_float_refinement_consistency(
2887            &refinement(vec![PredKind::InRangeF(fbound(0.0), fbound(1.0))]),
2888            &mut errs,
2889        );
2890        assert!(errs.is_empty());
2891        // Empty: 5.0..=1.0 → one error.
2892        let mut errs = vec![];
2893        check_float_refinement_consistency(
2894            &refinement(vec![PredKind::InRangeF(fbound(5.0), fbound(1.0))]),
2895            &mut errs,
2896        );
2897        assert_eq!(errs.len(), 1);
2898        assert_eq!(errs[0].category, "bynk.types.empty_refinement");
2899        // Degenerate-but-exclusive: Positive with InRangeF(0.0, 0.0) → lo==hi
2900        // and lo_exclusive → one error.
2901        let mut errs = vec![];
2902        check_float_refinement_consistency(
2903            &refinement(vec![
2904                PredKind::Positive,
2905                PredKind::InRangeF(fbound(0.0), fbound(0.0)),
2906            ]),
2907            &mut errs,
2908        );
2909        assert_eq!(errs.len(), 1);
2910    }
2911
2912    #[test]
2913    fn string_refinement_consistency() {
2914        // Consistent: MinLength(1), MaxLength(10).
2915        let mut errs = vec![];
2916        check_string_refinement_consistency(
2917            &refinement(vec![PredKind::MinLength(1), PredKind::MaxLength(10)]),
2918            &mut errs,
2919        );
2920        assert!(errs.is_empty());
2921        // min > max → one error.
2922        let mut errs = vec![];
2923        check_string_refinement_consistency(
2924            &refinement(vec![PredKind::MinLength(10), PredKind::MaxLength(2)]),
2925            &mut errs,
2926        );
2927        assert_eq!(errs.len(), 1);
2928        assert_eq!(errs[0].category, "bynk.types.empty_refinement");
2929        // Conflicting exact lengths → TWO errors (pinned as-is): the explicit
2930        // `Length(3)`/`Length(5)` conflict push, *plus* the subsequent
2931        // min_len(5) > max_len(3) empty-range push (each `Length` clamps both
2932        // bounds to itself).
2933        let mut errs = vec![];
2934        check_string_refinement_consistency(
2935            &refinement(vec![PredKind::Length(3), PredKind::Length(5)]),
2936            &mut errs,
2937        );
2938        assert_eq!(errs.len(), 2);
2939        assert!(
2940            errs.iter()
2941                .all(|e| e.category == "bynk.types.empty_refinement")
2942        );
2943    }
2944
2945    // -- numeric_mix -------------------------------------------------------
2946
2947    #[test]
2948    fn numeric_mix_int_float_pairs() {
2949        assert!(numeric_mix(Some(BaseType::Int), Some(BaseType::Float)));
2950        assert!(numeric_mix(Some(BaseType::Float), Some(BaseType::Int)));
2951        assert!(!numeric_mix(Some(BaseType::Int), Some(BaseType::Int)));
2952        assert!(!numeric_mix(Some(BaseType::Float), Some(BaseType::Float)));
2953        assert!(!numeric_mix(None, Some(BaseType::Int)));
2954    }
2955}