Skip to main content

aver/types/checker/
mod.rs

1/// Aver static type checker.
2///
3/// Two-phase analysis:
4///   Phase 1 — build a signature table from all FnDef nodes and builtins.
5///   Phase 2 — check top-level statements, then each FnDef for call-site
6///              argument types, return type, BinOp compatibility, and effects.
7///
8/// The checker resolves named generic variables at call sites. Error recovery
9/// uses `Type::Invalid`, which matches anything to suppress cascading diagnostics
10/// (Iron — A4).
11use std::collections::{HashMap, HashSet};
12
13use super::{Type, parse_type_str_strict};
14use crate::ast::{
15    BinOp, Expr, FnDef, Literal, Module, Pattern, Spanned, Stmt, TailCallData, TopLevel, TypeDef,
16};
17use crate::ir::{FnId, FnKey, SymbolTable, TypeId, TypeKey};
18
19mod builtins;
20pub mod effect_classification;
21pub mod effect_lifting;
22mod exhaustiveness;
23mod flow;
24pub mod hostile_effects;
25pub mod hostile_values;
26mod infer;
27mod memo;
28mod modules;
29pub mod oracle_subtypes;
30pub mod proof_trust_header;
31
32#[cfg(test)]
33mod tests;
34
35// ---------------------------------------------------------------------------
36// Public API
37// ---------------------------------------------------------------------------
38
39#[derive(Debug, Clone)]
40pub struct TypeError {
41    pub message: String,
42    pub line: usize,
43    pub col: usize,
44    /// Optional secondary span for multi-region diagnostics (e.g. declared type vs actual return).
45    pub secondary: Option<TypeErrorSpan>,
46}
47
48#[derive(Debug, Clone)]
49pub struct TypeErrorSpan {
50    pub line: usize,
51    pub col: usize,
52    pub label: String,
53}
54
55/// Result of type-checking that also carries memo-safety metadata.
56#[derive(Debug)]
57pub struct TypeCheckResult {
58    pub errors: Vec<TypeError>,
59    /// For each user-defined fn: (param_types, return_type, effects).
60    /// Used by the memo system to decide which fns qualify.
61    pub fn_sigs: HashMap<String, (Vec<Type>, Type, Vec<String>)>,
62    /// Set of type names whose values are memo-safe (hashable scalars / records of scalars).
63    pub memo_safe_types: HashSet<String>,
64    /// Unused binding warnings: (binding_name, fn_name, line).
65    pub unused_bindings: Vec<(String, String, usize)>,
66}
67
68pub fn run_type_check(items: &[TopLevel]) -> Vec<TypeError> {
69    run_type_check_with_base(items, None)
70}
71
72pub fn run_type_check_with_base(items: &[TopLevel], base_dir: Option<&str>) -> Vec<TypeError> {
73    run_type_check_full(items, base_dir).errors
74}
75
76pub fn run_type_check_full(items: &[TopLevel], base_dir: Option<&str>) -> TypeCheckResult {
77    let mut checker = TypeChecker::new_with_symbols(build_symbols_for_items(items, base_dir));
78    checker.check(items, base_dir);
79    finalize_check_result(checker, items)
80}
81
82/// Build the `SymbolTable` for a typecheck entry point. Mirrors the
83/// dep-resolution path inside [`TypeChecker::check`] so the table
84/// covers both entry items and any modules the entry file depends on.
85/// Filesystem errors are silently dropped here — the checker rebuilds
86/// its own diagnostics during the actual check, so duplicating them at
87/// the symbol-table layer would only produce noise.
88fn build_symbols_for_items(items: &[TopLevel], base_dir: Option<&str>) -> SymbolTable {
89    let dep_modules = base_dir
90        .and_then(|base| {
91            TypeChecker::module_decl(items).and_then(|m| {
92                crate::source::load_module_tree(&m.depends, base)
93                    .ok()
94                    .map(|loaded| symbols_dep_modules_from_loaded(&loaded))
95            })
96        })
97        .unwrap_or_default();
98    SymbolTable::build(items, &dep_modules)
99}
100
101/// Pre-loaded variant of [`build_symbols_for_items`] for the
102/// `WithLoaded` typecheck driver (playground virtual FS).
103fn build_symbols_with_loaded(
104    items: &[TopLevel],
105    loaded: &[crate::source::LoadedModule],
106) -> SymbolTable {
107    let dep_modules = symbols_dep_modules_from_loaded(loaded);
108    SymbolTable::build(items, &dep_modules)
109}
110
111fn symbols_dep_modules_from_loaded(
112    loaded: &[crate::source::LoadedModule],
113) -> Vec<crate::codegen::ModuleInfo> {
114    loaded
115        .iter()
116        .map(|m| crate::codegen::ModuleInfo {
117            prefix: m.dep_name.clone(),
118            depends: Vec::new(),
119            type_defs: m
120                .items
121                .iter()
122                .filter_map(|i| match i {
123                    TopLevel::TypeDef(td) => Some(td.clone()),
124                    _ => None,
125                })
126                .collect(),
127            fn_defs: m
128                .items
129                .iter()
130                .filter_map(|i| match i {
131                    TopLevel::FnDef(fd) => Some(fd.clone()),
132                    _ => None,
133                })
134                .collect(),
135            analysis: None,
136        })
137        .collect()
138}
139
140/// Variant of [`run_type_check_full`] that uses pre-loaded dependency
141/// modules instead of resolving them from disk. The playground feeds
142/// this from its in-memory virtual fs so multi-file projects type-
143/// check without any filesystem access.
144pub fn run_type_check_with_loaded(
145    items: &[TopLevel],
146    loaded: &[crate::source::LoadedModule],
147) -> TypeCheckResult {
148    let mut checker = TypeChecker::new_with_symbols(build_symbols_with_loaded(items, loaded));
149    checker.check_with_loaded(items, loaded);
150    finalize_check_result(checker, items)
151}
152
153/// Self-host variant of [`run_type_check_full`]: bypasses the
154/// opaque-type checks (construction, field access, pattern match).
155/// Used exclusively by `aver compile --with-self-host-support` so
156/// `self_hosted/domain/builtins.av` can round-trip opaque host
157/// types (e.g. `Tcp.Connection`) through the replay JSON contract.
158/// User code outside the self-host always goes through the regular
159/// [`run_type_check_full`] and stays bound by the opaque rules.
160pub fn run_type_check_full_self_host(
161    items: &[TopLevel],
162    base_dir: Option<&str>,
163) -> TypeCheckResult {
164    let mut checker = TypeChecker::new_with_symbols(build_symbols_for_items(items, base_dir));
165    checker.self_host_mode = true;
166    checker.check(items, base_dir);
167    finalize_check_result(checker, items)
168}
169
170/// Self-host variant of [`run_type_check_with_loaded`]. See
171/// [`run_type_check_full_self_host`] for the opaque-bypass rationale.
172pub fn run_type_check_with_loaded_self_host(
173    items: &[TopLevel],
174    loaded: &[crate::source::LoadedModule],
175) -> TypeCheckResult {
176    let mut checker = TypeChecker::new_with_symbols(build_symbols_with_loaded(items, loaded));
177    checker.self_host_mode = true;
178    checker.check_with_loaded(items, loaded);
179    finalize_check_result(checker, items)
180}
181
182fn finalize_check_result(mut checker: TypeChecker, items: &[TopLevel]) -> TypeCheckResult {
183    // Phase B (post peer-review #148): flatten the internal split
184    // (`fn_sigs` keyed by `FnId`, `extra_sigs` keyed by canonical
185    // string) into the exported `HashMap<String, _>` external
186    // consumers expect. The old bare-alias mirror was last-write-wins
187    // across modules: when two distinct dep modules each exposed `foo`,
188    // one would silently win the global `"foo"` slot and Rust codegen's
189    // `ctx.fn_sigs.get(&fd.name)` could pick up the wrong signature.
190    //
191    // Now bare aliases land only when unambiguous. Each canonical key
192    // is always present; bare-name keys land only when no other fn in
193    // the program shares that bare name. Consumers that previously
194    // relied on a non-deterministic global "foo" entry will see a miss
195    // and surface a clear "qualify the reference" error instead of
196    // mismatched parameters.
197    let entry_prefix = checker.current_module_prefix.clone();
198    let mut fn_sigs: HashMap<String, (Vec<Type>, Type, Vec<String>)> = HashMap::new();
199
200    // Iterate in `FnId` order for deterministic output (HashMap
201    // iteration order would otherwise leak non-determinism into the
202    // exported map and downstream diagnostics).
203    let mut ordered_user: Vec<(FnId, &FnSig)> =
204        checker.fn_sigs.iter().map(|(id, sig)| (*id, sig)).collect();
205    ordered_user.sort_by_key(|(id, _)| id.0);
206
207    // Tally bare-name owners. Phase B (peer review round 2): an
208    // entry-scope fn shadows any dep-module fn sharing the same bare
209    // name — source-level `doit()` inside `Main` unambiguously means
210    // `Main.doit`, even when a dep also exposes `doit`. We therefore
211    // suppress the bare alias only for dep-dep ambiguity (multiple
212    // dep modules share a bare name *and* the entry doesn't define
213    // one). When the entry owns the bare name, the bare alias points
214    // at the entry FnId; dep fns stay reachable only by qualified
215    // name.
216    let mut bare_entry_owner: HashMap<String, FnId> = HashMap::new();
217    let mut bare_dep_owners: HashMap<String, FnId> = HashMap::new();
218    let mut bare_dep_ambiguous: HashSet<String> = HashSet::new();
219    for (id, _) in &ordered_user {
220        let entry = checker.symbol_table.fn_entry(*id);
221        let bare = entry.key.name.as_str();
222        if entry.module.is_entry() {
223            bare_entry_owner.insert(bare.to_string(), *id);
224            continue;
225        }
226        match bare_dep_owners.get(bare) {
227            None => {
228                bare_dep_owners.insert(bare.to_string(), *id);
229            }
230            Some(prior) if prior == id => {}
231            Some(_) => {
232                bare_dep_ambiguous.insert(bare.to_string());
233            }
234        }
235    }
236
237    for (id, sig) in &ordered_user {
238        let entry = checker.symbol_table.fn_entry(*id);
239        let canonical = if entry.module.is_entry() {
240            match entry_prefix.as_deref() {
241                Some(prefix) => crate::visibility::qualified_name(prefix, &entry.key.name),
242                None => entry.key.name.clone(),
243            }
244        } else {
245            entry.key.canonical()
246        };
247        let triple = (sig.params.clone(), sig.ret.clone(), sig.effects.clone());
248        fn_sigs.insert(canonical.clone(), triple.clone());
249        // Bare alias rules:
250        //  - entry-scope owner always wins (shadows any dep with the
251        //    same bare name);
252        //  - dep-scope fn gets the bare alias only when (a) the entry
253        //    doesn't own it and (b) no other dep module conflicts.
254        if entry.key.name == canonical {
255            continue;
256        }
257        let is_entry_owner = bare_entry_owner.get(&entry.key.name) == Some(id);
258        let mut emit_bare = false;
259        if entry.module.is_entry() {
260            emit_bare = is_entry_owner;
261        } else if !bare_entry_owner.contains_key(&entry.key.name)
262            && !bare_dep_ambiguous.contains(&entry.key.name)
263        {
264            emit_bare = true;
265        }
266        if emit_bare {
267            fn_sigs.entry(entry.key.name.clone()).or_insert(triple);
268        }
269    }
270    for (k, sig) in &checker.extra_sigs {
271        fn_sigs
272            .entry(k.clone())
273            .or_insert_with(|| (sig.params.clone(), sig.ret.clone(), sig.effects.clone()));
274    }
275
276    let memo_safe_types = checker.compute_memo_safe_types(items);
277
278    check_module_effect_boundary(items, &mut checker.errors);
279
280    TypeCheckResult {
281        errors: checker.errors,
282        fn_sigs,
283        memo_safe_types,
284        unused_bindings: checker.unused_warnings,
285    }
286}
287
288/// Enforce module-level `effects [...]` declaration against per-fn effect
289/// usage. The rule:
290///
291/// - Module without `effects [...]` → legacy/mixed, no enforcement (0.13
292///   migration shim; 0.14+ may upgrade to soft warning).
293/// - Module with `effects [...]` (including `effects []` for explicit pure)
294///   → every function's `! [...]` must be covered by the module's declared
295///   surface. A namespace-level entry like `Disk` admits any `Disk.*`
296///   method; a method-level entry like `Time.now` admits only that one.
297fn check_module_effect_boundary(items: &[TopLevel], errors: &mut Vec<TypeError>) {
298    let Some(allowed) = items.iter().find_map(|i| match i {
299        TopLevel::Module(m) => m.effects.as_ref().map(|e| (e, m)),
300        _ => None,
301    }) else {
302        return;
303    };
304    let (allowed_list, module) = allowed;
305
306    let allowed_namespaces: HashSet<&str> = allowed_list
307        .iter()
308        .filter(|e| !e.contains('.'))
309        .map(|e| e.as_str())
310        .collect();
311    let allowed_methods: HashSet<&str> = allowed_list.iter().map(|e| e.as_str()).collect();
312
313    for item in items {
314        let TopLevel::FnDef(fd) = item else { continue };
315        for eff in &fd.effects {
316            let method = eff.node.as_str();
317            if allowed_methods.contains(method) {
318                continue;
319            }
320            if let Some((ns, _)) = method.split_once('.')
321                && allowed_namespaces.contains(ns)
322            {
323                continue;
324            }
325            errors.push(TypeError {
326                message: format!(
327                    "module '{}' declared `effects [{}]` but '{}' uses '{}' which is not in the declared boundary",
328                    module.name,
329                    allowed_list.join(", "),
330                    fd.name,
331                    method
332                ),
333                line: eff.line,
334                col: 1,
335                secondary: module.effects_line.map(|l| TypeErrorSpan {
336                    line: l,
337                    col: 1,
338                    label: "module effects declared here".to_string(),
339                }),
340            });
341        }
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Internal structures
347// ---------------------------------------------------------------------------
348
349#[derive(Debug, Clone)]
350struct FnSig {
351    params: Vec<Type>,
352    ret: Type,
353    effects: Vec<String>,
354}
355
356/// Iron — A5: typed key for `record_field_types`. Pre-A5 the map
357/// was keyed by `"TypeName.fieldName"` stringifications, which
358/// forced every reader to `strip_prefix(format!("{type}."))` and
359/// then re-check that the remainder didn't itself contain a dot
360/// (because the post-A3 dual-keying mirrored each entry under both
361/// the canonical `"Module.Type.field"` form and the bare alias
362/// `"Type.field"` — and the canonical form spuriously matched the
363/// `"Module."` prefix-strip when the read came from a module
364/// looking up its own fields). The struct key separates the two
365/// dimensions, so the canonical resolution happens once at
366/// insert/lookup (via `sig_aliases`) and iteration filters on
367/// `key.type_name == canonical` with no string-shape gymnastics.
368#[derive(Debug, Clone, PartialEq, Eq, Hash)]
369pub(crate) struct RecordFieldKey {
370    pub(crate) type_name: String,
371    pub(crate) field_name: String,
372}
373
374impl RecordFieldKey {
375    pub(crate) fn new(type_name: impl Into<String>, field_name: impl Into<String>) -> Self {
376        Self {
377            type_name: type_name.into(),
378            field_name: field_name.into(),
379        }
380    }
381}
382
383/// Bare-name resolution result. Tracks ambiguity explicitly so
384/// `resolve_fn_id` / `resolve_type_id` can refuse the look-up when
385/// two distinct identities surface the same source name. The
386/// `Ambiguous` variant carries the actual candidate IDs (only those
387/// that made it through visibility, since that's the population
388/// path) so diagnostics can suggest exactly the names the user can
389/// actually pick from — never a private dep type that happens to
390/// share the bare name.
391#[derive(Debug, Clone)]
392enum Resolution<T> {
393    Single(T),
394    Ambiguous(Vec<T>),
395}
396
397impl<T: Copy + PartialEq> Resolution<T> {
398    /// Merge another candidate identity into an alias entry. Two
399    /// distinct identities for the same bare name produce
400    /// `Ambiguous`; a duplicate registration of the same identity is
401    /// a no-op (same module re-traversed by another path).
402    fn merge(&mut self, candidate: T) {
403        match self {
404            Resolution::Single(existing) if *existing == candidate => {}
405            Resolution::Single(existing) => {
406                let prior = *existing;
407                *self = Resolution::Ambiguous(vec![prior, candidate]);
408            }
409            Resolution::Ambiguous(seen) => {
410                if !seen.contains(&candidate) {
411                    seen.push(candidate);
412                }
413            }
414        }
415    }
416
417    fn unambiguous(&self) -> Option<T> {
418        match self {
419            Resolution::Single(v) => Some(*v),
420            Resolution::Ambiguous(_) => None,
421        }
422    }
423}
424
425struct TypeChecker {
426    /// Resolved-identity table — phase B (#138). Populated by
427    /// [`TypeChecker::new_with_symbols`] from the program's `entry_items
428    /// + dep_modules` before any signature registration. Carries opaque
429    /// `FnId` / `TypeId` for every user-defined fn and type. The
430    /// checker resolves bare-name references through it instead of the
431    /// pre-phase-B `sig_aliases` string→string map.
432    symbol_table: SymbolTable,
433    /// User-defined function signatures, keyed by the opaque `FnId`
434    /// from `symbol_table`. Phase B (#138) migrated this away from
435    /// `HashMap<String, FnSig>` so that two modules each declaring `foo`
436    /// can't silently collide through bare-name keying.
437    ///
438    /// Built-in fn signatures (namespace methods like `Int.add`,
439    /// `Console.print`) and user constructors (variants of sum types,
440    /// product type constructors keyed by `"Module.Type.Variant"`)
441    /// don't have `FnId`s in the program symbol table — those live in
442    /// `extra_sigs` keyed by canonical string. `find_fn_sig` chains
443    /// the two for a unified bare-name → signature lookup.
444    fn_sigs: HashMap<FnId, FnSig>,
445    /// Builtin fn signatures + constructor signatures keyed by
446    /// canonical string. The non-`FnId` half of the split — entries
447    /// here either come from `register_builtins` (namespace methods)
448    /// or `register_type_def_sigs` (sum-type variant constructors,
449    /// product type "callable name" entries). Lookups against this
450    /// map use the canonical key directly; bare→canonical resolution
451    /// goes through `symbol_table.type_id_of` for type-derived keys.
452    extra_sigs: HashMap<String, FnSig>,
453    /// Bare → `FnId` aliases for cross-module imports. Populated
454    /// during `integrate_registry` from visibility-exposed aliases
455    /// (and during `build_signatures` for the current module's own
456    /// fns). The typed replacement for the pre-phase-B
457    /// `sig_aliases: HashMap<String, String>`: same bare-name routing
458    /// role, but the value side now carries opaque identity instead
459    /// of a string that needed re-resolution downstream.
460    ///
461    /// When two distinct modules each expose the same bare name
462    /// (`Pricing.percent` *and* `Math.percent` both surface a bare
463    /// `percent`), the entry switches to [`Resolution::Ambiguous`]
464    /// and `resolve_fn_id` refuses to silently pick one — the user
465    /// must qualify the reference. Avoids the
466    /// "global bare alias last-wins" bug class peer review #148
467    /// flagged.
468    bare_fn_aliases: HashMap<String, Resolution<FnId>>,
469    /// Bare → `TypeId` aliases. Same role as `bare_fn_aliases`
470    /// for the type-name dimension; consumed by `resolve_type_id`
471    /// and `canonical_type_name`.
472    bare_type_aliases: HashMap<String, Resolution<TypeId>>,
473    /// `FnId`s the current checker may legitimately resolve to.
474    /// Populated from `build_signatures` (the current module's own
475    /// fns — always visible from within the module) and from
476    /// `integrate_registry` (visibility-exposed entries from each
477    /// dep module — already filtered against the `exposes` contract
478    /// by `crate::visibility::SymbolRegistry::from_modules`).
479    ///
480    /// `resolve_fn_id` consults this set so a qualified
481    /// `C.helper()` reference fails when `helper` isn't exposed even
482    /// though the symbol table — which carries every fn from every
483    /// dep module regardless of visibility — has its `FnId`.
484    visible_fn_ids: HashSet<FnId>,
485    /// `TypeId`s the current checker may legitimately resolve to.
486    /// Same role as `visible_fn_ids` for the type-name dimension —
487    /// closes the qualified-private-import leak peer review round 4
488    /// flagged on `fn takes(s: C.Shape)` references against a `C`
489    /// whose `exposes` list didn't include `Shape`.
490    visible_type_ids: HashSet<TypeId>,
491    /// Per-module `depends` list, keyed by the dep module's
492    /// `dep_name`. Populated by `integrate_loaded_modules` so the
493    /// per-owner type resolver (`canonicalize_named_in_module`) can
494    /// walk an owner module's *own* depends when canonicalising its
495    /// exported signatures — not the entry module's or arbitrary
496    /// loaded siblings'. Round-6 peer review caught the leak: B
497    /// depends on A; Main depends on B, C; B's bare `Shape` was
498    /// resolving against `[A, C]` (Main's loaded tree) instead of
499    /// `[A]` (B's own depends), and `Shape` came back ambiguous.
500    module_depends: HashMap<String, Vec<String>>,
501    value_members: HashMap<String, Type>,
502    /// Field types for record types, keyed by `(type_name, field_name)`.
503    /// Populated for both user-defined `record` types and built-in records
504    /// (HttpResponse, Header). Single entry per (canonical type name, field);
505    /// lookup canonicalises `type_name` through `SymbolTable` at read time.
506    /// Enables checked dot-access on Named types.
507    record_field_types: HashMap<RecordFieldKey, Type>,
508    /// Variant names for sum types: "Shape" → ["Circle", "Rect", "Point"].
509    /// Pre-populated for Result and Option; extended by user-defined sum types.
510    type_variants: HashMap<String, Vec<String>>,
511    /// Module prefix of the items currently being checked. `None`
512    /// while checking entry-scope items. Per-module sub-checkers
513    /// (`check_loaded_module_bodies`) set this to the dep module's
514    /// prefix so bare-name resolution finds the local type/fn first.
515    current_module_prefix: Option<String>,
516    /// Top-level bindings visible from function bodies.
517    globals: HashMap<String, Type>,
518    /// Local bindings in the current function/scope.
519    locals: HashMap<String, Type>,
520    errors: Vec<TypeError>,
521    /// Return type of the function currently being checked; None at top level.
522    current_fn_ret: Option<Type>,
523    /// Line number of the function currently being checked; None at top level.
524    current_fn_line: Option<usize>,
525    /// Type names that are opaque in this module's context (imported via `exposes opaque`).
526    opaque_types: HashSet<String>,
527    /// When `true`, opaque-type construction + field-access + pattern-match
528    /// checks are bypassed. Used only by the self-host compile path
529    /// (`aver compile --with-self-host-support`) where
530    /// `self_hosted/domain/builtins.av` round-trips opaque host types
531    /// (e.g. `Tcp.Connection`) through the replay `Val` representation:
532    /// it serialises by reading `.id` / `.host` / `.port`, and
533    /// reconstructs by `Tcp.Connection(id = …, host = …, port = …)` on
534    /// replay deserialise. Both operations are forbidden in user code by
535    /// design (Phase 4.7+ fix #11), but the self-host has to read +
536    /// write the underlying record shape because that's the contract
537    /// with the replay JSON format. The flag is set by
538    /// [`run_type_check_full_self_host`] / [`run_type_check_with_loaded_self_host`]
539    /// and never user-toggleable from source.
540    self_host_mode: bool,
541    /// Names referenced during type checking of current function body (for unused detection).
542    used_names: HashSet<String>,
543    /// Bindings defined in the current function body: (name, line).
544    fn_bindings: Vec<(String, usize)>,
545    /// Unused binding warnings collected during checking: (binding_name, fn_name, line).
546    unused_warnings: Vec<(String, String, usize)>,
547    /// Oracle v1: `.result` / `.trace` / `.trace.*` projections are
548    /// only meaningful inside `verify <fn> trace` cases. This flag is
549    /// set true while checking such a case's LHS / RHS, false
550    /// otherwise. Outside verify-trace the projections are rejected at
551    /// check time — otherwise user code would type-check then crash
552    /// at runtime with "namespace has no member 'trace'".
553    in_verify_trace_context: bool,
554}
555
556impl TypeChecker {
557    fn new_with_symbols(symbol_table: SymbolTable) -> Self {
558        let mut type_variants = HashMap::new();
559        type_variants.insert(
560            "Result".to_string(),
561            vec!["Ok".to_string(), "Err".to_string()],
562        );
563        type_variants.insert(
564            "Option".to_string(),
565            vec!["Some".to_string(), "None".to_string()],
566        );
567
568        let mut tc = TypeChecker {
569            symbol_table,
570            fn_sigs: HashMap::new(),
571            extra_sigs: HashMap::new(),
572            bare_fn_aliases: HashMap::new(),
573            bare_type_aliases: HashMap::new(),
574            visible_fn_ids: HashSet::new(),
575            visible_type_ids: HashSet::new(),
576            module_depends: HashMap::new(),
577            value_members: HashMap::new(),
578            record_field_types: HashMap::new(),
579            type_variants,
580            current_module_prefix: None,
581            globals: HashMap::new(),
582            locals: HashMap::new(),
583            errors: Vec::new(),
584            current_fn_ret: None,
585            current_fn_line: None,
586            opaque_types: HashSet::new(),
587            self_host_mode: false,
588            used_names: HashSet::new(),
589            fn_bindings: Vec::new(),
590            unused_warnings: Vec::new(),
591            in_verify_trace_context: false,
592        };
593        tc.register_builtins();
594        tc
595    }
596
597    // -- Identity resolution (phase B) -------------------------------------
598
599    /// Resolve a source-faithful function reference (`"foo"`,
600    /// `"Module.foo"`, `"Tcp.send"`) to a `FnId` via the symbol table.
601    /// Tries, in order: literal-as-qualified (split `"Module.foo"`
602    /// into `(Module, foo)`), current-module-scoped bare name, then
603    /// entry-scope bare name, then the typed bare-alias map.
604    ///
605    /// Misses for builtin namespace methods and constructors — those
606    /// don't live in the program symbol table; callers fall back to
607    /// the `extra_sigs` string-keyed half.
608    pub(crate) fn resolve_fn_id(&self, name: &str) -> Option<FnId> {
609        // Phase B (peer review round 4): every `SymbolTable`
610        // resolution path filters through `visible_fn_ids` so a
611        // qualified `C.helper()` reference can't reach into a dep
612        // module's private fn just because the symbol table
613        // unconditionally stores every dep entry. Bare-name lookup
614        // already went through `bare_fn_aliases`, which is itself
615        // populated only from visibility-exposed entries — that
616        // branch stays as-is.
617        if let Some((prefix, n)) = name.rsplit_once('.') {
618            if let Some(id) = self.symbol_table.fn_id_of(&FnKey::in_module(prefix, n))
619                && self.visible_fn_ids.contains(&id)
620            {
621                return Some(id);
622            }
623            if self.current_module_prefix.as_deref() == Some(prefix)
624                && let Some(id) = self.symbol_table.fn_id_of(&FnKey::entry(n))
625                && self.visible_fn_ids.contains(&id)
626            {
627                return Some(id);
628            }
629        }
630        if let Some(prefix) = self.current_module_prefix.as_deref()
631            && let Some(id) = self.symbol_table.fn_id_of(&FnKey::in_module(prefix, name))
632            && self.visible_fn_ids.contains(&id)
633        {
634            return Some(id);
635        }
636        if let Some(id) = self.symbol_table.fn_id_of(&FnKey::entry(name))
637            && self.visible_fn_ids.contains(&id)
638        {
639            return Some(id);
640        }
641        self.bare_fn_aliases
642            .get(name)
643            .and_then(Resolution::unambiguous)
644    }
645
646    /// `true` when the bare alias map has recorded multiple distinct
647    /// `TypeId`s for `name` (cross-module same-bare-name import).
648    /// Distinct from "name doesn't resolve at all" — used by the
649    /// matcher to decide whether a mixed (Some, None) typed/raw
650    /// comparison should fall back to name equality (only when the
651    /// `None` side is genuinely a builtin / external name, not when
652    /// it's ambiguous bare reference whose typed identity we
653    /// deliberately suppressed).
654    pub(crate) fn type_name_is_ambiguous(&self, name: &str) -> bool {
655        matches!(
656            self.bare_type_aliases.get(name),
657            Some(Resolution::Ambiguous(_))
658        )
659    }
660
661    /// List the canonical names of every type that the bare alias map
662    /// recorded as a candidate for `bare`. The `Resolution::Ambiguous`
663    /// variant carries the actual conflicting `TypeId`s populated
664    /// through visibility-exposed aliases — never scans the full
665    /// `symbol_table.types`, so a private (non-exposed) dep type that
666    /// happens to share a bare name never appears in the diagnostic.
667    pub(crate) fn ambiguous_type_candidates(&self, bare: &str) -> Vec<String> {
668        let Some(Resolution::Ambiguous(ids)) = self.bare_type_aliases.get(bare) else {
669            return Vec::new();
670        };
671        let mut out: Vec<String> = ids
672            .iter()
673            .map(|id| self.symbol_table.type_entry(*id).key.canonical())
674            .collect();
675        out.sort();
676        out
677    }
678
679    /// Walk `ty` and emit diagnostics for every distinct unresolved
680    /// reason the typechecker deliberately blocked resolution.
681    /// Called from the signature-registration boundary
682    /// (`build_signatures`, `register_type_def_sigs`, flow's binding
683    /// annotations) so the user gets a clean explanation instead of
684    /// downstream `expected X, got X` cascades. Two reasons are
685    /// surfaced:
686    ///
687    ///   - Ambiguous bare reference: `Foo` matches multiple
688    ///     visibility-exposed `TypeId`s. Diagnostic suggests the
689    ///     qualified forms.
690    ///   - Private qualified import: `Module.Foo` exists in the
691    ///     symbol table but isn't on `Module`'s `exposes` list.
692    ///     Diagnostic names the dep + asks for the export.
693    pub(super) fn report_ambiguous_named(&mut self, ty: &Type, line: usize, source_ctx: &str) {
694        let mut seen_ambig: HashSet<String> = HashSet::new();
695        let mut seen_private: HashSet<String> = HashSet::new();
696        self.collect_unresolved_into(ty, &mut seen_ambig, &mut seen_private);
697        for name in seen_ambig {
698            let candidates = self.ambiguous_type_candidates(&name);
699            if candidates.is_empty() {
700                continue;
701            }
702            let suggestion = match candidates.as_slice() {
703                [a] => a.clone(),
704                [a, b] => format!("`{}` or `{}`", a, b),
705                more => {
706                    let last = more.last().expect("non-empty");
707                    let head = &more[..more.len() - 1];
708                    let joined = head
709                        .iter()
710                        .map(|c| format!("`{}`", c))
711                        .collect::<Vec<_>>()
712                        .join(", ");
713                    format!("{}, or `{}`", joined, last)
714                }
715            };
716            self.error_at_line(
717                line,
718                format!(
719                    "{source_ctx}: Ambiguous type name '{name}'; use {suggestion} to disambiguate"
720                ),
721            );
722        }
723        for qualified in seen_private {
724            let (module, type_name) = qualified
725                .rsplit_once('.')
726                .map(|(m, t)| (m.to_string(), t.to_string()))
727                .expect("private qualified name always has a `.`");
728            self.error_at_line(
729                line,
730                format!(
731                    "{source_ctx}: Type '{qualified}' is not exposed by module '{module}' — add '{type_name}' to its `exposes` list to import it",
732                ),
733            );
734        }
735    }
736
737    fn collect_unresolved_into(
738        &self,
739        ty: &Type,
740        ambig: &mut HashSet<String>,
741        private: &mut HashSet<String>,
742    ) {
743        match ty {
744            Type::Named { id: None, name } => {
745                if self.type_name_is_ambiguous(name) {
746                    ambig.insert(name.clone());
747                } else if self.type_name_is_private_import(name) {
748                    private.insert(name.clone());
749                }
750            }
751            Type::Named { .. }
752            | Type::Int
753            | Type::Float
754            | Type::Str
755            | Type::Bool
756            | Type::Unit
757            | Type::Var(_)
758            | Type::Invalid => {}
759            Type::Option(inner) | Type::List(inner) | Type::Vector(inner) => {
760                self.collect_unresolved_into(inner, ambig, private);
761            }
762            Type::Result(ok, err) => {
763                self.collect_unresolved_into(ok, ambig, private);
764                self.collect_unresolved_into(err, ambig, private);
765            }
766            Type::Map(k, v) => {
767                self.collect_unresolved_into(k, ambig, private);
768                self.collect_unresolved_into(v, ambig, private);
769            }
770            Type::Tuple(items) => {
771                for item in items {
772                    self.collect_unresolved_into(item, ambig, private);
773                }
774            }
775            Type::Fn(params, ret, _) => {
776                for p in params {
777                    self.collect_unresolved_into(p, ambig, private);
778                }
779                self.collect_unresolved_into(ret, ambig, private);
780            }
781        }
782    }
783
784    /// Register a bare → `FnId` alias, marking it `Ambiguous` if a
785    /// different identity is already registered under the same bare
786    /// name. Duplicate registration of the same identity (e.g. an
787    /// item walked twice by `integrate_registry` + `build_signatures`)
788    /// is a no-op.
789    pub(super) fn merge_bare_fn_alias(&mut self, alias: String, id: FnId) {
790        self.bare_fn_aliases
791            .entry(alias)
792            .and_modify(|r| r.merge(id))
793            .or_insert(Resolution::Single(id));
794    }
795
796    pub(super) fn merge_bare_type_alias(&mut self, alias: String, id: TypeId) {
797        self.bare_type_aliases
798            .entry(alias)
799            .and_modify(|r| r.merge(id))
800            .or_insert(Resolution::Single(id));
801    }
802
803    /// Type-side equivalent of [`Self::resolve_fn_id`]. Same
804    /// visibility gating: `SymbolTable` look-ups are filtered through
805    /// `visible_type_ids` so a qualified `C.Shape` reference can't
806    /// resolve to a private (non-exposed) type even though the symbol
807    /// table holds every dep type unconditionally.
808    pub(crate) fn resolve_type_id(&self, name: &str) -> Option<TypeId> {
809        if let Some((prefix, n)) = name.rsplit_once('.') {
810            if let Some(id) = self.symbol_table.type_id_of(&TypeKey::in_module(prefix, n))
811                && self.visible_type_ids.contains(&id)
812            {
813                return Some(id);
814            }
815            if self.current_module_prefix.as_deref() == Some(prefix)
816                && let Some(id) = self.symbol_table.type_id_of(&TypeKey::entry(n))
817                && self.visible_type_ids.contains(&id)
818            {
819                return Some(id);
820            }
821        }
822        if let Some(prefix) = self.current_module_prefix.as_deref()
823            && let Some(id) = self
824                .symbol_table
825                .type_id_of(&TypeKey::in_module(prefix, name))
826            && self.visible_type_ids.contains(&id)
827        {
828            return Some(id);
829        }
830        if let Some(id) = self.symbol_table.type_id_of(&TypeKey::entry(name))
831            && self.visible_type_ids.contains(&id)
832        {
833            return Some(id);
834        }
835        self.bare_type_aliases
836            .get(name)
837            .and_then(Resolution::unambiguous)
838    }
839
840    /// `true` when `name` (qualified `Module.Type` form) resolves to
841    /// an existing `TypeId` in the symbol table but the typechecker
842    /// hasn't registered that ID as visible to the current scope.
843    /// Distinguishes "type doesn't exist anywhere" (silently miss →
844    /// downstream "unknown type" error) from "type exists but its
845    /// dep module doesn't expose it" (explicit private-import
846    /// diagnostic emitted by `report_named_visibility_errors`).
847    pub(crate) fn type_name_is_private_import(&self, name: &str) -> bool {
848        let Some((prefix, n)) = name.rsplit_once('.') else {
849            return false;
850        };
851        if self.current_module_prefix.as_deref() == Some(prefix) {
852            // Self-references like `Main.foo` inside `Main` always
853            // resolve through the entry-scope alias; never a privacy
854            // failure.
855            return false;
856        }
857        let Some(id) = self.symbol_table.type_id_of(&TypeKey::in_module(prefix, n)) else {
858            return false;
859        };
860        !self.visible_type_ids.contains(&id)
861    }
862
863    /// Canonical name (`"Module.Type"` or bare entry name) for a
864    /// source-faithful type reference. Resolves through the symbol
865    /// table; falls back to the input string for references the table
866    /// doesn't know about (builtins, opaque host types, in-flight
867    /// recovery from earlier errors).
868    ///
869    /// For entry-scope types in a checker that's currently processing
870    /// items with a `module X` declaration, the returned name includes
871    /// the `X.` prefix even though the symbol table itself stores
872    /// entry items without one. This preserves the pre-phase-B
873    /// canonical view the typechecker's internal maps
874    /// (`type_variants`, `record_field_types`, …) are keyed against.
875    pub(crate) fn canonical_type_name(&self, name: &str) -> String {
876        match self.resolve_type_id(name) {
877            Some(id) => {
878                let entry = self.symbol_table.type_entry(id);
879                if entry.module.is_entry()
880                    && let Some(prefix) = self.current_module_prefix.as_deref()
881                {
882                    crate::visibility::qualified_name(prefix, &entry.key.name)
883                } else {
884                    entry.key.canonical()
885                }
886            }
887            None => name.to_string(),
888        }
889    }
890
891    // -- Unified lookups ---------------------------------------------------
892
893    fn find_fn_sig(&self, key: &str) -> Option<&FnSig> {
894        // Phase B: user fns live in `fn_sigs` keyed by `FnId`; everything
895        // else (builtins + sum-type variant constructors) stays in
896        // `extra_sigs`. Direct hit on `extra_sigs` covers references that
897        // came in already-canonicalised; `resolve_fn_id` chains the
898        // symbol-table lookups for bare/qualified user-fn references.
899        if let Some(id) = self.resolve_fn_id(key)
900            && let Some(sig) = self.fn_sigs.get(&id)
901        {
902            return Some(sig);
903        }
904        if let Some(sig) = self.extra_sigs.get(key) {
905            return Some(sig);
906        }
907        // Try canonicalised form for type-derived keys
908        // (`"Module.Type.Variant"`).
909        let canonical = self.canonical_extra_key(key);
910        if canonical != key {
911            return self.extra_sigs.get(&canonical);
912        }
913        None
914    }
915
916    /// Take a bare-or-qualified key that may name a constructor or a
917    /// per-type member (`"Shape.Circle"`, `"Status.Open"`) and resolve
918    /// the leading type segment through `canonical_type_name`. Uses
919    /// the typechecker view of canonical names (which include the
920    /// entry module's prefix), matching what `register_type_def_sigs`
921    /// inserts into `extra_sigs`.
922    fn canonical_extra_key(&self, key: &str) -> String {
923        if let Some((head, tail)) = key.split_once('.') {
924            let canonical_type = self.canonical_type_name(head);
925            if canonical_type != head {
926                return format!("{}.{}", canonical_type, tail);
927            }
928        }
929        key.to_string()
930    }
931
932    fn find_value_member(&self, key: &str) -> Option<&Type> {
933        if let Some(v) = self.value_members.get(key) {
934            return Some(v);
935        }
936        let canonical = self.canonical_extra_key(key);
937        if canonical != key {
938            return self.value_members.get(&canonical);
939        }
940        None
941    }
942
943    fn find_record_field_type(&self, type_name: &str, field_name: &str) -> Option<&Type> {
944        let direct = RecordFieldKey::new(type_name, field_name);
945        if let Some(ty) = self.record_field_types.get(&direct) {
946            return Some(ty);
947        }
948        let canonical_type = self.canonical_type_name(type_name);
949        if canonical_type != type_name {
950            let canonical = RecordFieldKey::new(canonical_type, field_name);
951            return self.record_field_types.get(&canonical);
952        }
953        None
954    }
955
956    fn fields_for_type(&self, type_name: &str) -> Vec<(String, Type)> {
957        let canonical = self.canonical_type_name(type_name);
958        let canonical_ref: &str = canonical.as_str();
959        self.record_field_types
960            .iter()
961            .filter(|(k, _)| k.type_name == canonical_ref || k.type_name == type_name)
962            .map(|(k, v)| (k.field_name.clone(), v.clone()))
963            .collect()
964    }
965
966    fn has_record_schema(&self, type_name: &str) -> bool {
967        let canonical = self.canonical_type_name(type_name);
968        let canonical_ref: &str = canonical.as_str();
969        self.record_field_types
970            .keys()
971            .any(|k| k.type_name == canonical_ref || k.type_name == type_name)
972    }
973
974    /// Look up the variant list for a named sum type. Resolves
975    /// `name` through `canonical_type_name` so bare references find
976    /// the canonical "Module.Type" entry registered by
977    /// `register_type_def_sigs` / `integrate_registry`.
978    pub(crate) fn variants_for(&self, name: &str) -> Option<&Vec<String>> {
979        if let Some(v) = self.type_variants.get(name) {
980            return Some(v);
981        }
982        let canonical = self.canonical_type_name(name);
983        if canonical != name {
984            return self.type_variants.get(&canonical);
985        }
986        None
987    }
988
989    pub(crate) fn has_variants_for(&self, name: &str) -> bool {
990        self.variants_for(name).is_some()
991    }
992
993    /// Iterate every fn signature regardless of which storage half
994    /// holds it. Used by the namespace-prefix check and by
995    /// `finalize_check_result` to flatten for external export.
996    fn all_fn_sigs(&self) -> impl Iterator<Item = (String, &FnSig)> + '_ {
997        let from_user = self.fn_sigs.iter().map(|(id, sig)| {
998            let name = self.symbol_table.fn_entry(*id).key.canonical();
999            (name, sig)
1000        });
1001        let from_extra = self.extra_sigs.iter().map(|(k, sig)| (k.clone(), sig));
1002        from_user.chain(from_extra)
1003    }
1004
1005    fn fn_sig_contains_canonical(&self, canonical: &str) -> bool {
1006        if let Some(id) = self.resolve_fn_id(canonical)
1007            && self.fn_sigs.contains_key(&id)
1008        {
1009            return true;
1010        }
1011        if self.extra_sigs.contains_key(canonical) {
1012            return true;
1013        }
1014        let canonical_form = self.canonical_extra_key(canonical);
1015        canonical_form != canonical && self.extra_sigs.contains_key(&canonical_form)
1016    }
1017
1018    /// Insert a fn signature under its canonical form. Routes to the
1019    /// `FnId`-keyed user map when the name resolves through the
1020    /// symbol table (i.e. it names a user fn declared in `items` or a
1021    /// dep module); otherwise it lands in the `extra_sigs` half.
1022    ///
1023    /// Also marks the `FnId` as visible to the current scope — every
1024    /// fn the checker's own `build_signatures` / `integrate_registry`
1025    /// path inserts is by definition reachable from here (own module
1026    /// items or visibility-exposed dep entries). The visibility
1027    /// gating in `resolve_fn_id` then refuses look-ups against any
1028    /// `FnId` that landed in the symbol table but not in this set —
1029    /// a qualified `C.helper()` reference whose `helper` isn't on
1030    /// `C`'s `exposes` list never gets inserted here and so never
1031    /// resolves.
1032    fn insert_fn_sig(&mut self, canonical: &str, sig: FnSig) {
1033        match self.fn_id_for_canonical(canonical) {
1034            Some(id) => {
1035                self.fn_sigs.insert(id, sig);
1036                self.visible_fn_ids.insert(id);
1037            }
1038            None => {
1039                self.extra_sigs.insert(canonical.to_string(), sig);
1040            }
1041        }
1042    }
1043
1044    /// `resolve_fn_id` minus the visibility filter — used by
1045    /// `insert_fn_sig` (where the very point of the insert is to
1046    /// register visibility) and by other boundary points that build
1047    /// the visible set itself. Production look-ups must go through
1048    /// `resolve_fn_id`.
1049    fn fn_id_for_canonical(&self, name: &str) -> Option<FnId> {
1050        if let Some((prefix, n)) = name.rsplit_once('.') {
1051            if let Some(id) = self.symbol_table.fn_id_of(&FnKey::in_module(prefix, n)) {
1052                return Some(id);
1053            }
1054            if self.current_module_prefix.as_deref() == Some(prefix)
1055                && let Some(id) = self.symbol_table.fn_id_of(&FnKey::entry(n))
1056            {
1057                return Some(id);
1058            }
1059        }
1060        if let Some(prefix) = self.current_module_prefix.as_deref()
1061            && let Some(id) = self.symbol_table.fn_id_of(&FnKey::in_module(prefix, name))
1062        {
1063            return Some(id);
1064        }
1065        self.symbol_table.fn_id_of(&FnKey::entry(name))
1066    }
1067
1068    /// Type-side equivalent of [`Self::fn_id_for_canonical`].
1069    fn type_id_for_canonical(&self, name: &str) -> Option<TypeId> {
1070        if let Some((prefix, n)) = name.rsplit_once('.') {
1071            if let Some(id) = self.symbol_table.type_id_of(&TypeKey::in_module(prefix, n)) {
1072                return Some(id);
1073            }
1074            if self.current_module_prefix.as_deref() == Some(prefix)
1075                && let Some(id) = self.symbol_table.type_id_of(&TypeKey::entry(n))
1076            {
1077                return Some(id);
1078            }
1079        }
1080        if let Some(prefix) = self.current_module_prefix.as_deref()
1081            && let Some(id) = self
1082                .symbol_table
1083                .type_id_of(&TypeKey::in_module(prefix, name))
1084        {
1085            return Some(id);
1086        }
1087        self.symbol_table.type_id_of(&TypeKey::entry(name))
1088    }
1089
1090    /// Mark a `TypeId` as visible to the current scope. Called from
1091    /// `register_type_def_sigs` (own module types) and
1092    /// `integrate_registry` (visibility-exposed dep types).
1093    fn mark_type_visible(&mut self, id: TypeId) {
1094        self.visible_type_ids.insert(id);
1095    }
1096
1097    // -- Helpers -----------------------------------------------------------
1098
1099    /// Check whether `required_effect` is satisfied by `caller_effects`.
1100    fn caller_has_effect(&self, caller_effects: &[String], required_effect: &str) -> bool {
1101        caller_effects
1102            .iter()
1103            .any(|declared| crate::effects::effect_satisfies(declared, required_effect))
1104    }
1105
1106    fn error(&mut self, msg: impl Into<String>) {
1107        let line = self.current_fn_line.unwrap_or(1);
1108        self.errors.push(TypeError {
1109            message: msg.into(),
1110            line,
1111            col: 0,
1112            secondary: None,
1113        });
1114    }
1115
1116    fn error_at_line(&mut self, line: usize, msg: impl Into<String>) {
1117        self.errors.push(TypeError {
1118            message: msg.into(),
1119            line,
1120            col: 0,
1121            secondary: None,
1122        });
1123    }
1124
1125    fn insert_sig(&mut self, name: &str, params: &[Type], ret: Type, effects: &[&str]) {
1126        // Builtins (Int.add, Console.print, …) are not part of the
1127        // user-program symbol table, so they always land in
1128        // `extra_sigs`. The `insert_fn_sig` router would normally
1129        // resolve user fns into `fn_sigs` — but no `register_builtins`
1130        // caller could ever name a user fn, so we short-circuit here
1131        // to avoid pointless symbol-table probes.
1132        self.extra_sigs.insert(
1133            name.to_string(),
1134            FnSig {
1135                params: params.to_vec(),
1136                ret,
1137                effects: effects.iter().map(|s| s.to_string()).collect(),
1138            },
1139        );
1140    }
1141
1142    fn fn_type_from_sig(sig: &FnSig) -> Type {
1143        Type::Fn(
1144            sig.params.clone(),
1145            Box::new(sig.ret.clone()),
1146            sig.effects.clone(),
1147        )
1148    }
1149
1150    fn sig_from_callable_type(ty: &Type) -> Option<FnSig> {
1151        match ty {
1152            Type::Fn(params, ret, effects) => Some(FnSig {
1153                params: params.clone(),
1154                ret: *ret.clone(),
1155                effects: effects.clone(),
1156            }),
1157            _ => None,
1158        }
1159    }
1160
1161    fn binding_type(&self, name: &str) -> Option<Type> {
1162        self.locals
1163            .get(name)
1164            .or_else(|| self.globals.get(name))
1165            .cloned()
1166    }
1167
1168    /// Phase B: `&self`-bearing constraint check. Resolves bare named
1169    /// types against the `SymbolTable` (carried on `self`) so
1170    /// source-faithful Spanned stamps still match against canonical fn
1171    /// signatures. Replaces the pre-phase-B `sig_aliases` string→string
1172    /// alias map with typed `TypeId` resolution.
1173    pub(super) fn compatible(&self, actual: &Type, expected: &Type) -> bool {
1174        let mut subst = HashMap::new();
1175        Self::match_expected_type_inner(actual, expected, &mut subst, Some(self))
1176    }
1177
1178    /// Static-form matcher (no symbol-table resolution). Tests use
1179    /// this directly; production code should reach for `compatible`
1180    /// instead.
1181    pub(super) fn match_expected_type(
1182        actual: &Type,
1183        expected: &Type,
1184        subst: &mut HashMap<String, Type>,
1185    ) -> bool {
1186        Self::match_expected_type_inner(actual, expected, subst, None)
1187    }
1188
1189    /// `&self` matcher that lets the caller carry a substitution
1190    /// (poly fn arg inference). The pure `compatible` helper above
1191    /// hides `subst` for the common "no `Type::Var` involved" callers;
1192    /// this method exposes it for the FnCall arg loop in `infer/expr.rs`.
1193    pub(super) fn match_with(
1194        &self,
1195        actual: &Type,
1196        expected: &Type,
1197        subst: &mut HashMap<String, Type>,
1198    ) -> bool {
1199        Self::match_expected_type_inner(actual, expected, subst, Some(self))
1200    }
1201
1202    fn match_expected_type_inner(
1203        actual: &Type,
1204        expected: &Type,
1205        subst: &mut HashMap<String, Type>,
1206        checker: Option<&TypeChecker>,
1207    ) -> bool {
1208        // Iron — A4: `Type::Invalid` is the checker's "we already
1209        // reported an error here, don't compound it" sentinel.
1210        // Returning `false` for it turned every downstream use site
1211        // into a fresh `expected X, got Invalid` diagnostic — a single
1212        // unknown-fn call could fan out to N + 1 errors (the unknown
1213        // fn plus one per downstream consumer). Treat Invalid as a
1214        // wildcard on either side so the original error stands alone.
1215        // Per-callsite guards like `!matches!(ty, Type::Invalid)`
1216        // around `self.compatible(...)` are now redundant but harmless;
1217        // sweeping them is deliberately out of scope here.
1218        if matches!(actual, Type::Invalid) || matches!(expected, Type::Invalid) {
1219            return true;
1220        }
1221        match expected {
1222            Type::Var(name) => Self::bind_expected_var(name, actual, subst),
1223            Type::Invalid => unreachable!("Type::Invalid handled by the early guard above"),
1224            Type::Int => matches!(actual, Type::Int),
1225            Type::Float => matches!(actual, Type::Float),
1226            Type::Str => matches!(actual, Type::Str),
1227            Type::Bool => matches!(actual, Type::Bool),
1228            Type::Unit => matches!(actual, Type::Unit),
1229            Type::Named {
1230                id: expected_id,
1231                name: expected_name,
1232            } => match actual {
1233                // Phase B: typed-identity comparison. When both sides
1234                // carry a `TypeId` (resolved against the symbol table)
1235                // we compare IDs directly — two unrelated modules
1236                // declaring `Shape` get distinct `TypeId`s by
1237                // construction and so stay incompatible. When the
1238                // checker is available we resolve either side's bare
1239                // string through `resolve_type_id` to bring it into
1240                // the typed identity domain; without the checker (or
1241                // for references that don't resolve, like builtin
1242                // `HttpResponse`) we fall back to canonical-name
1243                // equality.
1244                Type::Named {
1245                    id: actual_id,
1246                    name: actual_name,
1247                } => {
1248                    // Peer review round 6: do NOT auto-resolve an
1249                    // unresolved side in the matcher's
1250                    // (importer-context) symbol table. Upstream
1251                    // signature/binding boundaries
1252                    // (`canonicalize_named`,
1253                    // `canonicalize_named_in_module`) are
1254                    // responsible for stamping `id` in the correct
1255                    // owner context. If a `Type::Named` reaches the
1256                    // matcher with `id = None`, that's a deliberate
1257                    // unresolved state — either a genuine builtin
1258                    // (HttpResponse) or a resolution gap the matcher
1259                    // must surface, not silently paper over by
1260                    // re-resolving in the wrong scope.
1261                    let exp_id = *expected_id;
1262                    let act_id = *actual_id;
1263                    // Phase B (peer review round 2): the typed-identity
1264                    // comparison must reject mixed (Some, None) cases
1265                    // for user-defined types — otherwise an ambiguous
1266                    // bare reference (`Shape` when both `A.Shape` and
1267                    // `B.Shape` are exposed) silently matches against
1268                    // any specific `A.Shape` / `B.Shape` via the
1269                    // string fallback below. Distinguish "ambiguous
1270                    // bare reference, identity deliberately
1271                    // suppressed" from "builtin name that has no
1272                    // typed identity by design (`HttpResponse`,
1273                    // `Buffer`, …)" by asking the checker whether the
1274                    // unresolved side's name is recorded as
1275                    // ambiguous; reject in that case, allow name
1276                    // fallback otherwise.
1277                    match (exp_id, act_id) {
1278                        (Some(e), Some(a)) => e == a,
1279                        // Peer review round 6 entry-fallback bug:
1280                        // a dep module's unresolved bare `Shape` was
1281                        // silently binding to the entry module's
1282                        // `Shape` via name equality here. Reject all
1283                        // mixed (Some, None) cases. Builtins always
1284                        // exercise (None, None) below; user-source
1285                        // typed/raw mixes are by definition a
1286                        // resolution gap and must surface.
1287                        (Some(_), None) | (None, Some(_)) => false,
1288                        (None, None) => {
1289                            let exp = checker
1290                                .map(|c| c.canonical_type_name(expected_name))
1291                                .unwrap_or_else(|| expected_name.clone());
1292                            let act = checker
1293                                .map(|c| c.canonical_type_name(actual_name))
1294                                .unwrap_or_else(|| actual_name.clone());
1295                            exp == act
1296                        }
1297                    }
1298                }
1299                _ => false,
1300            },
1301            Type::Option(expected_inner) => match actual {
1302                Type::Option(actual_inner) => {
1303                    Self::match_expected_type_inner(actual_inner, expected_inner, subst, checker)
1304                }
1305                _ => false,
1306            },
1307            Type::List(expected_inner) => match actual {
1308                Type::List(actual_inner) => {
1309                    Self::match_expected_type_inner(actual_inner, expected_inner, subst, checker)
1310                }
1311                _ => false,
1312            },
1313            Type::Vector(expected_inner) => match actual {
1314                Type::Vector(actual_inner) => {
1315                    Self::match_expected_type_inner(actual_inner, expected_inner, subst, checker)
1316                }
1317                _ => false,
1318            },
1319            Type::Result(expected_ok, expected_err) => match actual {
1320                Type::Result(actual_ok, actual_err) => {
1321                    Self::match_expected_type_inner(actual_ok, expected_ok, subst, checker)
1322                        && Self::match_expected_type_inner(actual_err, expected_err, subst, checker)
1323                }
1324                _ => false,
1325            },
1326            Type::Map(expected_k, expected_v) => match actual {
1327                Type::Map(actual_k, actual_v) => {
1328                    Self::match_expected_type_inner(actual_k, expected_k, subst, checker)
1329                        && Self::match_expected_type_inner(actual_v, expected_v, subst, checker)
1330                }
1331                _ => false,
1332            },
1333            Type::Tuple(expected_items) => match actual {
1334                Type::Tuple(actual_items) if actual_items.len() == expected_items.len() => {
1335                    actual_items.iter().zip(expected_items.iter()).all(
1336                        |(actual_item, expected_item)| {
1337                            Self::match_expected_type_inner(
1338                                actual_item,
1339                                expected_item,
1340                                subst,
1341                                checker,
1342                            )
1343                        },
1344                    )
1345                }
1346                _ => false,
1347            },
1348            Type::Fn(expected_params, expected_ret, expected_effects) => match actual {
1349                Type::Fn(actual_params, actual_ret, actual_effects)
1350                    if actual_params.len() == expected_params.len() =>
1351                {
1352                    actual_params.iter().zip(expected_params.iter()).all(
1353                        |(actual_param, expected_param)| {
1354                            Self::match_expected_type_inner(
1355                                actual_param,
1356                                expected_param,
1357                                subst,
1358                                checker,
1359                            )
1360                        },
1361                    ) && Self::match_expected_type_inner(actual_ret, expected_ret, subst, checker)
1362                        && actual_effects.iter().all(|actual| {
1363                            expected_effects
1364                                .iter()
1365                                .any(|expected| crate::effects::effect_satisfies(expected, actual))
1366                        })
1367                }
1368                _ => false,
1369            },
1370        }
1371    }
1372
1373    fn bind_expected_var(name: &str, actual: &Type, subst: &mut HashMap<String, Type>) -> bool {
1374        match actual {
1375            Type::Var(actual_name) => return actual_name == name,
1376            // Iron — A4: matches the wildcard in `match_expected_type_inner`.
1377            // An already-errored actual binds vacuously instead of
1378            // refusing the unification and triggering a cascade.
1379            Type::Invalid => return true,
1380            _ => {}
1381        }
1382        if let Some(bound) = subst.get(name).cloned() {
1383            return Self::match_expected_type(actual, &bound, subst)
1384                && Self::match_expected_type(&bound, actual, subst);
1385            // bind_expected_var is alias-agnostic — Var bindings
1386            // never compare Named types against `sig_aliases` since
1387            // the binding rule already accepts whatever concrete
1388            // type the caller hands in.
1389        }
1390        // Occurs check — refuse `T := F<…T…>` style circular bindings.
1391        // Without this, polymorphic recursion patterns like `fn nest(v:
1392        // A) -> Unit; nest([v])` would insert `A → List<A>` into `subst`
1393        // and rely on downstream structural mismatch to terminate
1394        // matching. The HashMap entry itself is still a cycle that
1395        // later `instantiate_type` walks would have to skip; rejecting
1396        // the bind at source keeps the substitution map well-formed
1397        // and surfaces the constraint failure to the caller as a
1398        // normal type-incompatibility error.
1399        if Self::type_contains_var(actual, name) {
1400            return false;
1401        }
1402        subst.insert(name.to_string(), actual.clone());
1403        true
1404    }
1405
1406    /// Structural recursion over `ty` looking for any `Type::Var(name)`.
1407    /// Used by the occurs check in [`bind_expected_var`]; not exposed
1408    /// elsewhere because it's a one-step deep walk over a finite Type
1409    /// AST (no shared subterms, no cycles in the AST itself — the cycle
1410    /// would only exist in the substitution map, which the bind path
1411    /// is what guards).
1412    fn type_contains_var(ty: &Type, name: &str) -> bool {
1413        match ty {
1414            Type::Var(other) => other == name,
1415            Type::Int
1416            | Type::Float
1417            | Type::Str
1418            | Type::Bool
1419            | Type::Unit
1420            | Type::Invalid
1421            | Type::Named { .. } => false,
1422            Type::Option(inner) | Type::List(inner) | Type::Vector(inner) => {
1423                Self::type_contains_var(inner, name)
1424            }
1425            Type::Result(ok, err) => {
1426                Self::type_contains_var(ok, name) || Self::type_contains_var(err, name)
1427            }
1428            Type::Map(k, v) => Self::type_contains_var(k, name) || Self::type_contains_var(v, name),
1429            Type::Tuple(items) => items.iter().any(|t| Self::type_contains_var(t, name)),
1430            Type::Fn(params, ret, _effects) => {
1431                params.iter().any(|p| Self::type_contains_var(p, name))
1432                    || Self::type_contains_var(ret, name)
1433            }
1434        }
1435    }
1436
1437    pub(super) fn instantiate_type(ty: &Type, subst: &HashMap<String, Type>) -> Type {
1438        match ty {
1439            Type::Var(name) => subst.get(name).cloned().unwrap_or_else(|| ty.clone()),
1440            Type::Result(ok, err) => Type::Result(
1441                Box::new(Self::instantiate_type(ok, subst)),
1442                Box::new(Self::instantiate_type(err, subst)),
1443            ),
1444            Type::Option(inner) => Type::Option(Box::new(Self::instantiate_type(inner, subst))),
1445            Type::List(inner) => Type::List(Box::new(Self::instantiate_type(inner, subst))),
1446            Type::Vector(inner) => Type::Vector(Box::new(Self::instantiate_type(inner, subst))),
1447            Type::Map(k, v) => Type::Map(
1448                Box::new(Self::instantiate_type(k, subst)),
1449                Box::new(Self::instantiate_type(v, subst)),
1450            ),
1451            Type::Tuple(items) => Type::Tuple(
1452                items
1453                    .iter()
1454                    .map(|item| Self::instantiate_type(item, subst))
1455                    .collect(),
1456            ),
1457            Type::Fn(params, ret, effects) => Type::Fn(
1458                params
1459                    .iter()
1460                    .map(|param| Self::instantiate_type(param, subst))
1461                    .collect(),
1462                Box::new(Self::instantiate_type(ret, subst)),
1463                effects.clone(),
1464            ),
1465            Type::Int
1466            | Type::Float
1467            | Type::Str
1468            | Type::Bool
1469            | Type::Unit
1470            | Type::Invalid
1471            | Type::Named { .. } => ty.clone(),
1472        }
1473    }
1474}