Skip to main content

lex_types/checker/
mod.rs

1//! M3: type checker. Walks the canonical AST, infers types via unification,
2//! and checks declared signatures and effects.
3
4use crate::builtins::{module_for_import, module_scope};
5use crate::env::{TypeDefKind, TypeEnv, ty_from_canon_env};
6use crate::error::{PositionedError, TypeError};
7use crate::position::Position;
8use crate::types::*;
9use crate::unifier::{UnifyError, Unifier};
10use indexmap::IndexMap;
11use lex_ast as a;
12use std::collections::{BTreeMap, HashMap};
13
14mod exhaustive;
15mod parse_strict;
16
17pub use parse_strict::{rewrite_parse_calls, ParseSite};
18use parse_strict::*;
19
20/// Result of checking a whole program.
21pub struct ProgramTypes {
22    pub fn_signatures: IndexMap<String, Scheme>,
23    pub type_env: TypeEnv,
24    /// For #168: per-call required-fields map for `module.parse(s)`
25    /// calls whose inferred result type is `Result[Record{...}, _]`.
26    /// Keyed by the call's [`ParseSite`] (stage index + NodeId), so
27    /// the table stays valid for any structurally identical copy of
28    /// the checked stages (#777). Empty unless any matching call
29    /// sites were found.
30    ///
31    /// See [`check_and_rewrite_program`] for the function that
32    /// populates this and applies the rewrite in one step, and
33    /// [`rewrite_parse_calls`] to apply it to a separate copy.
34    pub parse_required_fields: HashMap<ParseSite, Vec<String>>,
35    /// For #322: per-call type schema alongside the field names.
36    /// Each entry is a `Vec<(field_name, type_tag)>` parallel to
37    /// `parse_required_fields`. Used by the rewrite pass to inject
38    /// the third argument to `parse_strict`.
39    pub parse_type_schemas: HashMap<ParseSite, Vec<(String, String)>>,
40}
41
42/// Variant of [`check_program`] that stamps a source [`Position`]
43/// onto every emitted error (#306 slice 1).
44///
45/// `positions` is keyed by function name and supplies the position
46/// of each `fn` declaration in the source. Errors from a given
47/// function are tagged with that function's position; errors that
48/// don't map to a single function (e.g. type-decl-level errors)
49/// keep `position = None`.
50///
51/// Slice 1 ships function-level granularity. Slice 1.5 will plumb
52/// per-expression spans through canonicalize so deep-body errors
53/// land on the offending sub-expression rather than its enclosing
54/// function.
55pub fn check_program_with_positions(
56    stages: &[a::Stage],
57    positions: &BTreeMap<String, Position>,
58) -> Result<ProgramTypes, Vec<PositionedError>> {
59    check_program_inner(stages, Some(positions), &BTreeMap::new(), &BTreeMap::new(), &BTreeMap::new())
60        .map_err(|errs| errs.into_iter().map(|(e, fn_name)| {
61            let pos = fn_name.as_deref().and_then(|n| positions.get(n)).cloned();
62            PositionedError::new(e, pos)
63        }).collect())
64}
65
66pub fn check_program(stages: &[a::Stage]) -> Result<ProgramTypes, Vec<TypeError>> {
67    check_program_inner(stages, None, &BTreeMap::new(), &BTreeMap::new(), &BTreeMap::new())
68        .map_err(|errs| errs.into_iter().map(|(e, _)| e).collect())
69}
70
71/// Like [`check_program`], but with a set of already-resolved dependency
72/// modules the head may import by *reference* (e.g. `"lex-nt/lib"`). Each
73/// value is that module's type — a [`Ty::Record`] of its exported
74/// functions, the same shape [`crate::builtins::module_scope`] produces for
75/// stdlib (build one with [`module_record_from_fields`]). Registry/git
76/// dependencies resolve through this map instead of being inlined into
77/// `stages` (#930): the op-log keeps the `import` edge and the write-time
78/// gate supplies the dependency's signatures here, so the head still
79/// type-checks against them without carrying their bodies.
80///
81/// An empty map reproduces [`check_program`] exactly — only stdlib imports
82/// resolve, and any `<alias>.name` reaching an unsupplied dependency is an
83/// unbound-reference error, as today.
84pub fn check_program_with_modules(
85    stages: &[a::Stage],
86    modules: &BTreeMap<String, Ty>,
87) -> Result<ProgramTypes, Vec<TypeError>> {
88    check_program_inner(stages, None, modules, &BTreeMap::new(), &BTreeMap::new())
89        .map_err(|errs| errs.into_iter().map(|(e, _)| e).collect())
90}
91
92/// Like [`check_program_with_modules`], but a dependency also contributes its
93/// exported **type declarations** (#930 completeness gap): non-inlined
94/// resolution otherwise carried only a dependency's function signatures, so a
95/// package referencing a dependency's exported *type* (e.g. a record used in an
96/// annotation, or its ADT constructors in a match) could not resolve it — the
97/// type read as opaque, a matching record literal failed to unify, and field
98/// access on it errored. `module_types` maps the same import *reference* keys as
99/// `modules` to the dependency's type declarations (bare names); they are
100/// registered under the importing file's alias (`<alias>.<Name>`), so
101/// `<alias>.Type` annotations resolve and the dependency's constructors are in
102/// scope — exactly as an inlined dependency's `type` decls used to be.
103pub fn check_program_with_module_ifaces(
104    stages: &[a::Stage],
105    modules: &BTreeMap<String, Ty>,
106    module_types: &BTreeMap<String, Vec<a::TypeDecl>>,
107) -> Result<ProgramTypes, Vec<TypeError>> {
108    check_program_inner(stages, None, modules, module_types, &BTreeMap::new())
109        .map_err(|errs| errs.into_iter().map(|(e, _)| e).collect())
110}
111
112/// Like [`check_program_with_module_ifaces`], but each dependency import also
113/// carries its **module mangle prefix** (#963), when the dependency was
114/// resolved as a whole package. In that mode `module_types` are prefix-named
115/// (`error_<hash>.DbErr`) and globally unique — registered as-is — and the
116/// import alias is mapped to the prefix so an alias-qualified type reference
117/// (`e.DbErr`) unfolds to the same type the dependency's own prefix-qualified
118/// signatures name. This makes a directly-imported module and the copies of it
119/// inlined into its sibling modules one type (the diamond). References with no
120/// entry in `module_prefixes` keep the #930 bare/alias-qualified path.
121pub fn check_program_with_deps(
122    stages: &[a::Stage],
123    modules: &BTreeMap<String, Ty>,
124    module_types: &BTreeMap<String, Vec<a::TypeDecl>>,
125    module_prefixes: &BTreeMap<String, String>,
126) -> Result<ProgramTypes, Vec<TypeError>> {
127    check_program_inner(stages, None, modules, module_types, module_prefixes)
128        .map_err(|errs| errs.into_iter().map(|(e, _)| e).collect())
129}
130
131/// Build a dependency module's value type — a record of its exported
132/// functions — from `(name, type)` pairs, for [`check_program_with_modules`]
133/// (#930). Callers never touch the record representation directly.
134///
135/// The record is bound under an import alias and generalized *as a whole*
136/// (Pass 1: `collect_vars`/`collect_eff_vars` over the record, then
137/// `instantiate` per reference). Each export, however, was generalized
138/// independently and so numbers its own variables from zero — two exports
139/// of one dependency both spelling `Var(0)` would be tied together by that
140/// whole-record generalization. So every export is renumbered into a
141/// disjoint block: type variables from `0` up, effect-row variables from
142/// [`EFF_VAR_BASE`] up (the same type/effect split stdlib's
143/// [`crate::stdlib_spec::module_record`] keeps). Monomorphic exports (the
144/// common case, e.g. `gcd(Int, Int) -> Int`) carry no variables and pass
145/// through unchanged.
146pub fn module_record_from_fields(fields: impl IntoIterator<Item = (String, Ty)>) -> Ty {
147    let mut next_ty: u32 = 0;
148    let mut next_eff: u32 = crate::stdlib_spec::EFF_VAR_BASE;
149    let renumbered: IndexMap<String, Ty> = fields
150        .into_iter()
151        .map(|(name, ty)| (name, renumber_field_vars(&ty, &mut next_ty, &mut next_eff)))
152        .collect();
153    Ty::Record(renumbered)
154}
155
156/// Rewrite every type variable and effect-row variable in `ty` to a fresh
157/// id drawn from the running counters, consistently within `ty`: a variable
158/// used more than once stays one variable, but distinct variables get
159/// distinct fresh ids, and no id is reused across separate calls (the
160/// counters advance). See [`module_record_from_fields`].
161fn renumber_field_vars(ty: &Ty, next_ty: &mut u32, next_eff: &mut u32) -> Ty {
162    fn walk(
163        t: &mut Ty,
164        ty_map: &mut HashMap<u32, u32>,
165        eff_map: &mut HashMap<u32, u32>,
166        next_ty: &mut u32,
167        next_eff: &mut u32,
168    ) {
169        match t {
170            Ty::Var(v) => {
171                let nv = *ty_map.entry(*v).or_insert_with(|| {
172                    let x = *next_ty;
173                    *next_ty += 1;
174                    x
175                });
176                *v = nv;
177            }
178            Ty::Prim(_) | Ty::Unit | Ty::Never => {}
179            Ty::List(inner) => walk(inner, ty_map, eff_map, next_ty, next_eff),
180            Ty::Tuple(items) => {
181                for it in items {
182                    walk(it, ty_map, eff_map, next_ty, next_eff);
183                }
184            }
185            Ty::Record(fs) => {
186                for v in fs.values_mut() {
187                    walk(v, ty_map, eff_map, next_ty, next_eff);
188                }
189            }
190            Ty::Con(_, args) => {
191                for a in args {
192                    walk(a, ty_map, eff_map, next_ty, next_eff);
193                }
194            }
195            Ty::Function { params, effects, ret } => {
196                for p in params {
197                    walk(p, ty_map, eff_map, next_ty, next_eff);
198                }
199                if let Some(v) = effects.var {
200                    let nv = *eff_map.entry(v).or_insert_with(|| {
201                        let x = *next_eff;
202                        *next_eff += 1;
203                        x
204                    });
205                    effects.var = Some(nv);
206                }
207                walk(ret, ty_map, eff_map, next_ty, next_eff);
208            }
209        }
210    }
211    let mut out = ty.clone();
212    let mut ty_map: HashMap<u32, u32> = HashMap::new();
213    let mut eff_map: HashMap<u32, u32> = HashMap::new();
214    walk(&mut out, &mut ty_map, &mut eff_map, next_ty, next_eff);
215    out
216}
217
218/// Register a dependency's exported type declarations under an import `alias`
219/// (#930 completeness). Each declaration is registered under `<alias>.<Name>`,
220/// and every reference *within* these declarations to a sibling dependency type
221/// (a bare `Named` whose name is one of this dependency's own types) is
222/// rewritten to the same qualified form, so the registered definitions stay
223/// self-consistent inside the alias namespace. Constructors keep their bare
224/// names — Lex's flat constructor namespace — and map to the qualified owning
225/// type, exactly as an inlined dependency's `type` decls did.
226fn register_dep_types(env: &mut TypeEnv, alias: &str, decls: &[a::TypeDecl]) {
227    let own: std::collections::HashSet<&str> = decls.iter().map(|d| d.name.as_str()).collect();
228    for d in decls {
229        let mut def = d.definition.clone();
230        qualify_type_expr(&mut def, alias, &own, &d.params);
231        let qualified_name = format!("{alias}.{}", d.name);
232        let qualified = a::TypeDecl {
233            name: qualified_name.clone(),
234            params: d.params.clone(),
235            definition: def,
236        };
237        // The only error `add_user_type` raises is a recursive alias with no
238        // constructor, which a well-formed published dependency never has;
239        // dropping it here just leaves that (malformed) type unresolved.
240        let _ = env.add_user_type(&qualified_name, qualified);
241    }
242}
243
244/// Register a dependency package's exported type declarations when it was
245/// resolved as a whole package (#963): the decls are already **prefix-named**
246/// (`error_<hash>.DbErr`) and their internal references are prefix-qualified by
247/// the loader, so they register as-is (globally unique — no alias
248/// qualification). For each type belonging to *this* import's module (name
249/// under `prefix`), an `<alias>.<Local>` alias entry is also registered so an
250/// alias-qualified annotation unfolds to the canonical prefixed type — the same
251/// type the module's own signatures name, and the same the copies inlined into
252/// sibling modules carry. `decls` is the whole loaded package, so every type
253/// the module's surface exposes resolves; re-registering across sibling imports
254/// is idempotent (content-identical).
255fn register_dep_types_prefixed(
256    env: &mut TypeEnv,
257    alias: &str,
258    prefix: &str,
259    decls: &[a::TypeDecl],
260) {
261    for d in decls {
262        // Register the canonical, prefix-named type as-is (its internal
263        // references are already prefix-qualified by the loader, and its name is
264        // globally unique — no alias rewriting).
265        let _ = env.add_user_type(&d.name, d.clone());
266    }
267    // Map this import's alias to the module prefix, so `ty_from_canon_env`
268    // normalizes an alias-qualified annotation (`e.DbErr`) to the canonical
269    // `error_<hash>.DbErr` — the same type the value record and inlined sibling
270    // copies name.
271    env.dep_alias_prefixes.insert(alias.to_string(), prefix.to_string());
272}
273
274/// Rewrite, in place, every `Named` reference in `t` that names one of the
275/// dependency's `own` types (and isn't shadowed by a local type `param`) to its
276/// `<alias>.`-qualified form. See [`register_dep_types`].
277fn qualify_type_expr(
278    t: &mut a::TypeExpr,
279    alias: &str,
280    own: &std::collections::HashSet<&str>,
281    params: &[String],
282) {
283    let qualify = |name: &mut String| {
284        if own.contains(name.as_str()) && !params.iter().any(|p| p == name) {
285            *name = format!("{alias}.{name}");
286        }
287    };
288    match t {
289        a::TypeExpr::Named { name, args } => {
290            qualify(name);
291            for a_ in args {
292                qualify_type_expr(a_, alias, own, params);
293            }
294        }
295        a::TypeExpr::Record { fields } => {
296            for f in fields {
297                qualify_type_expr(&mut f.ty, alias, own, params);
298            }
299        }
300        a::TypeExpr::Tuple { items } => {
301            for it in items {
302                qualify_type_expr(it, alias, own, params);
303            }
304        }
305        a::TypeExpr::Function { params: ps, ret, .. } => {
306            for p in ps {
307                qualify_type_expr(p, alias, own, params);
308            }
309            qualify_type_expr(ret, alias, own, params);
310        }
311        a::TypeExpr::Union { variants } => {
312            for v in variants {
313                if let Some(p) = &mut v.payload {
314                    qualify_type_expr(p, alias, own, params);
315                }
316            }
317        }
318        a::TypeExpr::RecordWithSpreads { spreads, fields } => {
319            for s in spreads.iter_mut() {
320                qualify(s);
321            }
322            for f in fields {
323                qualify_type_expr(&mut f.ty, alias, own, params);
324            }
325        }
326        a::TypeExpr::Refined { base, .. } => qualify_type_expr(base, alias, own, params),
327    }
328}
329
330/// Return a copy of `ty` with every `Ty::Con(name, ..)` whose `name` is one of
331/// the dependency's `own` type names rewritten to `<alias>.name` — so a
332/// dependency's value-record signatures name the same qualified types that
333/// [`register_dep_types`] registers. See the dep-import branch of
334/// [`check_program_inner`].
335fn qualify_ty_cons(ty: &Ty, alias: &str, own: &std::collections::HashSet<&str>) -> Ty {
336    match ty {
337        Ty::Con(name, args) => {
338            let n = if own.contains(name.as_str()) {
339                format!("{alias}.{name}")
340            } else {
341                name.clone()
342            };
343            Ty::Con(n, args.iter().map(|a| qualify_ty_cons(a, alias, own)).collect())
344        }
345        Ty::List(inner) => Ty::List(Box::new(qualify_ty_cons(inner, alias, own))),
346        Ty::Tuple(items) => Ty::Tuple(items.iter().map(|a| qualify_ty_cons(a, alias, own)).collect()),
347        Ty::Record(fs) => Ty::Record(
348            fs.iter().map(|(k, v)| (k.clone(), qualify_ty_cons(v, alias, own))).collect(),
349        ),
350        Ty::Function { params, effects, ret } => Ty::Function {
351            params: params.iter().map(|a| qualify_ty_cons(a, alias, own)).collect(),
352            effects: effects.clone(),
353            ret: Box::new(qualify_ty_cons(ret, alias, own)),
354        },
355        Ty::Var(_) | Ty::Prim(_) | Ty::Unit | Ty::Never => ty.clone(),
356    }
357}
358
359fn check_program_inner(
360    stages: &[a::Stage],
361    _positions: Option<&BTreeMap<String, Position>>,
362    modules: &BTreeMap<String, Ty>,
363    module_types: &BTreeMap<String, Vec<a::TypeDecl>>,
364    module_prefixes: &BTreeMap<String, String>,
365) -> Result<ProgramTypes, Vec<(TypeError, Option<String>)>> {
366    let mut tcx = Checker::new();
367    // Each entry is (error, optional fn name the error came from)
368    // so callers can resolve the error to a source position.
369    let mut errors: Vec<(TypeError, Option<String>)> = Vec::new();
370
371    // Pass 1: gather imports → bring module values into scope.
372    for stage in stages {
373        if let a::Stage::Import(i) = stage {
374            // Stdlib modules resolve to a built-in scope.
375            if let Some(mod_name) = module_for_import(&i.reference) {
376                if let Some(ty) = module_scope(mod_name, &tcx.type_env) {
377                    tcx.globals.insert(i.alias.clone(), Scheme {
378                        // Module-level signatures use Var(0..n) and
379                        // effect-vars on stdlib HOFs (list.map's `[E]`
380                        // etc.); generalize both.
381                        vars: collect_vars(&ty),
382                        eff_vars: collect_eff_vars(&ty),
383                        ty,
384                    });
385                    tcx.module_aliases.insert(i.alias.clone(), mod_name.to_string());
386                    continue;
387                }
388            }
389            // #930: a resolved registry/git dependency, supplied by the
390            // caller (the write-time gate) keyed by import reference,
391            // rather than inlined into `stages`. Bind its record under this
392            // file's alias so `<alias>.name` references type-check with the
393            // dependency's signatures but without its bodies present. The
394            // record is already generalized per export, so generalize it as
395            // a whole the same way a stdlib module scope is bound above.
396            // #963 prefixed mode: the dependency was resolved as a whole
397            // package, so `module_types` are prefix-named (`error_<hash>.DbErr`)
398            // and the value record already references them by prefix — register
399            // the decls as-is and map this import's alias to the module prefix
400            // so an alias-qualified annotation (`e.DbErr`) unfolds to the same
401            // canonical type. A reference with no prefix keeps the #930 bare /
402            // alias-qualified path.
403            match module_prefixes.get(&i.reference) {
404                Some(prefix) => {
405                    if let Some(ty) = modules.get(&i.reference) {
406                        tcx.globals.insert(i.alias.clone(), Scheme {
407                            vars: collect_vars(ty),
408                            eff_vars: collect_eff_vars(ty),
409                            ty: ty.clone(),
410                        });
411                    }
412                    if let Some(decls) = module_types.get(&i.reference) {
413                        register_dep_types_prefixed(&mut tcx.type_env, &i.alias, prefix, decls);
414                    }
415                }
416                None => {
417                    // #930 completeness (bare mode): the dependency's exported
418                    // type names, so both its value-record signatures and its
419                    // own `type` decls are rewritten to the alias namespace
420                    // consistently (a dependency fn `make() -> Rec` and the
421                    // registered `<alias>.Rec` must name the same type).
422                    let own: std::collections::HashSet<&str> = module_types
423                        .get(&i.reference)
424                        .map(|ds| ds.iter().map(|d| d.name.as_str()).collect())
425                        .unwrap_or_default();
426                    if let Some(ty) = modules.get(&i.reference) {
427                        let ty = qualify_ty_cons(ty, &i.alias, &own);
428                        tcx.globals.insert(i.alias.clone(), Scheme {
429                            vars: collect_vars(&ty),
430                            eff_vars: collect_eff_vars(&ty),
431                            ty,
432                        });
433                    }
434                    if let Some(decls) = module_types.get(&i.reference) {
435                        register_dep_types(&mut tcx.type_env, &i.alias, decls);
436                    }
437                }
438            }
439        }
440    }
441
442    // Pass 2: register user-declared types.
443    for stage in stages {
444        if let a::Stage::TypeDecl(td) = stage {
445            if let Err(e) = tcx.type_env.add_user_type(&td.name, td.clone()) {
446                errors.push((TypeError::RecursiveTypeWithoutConstructor {
447                    at_node: "n_0".into(),
448                    name: e,
449                }, None));
450            }
451        }
452    }
453
454    // Pass 3: register fn signatures (so mutual recursion works).
455    for stage in stages {
456        if let a::Stage::FnDecl(fd) = stage {
457            let scheme = function_scheme(fd, &tcx.type_env);
458            tcx.globals.insert(fd.name.clone(), scheme);
459            // #209 slice 2: keep the original params so call-site
460            // refinement discharge can see the predicate before it
461            // gets stripped to its base type by `ty_from_canon`.
462            tcx.fn_params.insert(fd.name.clone(), fd.params.clone());
463        }
464    }
465
466    // Pass 4: check each fn body. With #306 slice 1, every emitted
467    // error is paired with the source fn it came from so the public
468    // [`check_program_with_positions`] wrapper can stamp the
469    // function's source position onto a [`PositionedError`].
470    let mut signatures = IndexMap::new();
471    // #777: the parse-call side tables are keyed by (stage index,
472    // NodeId) rather than by expression address, so each FnDecl's
473    // NodeId map is computed up front. The walk is skipped entirely
474    // when no import could produce a rewritable call.
475    let wants_parse_sites = tcx.has_parse_capable_imports();
476    for (stage_idx, stage) in stages.iter().enumerate() {
477        if let a::Stage::FnDecl(fd) = stage {
478            tcx.stage_ids = if wants_parse_sites {
479                Some((stage_idx, a::expr_ids(stage)))
480            } else {
481                None
482            };
483            match tcx.check_fn(fd) {
484                Ok(scheme) => { signatures.insert(fd.name.clone(), scheme); }
485                Err(es) => {
486                    errors.extend(es.into_iter().map(|e| (e, Some(fd.name.clone()))));
487                }
488            }
489        }
490    }
491    tcx.stage_ids = None;
492
493    if errors.is_empty() {
494        // #168: walk pending parse-call records and resolve each
495        // call's return type now that all unification has settled.
496        // A call shows up here only if the call site syntactically
497        // looks like `<alias>.parse(s)` for an alias bound to one
498        // of {json, toml, yaml} via the import pass.
499        let mut parse_required_fields = HashMap::new();
500        let mut parse_type_schemas = HashMap::new();
501        for (site, ret_ty) in &tcx.pending_parse_calls {
502            if let Some((fields, schema)) = extract_record_fields_and_schema(&tcx.u, &tcx.type_env, ret_ty) {
503                parse_required_fields.insert(site.clone(), fields);
504                parse_type_schemas.insert(site.clone(), schema);
505            }
506        }
507        Ok(ProgramTypes {
508            fn_signatures: signatures,
509            type_env: tcx.type_env,
510            parse_required_fields,
511            parse_type_schemas,
512        })
513    } else {
514        Err(errors)
515    }
516}
517
518/// Type-check `stages` and rewrite every `module.parse(s)` call
519/// where the inferred T is a Record into the equivalent
520/// `module.parse_strict(s, [field_names])` (#168). Existing
521/// [`check_program`] keeps the old immutable signature for tests
522/// and tools that don't want the AST rewritten.
523pub fn check_and_rewrite_program(
524    stages: &mut [a::Stage],
525) -> Result<ProgramTypes, Vec<TypeError>> {
526    check_and_rewrite_program_with_modules(stages, &BTreeMap::new())
527}
528
529/// Like [`check_and_rewrite_program`], but resolving external dependency
530/// references through `modules` (#930) — the publish path checks the same
531/// non-inlined head its store gate will, so the two agree.
532pub fn check_and_rewrite_program_with_modules(
533    stages: &mut [a::Stage],
534    modules: &BTreeMap<String, Ty>,
535) -> Result<ProgramTypes, Vec<TypeError>> {
536    let pt = check_program_with_modules(&*stages, modules)?;
537    rewrite_parse_calls(stages, &pt);
538    Ok(pt)
539}
540
541/// Like [`check_and_rewrite_program_with_modules`], but a dependency also
542/// contributes its exported type declarations (#930 completeness — see
543/// [`check_program_with_module_ifaces`]).
544pub fn check_and_rewrite_program_with_module_ifaces(
545    stages: &mut [a::Stage],
546    modules: &BTreeMap<String, Ty>,
547    module_types: &BTreeMap<String, Vec<a::TypeDecl>>,
548) -> Result<ProgramTypes, Vec<TypeError>> {
549    let pt = check_program_with_module_ifaces(&*stages, modules, module_types)?;
550    rewrite_parse_calls(stages, &pt);
551    Ok(pt)
552}
553
554/// Like [`check_and_rewrite_program_with_module_ifaces`], but each dependency
555/// import also carries its module mangle prefix (#963 — see
556/// [`check_program_with_deps`]).
557pub fn check_and_rewrite_program_with_deps(
558    stages: &mut [a::Stage],
559    modules: &BTreeMap<String, Ty>,
560    module_types: &BTreeMap<String, Vec<a::TypeDecl>>,
561    module_prefixes: &BTreeMap<String, String>,
562) -> Result<ProgramTypes, Vec<TypeError>> {
563    let pt = check_program_with_deps(&*stages, modules, module_types, module_prefixes)?;
564    rewrite_parse_calls(stages, &pt);
565    Ok(pt)
566}
567
568/// `parse` → `parse_strict_typed` / `json_body` → `json_body_typed`
569/// (#168, `parse_strict.rs`) are synthesized onto an AST that has
570/// already been checked once — `rewrite_parse_calls` mutates a
571/// call's callee field name and appends two arguments, producing a
572/// call that the module's literal Lex-level record type has no
573/// field for (the `_typed` variants are native ops dispatched by
574/// name in `lex-runtime`'s `builtins.rs`, not declared Lex members).
575/// Re-checking those already-rewritten stages — as lex-store's
576/// write-time publish gate does on the same stages its caller just
577/// ran `check_and_rewrite_program` over — must accept the field
578/// rather than report it unknown. The synthesized signature is
579/// derived from the original field's rather than hardcoded, so it
580/// stays in sync with any future change to the base op's shape:
581/// same params (plus the two extra `List` arguments the rewrite
582/// always appends) and the same effects and return type.
583fn synthesize_decode_typed_field(field: &str, fields: &IndexMap<String, Ty>) -> Option<Ty> {
584    let base_field = match field {
585        "parse_strict_typed" => "parse",
586        "json_body_typed" => "json_body",
587        _ => return None,
588    };
589    let Ty::Function { params, effects, ret } = fields.get(base_field)? else {
590        return None;
591    };
592    let mut synthesized_params = params.clone();
593    synthesized_params.push(Ty::List(Box::new(Ty::Prim(Prim::Str))));
594    synthesized_params.push(Ty::List(Box::new(Ty::Tuple(vec![
595        Ty::Prim(Prim::Str),
596        Ty::Prim(Prim::Str),
597    ]))));
598    Some(Ty::Function {
599        params: synthesized_params,
600        effects: effects.clone(),
601        ret: ret.clone(),
602    })
603}
604
605fn collect_vars(t: &Ty) -> Vec<TyVarId> {
606    let mut out = Vec::new();
607    fn walk(t: &Ty, out: &mut Vec<TyVarId>) {
608        match t {
609            Ty::Var(v) => { if !out.contains(v) { out.push(*v); } }
610            Ty::Prim(_) | Ty::Unit | Ty::Never => {}
611            Ty::List(inner) => walk(inner, out),
612            Ty::Tuple(items) => for it in items { walk(it, out); },
613            Ty::Record(fs) => for v in fs.values() { walk(v, out); },
614            Ty::Con(_, args) => for a in args { walk(a, out); },
615            Ty::Function { params, ret, .. } => {
616                for p in params { walk(p, out); }
617                walk(ret, out);
618            }
619        }
620    }
621    walk(t, &mut out);
622    out
623}
624
625/// Walk a type and collect every effect-row variable id that appears
626/// inside any function-type's effect set. Used to generalize stdlib
627/// HOF schemes alongside ordinary type vars.
628fn collect_eff_vars(t: &Ty) -> Vec<u32> {
629    let mut out = Vec::new();
630    fn walk(t: &Ty, out: &mut Vec<u32>) {
631        match t {
632            Ty::Var(_) | Ty::Prim(_) | Ty::Unit | Ty::Never => {}
633            Ty::List(inner) => walk(inner, out),
634            Ty::Tuple(items) => for it in items { walk(it, out); },
635            Ty::Record(fs) => for v in fs.values() { walk(v, out); },
636            Ty::Con(_, args) => for a in args { walk(a, out); },
637            Ty::Function { params, effects, ret } => {
638                if let Some(v) = effects.var {
639                    if !out.contains(&v) { out.push(v); }
640                }
641                for p in params { walk(p, out); }
642                walk(ret, out);
643            }
644        }
645    }
646    walk(t, &mut out);
647    out
648}
649
650fn function_scheme(fd: &a::FnDecl, env: &TypeEnv) -> Scheme {
651    // Collect type-param ids in order; map their names to fresh Var(idx).
652    let params: Vec<Ty> = fd.params.iter().map(|p| ty_from_canon_env(&p.ty, &fd.type_params, env)).collect();
653    let ret = ty_from_canon_env(&fd.return_type, &fd.type_params, env);
654    // Plumb effect args (#207). A canonical-AST `EffectDecl` already
655    // carries `Option<EffectArg>`; map it into the type-system kind so
656    // subsumption can honor parameterized effects.
657    let effects = EffectSet {
658        concrete: {
659            let mut s = std::collections::BTreeSet::new();
660            for e in &fd.effects {
661                let arg = e.arg.as_ref().map(|a| match a {
662                    a::EffectArg::Str { value } => crate::types::EffectArg::Str(value.clone()),
663                    a::EffectArg::Int { value } => crate::types::EffectArg::Int(*value),
664                    a::EffectArg::Ident { value } => crate::types::EffectArg::Ident(value.clone()),
665                });
666                s.insert(crate::types::EffectKind { name: e.name.clone(), arg });
667            }
668            s
669        },
670        // Open-row tail on the function's own declared row: `-> [io | E] T`.
671        // Resolve `E` to its `type_params` index (shared id space with the
672        // type-var numbering; read back via the effect-subst map, so no
673        // collision with a same-indexed type param).
674        var: fd.effect_row_var
675            .as_ref()
676            .and_then(|n| fd.type_params.iter().position(|p| p == n))
677            .map(|i| i as u32),
678    };
679    let ty = Ty::Function { params, effects, ret: Box::new(ret) };
680    let vars: Vec<TyVarId> = (0..fd.type_params.len() as u32).collect();
681    // Generalize over any effect-row variable in the signature — a
682    // row-polymorphic parameter (`(Int) -> [io | E] Int`, like the stdlib
683    // HOFs) or the function's own open row (`-> [io | E] T`). Each is
684    // freshened per call site by `instantiate`, then bound to the caller's
685    // actual effects by `unify_effects`. Closed rows collect nothing, so
686    // their checking is unchanged.
687    let eff_vars = collect_eff_vars(&ty);
688    Scheme { vars, eff_vars, ty }
689}
690
691struct Checker {
692    u: Unifier,
693    type_env: TypeEnv,
694    globals: IndexMap<String, Scheme>,
695    /// Imported alias → canonical module name (e.g. `cfg` → `toml`).
696    /// Populated during the import pass; consulted by `check_call`
697    /// to recognise `cfg.parse(...)` as a stdlib parse call.
698    module_aliases: IndexMap<String, String>,
699    /// For #168: every `<alias>.parse(s)` call where alias is in
700    /// `module_aliases` and maps to {json, toml, yaml} (or
701    /// `http.json_body`, #684), recorded here as
702    /// `(call_site, return_type_var)`. After the whole program
703    /// type-checks, we walk this and resolve each return type
704    /// through the unifier — at that point any `Result[Manifest, _]`
705    /// constraints from match patterns or let-annotations have
706    /// settled.
707    pending_parse_calls: Vec<(ParseSite, Ty)>,
708    /// #777: NodeId map for the FnDecl stage currently being checked,
709    /// `(stage index, &CExpr address → NodeId)`. Set by
710    /// `check_program_inner` before each `check_fn` when the program
711    /// imports a decode-capable module, `None` otherwise. Used only to
712    /// translate a parse call's address into a stable [`ParseSite`].
713    stage_ids: Option<(usize, HashMap<*const a::CExpr, a::NodeId>)>,
714    /// Per-function param list, retained so call-site discharge can
715    /// see refinement predicates (#209 slice 2). The main `globals`
716    /// scheme strips refinements (`Refined` unifies as its base);
717    /// this side-table keeps the pre-stripped `TypeExpr` available
718    /// for static discharge of literal arguments.
719    fn_params: IndexMap<String, Vec<a::Param>>,
720    /// Errors recovered from independent sub-expressions within a
721    /// function body (discarded `Block` statements, `Let` binding
722    /// values) so a single `lex check` run surfaces every independent
723    /// error instead of stopping at the first (#566). Drained by
724    /// `check_fn` after each body/example check.
725    recovered_errors: Vec<TypeError>,
726    /// Effect-row variables in scope for the function currently being
727    /// checked: surface name (e.g. `E`) → the instantiated fresh effect-var
728    /// id allocated for it. Lets a row-polymorphic *lambda* inside the body
729    /// (`fn (r) -> [io | E] R { ... }`) resolve its tail `E` to the same id
730    /// as the enclosing function's signature, so effects flow through the
731    /// closure (e.g. into `net.serve_fn`) instead of being silently dropped.
732    /// Empty for closed-row functions.
733    eff_row_scope: IndexMap<String, u32>,
734}
735
736impl Checker {
737    fn new() -> Self {
738        Self {
739            u: Unifier::new(),
740            type_env: TypeEnv::new_with_builtins(),
741            globals: IndexMap::new(),
742            module_aliases: IndexMap::new(),
743            pending_parse_calls: Vec::new(),
744            stage_ids: None,
745            fn_params: IndexMap::new(),
746            recovered_errors: Vec::new(),
747            eff_row_scope: IndexMap::new(),
748        }
749    }
750
751    /// Check an independent sub-expression but, on error, record it and
752    /// continue with a fresh type variable rather than aborting the whole
753    /// body. Used for positions whose result type does not flow into a
754    /// strict constraint — a discarded `Block` statement, or a `Let`
755    /// binding's value — so `check_fn` can surface every independent error
756    /// in one pass (#566). A fresh var unifies with anything, so recovery
757    /// does not manufacture spurious follow-on mismatches.
758    fn check_expr_recover(
759        &mut self,
760        e: &a::CExpr,
761        node_id: &str,
762        locals: &mut IndexMap<String, Ty>,
763        effs: &mut EffectSet,
764    ) -> Ty {
765        match self.check_expr(e, node_id, locals, effs) {
766            Ok(ty) => ty,
767            Err(err) => {
768                self.recovered_errors.push(err);
769                self.u.fresh()
770            }
771        }
772    }
773
774    /// If `ty` is a `Ty::Con(name, args)` whose definition is a type
775    /// alias (record or otherwise), return the aliased type with the
776    /// alias's formal parameters substituted by `args`. For zero-arg
777    /// aliases this is the identity substitution. For parametric
778    /// aliases (#439, e.g. `type Box[T] = { value :: T }`), the
779    /// formal `Ty::Var(i)` for the i-th param is replaced by `args[i]`
780    /// so `Box[Str]` unfolds to `{ value :: Str }` rather than to the
781    /// unsubstituted body. Returns `ty` unchanged when arity doesn't
782    /// match or the name doesn't resolve to an alias.
783    fn unfold_record_alias(&self, ty: Ty) -> Ty {
784        if let Ty::Con(ref n, ref args) = ty {
785            if let Some(td) = self.type_env.types.get(n) {
786                if let TypeDefKind::Alias(inner) = &td.kind {
787                    if td.params.len() != args.len() {
788                        return ty;
789                    }
790                    if td.params.is_empty() {
791                        return inner.clone();
792                    }
793                    let mut subst = IndexMap::new();
794                    for (i, a) in args.iter().enumerate() {
795                        subst.insert(i as u32, a.clone());
796                    }
797                    return subst_vars(inner, &subst, &IndexMap::new());
798                }
799            }
800        }
801        ty
802    }
803
804    /// True iff `ty` is a `Ty::Con(name, args)` whose definition is a
805    /// `TypeDefKind::Alias` and whose arity matches. Used by
806    /// `unify_coerce_inner` to detect the case where both sides are
807    /// nominal aliases and unfolding would collapse the nominal
808    /// distinction (#323 / #439). For parametric aliases the arity
809    /// match guards against `Box[Str]` vs an inconsistent `Box[Str, Int]`.
810    fn is_alias_con(&self, ty: &Ty) -> bool {
811        if let Ty::Con(name, args) = ty {
812            if let Some(td) = self.type_env.types.get(name) {
813                if matches!(td.kind, TypeDefKind::Alias(_))
814                    && td.params.len() == args.len()
815                {
816                    return true;
817                }
818            }
819        }
820        false
821    }
822
823    /// Unify two types, asymmetrically coercing an anonymous record
824    /// against a nominal record alias at any level of nesting. So a
825    /// `{ x: 1, y: 2 }` literal can be passed to a fn taking
826    /// `Inner = { x :: Int, y :: Int }`, even when the literal is the
827    /// inner field of an outer record literal.
828    ///
829    /// We deliberately keep nominal-vs-nominal mismatches strict: two
830    /// distinct `Ty::Con` names won't unify just because their record
831    /// shapes match. The coercion fires only when one side is a bare
832    /// `Ty::Record` and the other is a `Ty::Con` whose alias is a
833    /// record.
834    fn unify_with_record_coercion(&mut self, a: &Ty, b: &Ty) -> Result<(), UnifyError> {
835        let a = self.u.resolve(a);
836        let b = self.u.resolve(b);
837        self.unify_coerce_inner(a, b)
838    }
839
840    fn unify_coerce_inner(&mut self, a: Ty, b: Ty) -> Result<(), UnifyError> {
841        // #323: alias unfolding. If exactly one side is an `alias-Con`
842        // — a 0-arg `Ty::Con(name, [])` whose definition is a type
843        // alias (Record or non-record) — unfold both sides so the
844        // structural cases below can match (`Errors` ↔ `List[…]`,
845        // `Path` ↔ `Tuple(…)`, `Maybe` ↔ `Option[…]`,
846        // `UserId` ↔ `Int`, …).
847        //
848        // Three cases intentionally bypass unfolding:
849        //
850        // - **Same-named Cons** (`Test` vs `Test`): preserve nominal
851        //   identity. The Con-Con same-name case below recurses on
852        //   args; eager unfold here would force the nominal name
853        //   to evaporate, breaking unifications elsewhere that
854        //   still see the nominal `Con`.
855        // - **Var on either side**: don't unfold against an unbound
856        //   variable, because the plain unifier would bind the var
857        //   to the unfolded shape and lose the nominal name. The
858        //   var binds to the nominal `Con` instead, and later
859        //   unifications against concrete shapes re-enter this
860        //   function and unfold then.
861        // - **Two distinct alias-Cons** (`Apple` vs `Box`, both
862        //   declared as record aliases with identical shapes):
863        //   preserve nominal distinction between aliases. Unfolding
864        //   both would collapse the test of "same shape, different
865        //   names" into "same shape" and erase the names.
866        let (a, b) = match (&a, &b) {
867            (Ty::Con(n1, _), Ty::Con(n2, _)) if n1 == n2 => (a, b),
868            (Ty::Var(_), _) | (_, Ty::Var(_)) => (a, b),
869            (Ty::Con(_, _), Ty::Con(_, _))
870                if self.is_alias_con(&a) && self.is_alias_con(&b) =>
871            {
872                (a, b)
873            }
874            _ => {
875                let a_u = if let Ty::Con(_, _) = &a {
876                    self.unfold_record_alias(a.clone())
877                } else {
878                    a
879                };
880                let b_u = if let Ty::Con(_, _) = &b {
881                    self.unfold_record_alias(b.clone())
882                } else {
883                    b
884                };
885                (a_u, b_u)
886            }
887        };
888
889        match (&a, &b) {
890            (Ty::Record(fa), Ty::Record(fb)) => {
891                if fa.len() != fb.len() {
892                    return Err(UnifyError::Mismatch { a: a.clone(), b: b.clone() });
893                }
894                for (k, va) in fa.clone() {
895                    match fb.get(&k) {
896                        Some(vb) => self.unify_coerce_inner(va, vb.clone())?,
897                        None => return Err(UnifyError::Mismatch { a: a.clone(), b: b.clone() }),
898                    }
899                }
900                Ok(())
901            }
902            (Ty::List(ta), Ty::List(tb)) => {
903                self.unify_coerce_inner((**ta).clone(), (**tb).clone())
904            }
905            (Ty::Tuple(xs), Ty::Tuple(ys)) if xs.len() == ys.len() => {
906                for (x, y) in xs.clone().into_iter().zip(ys.clone()) {
907                    self.unify_coerce_inner(x, y)?;
908                }
909                Ok(())
910            }
911            // Recurse into Con-Con pairs so record-alias coercion reaches
912            // arbitrary nesting depth (e.g. Result[T, MyAlias]) (#328).
913            (Ty::Con(n1, a1), Ty::Con(n2, a2)) if n1 == n2 && a1.len() == a2.len() => {
914                for (x, y) in a1.clone().into_iter().zip(a2.clone()) {
915                    self.unify_coerce_inner(x, y)?;
916                }
917                Ok(())
918            }
919            // #345: recurse into Function types so alias coercion fires on
920            // closure params / return types. Without this, a closure annotated
921            // `(Errors, Errors) -> Errors` fails to unify with the expected
922            // `(List[?n], ?m) -> List[?n]` even though `Errors = List[Error]`.
923            (Ty::Function { params: pa, effects: ea, ret: ra },
924             Ty::Function { params: pb, effects: eb, ret: rb })
925            if pa.len() == pb.len() => {
926                for (x, y) in pa.clone().into_iter().zip(pb.clone()) {
927                    self.unify_coerce_inner(x, y)?;
928                }
929                // Propagate the EffectMismatch verbatim (rather than
930                // collapsing it into a whole-type Mismatch) so the
931                // invariant-effect-row case surfaces as its own
932                // rule_tag with the narrow-the-body fix (#565).
933                self.u.unify_effects(ea, eb)?;
934                self.unify_coerce_inner((**ra).clone(), (**rb).clone())
935            }
936            _ => self.u.unify(&a, &b),
937        }
938    }
939
940    fn check_fn(&mut self, fd: &a::FnDecl) -> Result<Scheme, Vec<TypeError>> {
941        // Instantiate fn's signature with fresh vars for its type params.
942        let scheme = function_scheme(fd, &self.type_env);
943        let (inst_ty, eff_subst) = instantiate_with_eff(&scheme, &mut self.u);
944        let (param_tys, declared_effects, ret_ty) = match inst_ty {
945            Ty::Function { params, effects, ret } => (params, effects, *ret),
946            _ => unreachable!(),
947        };
948
949        // Map this function's surface row-variable names to their freshly
950        // instantiated effect-var ids, so a row-polymorphic lambda in the
951        // body can join the enclosing row (see `eff_row_scope`). A type
952        // param at index `i` is a row var iff `i` was generalized as an
953        // effect var (`scheme.eff_vars`) and thus appears in `eff_subst`.
954        let saved_scope = std::mem::take(&mut self.eff_row_scope);
955        for (i, name) in fd.type_params.iter().enumerate() {
956            if let Some(fresh) = eff_subst.get(&(i as u32)) {
957                self.eff_row_scope.insert(name.clone(), *fresh);
958            }
959        }
960
961        let mut locals: IndexMap<String, Ty> = IndexMap::new();
962        for (p, t) in fd.params.iter().zip(param_tys.iter()) {
963            locals.insert(p.name.clone(), t.clone());
964        }
965
966        // Accumulate all errors within this function rather than returning on the
967        // first one (#566). Body errors and example errors are independent — an
968        // agent can fix both in one pass instead of running lex check repeatedly.
969        let mut errors: Vec<TypeError> = Vec::new();
970        let mut inferred_effects = EffectSet::empty();
971
972        // Check body. Save the error but continue to example checking.
973        let body_ok = match self.check_expr(&fd.body, "n_0", &mut locals, &mut inferred_effects) {
974            Ok(body_ty) => {
975                // The body may produce an anonymous record literal where the
976                // signature expects a nominal record alias (and vice-versa,
977                // and at any nested level). `unify_with_record_coercion`
978                // handles that asymmetry while keeping nominal-vs-nominal
979                // mismatches strict.
980                if let Err(e) = self.unify_with_record_coercion(&body_ty, &ret_ty) {
981                    errors.push(mismatch_err("n_0", e, &self.u, vec![format!("in function `{}`", fd.name)]));
982                    false
983                } else {
984                    true
985                }
986            }
987            Err(e) => { errors.push(e); false }
988        };
989
990        // Surface errors recovered from independent positions in the body
991        // (discarded `Block` statements, `Let` values) so every independent
992        // error is reported in one pass (#566), not just the first.
993        let body_had_recovered = !self.recovered_errors.is_empty();
994        errors.append(&mut self.recovered_errors);
995
996        // Skip the effect-not-declared check when the body had recovered
997        // errors: effect inference is incomplete (recovered sub-exprs became
998        // fresh vars that contribute no effects), so a missing/extra effect
999        // would be misleading noise next to the real errors.
1000        if body_ok && !body_had_recovered && !inferred_effects.is_subset(&declared_effects) {
1001            for e in inferred_effects.concrete.iter() {
1002                if !declared_effects.concrete.iter().any(|d| d.subsumes(e)) {
1003                    errors.push(TypeError::EffectNotDeclared {
1004                        at_node: "n_0".into(),
1005                        effect: e.pretty(),
1006                    });
1007                    break;
1008                }
1009            }
1010        }
1011
1012        // #369: signature-level examples. Pure-only in v1; arg arity
1013        // must match params; each arg type-checks against its param,
1014        // each expected type-checks against the return type.
1015        // Check all examples regardless of body success (#566).
1016        if !fd.examples.is_empty() {
1017            if !declared_effects.concrete.is_empty() {
1018                errors.push(TypeError::ExamplesOnEffectfulFn {
1019                    at_node: "n_0".into(),
1020                    fn_name: fd.name.clone(),
1021                });
1022            } else {
1023                for (case_index, ex) in fd.examples.iter().enumerate() {
1024                    if ex.args.len() != param_tys.len() {
1025                        errors.push(TypeError::ExampleArityMismatch {
1026                            at_node: "n_0".into(),
1027                            fn_name: fd.name.clone(),
1028                            case_index,
1029                            expected: param_tys.len(),
1030                            got: ex.args.len(),
1031                        });
1032                        continue;
1033                    }
1034                    let mut example_locals: IndexMap<String, Ty> = IndexMap::new();
1035                    let mut example_effects = EffectSet::empty();
1036                    let mut args_ok = true;
1037                    for (i, (arg, expected_ty)) in
1038                        ex.args.iter().zip(param_tys.iter()).enumerate()
1039                    {
1040                        match self.check_expr(arg, "n_0", &mut example_locals, &mut example_effects) {
1041                            Ok(arg_ty) => {
1042                                if let Err(e) = self.unify_with_record_coercion(&arg_ty, expected_ty) {
1043                                    errors.push(mismatch_err(
1044                                        "n_0", e, &self.u,
1045                                        vec![format!("in example #{} for `{}`, argument {}", case_index + 1, fd.name, i + 1)],
1046                                    ));
1047                                    args_ok = false;
1048                                }
1049                            }
1050                            Err(e) => { errors.push(e); args_ok = false; }
1051                        }
1052                    }
1053                    if args_ok {
1054                        match self.check_expr(&ex.expected, "n_0", &mut example_locals, &mut example_effects) {
1055                            Ok(expected_ty) => {
1056                                if let Err(e) = self.unify_with_record_coercion(&expected_ty, &ret_ty) {
1057                                    errors.push(mismatch_err(
1058                                        "n_0", e, &self.u,
1059                                        vec![format!("in example #{} for `{}`, expected value", case_index + 1, fd.name)],
1060                                    ));
1061                                }
1062                            }
1063                            Err(e) => errors.push(e),
1064                        }
1065                    }
1066                    // The example's args/expected are expected to be pure
1067                    // by construction (literals in the common case); if
1068                    // they invoked effects, they'd break the pure-only
1069                    // discipline. Reject the first one via the same effect rule.
1070                    if let Some(e) = example_effects.concrete.iter().next() {
1071                        errors.push(TypeError::EffectNotDeclared {
1072                            at_node: "n_0".into(),
1073                            effect: e.pretty(),
1074                        });
1075                    }
1076                }
1077            }
1078        }
1079
1080        // Catch any errors recovered while checking example sub-expressions.
1081        errors.append(&mut self.recovered_errors);
1082        // Restore the enclosing function's row-var scope (functions are
1083        // checked one at a time, so this is just defensive symmetry).
1084        self.eff_row_scope = saved_scope;
1085        if errors.is_empty() { Ok(scheme) } else { Err(errors) }
1086    }
1087
1088    fn check_expr(
1089        &mut self,
1090        e: &a::CExpr,
1091        node_id: &str,
1092        locals: &mut IndexMap<String, Ty>,
1093        effs: &mut EffectSet,
1094    ) -> Result<Ty, TypeError> {
1095        match e {
1096            a::CExpr::Literal { value } => Ok(lit_type(value)),
1097            a::CExpr::Var { name } => {
1098                if let Some(t) = locals.get(name) {
1099                    return Ok(t.clone());
1100                }
1101                if let Some(scheme) = self.globals.get(name).cloned() {
1102                    return Ok(instantiate(&scheme, &mut self.u));
1103                }
1104                Err(TypeError::UnknownIdentifier { at_node: node_id.into(), name: name.clone() })
1105            }
1106            a::CExpr::Constructor { name, args } => self.check_constructor(name, args, node_id, locals, effs),
1107            a::CExpr::Call { callee, args } => self.check_call(e, callee, args, node_id, locals, effs),
1108            a::CExpr::Let { name, ty, value, body } => {
1109                // Recover if the bound value fails to check: record the error
1110                // and bind the name to a fresh var so the `let` body (which
1111                // may hold further independent errors) is still checked (#566).
1112                let v_ty = self.check_expr_recover(value, node_id, locals, effs);
1113                if let Some(declared) = ty {
1114                    let d = ty_from_canon_env(declared, &[], &self.type_env);
1115                    if let Err(err) = self.unify_with_record_coercion(&v_ty, &d) {
1116                        return Err(mismatch_err(node_id, err, &self.u, vec![format!("in let `{}`", name)]));
1117                    }
1118                }
1119                let prev = locals.insert(name.clone(), v_ty);
1120                let body_ty = self.check_expr(body, node_id, locals, effs)?;
1121                match prev {
1122                    Some(p) => { locals.insert(name.clone(), p); }
1123                    None => { locals.shift_remove(name); }
1124                }
1125                Ok(body_ty)
1126            }
1127            a::CExpr::Match { scrutinee, arms } => {
1128                let scrut_ty = self.check_expr(scrutinee, node_id, locals, effs)?;
1129                if arms.is_empty() {
1130                    return Err(TypeError::NonExhaustiveMatch {
1131                        at_node: node_id.into(), missing: vec!["_".into()]
1132                    });
1133                }
1134                let result_ty = self.u.fresh();
1135                for arm in arms {
1136                    let mut arm_locals = locals.clone();
1137                    self.bind_pattern(&arm.pattern, &scrut_ty, &mut arm_locals, node_id)?;
1138                    let arm_ty = self.check_expr(&arm.body, node_id, &mut arm_locals, effs)?;
1139                    if let Err(err) = self.unify_with_record_coercion(&arm_ty, &result_ty) {
1140                        return Err(mismatch_err(node_id, err, &self.u, vec!["in match arm".into()]));
1141                    }
1142                }
1143                // Exhaustiveness (#766). Runs after every arm has been
1144                // bound so the scrutinee's type is as resolved as it is
1145                // going to get (a constructor pattern against a type
1146                // variable pins the variable to its union).
1147                let rows: Vec<Vec<a::Pattern>> = arms.iter().map(|arm| vec![arm.pattern.clone()]).collect();
1148                if let Some(witnesses) = self.missing_patterns(&rows, std::slice::from_ref(&scrut_ty)) {
1149                    return Err(TypeError::NonExhaustiveMatch {
1150                        at_node: node_id.into(),
1151                        missing: witnesses.into_iter().map(|w| w.join(", ")).collect(),
1152                    });
1153                }
1154                Ok(result_ty)
1155            }
1156            a::CExpr::Block { statements, result } => {
1157                // Each statement's value is discarded, so an error in one
1158                // doesn't feed a later type — recover and keep checking the
1159                // rest so every independent error surfaces in one pass (#566).
1160                for s in statements {
1161                    let _ = self.check_expr_recover(s, node_id, locals, effs);
1162                }
1163                self.check_expr(result, node_id, locals, effs)
1164            }
1165            a::CExpr::RecordLit { fields } => {
1166                let mut tys = IndexMap::new();
1167                for f in fields {
1168                    if tys.contains_key(&f.name) {
1169                        return Err(TypeError::DuplicateField {
1170                            at_node: node_id.into(), field: f.name.clone()
1171                        });
1172                    }
1173                    let ft = self.check_expr(&f.value, node_id, locals, effs)?;
1174                    tys.insert(f.name.clone(), ft);
1175                }
1176                Ok(Ty::Record(tys))
1177            }
1178            a::CExpr::TupleLit { items } => {
1179                let mut ts = Vec::new();
1180                for it in items { ts.push(self.check_expr(it, node_id, locals, effs)?); }
1181                Ok(Ty::Tuple(ts))
1182            }
1183            a::CExpr::ListLit { items } => {
1184                let elem = self.u.fresh();
1185                for it in items {
1186                    let t = self.check_expr(it, node_id, locals, effs)?;
1187                    if let Err(err) = self.unify_with_record_coercion(&t, &elem) {
1188                        return Err(mismatch_err(node_id, err, &self.u, vec!["in list literal".into()]));
1189                    }
1190                }
1191                Ok(Ty::List(Box::new(elem)))
1192            }
1193            a::CExpr::FieldAccess { value, field } => {
1194                // #963: `<alias>.Ctor` used as a value — a nullary constructor
1195                // of a resolved dependency module (a payload constructor used
1196                // this way is handled in `check_call`). Same rationale as the
1197                // qualified-constructor call path.
1198                if let a::CExpr::Var { name: alias } = &**value {
1199                    if self.type_env.dep_alias_prefixes.contains_key(alias)
1200                        && self.type_env.ctor_to_type.contains_key(field)
1201                    {
1202                        return self.check_constructor(field, &[], node_id, locals, effs);
1203                    }
1204                }
1205                let vt = self.check_expr(value, node_id, locals, effs)?;
1206                let resolved = self.u.resolve(&vt);
1207                // Unfold a Record-aliased Con (e.g. `type Request = { ... }`
1208                // or `type Box[T] = { value :: T }`). For parametric aliases
1209                // the helper substitutes the actual args for the formal
1210                // params; the post-unfold shape is only a Record when the
1211                // alias body was a record, so non-record aliases (e.g.
1212                // `type UserId = Int`) fall through to the
1213                // "expected record" error below.
1214                let resolved = if let Ty::Con(_, _) = &resolved {
1215                    let unfolded = self.unfold_record_alias(resolved.clone());
1216                    if matches!(unfolded, Ty::Record(_)) {
1217                        unfolded
1218                    } else {
1219                        resolved
1220                    }
1221                } else {
1222                    resolved
1223                };
1224                match resolved {
1225                    Ty::Record(fields) => fields.get(field).cloned()
1226                        .or_else(|| synthesize_decode_typed_field(field, &fields))
1227                        .ok_or_else(|| TypeError::UnknownField {
1228                            at_node: node_id.into(),
1229                            record_type: Ty::Record(fields.clone()).pretty(),
1230                            field: field.clone(),
1231                        }),
1232                    other => Err(TypeError::TypeMismatch {
1233                        at_node: node_id.into(),
1234                        expected: "record".into(),
1235                        got: other.pretty(),
1236                        context: vec![format!("field access `.{}`", field)],
1237                    }),
1238                }
1239            }
1240            a::CExpr::Lambda { params, return_type, effects: l_effects, effect_row_var: l_row_var, body } => {
1241                let param_tys: Vec<Ty> = params.iter().map(|p| ty_from_canon_env(&p.ty, &[], &self.type_env)).collect();
1242                let ret_ty = ty_from_canon_env(return_type, &[], &self.type_env);
1243                // A row-polymorphic lambda (`fn (..) -> [io | E] ..`) resolves
1244                // its tail `E` to the enclosing function's instantiated row-var
1245                // id (recorded in `eff_row_scope`), so effects produced in the
1246                // body — e.g. by calling a row-poly parameter — flow out through
1247                // the closure's type (into `net.serve_fn` etc.) rather than
1248                // being dropped. An unknown name is a plain error.
1249                let row_var = match l_row_var {
1250                    Some(name) => match self.eff_row_scope.get(name) {
1251                        Some(id) => Some(*id),
1252                        None => {
1253                            return Err(TypeError::EffectNotDeclared {
1254                                at_node: node_id.into(),
1255                                effect: format!("unbound effect-row variable `{}`", name),
1256                            });
1257                        }
1258                    },
1259                    None => None,
1260                };
1261                let declared = EffectSet {
1262                    concrete: {
1263                        let mut s = std::collections::BTreeSet::new();
1264                        for e in l_effects {
1265                            let arg = e.arg.as_ref().map(|a| match a {
1266                                a::EffectArg::Str { value } => crate::types::EffectArg::Str(value.clone()),
1267                                a::EffectArg::Int { value } => crate::types::EffectArg::Int(*value),
1268                                a::EffectArg::Ident { value } => crate::types::EffectArg::Ident(value.clone()),
1269                            });
1270                            s.insert(crate::types::EffectKind { name: e.name.clone(), arg });
1271                        }
1272                        s
1273                    },
1274                    var: row_var,
1275                };
1276                let mut inner_locals = locals.clone();
1277                for (p, t) in params.iter().zip(param_tys.iter()) {
1278                    inner_locals.insert(p.name.clone(), t.clone());
1279                }
1280                let mut inner_effs = EffectSet::empty();
1281                let body_ty = self.check_expr(body, node_id, &mut inner_locals, &mut inner_effs)?;
1282                if let Err(err) = self.unify_with_record_coercion(&body_ty, &ret_ty) {
1283                    return Err(mismatch_err(node_id, err, &self.u, vec!["in lambda body".into()]));
1284                }
1285                if !inner_effs.is_subset(&declared) {
1286                    for e in inner_effs.concrete.iter() {
1287                        if !declared.concrete.iter().any(|d| d.subsumes(e)) {
1288                            return Err(TypeError::EffectNotDeclared {
1289                                at_node: node_id.into(),
1290                                effect: e.pretty(),
1291                            });
1292                        }
1293                    }
1294                }
1295                // The body produced an open effect row (e.g. by calling a
1296                // row-polymorphic parameter), but the lambda's declared row
1297                // doesn't carry that same tail — without `| E` the extra
1298                // effects would be silently dropped at the closure boundary.
1299                // Require the lambda to declare the matching open row.
1300                if let Some(iv) = inner_effs.var {
1301                    if declared.var != Some(iv) {
1302                        return Err(TypeError::EffectNotDeclared {
1303                            at_node: node_id.into(),
1304                            effect: "open effect row (annotate the lambda's effects with `| <row-var>`)".into(),
1305                        });
1306                    }
1307                }
1308                Ok(Ty::function(param_tys, declared, ret_ty))
1309            }
1310            a::CExpr::BinOp { op, lhs, rhs } => self.check_binop(op, lhs, rhs, node_id, locals, effs),
1311            a::CExpr::UnaryOp { op, expr } => {
1312                let t = self.check_expr(expr, node_id, locals, effs)?;
1313                match op.as_str() {
1314                    "-" => {
1315                        // Either Int or Float; we pick Int by default if unconstrained.
1316                        let r = self.u.resolve(&t);
1317                        match r {
1318                            Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) => Ok(t),
1319                            Ty::Var(_) => {
1320                                // default to Int.
1321                                self.u.unify(&t, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![]))?;
1322                                Ok(Ty::int())
1323                            }
1324                            other => Err(TypeError::TypeMismatch {
1325                                at_node: node_id.into(),
1326                                expected: "Int or Float".into(),
1327                                got: other.pretty(),
1328                                context: vec!["unary `-`".into()],
1329                            }),
1330                        }
1331                    }
1332                    "not" => {
1333                        self.u.unify(&t, &Ty::bool()).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["unary `not`".into()]))?;
1334                        Ok(Ty::bool())
1335                    }
1336                    other => panic!("unknown unary op: {other}"),
1337                }
1338            }
1339            a::CExpr::Return { value } => {
1340                // For now treat Return as having type Never; the surrounding
1341                // context will unify with the actual return type.
1342                self.check_expr(value, node_id, locals, effs)?;
1343                Ok(Ty::Never)
1344            }
1345        }
1346    }
1347
1348    fn check_binop(
1349        &mut self,
1350        op: &str,
1351        lhs: &a::CExpr,
1352        rhs: &a::CExpr,
1353        node_id: &str,
1354        locals: &mut IndexMap<String, Ty>,
1355        effs: &mut EffectSet,
1356    ) -> Result<Ty, TypeError> {
1357        let lt = self.check_expr(lhs, node_id, locals, effs)?;
1358        let rt = self.check_expr(rhs, node_id, locals, effs)?;
1359        match op {
1360            "+" => {
1361                // #308: `+` is overloaded over Int, Float, and Str.
1362                // Str concatenation dispatches at the VM layer
1363                // (Op::NumAdd in bytecode handles all three).
1364                // #323: unfold one-step type aliases on the resolved
1365                // type so `type UserId = Int; id + id` works under
1366                // Option-A transparency. Same below for the other
1367                // numeric operator groups.
1368                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1369                let r = self.unfold_record_alias(self.u.resolve(&lt));
1370                match r {
1371                    Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) | Ty::Prim(Prim::Str) => Ok(lt),
1372                    Ty::Var(_) => {
1373                        self.u.unify(&lt, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1374                        Ok(Ty::int())
1375                    }
1376                    other => Err(TypeError::TypeMismatch {
1377                        at_node: node_id.into(),
1378                        expected: "Int, Float, or Str".into(),
1379                        got: other.pretty(),
1380                        context: vec![format!("operator `{op}`")],
1381                    }),
1382                }
1383            }
1384            "-" | "*" | "/" | "%" => {
1385                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1386                let r = self.unfold_record_alias(self.u.resolve(&lt));
1387                match r {
1388                    Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) => Ok(lt),
1389                    Ty::Var(_) => {
1390                        self.u.unify(&lt, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1391                        Ok(Ty::int())
1392                    }
1393                    other => Err(TypeError::TypeMismatch {
1394                        at_node: node_id.into(),
1395                        expected: "Int or Float".into(),
1396                        got: other.pretty(),
1397                        context: vec![format!("operator `{op}`")],
1398                    }),
1399                }
1400            }
1401            "==" | "!=" => {
1402                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1403                Ok(Ty::bool())
1404            }
1405            "<" | "<=" | ">" | ">=" => {
1406                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1407                let r = self.unfold_record_alias(self.u.resolve(&lt));
1408                match r {
1409                    Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) | Ty::Prim(Prim::Str) => Ok(Ty::bool()),
1410                    Ty::Var(_) => {
1411                        self.u.unify(&lt, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1412                        Ok(Ty::bool())
1413                    }
1414                    other => Err(TypeError::TypeMismatch {
1415                        at_node: node_id.into(),
1416                        expected: "Int, Float, or Str".into(),
1417                        got: other.pretty(),
1418                        context: vec![format!("operator `{op}`")],
1419                    }),
1420                }
1421            }
1422            "and" | "or" => {
1423                self.u.unify(&lt, &Ty::bool()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1424                self.u.unify(&rt, &Ty::bool()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1425                Ok(Ty::bool())
1426            }
1427            other => panic!("unknown binop: {other}"),
1428        }
1429    }
1430
1431    fn check_call(
1432        &mut self,
1433        call_expr: &a::CExpr,
1434        callee: &a::CExpr,
1435        args: &[a::CExpr],
1436        node_id: &str,
1437        locals: &mut IndexMap<String, Ty>,
1438        effs: &mut EffectSet,
1439    ) -> Result<Ty, TypeError> {
1440        // #963: a qualified constructor call, `<alias>.Ctor(args)`, where the
1441        // alias is a resolved dependency module and `Ctor` is one of its
1442        // exported constructors. Non-inlined resolution keeps the reference
1443        // qualified (an inlined dependency would have rewritten it to the bare,
1444        // flat-namespace constructor), so route it to constructor checking
1445        // rather than letting it read as a field access on the module record.
1446        if let a::CExpr::FieldAccess { value, field } = callee {
1447            if let a::CExpr::Var { name: alias } = &**value {
1448                if self.type_env.dep_alias_prefixes.contains_key(alias)
1449                    && self.type_env.ctor_to_type.contains_key(field)
1450                {
1451                    return self.check_constructor(field, args, node_id, locals, effs);
1452                }
1453            }
1454        }
1455        // #168: identify the call before the recursive descent so we
1456        // can later rewrite this exact node. The identity is a stable
1457        // (stage, NodeId) pair rather than the expression's address
1458        // (#777), so the resulting table can be applied to any copy
1459        // of the checked stages. `is_module_parse_call` recognises
1460        // `<alias>.parse` where alias was bound to one of {json,
1461        // toml, yaml} during the import pass.
1462        let parse_site = if self.is_module_parse_call(callee) {
1463            self.parse_site_of(call_expr)
1464        } else {
1465            None
1466        };
1467        let callee_ty = self.check_expr(callee, node_id, locals, effs)?;
1468        let resolved = self.u.resolve(&callee_ty);
1469        match resolved {
1470            Ty::Function { params, effects, ret } => {
1471                if params.len() != args.len() {
1472                    return Err(TypeError::ArityMismatch {
1473                        at_node: node_id.into(),
1474                        expected: params.len(),
1475                        got: args.len(),
1476                    });
1477                }
1478                for (i, (a, p)) in args.iter().zip(params.iter()).enumerate() {
1479                    let at = self.check_expr(a, node_id, locals, effs)?;
1480                    if let Err(err) = self.unify_with_record_coercion(&at, p) {
1481                        return Err(mismatch_err(node_id, err, &self.u, vec![format!("argument {} of call", i + 1)]));
1482                    }
1483                }
1484                // #209 slice 2: refinement discharge for direct named
1485                // calls. Look up the callee's original params (kept
1486                // pre-strip in `fn_params`), and for each refined
1487                // param attempt static discharge against the call
1488                // arg. Refuted = type error; Deferred = pass (slice
1489                // 3 will add a runtime residual check).
1490                if let a::CExpr::Var { name: callee_name } = callee {
1491                    if let Some(callee_params) = self.fn_params.get(callee_name).cloned() {
1492                        for (i, (param, arg)) in callee_params.iter().zip(args.iter()).enumerate() {
1493                            if let a::TypeExpr::Refined { binding, predicate, .. } = &param.ty {
1494                                let outcome = crate::discharge::try_discharge(
1495                                    predicate, binding, arg);
1496                                if let crate::discharge::DischargeOutcome::Refuted { reason } = outcome {
1497                                    return Err(TypeError::RefinementViolation {
1498                                        at_node: node_id.into(),
1499                                        fn_name: callee_name.clone(),
1500                                        param_index: i,
1501                                        binding: binding.clone(),
1502                                        reason,
1503                                    });
1504                                }
1505                            }
1506                        }
1507                    }
1508                }
1509                // Re-resolve effects after unifying args: an effect-row
1510                // variable on the function type may have been bound by
1511                // an argument's closure type, and we want the
1512                // *post-binding* set when propagating to the caller.
1513                let resolved_effects = self.u.resolve_effects(&effects);
1514                effs.extend(&resolved_effects);
1515                // #168: snapshot the post-arg-unification return type
1516                // for stdlib parse calls. Resolution to the eventual
1517                // `Result[Record{...}, _]` shape happens at the end
1518                // of `check_program` once the whole program's
1519                // unification has settled — match-pattern annotations
1520                // and let-type-annotations may bind T after this
1521                // point.
1522                if let Some(site) = parse_site {
1523                    self.pending_parse_calls.push((site, (*ret).clone()));
1524                }
1525                Ok(*ret)
1526            }
1527            Ty::Var(_) => {
1528                // Build a function type and unify.
1529                let mut p_tys = Vec::new();
1530                for a in args { p_tys.push(self.check_expr(a, node_id, locals, effs)?); }
1531                let r = self.u.fresh();
1532                let f = Ty::function(p_tys, EffectSet::empty(), r.clone());
1533                self.u.unify(&callee_ty, &f).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["in call".into()]))?;
1534                Ok(r)
1535            }
1536            other => Err(TypeError::TypeMismatch {
1537                at_node: node_id.into(),
1538                expected: "function".into(),
1539                got: other.pretty(),
1540                context: vec!["in call".into()],
1541            }),
1542        }
1543    }
1544
1545    fn check_constructor(
1546        &mut self,
1547        name: &str,
1548        args: &[a::CExpr],
1549        node_id: &str,
1550        locals: &mut IndexMap<String, Ty>,
1551        effs: &mut EffectSet,
1552    ) -> Result<Ty, TypeError> {
1553        let owning = self.type_env.ctor_to_type.get(name).cloned()
1554            .ok_or_else(|| TypeError::UnknownVariant {
1555                at_node: node_id.into(),
1556                constructor: name.to_string(),
1557            })?;
1558        let def = self.type_env.types.get(&owning).cloned()
1559            .expect("ctor_to_type points to a real type");
1560        let variants = match &def.kind {
1561            TypeDefKind::Union(v) => v.clone(),
1562            _ => return Err(TypeError::UnknownVariant {
1563                at_node: node_id.into(),
1564                constructor: name.to_string(),
1565            }),
1566        };
1567        // Instantiate the type's params with fresh vars; substitute into
1568        // both the variant's payload type and the resulting Con(...).
1569        let mut subst = IndexMap::new();
1570        let mut con_args = Vec::with_capacity(def.params.len());
1571        for (i, _p) in def.params.iter().enumerate() {
1572            let fresh = self.u.fresh();
1573            subst.insert(i as u32, fresh.clone());
1574            con_args.push(fresh);
1575        }
1576        let payload = variants.get(name).cloned().flatten();
1577        match (payload, args) {
1578            (None, []) => Ok(Ty::Con(owning, con_args)),
1579            (Some(payload), args) => {
1580                let inst_payload = subst_vars(&payload, &subst, &IndexMap::new());
1581                let arg_count = match &inst_payload {
1582                    Ty::Tuple(items) => items.len(),
1583                    _ => 1,
1584                };
1585                if arg_count != args.len() {
1586                    return Err(TypeError::ArityMismatch {
1587                        at_node: node_id.into(),
1588                        expected: arg_count,
1589                        got: args.len(),
1590                    });
1591                }
1592                if args.len() == 1 {
1593                    let at = self.check_expr(&args[0], node_id, locals, effs)?;
1594                    self.unify_with_record_coercion(&at, &inst_payload).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("constructor `{}`", name)]))?;
1595                } else if let Ty::Tuple(items) = inst_payload {
1596                    for (i, (a, t)) in args.iter().zip(items.iter()).enumerate() {
1597                        let at = self.check_expr(a, node_id, locals, effs)?;
1598                        self.unify_with_record_coercion(&at, t).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("constructor `{}` arg {}", name, i + 1)]))?;
1599                    }
1600                }
1601                Ok(Ty::Con(owning, con_args))
1602            }
1603            (None, _) => Err(TypeError::ArityMismatch {
1604                at_node: node_id.into(), expected: 0, got: args.len(),
1605            }),
1606        }
1607    }
1608
1609    fn bind_pattern(
1610        &mut self,
1611        pat: &a::Pattern,
1612        ty: &Ty,
1613        locals: &mut IndexMap<String, Ty>,
1614        node_id: &str,
1615    ) -> Result<(), TypeError> {
1616        match pat {
1617            a::Pattern::PWild => Ok(()),
1618            a::Pattern::PVar { name } => {
1619                locals.insert(name.clone(), ty.clone());
1620                Ok(())
1621            }
1622            a::Pattern::PLiteral { value } => {
1623                let lt = lit_type(value);
1624                self.unify_with_record_coercion(&lt, ty).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["in pattern".into()]))?;
1625                Ok(())
1626            }
1627            a::Pattern::PConstructor { name, args } => {
1628                // Re-use constructor logic but in pattern position.
1629                let owning = self.type_env.ctor_to_type.get(name).cloned()
1630                    .ok_or_else(|| TypeError::UnknownVariant {
1631                        at_node: node_id.into(), constructor: name.clone(),
1632                    })?;
1633                let def = self.type_env.types.get(&owning).cloned().unwrap();
1634                let mut subst = IndexMap::new();
1635                let mut con_args = Vec::new();
1636                for (i, _) in def.params.iter().enumerate() {
1637                    let fresh = self.u.fresh();
1638                    subst.insert(i as u32, fresh.clone());
1639                    con_args.push(fresh);
1640                }
1641                let con_ty = Ty::Con(owning.clone(), con_args);
1642                self.unify_with_record_coercion(&con_ty, ty).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("constructor pattern `{}`", name)]))?;
1643                let payload = match &def.kind {
1644                    TypeDefKind::Union(v) => v.get(name).cloned().flatten(),
1645                    _ => None,
1646                };
1647                match (payload, args.as_slice()) {
1648                    (None, []) => Ok(()),
1649                    (Some(payload), args) => {
1650                        let inst = subst_vars(&payload, &subst, &IndexMap::new());
1651                        if args.len() == 1 {
1652                            self.bind_pattern(&args[0], &inst, locals, node_id)?;
1653                        } else if let Ty::Tuple(items) = inst {
1654                            for (a, t) in args.iter().zip(items.iter()) {
1655                                self.bind_pattern(a, t, locals, node_id)?;
1656                            }
1657                        }
1658                        Ok(())
1659                    }
1660                    (None, _) => Err(TypeError::ArityMismatch {
1661                        at_node: node_id.into(), expected: 0, got: args.len(),
1662                    }),
1663                }
1664            }
1665            a::Pattern::PRecord { fields } => {
1666                // Unfold a record-aliased Con (`type Bands = { ... }`)
1667                // so a structural `{ idea: pat, ... }` pattern can match
1668                // a nominal-typed scrutinee, mirror of #79's literal
1669                // coercion at every position.
1670                let resolved = self.unfold_record_alias(self.u.resolve(ty));
1671                let rec = match resolved {
1672                    Ty::Record(r) => r,
1673                    _ => return Err(TypeError::TypeMismatch {
1674                        at_node: node_id.into(),
1675                        expected: "record".into(),
1676                        got: ty.pretty(),
1677                        context: vec!["in record pattern".into()],
1678                    }),
1679                };
1680                for f in fields {
1681                    let ft = rec.get(&f.name).cloned()
1682                        .ok_or_else(|| TypeError::UnknownField {
1683                            at_node: node_id.into(),
1684                            record_type: Ty::Record(rec.clone()).pretty(),
1685                            field: f.name.clone(),
1686                        })?;
1687                    self.bind_pattern(&f.pattern, &ft, locals, node_id)?;
1688                }
1689                Ok(())
1690            }
1691            a::Pattern::PTuple { items } => {
1692                // An empty-tuple pattern `()` is equivalent to Unit.
1693                if items.is_empty() {
1694                    return self.unify_with_record_coercion(&Ty::Unit, ty)
1695                        .map_err(|e| mismatch_err(node_id, e, &self.u, vec!["in unit pattern".into()]));
1696                }
1697                let resolved = self.u.resolve(ty);
1698                let tup = match resolved {
1699                    Ty::Tuple(t) => t,
1700                    Ty::Var(_) => {
1701                        let fresh: Vec<Ty> = items.iter().map(|_| self.u.fresh()).collect();
1702                        let tup_ty = Ty::Tuple(fresh.clone());
1703                        self.unify_with_record_coercion(&tup_ty, ty).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["in tuple pattern".into()]))?;
1704                        fresh
1705                    }
1706                    other => {
1707                        return Err(TypeError::TypeMismatch {
1708                            at_node: node_id.into(),
1709                            expected: "tuple".into(),
1710                            got: other.pretty(),
1711                            context: vec!["in tuple pattern".into()],
1712                        });
1713                    }
1714                };
1715                if tup.len() != items.len() {
1716                    return Err(TypeError::ArityMismatch {
1717                        at_node: node_id.into(), expected: tup.len(), got: items.len(),
1718                    });
1719                }
1720                for (p, t) in items.iter().zip(tup.iter()) {
1721                    self.bind_pattern(p, t, locals, node_id)?;
1722                }
1723                Ok(())
1724            }
1725        }
1726    }
1727}
1728
1729fn lit_type(l: &a::CLit) -> Ty {
1730    match l {
1731        a::CLit::Int { .. } => Ty::int(),
1732        a::CLit::Float { .. } => Ty::float(),
1733        a::CLit::Str { .. } => Ty::str(),
1734        a::CLit::Bytes { .. } => Ty::bytes(),
1735        a::CLit::Bool { .. } => Ty::bool(),
1736        a::CLit::Unit => Ty::Unit,
1737    }
1738}
1739
1740fn instantiate(s: &Scheme, u: &mut Unifier) -> Ty {
1741    instantiate_with_eff(s, u).0
1742}
1743
1744/// Like `instantiate`, but also returns the effect-var substitution
1745/// (scheme effect-var id → fresh id). `check_fn` uses it to map the
1746/// function's surface row-variable names to their instantiated ids, so a
1747/// row-polymorphic lambda in the body can join the same row.
1748fn instantiate_with_eff(s: &Scheme, u: &mut Unifier) -> (Ty, IndexMap<u32, u32>) {
1749    let mut ty_subst = IndexMap::new();
1750    for v in &s.vars { ty_subst.insert(*v, u.fresh()); }
1751    let mut eff_subst = IndexMap::new();
1752    for v in &s.eff_vars { eff_subst.insert(*v, u.fresh_eff_id()); }
1753    let ty = subst_vars(&s.ty, &ty_subst, &eff_subst);
1754    (ty, eff_subst)
1755}
1756
1757fn subst_vars(
1758    t: &Ty,
1759    subst: &IndexMap<TyVarId, Ty>,
1760    eff_subst: &IndexMap<u32, u32>,
1761) -> Ty {
1762    match t {
1763        Ty::Var(v) => subst.get(v).cloned().unwrap_or_else(|| Ty::Var(*v)),
1764        Ty::Prim(_) | Ty::Unit | Ty::Never => t.clone(),
1765        Ty::List(inner) => Ty::List(Box::new(subst_vars(inner, subst, eff_subst))),
1766        Ty::Tuple(items) => Ty::Tuple(items.iter().map(|t| subst_vars(t, subst, eff_subst)).collect()),
1767        Ty::Record(fs) => {
1768            let mut out = IndexMap::new();
1769            for (k, v) in fs { out.insert(k.clone(), subst_vars(v, subst, eff_subst)); }
1770            Ty::Record(out)
1771        }
1772        Ty::Con(n, args) => Ty::Con(n.clone(),
1773            args.iter().map(|t| subst_vars(t, subst, eff_subst)).collect()),
1774        Ty::Function { params, effects, ret } => {
1775            // Refresh the effect-row variable if it's quantified in the
1776            // scheme; concrete kinds carry through unchanged.
1777            let new_effects = EffectSet {
1778                concrete: effects.concrete.clone(),
1779                var: effects.var.and_then(|v| eff_subst.get(&v).copied()).or(effects.var),
1780            };
1781            Ty::Function {
1782                params: params.iter().map(|t| subst_vars(t, subst, eff_subst)).collect(),
1783                effects: new_effects,
1784                ret: Box::new(subst_vars(ret, subst, eff_subst)),
1785            }
1786        }
1787    }
1788}
1789
1790fn mismatch_err(node_id: &str, e: UnifyError, u: &Unifier, context: Vec<String>) -> TypeError {
1791    match e {
1792        UnifyError::Mismatch { a, b } => TypeError::TypeMismatch {
1793            at_node: node_id.into(),
1794            expected: u.resolve(&b).pretty(),
1795            got: u.resolve(&a).pretty(),
1796            context,
1797        },
1798        UnifyError::Infinite { .. } => TypeError::InfiniteType { at_node: node_id.into() },
1799        UnifyError::EffectMismatch { a, b } => {
1800            // Render the two rows in compact form, e.g. `[net]` vs `[]`.
1801            // Effect rows are invariant, so this is its own rule_tag
1802            // (#565) rather than a generic type-mismatch — the
1803            // explanation steers the fix toward narrowing the body.
1804            let render = |e: &EffectSet| -> String {
1805                let mut parts: Vec<String> = e.concrete.iter()
1806                    .map(crate::types::EffectKind::pretty).collect();
1807                if let Some(v) = e.var { parts.push(format!("?e{}", v)); }
1808                if parts.is_empty() { "[]".into() } else { format!("[{}]", parts.join(", ")) }
1809            };
1810            TypeError::EffectRowMismatch {
1811                at_node: node_id.into(),
1812                expected: render(&b),
1813                got: render(&a),
1814                context,
1815            }
1816        }
1817    }
1818}