Skip to main content

harn_parser/
typechecker.rs

1use std::collections::BTreeMap;
2
3use crate::ast::*;
4use crate::builtin_signatures;
5use harn_lexer::{FixEdit, Span};
6
7/// An inlay hint produced during type checking.
8#[derive(Debug, Clone)]
9pub struct InlayHintInfo {
10    /// Position (line, column) where the hint should be displayed (after the variable name).
11    pub line: usize,
12    pub column: usize,
13    /// The type label to display (e.g. ": string").
14    pub label: String,
15}
16
17/// A diagnostic produced by the type checker.
18#[derive(Debug, Clone)]
19pub struct TypeDiagnostic {
20    pub message: String,
21    pub severity: DiagnosticSeverity,
22    pub span: Option<Span>,
23    pub help: Option<String>,
24    /// Machine-applicable fix edits.
25    pub fix: Option<Vec<FixEdit>>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum DiagnosticSeverity {
30    Error,
31    Warning,
32}
33
34/// Inferred type of an expression. None means unknown/untyped (gradual typing).
35type InferredType = Option<TypeExpr>;
36
37/// The polarity of a position in a type when checking subtyping.
38///
39/// Polarity propagates through compound types: `FnType` flips it on
40/// its parameters; `Invariant` absorbs any further flip.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum Polarity {
43    Covariant,
44    Contravariant,
45    Invariant,
46}
47
48impl Polarity {
49    /// Compose the outer position polarity with the declared variance
50    /// of a type parameter (or the hard-coded variance of a compound
51    /// type constructor's slot).
52    fn compose(self, child: Variance) -> Polarity {
53        match (self, child) {
54            (_, Variance::Invariant) | (Polarity::Invariant, _) => Polarity::Invariant,
55            (p, Variance::Covariant) => p,
56            (Polarity::Covariant, Variance::Contravariant) => Polarity::Contravariant,
57            (Polarity::Contravariant, Variance::Contravariant) => Polarity::Covariant,
58        }
59    }
60}
61
62#[derive(Debug, Clone)]
63struct EnumDeclInfo {
64    type_params: Vec<TypeParam>,
65    variants: Vec<EnumVariant>,
66}
67
68#[derive(Debug, Clone)]
69struct StructDeclInfo {
70    type_params: Vec<TypeParam>,
71    fields: Vec<StructField>,
72}
73
74#[derive(Debug, Clone)]
75struct InterfaceDeclInfo {
76    type_params: Vec<TypeParam>,
77    associated_types: Vec<(String, Option<TypeExpr>)>,
78    methods: Vec<InterfaceMethod>,
79}
80
81/// Scope for tracking variable types.
82#[derive(Debug, Clone)]
83struct TypeScope {
84    /// Variable name → inferred type.
85    vars: BTreeMap<String, InferredType>,
86    /// Function name → (param types, return type).
87    functions: BTreeMap<String, FnSignature>,
88    /// Named type aliases.
89    type_aliases: BTreeMap<String, TypeExpr>,
90    /// Enum declarations with generic and variant metadata.
91    enums: BTreeMap<String, EnumDeclInfo>,
92    /// Interface declarations with associated types and methods.
93    interfaces: BTreeMap<String, InterfaceDeclInfo>,
94    /// Struct declarations with generic and field metadata.
95    structs: BTreeMap<String, StructDeclInfo>,
96    /// Impl block methods: type_name → method signatures.
97    impl_methods: BTreeMap<String, Vec<ImplMethodSig>>,
98    /// Generic type parameter names in scope (treated as compatible with any type).
99    generic_type_params: std::collections::BTreeSet<String>,
100    /// Where-clause constraints: type_param → interface_bound.
101    /// Used for definition-site checking of generic function bodies.
102    where_constraints: BTreeMap<String, String>,
103    /// Variables declared with `var` (mutable). Variables not in this set
104    /// are immutable (`let`, function params, loop vars, etc.).
105    mutable_vars: std::collections::BTreeSet<String>,
106    /// Variables that have been narrowed by flow-sensitive refinement.
107    /// Maps var name → pre-narrowing type (used to restore on reassignment).
108    narrowed_vars: BTreeMap<String, InferredType>,
109    /// Schema literals bound to variables, reduced to a TypeExpr subset so
110    /// `schema_is(x, some_schema)` can participate in flow refinement.
111    schema_bindings: BTreeMap<String, InferredType>,
112    /// Variables holding unvalidated values from boundary APIs (json_parse, llm_call, etc.).
113    /// Maps var name → source function name (e.g. "json_parse").
114    /// Empty string = explicitly cleared (shadows parent scope entry).
115    untyped_sources: BTreeMap<String, String>,
116    /// Concrete `type_of` variants ruled out on the current flow path for each
117    /// `unknown`-typed variable. Drives the exhaustive-narrowing warning at
118    /// `unreachable()` / `throw` / `never`-returning calls.
119    unknown_ruled_out: BTreeMap<String, Vec<String>>,
120    parent: Option<Box<TypeScope>>,
121}
122
123/// Method signature extracted from an impl block (for interface checking).
124#[derive(Debug, Clone)]
125struct ImplMethodSig {
126    name: String,
127    /// Number of parameters excluding `self`.
128    param_count: usize,
129    /// Parameter types (excluding `self`), None means untyped.
130    param_types: Vec<Option<TypeExpr>>,
131    /// Return type, None means untyped.
132    return_type: Option<TypeExpr>,
133}
134
135#[derive(Debug, Clone)]
136struct FnSignature {
137    params: Vec<(String, InferredType)>,
138    return_type: InferredType,
139    /// Generic type parameter names declared on the function.
140    type_param_names: Vec<String>,
141    /// Number of required parameters (those without defaults).
142    required_params: usize,
143    /// Where-clause constraints: (type_param_name, interface_bound).
144    where_clauses: Vec<(String, String)>,
145    /// True if the last parameter is a rest parameter.
146    has_rest: bool,
147}
148
149impl TypeScope {
150    fn new() -> Self {
151        let mut scope = Self {
152            vars: BTreeMap::new(),
153            functions: BTreeMap::new(),
154            type_aliases: BTreeMap::new(),
155            enums: BTreeMap::new(),
156            interfaces: BTreeMap::new(),
157            structs: BTreeMap::new(),
158            impl_methods: BTreeMap::new(),
159            generic_type_params: std::collections::BTreeSet::new(),
160            where_constraints: BTreeMap::new(),
161            mutable_vars: std::collections::BTreeSet::new(),
162            narrowed_vars: BTreeMap::new(),
163            schema_bindings: BTreeMap::new(),
164            untyped_sources: BTreeMap::new(),
165            unknown_ruled_out: BTreeMap::new(),
166            parent: None,
167        };
168        scope.enums.insert(
169            "Result".into(),
170            EnumDeclInfo {
171                type_params: vec![
172                    TypeParam {
173                        name: "T".into(),
174                        variance: Variance::Covariant,
175                    },
176                    TypeParam {
177                        name: "E".into(),
178                        variance: Variance::Covariant,
179                    },
180                ],
181                variants: vec![
182                    EnumVariant {
183                        name: "Ok".into(),
184                        fields: vec![TypedParam {
185                            name: "value".into(),
186                            type_expr: Some(TypeExpr::Named("T".into())),
187                            default_value: None,
188                            rest: false,
189                        }],
190                    },
191                    EnumVariant {
192                        name: "Err".into(),
193                        fields: vec![TypedParam {
194                            name: "error".into(),
195                            type_expr: Some(TypeExpr::Named("E".into())),
196                            default_value: None,
197                            rest: false,
198                        }],
199                    },
200                ],
201            },
202        );
203        scope
204    }
205
206    fn child(&self) -> Self {
207        Self {
208            vars: BTreeMap::new(),
209            functions: BTreeMap::new(),
210            type_aliases: BTreeMap::new(),
211            enums: BTreeMap::new(),
212            interfaces: BTreeMap::new(),
213            structs: BTreeMap::new(),
214            impl_methods: BTreeMap::new(),
215            generic_type_params: std::collections::BTreeSet::new(),
216            where_constraints: BTreeMap::new(),
217            mutable_vars: std::collections::BTreeSet::new(),
218            narrowed_vars: BTreeMap::new(),
219            schema_bindings: BTreeMap::new(),
220            untyped_sources: BTreeMap::new(),
221            unknown_ruled_out: BTreeMap::new(),
222            parent: Some(Box::new(self.clone())),
223        }
224    }
225
226    fn get_var(&self, name: &str) -> Option<&InferredType> {
227        self.vars
228            .get(name)
229            .or_else(|| self.parent.as_ref()?.get_var(name))
230    }
231
232    /// Record that a concrete `type_of` variant has been ruled out for
233    /// an `unknown`-typed variable on the current flow path.
234    fn add_unknown_ruled_out(&mut self, var_name: &str, type_name: &str) {
235        if !self.unknown_ruled_out.contains_key(var_name) {
236            let inherited = self.lookup_unknown_ruled_out(var_name);
237            self.unknown_ruled_out
238                .insert(var_name.to_string(), inherited);
239        }
240        let entry = self
241            .unknown_ruled_out
242            .get_mut(var_name)
243            .expect("just inserted");
244        if !entry.iter().any(|t| t == type_name) {
245            entry.push(type_name.to_string());
246        }
247    }
248
249    /// Return the ruled-out concrete types recorded for `var_name` across
250    /// the current scope chain (child entries mask parent entries).
251    fn lookup_unknown_ruled_out(&self, var_name: &str) -> Vec<String> {
252        if let Some(list) = self.unknown_ruled_out.get(var_name) {
253            list.clone()
254        } else if let Some(parent) = &self.parent {
255            parent.lookup_unknown_ruled_out(var_name)
256        } else {
257            Vec::new()
258        }
259    }
260
261    /// Collect every `unknown`-typed variable that has at least one ruled-out
262    /// concrete type on the current flow path (merged across parent scopes).
263    fn collect_unknown_ruled_out(&self) -> BTreeMap<String, Vec<String>> {
264        let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
265        self.collect_unknown_ruled_out_inner(&mut out);
266        out
267    }
268
269    fn collect_unknown_ruled_out_inner(&self, acc: &mut BTreeMap<String, Vec<String>>) {
270        if let Some(parent) = &self.parent {
271            parent.collect_unknown_ruled_out_inner(acc);
272        }
273        for (name, list) in &self.unknown_ruled_out {
274            acc.insert(name.clone(), list.clone());
275        }
276    }
277
278    /// Drop the ruled-out set for a variable (used on reassignment).
279    fn clear_unknown_ruled_out(&mut self, var_name: &str) {
280        // Shadow any parent entry with an empty list so lookups in this
281        // scope (and its children) treat the variable as un-narrowed.
282        self.unknown_ruled_out
283            .insert(var_name.to_string(), Vec::new());
284    }
285
286    fn get_fn(&self, name: &str) -> Option<&FnSignature> {
287        self.functions
288            .get(name)
289            .or_else(|| self.parent.as_ref()?.get_fn(name))
290    }
291
292    fn get_schema_binding(&self, name: &str) -> Option<&InferredType> {
293        self.schema_bindings
294            .get(name)
295            .or_else(|| self.parent.as_ref()?.get_schema_binding(name))
296    }
297
298    fn resolve_type(&self, name: &str) -> Option<&TypeExpr> {
299        self.type_aliases
300            .get(name)
301            .or_else(|| self.parent.as_ref()?.resolve_type(name))
302    }
303
304    fn is_generic_type_param(&self, name: &str) -> bool {
305        self.generic_type_params.contains(name)
306            || self
307                .parent
308                .as_ref()
309                .is_some_and(|p| p.is_generic_type_param(name))
310    }
311
312    fn get_where_constraint(&self, type_param: &str) -> Option<&str> {
313        self.where_constraints
314            .get(type_param)
315            .map(|s| s.as_str())
316            .or_else(|| {
317                self.parent
318                    .as_ref()
319                    .and_then(|p| p.get_where_constraint(type_param))
320            })
321    }
322
323    fn get_enum(&self, name: &str) -> Option<&EnumDeclInfo> {
324        self.enums
325            .get(name)
326            .or_else(|| self.parent.as_ref()?.get_enum(name))
327    }
328
329    fn get_interface(&self, name: &str) -> Option<&InterfaceDeclInfo> {
330        self.interfaces
331            .get(name)
332            .or_else(|| self.parent.as_ref()?.get_interface(name))
333    }
334
335    fn get_struct(&self, name: &str) -> Option<&StructDeclInfo> {
336        self.structs
337            .get(name)
338            .or_else(|| self.parent.as_ref()?.get_struct(name))
339    }
340
341    fn get_impl_methods(&self, name: &str) -> Option<&Vec<ImplMethodSig>> {
342        self.impl_methods
343            .get(name)
344            .or_else(|| self.parent.as_ref()?.get_impl_methods(name))
345    }
346
347    /// Look up declared variance for each type parameter of a
348    /// constructor (enum / struct / interface). Returns `None` if the
349    /// name is not a known user-declared generic. Built-in type
350    /// constructors that live outside `Applied` (list, dict, iter,
351    /// FnType) are handled directly in the subtype match arms rather
352    /// than via this table.
353    fn variance_of(&self, name: &str) -> Option<Vec<Variance>> {
354        if let Some(info) = self.get_enum(name) {
355            return Some(info.type_params.iter().map(|tp| tp.variance).collect());
356        }
357        if let Some(info) = self.get_struct(name) {
358            return Some(info.type_params.iter().map(|tp| tp.variance).collect());
359        }
360        if let Some(info) = self.get_interface(name) {
361            return Some(info.type_params.iter().map(|tp| tp.variance).collect());
362        }
363        None
364    }
365
366    fn define_var(&mut self, name: &str, ty: InferredType) {
367        self.vars.insert(name.to_string(), ty);
368    }
369
370    fn define_var_mutable(&mut self, name: &str, ty: InferredType) {
371        self.vars.insert(name.to_string(), ty);
372        self.mutable_vars.insert(name.to_string());
373    }
374
375    fn define_schema_binding(&mut self, name: &str, ty: InferredType) {
376        self.schema_bindings.insert(name.to_string(), ty);
377    }
378
379    /// Check whether a variable holds an unvalidated boundary-API value.
380    /// Returns the source function name (e.g. "json_parse") or `None`.
381    fn is_untyped_source(&self, name: &str) -> Option<&str> {
382        if let Some(source) = self.untyped_sources.get(name) {
383            if source.is_empty() {
384                return None; // explicitly cleared in this scope
385            }
386            return Some(source.as_str());
387        }
388        self.parent.as_ref()?.is_untyped_source(name)
389    }
390
391    fn mark_untyped_source(&mut self, name: &str, source: &str) {
392        self.untyped_sources
393            .insert(name.to_string(), source.to_string());
394    }
395
396    /// Clear the untyped-source flag for a variable, shadowing any parent entry.
397    fn clear_untyped_source(&mut self, name: &str) {
398        self.untyped_sources.insert(name.to_string(), String::new());
399    }
400
401    /// Check if a variable is mutable (declared with `var`).
402    fn is_mutable(&self, name: &str) -> bool {
403        self.mutable_vars.contains(name) || self.parent.as_ref().is_some_and(|p| p.is_mutable(name))
404    }
405
406    fn define_fn(&mut self, name: &str, sig: FnSignature) {
407        self.functions.insert(name.to_string(), sig);
408    }
409}
410
411/// Bidirectional type refinements extracted from a condition.
412/// Each path contains a list of (variable_name, narrowed_type) pairs.
413#[derive(Debug, Clone, Default)]
414struct Refinements {
415    /// Narrowings when the condition evaluates to true/truthy.
416    truthy: Vec<(String, InferredType)>,
417    /// Narrowings when the condition evaluates to false/falsy.
418    falsy: Vec<(String, InferredType)>,
419    /// Concrete `type_of` variants (var_name, type_name) to add to the
420    /// ruled-out coverage set on the truthy branch. Only populated for
421    /// `type_of(x) != "T"` patterns against `unknown`-typed values.
422    truthy_ruled_out: Vec<(String, String)>,
423    /// Same as `truthy_ruled_out` but applied on the falsy branch — the
424    /// common case for `if type_of(x) == "T" { return }` exhaustiveness
425    /// patterns.
426    falsy_ruled_out: Vec<(String, String)>,
427}
428
429impl Refinements {
430    fn empty() -> Self {
431        Self::default()
432    }
433
434    /// Swap truthy and falsy (used for negation).
435    fn inverted(self) -> Self {
436        Self {
437            truthy: self.falsy,
438            falsy: self.truthy,
439            truthy_ruled_out: self.falsy_ruled_out,
440            falsy_ruled_out: self.truthy_ruled_out,
441        }
442    }
443
444    /// Apply the truthy-branch narrowings and ruled-out additions to `scope`.
445    fn apply_truthy(&self, scope: &mut TypeScope) {
446        apply_refinements(scope, &self.truthy);
447        for (var, ty) in &self.truthy_ruled_out {
448            scope.add_unknown_ruled_out(var, ty);
449        }
450    }
451
452    /// Apply the falsy-branch narrowings and ruled-out additions to `scope`.
453    fn apply_falsy(&self, scope: &mut TypeScope) {
454        apply_refinements(scope, &self.falsy);
455        for (var, ty) in &self.falsy_ruled_out {
456            scope.add_unknown_ruled_out(var, ty);
457        }
458    }
459}
460
461/// Known return types for builtin functions. Delegates to the shared
462/// [`builtin_signatures`] registry — see that module for the full table.
463fn builtin_return_type(name: &str) -> InferredType {
464    builtin_signatures::builtin_return_type(name)
465}
466
467/// Check if a name is a known builtin. Delegates to the shared
468/// [`builtin_signatures`] registry.
469fn is_builtin(name: &str) -> bool {
470    builtin_signatures::is_builtin(name)
471}
472
473/// The static type checker.
474pub struct TypeChecker {
475    diagnostics: Vec<TypeDiagnostic>,
476    scope: TypeScope,
477    source: Option<String>,
478    hints: Vec<InlayHintInfo>,
479    /// When true, flag unvalidated boundary-API values used in field access.
480    strict_types: bool,
481    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
482    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
483    fn_depth: usize,
484}
485
486impl TypeChecker {
487    fn wildcard_type() -> TypeExpr {
488        TypeExpr::Named("_".into())
489    }
490
491    fn is_wildcard_type(ty: &TypeExpr) -> bool {
492        matches!(ty, TypeExpr::Named(name) if name == "_")
493    }
494
495    fn base_type_name(ty: &TypeExpr) -> Option<&str> {
496        match ty {
497            TypeExpr::Named(name) => Some(name.as_str()),
498            TypeExpr::Applied { name, .. } => Some(name.as_str()),
499            _ => None,
500        }
501    }
502
503    pub fn new() -> Self {
504        Self {
505            diagnostics: Vec::new(),
506            scope: TypeScope::new(),
507            source: None,
508            hints: Vec::new(),
509            strict_types: false,
510            fn_depth: 0,
511        }
512    }
513
514    /// Create a type checker with strict types mode.
515    /// When enabled, flags unvalidated boundary-API values used in field access.
516    pub fn with_strict_types(strict: bool) -> Self {
517        Self {
518            diagnostics: Vec::new(),
519            scope: TypeScope::new(),
520            source: None,
521            hints: Vec::new(),
522            strict_types: strict,
523            fn_depth: 0,
524        }
525    }
526
527    /// Check a program with source text for autofix generation.
528    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
529        self.source = Some(source.to_string());
530        self.check_inner(program).0
531    }
532
533    /// Check a program with strict types mode and source text.
534    pub fn check_strict_with_source(
535        mut self,
536        program: &[SNode],
537        source: &str,
538    ) -> Vec<TypeDiagnostic> {
539        self.source = Some(source.to_string());
540        self.check_inner(program).0
541    }
542
543    /// Check a program and return diagnostics.
544    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
545        self.check_inner(program).0
546    }
547
548    /// Check whether a function call value is a boundary source that produces
549    /// unvalidated data.  Returns `None` if the value is type-safe
550    /// (e.g. llm_call with a schema option, or a non-boundary function).
551    fn detect_boundary_source(value: &SNode, scope: &TypeScope) -> Option<String> {
552        match &value.node {
553            Node::FunctionCall { name, args } => {
554                if !builtin_signatures::is_untyped_boundary_source(name) {
555                    return None;
556                }
557                // llm_call/llm_completion with a schema option are type-safe
558                if (name == "llm_call" || name == "llm_completion")
559                    && Self::llm_call_has_typed_schema_option(args, scope)
560                {
561                    return None;
562                }
563                Some(name.clone())
564            }
565            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
566            _ => None,
567        }
568    }
569
570    /// True if an `llm_call` / `llm_completion` options dict names a
571    /// resolvable output schema. Used by the strict-types boundary checks
572    /// to suppress "unvalidated" warnings when the call site is typed.
573    /// Actual return-type narrowing is driven by the generic-builtin
574    /// dispatch path in `infer_type`, not this helper.
575    fn llm_call_has_typed_schema_option(args: &[SNode], scope: &TypeScope) -> bool {
576        let Some(opts) = args.get(2) else {
577            return false;
578        };
579        let Node::DictLiteral(entries) = &opts.node else {
580            return false;
581        };
582        entries.iter().any(|entry| {
583            let key = match &entry.key.node {
584                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
585                _ => return false,
586            };
587            (key == "schema" || key == "output_schema")
588                && schema_type_expr_from_node(&entry.value, scope).is_some()
589        })
590    }
591
592    /// Check whether a type annotation is a concrete shape/struct type
593    /// (as opposed to bare `dict` or no annotation).
594    fn is_concrete_type(ty: &TypeExpr) -> bool {
595        matches!(
596            ty,
597            TypeExpr::Shape(_)
598                | TypeExpr::Applied { .. }
599                | TypeExpr::FnType { .. }
600                | TypeExpr::List(_)
601                | TypeExpr::Iter(_)
602                | TypeExpr::DictType(_, _)
603        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
604    }
605
606    /// Check a program and return both diagnostics and inlay hints.
607    pub fn check_with_hints(
608        mut self,
609        program: &[SNode],
610        source: &str,
611    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
612        self.source = Some(source.to_string());
613        self.check_inner(program)
614    }
615
616    fn check_inner(mut self, program: &[SNode]) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
617        // First pass: collect declarations (type/enum/struct/interface) into scope
618        // before type-checking bodies so forward references resolve.
619        Self::register_declarations_into(&mut self.scope, program);
620        for snode in program {
621            if let Node::Pipeline { body, .. } = &snode.node {
622                Self::register_declarations_into(&mut self.scope, body);
623            }
624        }
625
626        for snode in program {
627            match &snode.node {
628                Node::Pipeline { params, body, .. } => {
629                    let mut child = self.scope.child();
630                    for p in params {
631                        child.define_var(p, None);
632                    }
633                    self.fn_depth += 1;
634                    self.check_block(body, &mut child);
635                    self.fn_depth -= 1;
636                }
637                Node::FnDecl {
638                    name,
639                    type_params,
640                    params,
641                    return_type,
642                    where_clauses,
643                    body,
644                    ..
645                } => {
646                    let required_params =
647                        params.iter().filter(|p| p.default_value.is_none()).count();
648                    let sig = FnSignature {
649                        params: params
650                            .iter()
651                            .map(|p| (p.name.clone(), p.type_expr.clone()))
652                            .collect(),
653                        return_type: return_type.clone(),
654                        type_param_names: type_params.iter().map(|tp| tp.name.clone()).collect(),
655                        required_params,
656                        where_clauses: where_clauses
657                            .iter()
658                            .map(|wc| (wc.type_name.clone(), wc.bound.clone()))
659                            .collect(),
660                        has_rest: params.last().is_some_and(|p| p.rest),
661                    };
662                    self.scope.define_fn(name, sig);
663                    self.check_fn_body(type_params, params, return_type, body, where_clauses);
664                }
665                _ => {
666                    let mut scope = self.scope.clone();
667                    self.check_node(snode, &mut scope);
668                    // Promote top-level definitions out of the temporary scope.
669                    for (name, ty) in scope.vars {
670                        self.scope.vars.entry(name).or_insert(ty);
671                    }
672                    for name in scope.mutable_vars {
673                        self.scope.mutable_vars.insert(name);
674                    }
675                }
676            }
677        }
678
679        (self.diagnostics, self.hints)
680    }
681
682    /// Register type, enum, interface, and struct declarations from AST nodes into a scope.
683    fn register_declarations_into(scope: &mut TypeScope, nodes: &[SNode]) {
684        for snode in nodes {
685            match &snode.node {
686                Node::TypeDecl {
687                    name,
688                    type_params: _,
689                    type_expr,
690                } => {
691                    scope.type_aliases.insert(name.clone(), type_expr.clone());
692                }
693                Node::EnumDecl {
694                    name,
695                    type_params,
696                    variants,
697                    ..
698                } => {
699                    scope.enums.insert(
700                        name.clone(),
701                        EnumDeclInfo {
702                            type_params: type_params.clone(),
703                            variants: variants.clone(),
704                        },
705                    );
706                }
707                Node::InterfaceDecl {
708                    name,
709                    type_params,
710                    associated_types,
711                    methods,
712                } => {
713                    scope.interfaces.insert(
714                        name.clone(),
715                        InterfaceDeclInfo {
716                            type_params: type_params.clone(),
717                            associated_types: associated_types.clone(),
718                            methods: methods.clone(),
719                        },
720                    );
721                }
722                Node::StructDecl {
723                    name,
724                    type_params,
725                    fields,
726                    ..
727                } => {
728                    scope.structs.insert(
729                        name.clone(),
730                        StructDeclInfo {
731                            type_params: type_params.clone(),
732                            fields: fields.clone(),
733                        },
734                    );
735                }
736                Node::ImplBlock {
737                    type_name, methods, ..
738                } => {
739                    let sigs: Vec<ImplMethodSig> = methods
740                        .iter()
741                        .filter_map(|m| {
742                            if let Node::FnDecl {
743                                name,
744                                params,
745                                return_type,
746                                ..
747                            } = &m.node
748                            {
749                                let non_self: Vec<_> =
750                                    params.iter().filter(|p| p.name != "self").collect();
751                                let param_count = non_self.len();
752                                let param_types: Vec<Option<TypeExpr>> =
753                                    non_self.iter().map(|p| p.type_expr.clone()).collect();
754                                Some(ImplMethodSig {
755                                    name: name.clone(),
756                                    param_count,
757                                    param_types,
758                                    return_type: return_type.clone(),
759                                })
760                            } else {
761                                None
762                            }
763                        })
764                        .collect();
765                    scope.impl_methods.insert(type_name.clone(), sigs);
766                }
767                _ => {}
768            }
769        }
770    }
771
772    fn check_block(&mut self, stmts: &[SNode], scope: &mut TypeScope) {
773        let mut definitely_exited = false;
774        for stmt in stmts {
775            if definitely_exited {
776                self.warning_at("unreachable code".to_string(), stmt.span);
777                break; // warn once per block
778            }
779            self.check_node(stmt, scope);
780            if Self::stmt_definitely_exits(stmt) {
781                definitely_exited = true;
782            }
783        }
784    }
785
786    /// Check whether a single statement definitely exits (delegates to the free function).
787    fn stmt_definitely_exits(stmt: &SNode) -> bool {
788        stmt_definitely_exits(stmt)
789    }
790
791    /// Define variables from a destructuring pattern in the given scope (as unknown type).
792    fn define_pattern_vars(pattern: &BindingPattern, scope: &mut TypeScope, mutable: bool) {
793        let define = |scope: &mut TypeScope, name: &str| {
794            if mutable {
795                scope.define_var_mutable(name, None);
796            } else {
797                scope.define_var(name, None);
798            }
799        };
800        match pattern {
801            BindingPattern::Identifier(name) => {
802                define(scope, name);
803            }
804            BindingPattern::Dict(fields) => {
805                for field in fields {
806                    let name = field.alias.as_deref().unwrap_or(&field.key);
807                    define(scope, name);
808                }
809            }
810            BindingPattern::List(elements) => {
811                for elem in elements {
812                    define(scope, &elem.name);
813                }
814            }
815            BindingPattern::Pair(a, b) => {
816                define(scope, a);
817                define(scope, b);
818            }
819        }
820    }
821
822    /// Type-check default value expressions within a destructuring pattern.
823    fn check_pattern_defaults(&mut self, pattern: &BindingPattern, scope: &mut TypeScope) {
824        match pattern {
825            BindingPattern::Identifier(_) => {}
826            BindingPattern::Dict(fields) => {
827                for field in fields {
828                    if let Some(default) = &field.default_value {
829                        self.check_binops(default, scope);
830                    }
831                }
832            }
833            BindingPattern::List(elements) => {
834                for elem in elements {
835                    if let Some(default) = &elem.default_value {
836                        self.check_binops(default, scope);
837                    }
838                }
839            }
840            BindingPattern::Pair(_, _) => {}
841        }
842    }
843
844    fn check_node(&mut self, snode: &SNode, scope: &mut TypeScope) {
845        let span = snode.span;
846        match &snode.node {
847            Node::LetBinding {
848                pattern,
849                type_ann,
850                value,
851            } => {
852                self.check_binops(value, scope);
853                let inferred = self.infer_type(value, scope);
854                if let BindingPattern::Identifier(name) = pattern {
855                    if let Some(expected) = type_ann {
856                        if let Some(actual) = &inferred {
857                            if !self.types_compatible(expected, actual, scope) {
858                                let mut msg = format!(
859                                    "'{}' declared as {}, but assigned {}",
860                                    name,
861                                    format_type(expected),
862                                    format_type(actual)
863                                );
864                                if let Some(detail) = shape_mismatch_detail(expected, actual) {
865                                    msg.push_str(&format!(" ({})", detail));
866                                }
867                                self.error_at(msg, span);
868                            }
869                        }
870                    }
871                    // Collect inlay hint when type is inferred (no annotation)
872                    if type_ann.is_none() {
873                        if let Some(ref ty) = inferred {
874                            if !is_obvious_type(value, ty) {
875                                self.hints.push(InlayHintInfo {
876                                    line: span.line,
877                                    column: span.column + "let ".len() + name.len(),
878                                    label: format!(": {}", format_type(ty)),
879                                });
880                            }
881                        }
882                    }
883                    let ty = type_ann.clone().or(inferred);
884                    scope.define_var(name, ty);
885                    scope.define_schema_binding(name, schema_type_expr_from_node(value, scope));
886                    // Strict types: mark variables assigned from boundary APIs
887                    if self.strict_types {
888                        if let Some(boundary) = Self::detect_boundary_source(value, scope) {
889                            let has_concrete_ann =
890                                type_ann.as_ref().is_some_and(Self::is_concrete_type);
891                            if !has_concrete_ann {
892                                scope.mark_untyped_source(name, &boundary);
893                            }
894                        }
895                    }
896                } else {
897                    self.check_pattern_defaults(pattern, scope);
898                    Self::define_pattern_vars(pattern, scope, false);
899                }
900            }
901
902            Node::VarBinding {
903                pattern,
904                type_ann,
905                value,
906            } => {
907                self.check_binops(value, scope);
908                let inferred = self.infer_type(value, scope);
909                if let BindingPattern::Identifier(name) = pattern {
910                    if let Some(expected) = type_ann {
911                        if let Some(actual) = &inferred {
912                            if !self.types_compatible(expected, actual, scope) {
913                                let mut msg = format!(
914                                    "'{}' declared as {}, but assigned {}",
915                                    name,
916                                    format_type(expected),
917                                    format_type(actual)
918                                );
919                                if let Some(detail) = shape_mismatch_detail(expected, actual) {
920                                    msg.push_str(&format!(" ({})", detail));
921                                }
922                                self.error_at(msg, span);
923                            }
924                        }
925                    }
926                    if type_ann.is_none() {
927                        if let Some(ref ty) = inferred {
928                            if !is_obvious_type(value, ty) {
929                                self.hints.push(InlayHintInfo {
930                                    line: span.line,
931                                    column: span.column + "var ".len() + name.len(),
932                                    label: format!(": {}", format_type(ty)),
933                                });
934                            }
935                        }
936                    }
937                    let ty = type_ann.clone().or(inferred);
938                    scope.define_var_mutable(name, ty);
939                    scope.define_schema_binding(name, schema_type_expr_from_node(value, scope));
940                    // Strict types: mark variables assigned from boundary APIs
941                    if self.strict_types {
942                        if let Some(boundary) = Self::detect_boundary_source(value, scope) {
943                            let has_concrete_ann =
944                                type_ann.as_ref().is_some_and(Self::is_concrete_type);
945                            if !has_concrete_ann {
946                                scope.mark_untyped_source(name, &boundary);
947                            }
948                        }
949                    }
950                } else {
951                    self.check_pattern_defaults(pattern, scope);
952                    Self::define_pattern_vars(pattern, scope, true);
953                }
954            }
955
956            Node::FnDecl {
957                name,
958                type_params,
959                params,
960                return_type,
961                where_clauses,
962                body,
963                ..
964            } => {
965                let required_params = params.iter().filter(|p| p.default_value.is_none()).count();
966                let sig = FnSignature {
967                    params: params
968                        .iter()
969                        .map(|p| (p.name.clone(), p.type_expr.clone()))
970                        .collect(),
971                    return_type: return_type.clone(),
972                    type_param_names: type_params.iter().map(|tp| tp.name.clone()).collect(),
973                    required_params,
974                    where_clauses: where_clauses
975                        .iter()
976                        .map(|wc| (wc.type_name.clone(), wc.bound.clone()))
977                        .collect(),
978                    has_rest: params.last().is_some_and(|p| p.rest),
979                };
980                scope.define_fn(name, sig.clone());
981                scope.define_var(name, None);
982                self.check_fn_decl_variance(type_params, params, return_type.as_ref(), name, span);
983                self.check_fn_body(type_params, params, return_type, body, where_clauses);
984            }
985
986            Node::ToolDecl {
987                name,
988                params,
989                return_type,
990                body,
991                ..
992            } => {
993                // Register the tool like a function for type checking purposes
994                let required_params = params.iter().filter(|p| p.default_value.is_none()).count();
995                let sig = FnSignature {
996                    params: params
997                        .iter()
998                        .map(|p| (p.name.clone(), p.type_expr.clone()))
999                        .collect(),
1000                    return_type: return_type.clone(),
1001                    type_param_names: Vec::new(),
1002                    required_params,
1003                    where_clauses: Vec::new(),
1004                    has_rest: params.last().is_some_and(|p| p.rest),
1005                };
1006                scope.define_fn(name, sig);
1007                scope.define_var(name, None);
1008                self.check_fn_body(&[], params, return_type, body, &[]);
1009            }
1010
1011            Node::FunctionCall { name, args } => {
1012                self.check_call(name, args, scope, span);
1013                // Strict types: schema_expect clears untyped source status
1014                if self.strict_types && name == "schema_expect" && args.len() >= 2 {
1015                    if let Node::Identifier(var_name) = &args[0].node {
1016                        scope.clear_untyped_source(var_name);
1017                        if let Some(schema_type) = schema_type_expr_from_node(&args[1], scope) {
1018                            scope.define_var(var_name, Some(schema_type));
1019                        }
1020                    }
1021                }
1022            }
1023
1024            Node::IfElse {
1025                condition,
1026                then_body,
1027                else_body,
1028            } => {
1029                self.check_node(condition, scope);
1030                let refs = Self::extract_refinements(condition, scope);
1031
1032                let mut then_scope = scope.child();
1033                refs.apply_truthy(&mut then_scope);
1034                // Strict types: schema_is/is_type in condition clears
1035                // untyped source in then-branch
1036                if self.strict_types {
1037                    if let Node::FunctionCall { name, args } = &condition.node {
1038                        if (name == "schema_is" || name == "is_type") && args.len() == 2 {
1039                            if let Node::Identifier(var_name) = &args[0].node {
1040                                then_scope.clear_untyped_source(var_name);
1041                            }
1042                        }
1043                    }
1044                }
1045                self.check_block(then_body, &mut then_scope);
1046
1047                if let Some(else_body) = else_body {
1048                    let mut else_scope = scope.child();
1049                    refs.apply_falsy(&mut else_scope);
1050                    self.check_block(else_body, &mut else_scope);
1051
1052                    // Post-branch narrowing: if one branch definitely exits,
1053                    // apply the other branch's refinements to the outer scope
1054                    if Self::block_definitely_exits(then_body)
1055                        && !Self::block_definitely_exits(else_body)
1056                    {
1057                        refs.apply_falsy(scope);
1058                    } else if Self::block_definitely_exits(else_body)
1059                        && !Self::block_definitely_exits(then_body)
1060                    {
1061                        refs.apply_truthy(scope);
1062                    }
1063                } else {
1064                    // No else: if then-body always exits, apply falsy after
1065                    if Self::block_definitely_exits(then_body) {
1066                        refs.apply_falsy(scope);
1067                    }
1068                }
1069            }
1070
1071            Node::ForIn {
1072                pattern,
1073                iterable,
1074                body,
1075            } => {
1076                self.check_node(iterable, scope);
1077                let mut loop_scope = scope.child();
1078                let iter_type = self.infer_type(iterable, scope);
1079                if let BindingPattern::Identifier(variable) = pattern {
1080                    // Infer loop variable type from iterable
1081                    let elem_type = match iter_type {
1082                        Some(TypeExpr::List(inner)) => Some(*inner),
1083                        Some(TypeExpr::Iter(inner)) => Some(*inner),
1084                        Some(TypeExpr::Applied { ref name, ref args })
1085                            if name == "Iter" && args.len() == 1 =>
1086                        {
1087                            Some(args[0].clone())
1088                        }
1089                        Some(TypeExpr::Named(n)) if n == "string" => {
1090                            Some(TypeExpr::Named("string".into()))
1091                        }
1092                        // Iterating a range always yields ints.
1093                        Some(TypeExpr::Named(n)) if n == "range" => {
1094                            Some(TypeExpr::Named("int".into()))
1095                        }
1096                        _ => None,
1097                    };
1098                    loop_scope.define_var(variable, elem_type);
1099                } else if let BindingPattern::Pair(a, b) = pattern {
1100                    // Pair destructuring: `for (k, v) in iter` — extract K, V
1101                    // from the yielded Pair<K, V>.
1102                    let (ka, vb) = match &iter_type {
1103                        Some(TypeExpr::Iter(inner)) => {
1104                            if let TypeExpr::Applied { name, args } = inner.as_ref() {
1105                                if name == "Pair" && args.len() == 2 {
1106                                    (Some(args[0].clone()), Some(args[1].clone()))
1107                                } else {
1108                                    (None, None)
1109                                }
1110                            } else {
1111                                (None, None)
1112                            }
1113                        }
1114                        Some(TypeExpr::Applied { name, args })
1115                            if name == "Iter" && args.len() == 1 =>
1116                        {
1117                            if let TypeExpr::Applied { name: n2, args: a2 } = &args[0] {
1118                                if n2 == "Pair" && a2.len() == 2 {
1119                                    (Some(a2[0].clone()), Some(a2[1].clone()))
1120                                } else {
1121                                    (None, None)
1122                                }
1123                            } else {
1124                                (None, None)
1125                            }
1126                        }
1127                        _ => (None, None),
1128                    };
1129                    loop_scope.define_var(a, ka);
1130                    loop_scope.define_var(b, vb);
1131                } else {
1132                    self.check_pattern_defaults(pattern, &mut loop_scope);
1133                    Self::define_pattern_vars(pattern, &mut loop_scope, false);
1134                }
1135                self.check_block(body, &mut loop_scope);
1136            }
1137
1138            Node::WhileLoop { condition, body } => {
1139                self.check_node(condition, scope);
1140                let refs = Self::extract_refinements(condition, scope);
1141                let mut loop_scope = scope.child();
1142                refs.apply_truthy(&mut loop_scope);
1143                self.check_block(body, &mut loop_scope);
1144            }
1145
1146            Node::RequireStmt { condition, message } => {
1147                self.check_node(condition, scope);
1148                if let Some(message) = message {
1149                    self.check_node(message, scope);
1150                }
1151            }
1152
1153            Node::TryCatch {
1154                body,
1155                error_var,
1156                error_type,
1157                catch_body,
1158                finally_body,
1159                ..
1160            } => {
1161                let mut try_scope = scope.child();
1162                self.check_block(body, &mut try_scope);
1163                let mut catch_scope = scope.child();
1164                if let Some(var) = error_var {
1165                    catch_scope.define_var(var, error_type.clone());
1166                }
1167                self.check_block(catch_body, &mut catch_scope);
1168                if let Some(fb) = finally_body {
1169                    let mut finally_scope = scope.child();
1170                    self.check_block(fb, &mut finally_scope);
1171                }
1172            }
1173
1174            Node::TryExpr { body } => {
1175                let mut try_scope = scope.child();
1176                self.check_block(body, &mut try_scope);
1177            }
1178
1179            Node::TryStar { operand } => {
1180                if self.fn_depth == 0 {
1181                    self.error_at(
1182                        "try* requires an enclosing function (fn, tool, or pipeline) so the rethrow has a target".to_string(),
1183                        span,
1184                    );
1185                }
1186                self.check_node(operand, scope);
1187            }
1188
1189            Node::ReturnStmt {
1190                value: Some(val), ..
1191            } => {
1192                self.check_node(val, scope);
1193            }
1194
1195            Node::Assignment {
1196                target, value, op, ..
1197            } => {
1198                self.check_node(value, scope);
1199                if let Node::Identifier(name) = &target.node {
1200                    // Compile-time immutability check
1201                    if scope.get_var(name).is_some() && !scope.is_mutable(name) {
1202                        self.warning_at(
1203                            format!(
1204                                "Cannot assign to '{}': variable is immutable (declared with 'let')",
1205                                name
1206                            ),
1207                            span,
1208                        );
1209                    }
1210
1211                    if let Some(Some(var_type)) = scope.get_var(name) {
1212                        let value_type = self.infer_type(value, scope);
1213                        let assigned = if let Some(op) = op {
1214                            let var_inferred = scope.get_var(name).cloned().flatten();
1215                            infer_binary_op_type(op, &var_inferred, &value_type)
1216                        } else {
1217                            value_type
1218                        };
1219                        if let Some(actual) = &assigned {
1220                            // Check against the original (pre-narrowing) type if narrowed
1221                            let check_type = scope
1222                                .narrowed_vars
1223                                .get(name)
1224                                .and_then(|t| t.as_ref())
1225                                .unwrap_or(var_type);
1226                            if !self.types_compatible(check_type, actual, scope) {
1227                                self.error_at(
1228                                    format!(
1229                                        "can't assign {} to '{}' (declared as {})",
1230                                        format_type(actual),
1231                                        name,
1232                                        format_type(check_type)
1233                                    ),
1234                                    span,
1235                                );
1236                            }
1237                        }
1238                    }
1239
1240                    // Invalidate narrowing on reassignment: restore original type
1241                    if let Some(original) = scope.narrowed_vars.remove(name) {
1242                        scope.define_var(name, original);
1243                    }
1244                    scope.define_schema_binding(name, None);
1245                    scope.clear_unknown_ruled_out(name);
1246                }
1247            }
1248
1249            Node::TypeDecl {
1250                name,
1251                type_params,
1252                type_expr,
1253            } => {
1254                scope.type_aliases.insert(name.clone(), type_expr.clone());
1255                self.check_type_alias_decl_variance(type_params, type_expr, name, span);
1256            }
1257
1258            Node::EnumDecl {
1259                name,
1260                type_params,
1261                variants,
1262                ..
1263            } => {
1264                scope.enums.insert(
1265                    name.clone(),
1266                    EnumDeclInfo {
1267                        type_params: type_params.clone(),
1268                        variants: variants.clone(),
1269                    },
1270                );
1271                self.check_enum_decl_variance(type_params, variants, name, span);
1272            }
1273
1274            Node::StructDecl {
1275                name,
1276                type_params,
1277                fields,
1278                ..
1279            } => {
1280                scope.structs.insert(
1281                    name.clone(),
1282                    StructDeclInfo {
1283                        type_params: type_params.clone(),
1284                        fields: fields.clone(),
1285                    },
1286                );
1287                self.check_struct_decl_variance(type_params, fields, name, span);
1288            }
1289
1290            Node::InterfaceDecl {
1291                name,
1292                type_params,
1293                associated_types,
1294                methods,
1295            } => {
1296                scope.interfaces.insert(
1297                    name.clone(),
1298                    InterfaceDeclInfo {
1299                        type_params: type_params.clone(),
1300                        associated_types: associated_types.clone(),
1301                        methods: methods.clone(),
1302                    },
1303                );
1304                self.check_interface_decl_variance(type_params, methods, name, span);
1305            }
1306
1307            Node::ImplBlock {
1308                type_name, methods, ..
1309            } => {
1310                // Register impl methods for interface satisfaction checking
1311                let sigs: Vec<ImplMethodSig> = methods
1312                    .iter()
1313                    .filter_map(|m| {
1314                        if let Node::FnDecl {
1315                            name,
1316                            params,
1317                            return_type,
1318                            ..
1319                        } = &m.node
1320                        {
1321                            let non_self: Vec<_> =
1322                                params.iter().filter(|p| p.name != "self").collect();
1323                            let param_count = non_self.len();
1324                            let param_types: Vec<Option<TypeExpr>> =
1325                                non_self.iter().map(|p| p.type_expr.clone()).collect();
1326                            Some(ImplMethodSig {
1327                                name: name.clone(),
1328                                param_count,
1329                                param_types,
1330                                return_type: return_type.clone(),
1331                            })
1332                        } else {
1333                            None
1334                        }
1335                    })
1336                    .collect();
1337                scope.impl_methods.insert(type_name.clone(), sigs);
1338                for method_sn in methods {
1339                    self.check_node(method_sn, scope);
1340                }
1341            }
1342
1343            Node::TryOperator { operand } => {
1344                self.check_node(operand, scope);
1345            }
1346
1347            Node::MatchExpr { value, arms } => {
1348                self.check_node(value, scope);
1349                let value_type = self.infer_type(value, scope);
1350                for arm in arms {
1351                    self.check_node(&arm.pattern, scope);
1352                    // Check for incompatible literal pattern types
1353                    if let Some(ref vt) = value_type {
1354                        let value_type_name = format_type(vt);
1355                        let mismatch = match &arm.pattern.node {
1356                            Node::StringLiteral(_) => {
1357                                !self.types_compatible(vt, &TypeExpr::Named("string".into()), scope)
1358                            }
1359                            Node::IntLiteral(_) => {
1360                                !self.types_compatible(vt, &TypeExpr::Named("int".into()), scope)
1361                                    && !self.types_compatible(
1362                                        vt,
1363                                        &TypeExpr::Named("float".into()),
1364                                        scope,
1365                                    )
1366                            }
1367                            Node::FloatLiteral(_) => {
1368                                !self.types_compatible(vt, &TypeExpr::Named("float".into()), scope)
1369                                    && !self.types_compatible(
1370                                        vt,
1371                                        &TypeExpr::Named("int".into()),
1372                                        scope,
1373                                    )
1374                            }
1375                            Node::BoolLiteral(_) => {
1376                                !self.types_compatible(vt, &TypeExpr::Named("bool".into()), scope)
1377                            }
1378                            _ => false,
1379                        };
1380                        if mismatch {
1381                            let pattern_type = match &arm.pattern.node {
1382                                Node::StringLiteral(_) => "string",
1383                                Node::IntLiteral(_) => "int",
1384                                Node::FloatLiteral(_) => "float",
1385                                Node::BoolLiteral(_) => "bool",
1386                                _ => unreachable!(),
1387                            };
1388                            self.warning_at(
1389                                format!(
1390                                    "Match pattern type mismatch: matching {} against {} literal",
1391                                    value_type_name, pattern_type
1392                                ),
1393                                arm.pattern.span,
1394                            );
1395                        }
1396                    }
1397                    let mut arm_scope = scope.child();
1398                    // Narrow the matched value's type in each arm
1399                    if let Node::Identifier(var_name) = &value.node {
1400                        if let Some(Some(TypeExpr::Union(members))) = scope.get_var(var_name) {
1401                            let narrowed = match &arm.pattern.node {
1402                                Node::NilLiteral => narrow_to_single(members, "nil"),
1403                                Node::StringLiteral(_) => narrow_to_single(members, "string"),
1404                                Node::IntLiteral(_) => narrow_to_single(members, "int"),
1405                                Node::FloatLiteral(_) => narrow_to_single(members, "float"),
1406                                Node::BoolLiteral(_) => narrow_to_single(members, "bool"),
1407                                _ => None,
1408                            };
1409                            if let Some(narrowed_type) = narrowed {
1410                                arm_scope.define_var(var_name, Some(narrowed_type));
1411                            }
1412                        }
1413                    }
1414                    if let Some(ref guard) = arm.guard {
1415                        self.check_node(guard, &mut arm_scope);
1416                    }
1417                    self.check_block(&arm.body, &mut arm_scope);
1418                }
1419                self.check_match_exhaustiveness(value, arms, scope, span);
1420            }
1421
1422            // Recurse into nested expressions + validate binary op types
1423            Node::BinaryOp { op, left, right } => {
1424                self.check_node(left, scope);
1425                self.check_node(right, scope);
1426                // Validate operator/type compatibility
1427                let lt = self.infer_type(left, scope);
1428                let rt = self.infer_type(right, scope);
1429                if let (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) = (&lt, &rt) {
1430                    match op.as_str() {
1431                        "-" | "/" | "%" | "**" => {
1432                            let numeric = ["int", "float"];
1433                            if !numeric.contains(&l.as_str()) || !numeric.contains(&r.as_str()) {
1434                                self.error_at(
1435                                    format!(
1436                                        "can't use '{}' on {} and {} (needs numeric operands)",
1437                                        op, l, r
1438                                    ),
1439                                    span,
1440                                );
1441                            }
1442                        }
1443                        "*" => {
1444                            let numeric = ["int", "float"];
1445                            let is_numeric =
1446                                numeric.contains(&l.as_str()) && numeric.contains(&r.as_str());
1447                            let is_string_repeat =
1448                                (l == "string" && r == "int") || (l == "int" && r == "string");
1449                            if !is_numeric && !is_string_repeat {
1450                                self.error_at(
1451                                    format!("can't multiply {} and {} (try string * int)", l, r),
1452                                    span,
1453                                );
1454                            }
1455                        }
1456                        "+" => {
1457                            let valid = matches!(
1458                                (l.as_str(), r.as_str()),
1459                                ("int" | "float", "int" | "float")
1460                                    | ("string", "string")
1461                                    | ("list", "list")
1462                                    | ("dict", "dict")
1463                            );
1464                            if !valid {
1465                                let msg = format!("can't add {} and {}", l, r);
1466                                // Offer interpolation fix when one side is string
1467                                let fix = if l == "string" || r == "string" {
1468                                    self.build_interpolation_fix(left, right, l == "string", span)
1469                                } else {
1470                                    None
1471                                };
1472                                if let Some(fix) = fix {
1473                                    self.error_at_with_fix(msg, span, fix);
1474                                } else {
1475                                    self.error_at(msg, span);
1476                                }
1477                            }
1478                        }
1479                        "<" | ">" | "<=" | ">=" => {
1480                            let comparable = ["int", "float", "string"];
1481                            if !comparable.contains(&l.as_str())
1482                                || !comparable.contains(&r.as_str())
1483                            {
1484                                self.warning_at(
1485                                    format!(
1486                                        "Comparison '{}' may not be meaningful for types {} and {}",
1487                                        op, l, r
1488                                    ),
1489                                    span,
1490                                );
1491                            } else if (l == "string") != (r == "string") {
1492                                self.warning_at(
1493                                    format!(
1494                                        "Comparing {} with {} using '{}' may give unexpected results",
1495                                        l, r, op
1496                                    ),
1497                                    span,
1498                                );
1499                            }
1500                        }
1501                        _ => {}
1502                    }
1503                }
1504            }
1505            Node::UnaryOp { operand, .. } => {
1506                self.check_node(operand, scope);
1507            }
1508            Node::MethodCall {
1509                object,
1510                method,
1511                args,
1512                ..
1513            }
1514            | Node::OptionalMethodCall {
1515                object,
1516                method,
1517                args,
1518                ..
1519            } => {
1520                self.check_node(object, scope);
1521                for arg in args {
1522                    self.check_node(arg, scope);
1523                }
1524                // Definition-site generic checking: if the object's type is a
1525                // constrained generic param (where T: Interface), verify the
1526                // method exists in the bound interface.
1527                if let Some(TypeExpr::Named(type_name)) = self.infer_type(object, scope) {
1528                    if scope.is_generic_type_param(&type_name) {
1529                        if let Some(iface_name) = scope.get_where_constraint(&type_name) {
1530                            if let Some(iface_methods) = scope.get_interface(iface_name) {
1531                                let has_method =
1532                                    iface_methods.methods.iter().any(|m| m.name == *method);
1533                                if !has_method {
1534                                    self.warning_at(
1535                                        format!(
1536                                            "Method '{}' not found in interface '{}' (constraint on '{}')",
1537                                            method, iface_name, type_name
1538                                        ),
1539                                        span,
1540                                    );
1541                                }
1542                            }
1543                        }
1544                    }
1545                }
1546            }
1547            Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
1548                if self.strict_types {
1549                    // Direct property access on boundary function result
1550                    if let Node::FunctionCall { name, args } = &object.node {
1551                        if builtin_signatures::is_untyped_boundary_source(name) {
1552                            let has_schema = (name == "llm_call" || name == "llm_completion")
1553                                && Self::llm_call_has_typed_schema_option(args, scope);
1554                            if !has_schema {
1555                                self.warning_at_with_help(
1556                                    format!(
1557                                        "Direct property access on unvalidated `{}()` result",
1558                                        name
1559                                    ),
1560                                    span,
1561                                    "assign to a variable and validate with schema_expect() or a type annotation first".to_string(),
1562                                );
1563                            }
1564                        }
1565                    }
1566                    // Property access on known untyped variable
1567                    if let Node::Identifier(name) = &object.node {
1568                        if let Some(source) = scope.is_untyped_source(name) {
1569                            self.warning_at_with_help(
1570                                format!(
1571                                    "Accessing property on unvalidated value '{}' from `{}`",
1572                                    name, source
1573                                ),
1574                                span,
1575                                "validate with schema_expect(), schema_is() in an if-condition, or add a shape type annotation".to_string(),
1576                            );
1577                        }
1578                    }
1579                }
1580                self.check_node(object, scope);
1581            }
1582            Node::SubscriptAccess { object, index } => {
1583                if self.strict_types {
1584                    if let Node::FunctionCall { name, args } = &object.node {
1585                        if builtin_signatures::is_untyped_boundary_source(name) {
1586                            let has_schema = (name == "llm_call" || name == "llm_completion")
1587                                && Self::llm_call_has_typed_schema_option(args, scope);
1588                            if !has_schema {
1589                                self.warning_at_with_help(
1590                                    format!(
1591                                        "Direct subscript access on unvalidated `{}()` result",
1592                                        name
1593                                    ),
1594                                    span,
1595                                    "assign to a variable and validate with schema_expect() or a type annotation first".to_string(),
1596                                );
1597                            }
1598                        }
1599                    }
1600                    if let Node::Identifier(name) = &object.node {
1601                        if let Some(source) = scope.is_untyped_source(name) {
1602                            self.warning_at_with_help(
1603                                format!(
1604                                    "Subscript access on unvalidated value '{}' from `{}`",
1605                                    name, source
1606                                ),
1607                                span,
1608                                "validate with schema_expect(), schema_is() in an if-condition, or add a shape type annotation".to_string(),
1609                            );
1610                        }
1611                    }
1612                }
1613                self.check_node(object, scope);
1614                self.check_node(index, scope);
1615            }
1616            Node::SliceAccess { object, start, end } => {
1617                self.check_node(object, scope);
1618                if let Some(s) = start {
1619                    self.check_node(s, scope);
1620                }
1621                if let Some(e) = end {
1622                    self.check_node(e, scope);
1623                }
1624            }
1625
1626            Node::Ternary {
1627                condition,
1628                true_expr,
1629                false_expr,
1630            } => {
1631                self.check_node(condition, scope);
1632                let refs = Self::extract_refinements(condition, scope);
1633
1634                let mut true_scope = scope.child();
1635                refs.apply_truthy(&mut true_scope);
1636                self.check_node(true_expr, &mut true_scope);
1637
1638                let mut false_scope = scope.child();
1639                refs.apply_falsy(&mut false_scope);
1640                self.check_node(false_expr, &mut false_scope);
1641            }
1642
1643            Node::ThrowStmt { value } => {
1644                self.check_node(value, scope);
1645                // A `throw` in the tail of a `type_of`-narrowing chain claims
1646                // exhaustiveness on the enclosing `unknown`-typed variable.
1647                // Warn if the claim isn't actually complete.
1648                self.check_unknown_exhaustiveness(scope, snode.span, "throw");
1649            }
1650
1651            Node::GuardStmt {
1652                condition,
1653                else_body,
1654            } => {
1655                self.check_node(condition, scope);
1656                let refs = Self::extract_refinements(condition, scope);
1657
1658                let mut else_scope = scope.child();
1659                refs.apply_falsy(&mut else_scope);
1660                self.check_block(else_body, &mut else_scope);
1661
1662                // After guard, condition is true — apply truthy refinements
1663                // to the OUTER scope (guard's else-body must exit)
1664                refs.apply_truthy(scope);
1665            }
1666
1667            Node::SpawnExpr { body } => {
1668                let mut spawn_scope = scope.child();
1669                self.check_block(body, &mut spawn_scope);
1670            }
1671
1672            Node::Parallel {
1673                mode,
1674                expr,
1675                variable,
1676                body,
1677                options,
1678            } => {
1679                self.check_node(expr, scope);
1680                for (key, value) in options {
1681                    // `max_concurrent` must resolve to `int`; other keys
1682                    // are rejected by the parser, so no need to match
1683                    // here. Still type-check the expression so bad
1684                    // references surface a diagnostic.
1685                    self.check_node(value, scope);
1686                    if key == "max_concurrent" {
1687                        if let Some(ty) = self.infer_type(value, scope) {
1688                            if !matches!(ty, TypeExpr::Named(ref n) if n == "int") {
1689                                self.error_at(
1690                                    format!(
1691                                        "`max_concurrent` on `parallel` must be int, got {ty:?}"
1692                                    ),
1693                                    value.span,
1694                                );
1695                            }
1696                        }
1697                    }
1698                }
1699                let mut par_scope = scope.child();
1700                if let Some(var) = variable {
1701                    let var_type = match mode {
1702                        ParallelMode::Count => Some(TypeExpr::Named("int".into())),
1703                        ParallelMode::Each | ParallelMode::Settle => {
1704                            match self.infer_type(expr, scope) {
1705                                Some(TypeExpr::List(inner)) => Some(*inner),
1706                                _ => None,
1707                            }
1708                        }
1709                    };
1710                    par_scope.define_var(var, var_type);
1711                }
1712                self.check_block(body, &mut par_scope);
1713            }
1714
1715            Node::SelectExpr {
1716                cases,
1717                timeout,
1718                default_body,
1719            } => {
1720                for case in cases {
1721                    self.check_node(&case.channel, scope);
1722                    let mut case_scope = scope.child();
1723                    case_scope.define_var(&case.variable, None);
1724                    self.check_block(&case.body, &mut case_scope);
1725                }
1726                if let Some((dur, body)) = timeout {
1727                    self.check_node(dur, scope);
1728                    let mut timeout_scope = scope.child();
1729                    self.check_block(body, &mut timeout_scope);
1730                }
1731                if let Some(body) = default_body {
1732                    let mut default_scope = scope.child();
1733                    self.check_block(body, &mut default_scope);
1734                }
1735            }
1736
1737            Node::DeadlineBlock { duration, body } => {
1738                self.check_node(duration, scope);
1739                let mut block_scope = scope.child();
1740                self.check_block(body, &mut block_scope);
1741            }
1742
1743            Node::MutexBlock { body } | Node::DeferStmt { body } => {
1744                let mut block_scope = scope.child();
1745                self.check_block(body, &mut block_scope);
1746            }
1747
1748            Node::Retry { count, body } => {
1749                self.check_node(count, scope);
1750                let mut retry_scope = scope.child();
1751                self.check_block(body, &mut retry_scope);
1752            }
1753
1754            Node::Closure { params, body, .. } => {
1755                let mut closure_scope = scope.child();
1756                for p in params {
1757                    closure_scope.define_var(&p.name, p.type_expr.clone());
1758                }
1759                self.fn_depth += 1;
1760                self.check_block(body, &mut closure_scope);
1761                self.fn_depth -= 1;
1762            }
1763
1764            Node::ListLiteral(elements) => {
1765                for elem in elements {
1766                    self.check_node(elem, scope);
1767                }
1768            }
1769
1770            Node::DictLiteral(entries) => {
1771                for entry in entries {
1772                    self.check_node(&entry.key, scope);
1773                    self.check_node(&entry.value, scope);
1774                }
1775            }
1776
1777            Node::RangeExpr { start, end, .. } => {
1778                self.check_node(start, scope);
1779                self.check_node(end, scope);
1780            }
1781
1782            Node::Spread(inner) => {
1783                self.check_node(inner, scope);
1784            }
1785
1786            Node::Block(stmts) => {
1787                let mut block_scope = scope.child();
1788                self.check_block(stmts, &mut block_scope);
1789            }
1790
1791            Node::YieldExpr { value } => {
1792                if let Some(v) = value {
1793                    self.check_node(v, scope);
1794                }
1795            }
1796
1797            Node::StructConstruct {
1798                struct_name,
1799                fields,
1800            } => {
1801                for entry in fields {
1802                    self.check_node(&entry.key, scope);
1803                    self.check_node(&entry.value, scope);
1804                }
1805                if let Some(struct_info) = scope.get_struct(struct_name).cloned() {
1806                    let type_bindings = self.infer_struct_bindings(&struct_info, fields, scope);
1807                    // Warn on unknown fields
1808                    for entry in fields {
1809                        if let Node::StringLiteral(key) | Node::Identifier(key) = &entry.key.node {
1810                            if !struct_info.fields.iter().any(|field| field.name == *key) {
1811                                self.warning_at(
1812                                    format!("Unknown field '{}' in struct '{}'", key, struct_name),
1813                                    entry.key.span,
1814                                );
1815                            }
1816                        }
1817                    }
1818                    // Warn on missing required fields
1819                    let provided: Vec<String> = fields
1820                        .iter()
1821                        .filter_map(|e| match &e.key.node {
1822                            Node::StringLiteral(k) | Node::Identifier(k) => Some(k.clone()),
1823                            _ => None,
1824                        })
1825                        .collect();
1826                    for field in &struct_info.fields {
1827                        if !field.optional && !provided.contains(&field.name) {
1828                            self.warning_at(
1829                                format!(
1830                                    "Missing field '{}' in struct '{}' construction",
1831                                    field.name, struct_name
1832                                ),
1833                                span,
1834                            );
1835                        }
1836                    }
1837                    for field in &struct_info.fields {
1838                        let Some(expected_type) = &field.type_expr else {
1839                            continue;
1840                        };
1841                        let Some(entry) = fields.iter().find(|entry| {
1842                            matches!(&entry.key.node, Node::StringLiteral(key) | Node::Identifier(key) if key == &field.name)
1843                        }) else {
1844                            continue;
1845                        };
1846                        let Some(actual_type) = self.infer_type(&entry.value, scope) else {
1847                            continue;
1848                        };
1849                        let expected = Self::apply_type_bindings(expected_type, &type_bindings);
1850                        if !self.types_compatible(&expected, &actual_type, scope) {
1851                            self.error_at(
1852                                format!(
1853                                    "Field '{}' in struct '{}' expects {}, got {}",
1854                                    field.name,
1855                                    struct_name,
1856                                    format_type(&expected),
1857                                    format_type(&actual_type)
1858                                ),
1859                                entry.value.span,
1860                            );
1861                        }
1862                    }
1863                }
1864            }
1865
1866            Node::EnumConstruct {
1867                enum_name,
1868                variant,
1869                args,
1870            } => {
1871                for arg in args {
1872                    self.check_node(arg, scope);
1873                }
1874                if let Some(enum_info) = scope.get_enum(enum_name).cloned() {
1875                    let Some(enum_variant) = enum_info
1876                        .variants
1877                        .iter()
1878                        .find(|enum_variant| enum_variant.name == *variant)
1879                    else {
1880                        self.warning_at(
1881                            format!("Unknown variant '{}' in enum '{}'", variant, enum_name),
1882                            span,
1883                        );
1884                        return;
1885                    };
1886                    if args.len() != enum_variant.fields.len() {
1887                        self.warning_at(
1888                            format!(
1889                                "{}.{} expects {} argument(s), got {}",
1890                                enum_name,
1891                                variant,
1892                                enum_variant.fields.len(),
1893                                args.len()
1894                            ),
1895                            span,
1896                        );
1897                    }
1898                    let type_param_set: std::collections::BTreeSet<String> = enum_info
1899                        .type_params
1900                        .iter()
1901                        .map(|tp| tp.name.clone())
1902                        .collect();
1903                    let mut type_bindings = BTreeMap::new();
1904                    for (field, arg) in enum_variant.fields.iter().zip(args.iter()) {
1905                        let Some(expected_type) = &field.type_expr else {
1906                            continue;
1907                        };
1908                        let Some(actual_type) = self.infer_type(arg, scope) else {
1909                            continue;
1910                        };
1911                        if let Err(message) = Self::extract_type_bindings(
1912                            expected_type,
1913                            &actual_type,
1914                            &type_param_set,
1915                            &mut type_bindings,
1916                        ) {
1917                            self.error_at(message, arg.span);
1918                        }
1919                    }
1920                    for (field, arg) in enum_variant.fields.iter().zip(args.iter()) {
1921                        let Some(expected_type) = &field.type_expr else {
1922                            continue;
1923                        };
1924                        let Some(actual_type) = self.infer_type(arg, scope) else {
1925                            continue;
1926                        };
1927                        let expected = Self::apply_type_bindings(expected_type, &type_bindings);
1928                        if !self.types_compatible(&expected, &actual_type, scope) {
1929                            self.error_at(
1930                                format!(
1931                                    "{}.{} expects {}: {}, got {}",
1932                                    enum_name,
1933                                    variant,
1934                                    field.name,
1935                                    format_type(&expected),
1936                                    format_type(&actual_type)
1937                                ),
1938                                arg.span,
1939                            );
1940                        }
1941                    }
1942                }
1943            }
1944
1945            Node::InterpolatedString(_) => {}
1946
1947            Node::StringLiteral(_)
1948            | Node::RawStringLiteral(_)
1949            | Node::IntLiteral(_)
1950            | Node::FloatLiteral(_)
1951            | Node::BoolLiteral(_)
1952            | Node::NilLiteral
1953            | Node::Identifier(_)
1954            | Node::DurationLiteral(_)
1955            | Node::BreakStmt
1956            | Node::ContinueStmt
1957            | Node::ReturnStmt { value: None }
1958            | Node::ImportDecl { .. }
1959            | Node::SelectiveImport { .. } => {}
1960
1961            // Declarations already handled above; catch remaining variants
1962            // that have no meaningful type-check behavior.
1963            Node::Pipeline { body, .. } | Node::OverrideDecl { body, .. } => {
1964                let mut decl_scope = scope.child();
1965                self.fn_depth += 1;
1966                self.check_block(body, &mut decl_scope);
1967                self.fn_depth -= 1;
1968            }
1969        }
1970    }
1971
1972    fn check_fn_body(
1973        &mut self,
1974        type_params: &[TypeParam],
1975        params: &[TypedParam],
1976        return_type: &Option<TypeExpr>,
1977        body: &[SNode],
1978        where_clauses: &[WhereClause],
1979    ) {
1980        self.fn_depth += 1;
1981        self.check_fn_body_inner(type_params, params, return_type, body, where_clauses);
1982        self.fn_depth -= 1;
1983    }
1984
1985    fn check_fn_body_inner(
1986        &mut self,
1987        type_params: &[TypeParam],
1988        params: &[TypedParam],
1989        return_type: &Option<TypeExpr>,
1990        body: &[SNode],
1991        where_clauses: &[WhereClause],
1992    ) {
1993        let mut fn_scope = self.scope.child();
1994        // Register generic type parameters so they are treated as compatible
1995        // with any concrete type during type checking.
1996        for tp in type_params {
1997            fn_scope.generic_type_params.insert(tp.name.clone());
1998        }
1999        // Store where-clause constraints for definition-site checking
2000        for wc in where_clauses {
2001            fn_scope
2002                .where_constraints
2003                .insert(wc.type_name.clone(), wc.bound.clone());
2004        }
2005        for param in params {
2006            fn_scope.define_var(&param.name, param.type_expr.clone());
2007            if let Some(default) = &param.default_value {
2008                self.check_node(default, &mut fn_scope);
2009            }
2010        }
2011        // Snapshot scope before main pass (which may mutate it with narrowing)
2012        // so that return-type checking starts from the original parameter types.
2013        let ret_scope_base = if return_type.is_some() {
2014            Some(fn_scope.child())
2015        } else {
2016            None
2017        };
2018
2019        self.check_block(body, &mut fn_scope);
2020
2021        // Check return statements against declared return type
2022        if let Some(ret_type) = return_type {
2023            let mut ret_scope = ret_scope_base.unwrap();
2024            for stmt in body {
2025                self.check_return_type(stmt, ret_type, &mut ret_scope);
2026            }
2027        }
2028    }
2029
2030    fn check_return_type(&mut self, snode: &SNode, expected: &TypeExpr, scope: &mut TypeScope) {
2031        let span = snode.span;
2032        match &snode.node {
2033            Node::ReturnStmt { value: Some(val) } => {
2034                let inferred = self.infer_type(val, scope);
2035                if let Some(actual) = &inferred {
2036                    if !self.types_compatible(expected, actual, scope) {
2037                        self.error_at(
2038                            format!(
2039                                "return type doesn't match: expected {}, got {}",
2040                                format_type(expected),
2041                                format_type(actual)
2042                            ),
2043                            span,
2044                        );
2045                    }
2046                }
2047            }
2048            Node::IfElse {
2049                condition,
2050                then_body,
2051                else_body,
2052            } => {
2053                let refs = Self::extract_refinements(condition, scope);
2054                let mut then_scope = scope.child();
2055                refs.apply_truthy(&mut then_scope);
2056                for stmt in then_body {
2057                    self.check_return_type(stmt, expected, &mut then_scope);
2058                }
2059                if let Some(else_body) = else_body {
2060                    let mut else_scope = scope.child();
2061                    refs.apply_falsy(&mut else_scope);
2062                    for stmt in else_body {
2063                        self.check_return_type(stmt, expected, &mut else_scope);
2064                    }
2065                    // Post-branch narrowing for return type checking
2066                    if Self::block_definitely_exits(then_body)
2067                        && !Self::block_definitely_exits(else_body)
2068                    {
2069                        refs.apply_falsy(scope);
2070                    } else if Self::block_definitely_exits(else_body)
2071                        && !Self::block_definitely_exits(then_body)
2072                    {
2073                        refs.apply_truthy(scope);
2074                    }
2075                } else {
2076                    // No else: if then-body always exits, apply falsy after
2077                    if Self::block_definitely_exits(then_body) {
2078                        refs.apply_falsy(scope);
2079                    }
2080                }
2081            }
2082            _ => {}
2083        }
2084    }
2085
2086    /// Check if a match expression on an enum's `.variant` property covers all variants.
2087    /// Extract narrowing info from nil-check conditions like `x != nil`.
2088    /// Returns (var_name, narrowed_type) where narrowed_type removes nil from a union.
2089    /// Check if a type satisfies an interface (Go-style implicit satisfaction).
2090    /// A type satisfies an interface if its impl block has all the required methods.
2091    fn satisfies_interface(
2092        &self,
2093        type_name: &str,
2094        interface_name: &str,
2095        interface_bindings: &BTreeMap<String, TypeExpr>,
2096        scope: &TypeScope,
2097    ) -> bool {
2098        self.interface_mismatch_reason(type_name, interface_name, interface_bindings, scope)
2099            .is_none()
2100    }
2101
2102    /// Return a detailed reason why a type does not satisfy an interface, or None
2103    /// if it does satisfy it.  Used for producing actionable warning messages.
2104    fn interface_mismatch_reason(
2105        &self,
2106        type_name: &str,
2107        interface_name: &str,
2108        interface_bindings: &BTreeMap<String, TypeExpr>,
2109        scope: &TypeScope,
2110    ) -> Option<String> {
2111        let interface_info = match scope.get_interface(interface_name) {
2112            Some(info) => info,
2113            None => return Some(format!("interface '{}' not found", interface_name)),
2114        };
2115        let impl_methods = match scope.get_impl_methods(type_name) {
2116            Some(methods) => methods,
2117            None => {
2118                if interface_info.methods.is_empty() {
2119                    return None;
2120                }
2121                let names: Vec<_> = interface_info
2122                    .methods
2123                    .iter()
2124                    .map(|m| m.name.as_str())
2125                    .collect();
2126                return Some(format!("missing method(s): {}", names.join(", ")));
2127            }
2128        };
2129        let mut bindings = interface_bindings.clone();
2130        let associated_type_names: std::collections::BTreeSet<String> = interface_info
2131            .associated_types
2132            .iter()
2133            .map(|(name, _)| name.clone())
2134            .collect();
2135        for iface_method in &interface_info.methods {
2136            let iface_params: Vec<_> = iface_method
2137                .params
2138                .iter()
2139                .filter(|p| p.name != "self")
2140                .collect();
2141            let iface_param_count = iface_params.len();
2142            let matching_impl = impl_methods.iter().find(|im| im.name == iface_method.name);
2143            let impl_method = match matching_impl {
2144                Some(m) => m,
2145                None => {
2146                    return Some(format!("missing method '{}'", iface_method.name));
2147                }
2148            };
2149            if impl_method.param_count != iface_param_count {
2150                return Some(format!(
2151                    "method '{}' has {} parameter(s), expected {}",
2152                    iface_method.name, impl_method.param_count, iface_param_count
2153                ));
2154            }
2155            // Check parameter types where both sides specify them
2156            for (i, iface_param) in iface_params.iter().enumerate() {
2157                if let (Some(expected), Some(actual)) = (
2158                    &iface_param.type_expr,
2159                    impl_method.param_types.get(i).and_then(|t| t.as_ref()),
2160                ) {
2161                    if let Err(message) = Self::extract_type_bindings(
2162                        expected,
2163                        actual,
2164                        &associated_type_names,
2165                        &mut bindings,
2166                    ) {
2167                        return Some(message);
2168                    }
2169                    let expected = Self::apply_type_bindings(expected, &bindings);
2170                    if !self.types_compatible(&expected, actual, scope) {
2171                        return Some(format!(
2172                            "method '{}' parameter {} has type '{}', expected '{}'",
2173                            iface_method.name,
2174                            i + 1,
2175                            format_type(actual),
2176                            format_type(&expected),
2177                        ));
2178                    }
2179                }
2180            }
2181            // Check return type where both sides specify it
2182            if let (Some(expected_ret), Some(actual_ret)) =
2183                (&iface_method.return_type, &impl_method.return_type)
2184            {
2185                if let Err(message) = Self::extract_type_bindings(
2186                    expected_ret,
2187                    actual_ret,
2188                    &associated_type_names,
2189                    &mut bindings,
2190                ) {
2191                    return Some(message);
2192                }
2193                let expected_ret = Self::apply_type_bindings(expected_ret, &bindings);
2194                if !self.types_compatible(&expected_ret, actual_ret, scope) {
2195                    return Some(format!(
2196                        "method '{}' returns '{}', expected '{}'",
2197                        iface_method.name,
2198                        format_type(actual_ret),
2199                        format_type(&expected_ret),
2200                    ));
2201                }
2202            }
2203        }
2204        for (assoc_name, default_type) in &interface_info.associated_types {
2205            if let (Some(default_type), Some(actual)) = (default_type, bindings.get(assoc_name)) {
2206                let expected = Self::apply_type_bindings(default_type, &bindings);
2207                if !self.types_compatible(&expected, actual, scope) {
2208                    return Some(format!(
2209                        "associated type '{}' resolves to '{}', expected '{}'",
2210                        assoc_name,
2211                        format_type(actual),
2212                        format_type(&expected),
2213                    ));
2214                }
2215            }
2216        }
2217        None
2218    }
2219
2220    fn bind_type_param(
2221        param_name: &str,
2222        concrete: &TypeExpr,
2223        bindings: &mut BTreeMap<String, TypeExpr>,
2224    ) -> Result<(), String> {
2225        if let Some(existing) = bindings.get(param_name) {
2226            if existing != concrete {
2227                return Err(format!(
2228                    "type parameter '{}' was inferred as both {} and {}",
2229                    param_name,
2230                    format_type(existing),
2231                    format_type(concrete)
2232                ));
2233            }
2234            return Ok(());
2235        }
2236        bindings.insert(param_name.to_string(), concrete.clone());
2237        Ok(())
2238    }
2239
2240    /// Recursively extract type parameter bindings from matching param/arg types.
2241    /// E.g., param_type=list<T> + arg_type=list<Dog> → binds T=Dog.
2242    fn extract_type_bindings(
2243        param_type: &TypeExpr,
2244        arg_type: &TypeExpr,
2245        type_params: &std::collections::BTreeSet<String>,
2246        bindings: &mut BTreeMap<String, TypeExpr>,
2247    ) -> Result<(), String> {
2248        match (param_type, arg_type) {
2249            (TypeExpr::Named(param_name), concrete) if type_params.contains(param_name) => {
2250                Self::bind_type_param(param_name, concrete, bindings)
2251            }
2252            (TypeExpr::List(p_inner), TypeExpr::List(a_inner)) => {
2253                Self::extract_type_bindings(p_inner, a_inner, type_params, bindings)
2254            }
2255            (TypeExpr::DictType(pk, pv), TypeExpr::DictType(ak, av)) => {
2256                Self::extract_type_bindings(pk, ak, type_params, bindings)?;
2257                Self::extract_type_bindings(pv, av, type_params, bindings)
2258            }
2259            (
2260                TypeExpr::Applied {
2261                    name: p_name,
2262                    args: p_args,
2263                },
2264                TypeExpr::Applied {
2265                    name: a_name,
2266                    args: a_args,
2267                },
2268            ) if p_name == a_name && p_args.len() == a_args.len() => {
2269                for (param, arg) in p_args.iter().zip(a_args.iter()) {
2270                    Self::extract_type_bindings(param, arg, type_params, bindings)?;
2271                }
2272                Ok(())
2273            }
2274            (TypeExpr::Shape(param_fields), TypeExpr::Shape(arg_fields)) => {
2275                for param_field in param_fields {
2276                    if let Some(arg_field) = arg_fields
2277                        .iter()
2278                        .find(|field| field.name == param_field.name)
2279                    {
2280                        Self::extract_type_bindings(
2281                            &param_field.type_expr,
2282                            &arg_field.type_expr,
2283                            type_params,
2284                            bindings,
2285                        )?;
2286                    }
2287                }
2288                Ok(())
2289            }
2290            (
2291                TypeExpr::FnType {
2292                    params: p_params,
2293                    return_type: p_ret,
2294                },
2295                TypeExpr::FnType {
2296                    params: a_params,
2297                    return_type: a_ret,
2298                },
2299            ) => {
2300                for (param, arg) in p_params.iter().zip(a_params.iter()) {
2301                    Self::extract_type_bindings(param, arg, type_params, bindings)?;
2302                }
2303                Self::extract_type_bindings(p_ret, a_ret, type_params, bindings)
2304            }
2305            _ => Ok(()),
2306        }
2307    }
2308
2309    /// Bind type parameters by walking a param [`TypeExpr`] against an
2310    /// argument AST node. Used by the generic-builtin dispatch path for
2311    /// `llm_call`, `schema_parse`, etc.
2312    ///
2313    /// Unlike [`extract_type_bindings`], which matches a param type against
2314    /// an inferred arg *type*, this walks the arg *node* so that
2315    /// `Schema<T>` in a param position can pull `T` from the structural
2316    /// value of the corresponding argument (e.g. a type alias identifier
2317    /// or an inline JSON-Schema dict literal). When the param is not a
2318    /// `Schema<_>` or shape marker, we fall back to standard type-based
2319    /// binding against the arg's inferred type.
2320    fn bind_from_arg_node(
2321        &self,
2322        param: &TypeExpr,
2323        arg: &SNode,
2324        type_params: &std::collections::BTreeSet<String>,
2325        bindings: &mut BTreeMap<String, TypeExpr>,
2326        scope: &TypeScope,
2327    ) -> Result<(), String> {
2328        match param {
2329            TypeExpr::Applied { name, args } if name == "Schema" && args.len() == 1 => {
2330                if let TypeExpr::Named(tp) = &args[0] {
2331                    if type_params.contains(tp) {
2332                        if let Some(resolved) = schema_type_expr_from_node(arg, scope) {
2333                            Self::bind_type_param(tp, &resolved, bindings)?;
2334                        }
2335                    }
2336                }
2337                Ok(())
2338            }
2339            TypeExpr::Shape(fields) => {
2340                if let Node::DictLiteral(entries) = &arg.node {
2341                    for field in fields {
2342                        let matching = entries.iter().find(|entry| match &entry.key.node {
2343                            Node::StringLiteral(key) | Node::Identifier(key) => key == &field.name,
2344                            _ => false,
2345                        });
2346                        if let Some(entry) = matching {
2347                            self.bind_from_arg_node(
2348                                &field.type_expr,
2349                                &entry.value,
2350                                type_params,
2351                                bindings,
2352                                scope,
2353                            )?;
2354                        }
2355                    }
2356                    return Ok(());
2357                }
2358                if let Some(arg_ty) = self.infer_type(arg, scope) {
2359                    Self::extract_type_bindings(param, &arg_ty, type_params, bindings)?;
2360                }
2361                Ok(())
2362            }
2363            _ => {
2364                if let Some(arg_ty) = self.infer_type(arg, scope) {
2365                    Self::extract_type_bindings(param, &arg_ty, type_params, bindings)?;
2366                }
2367                Ok(())
2368            }
2369        }
2370    }
2371
2372    fn apply_type_bindings(ty: &TypeExpr, bindings: &BTreeMap<String, TypeExpr>) -> TypeExpr {
2373        match ty {
2374            TypeExpr::Named(name) => bindings
2375                .get(name)
2376                .cloned()
2377                .unwrap_or_else(|| TypeExpr::Named(name.clone())),
2378            TypeExpr::Union(items) => TypeExpr::Union(
2379                items
2380                    .iter()
2381                    .map(|item| Self::apply_type_bindings(item, bindings))
2382                    .collect(),
2383            ),
2384            TypeExpr::Shape(fields) => TypeExpr::Shape(
2385                fields
2386                    .iter()
2387                    .map(|field| ShapeField {
2388                        name: field.name.clone(),
2389                        type_expr: Self::apply_type_bindings(&field.type_expr, bindings),
2390                        optional: field.optional,
2391                    })
2392                    .collect(),
2393            ),
2394            TypeExpr::List(inner) => {
2395                TypeExpr::List(Box::new(Self::apply_type_bindings(inner, bindings)))
2396            }
2397            TypeExpr::Iter(inner) => {
2398                TypeExpr::Iter(Box::new(Self::apply_type_bindings(inner, bindings)))
2399            }
2400            TypeExpr::DictType(key, value) => TypeExpr::DictType(
2401                Box::new(Self::apply_type_bindings(key, bindings)),
2402                Box::new(Self::apply_type_bindings(value, bindings)),
2403            ),
2404            TypeExpr::Applied { name, args } => TypeExpr::Applied {
2405                name: name.clone(),
2406                args: args
2407                    .iter()
2408                    .map(|arg| Self::apply_type_bindings(arg, bindings))
2409                    .collect(),
2410            },
2411            TypeExpr::FnType {
2412                params,
2413                return_type,
2414            } => TypeExpr::FnType {
2415                params: params
2416                    .iter()
2417                    .map(|param| Self::apply_type_bindings(param, bindings))
2418                    .collect(),
2419                return_type: Box::new(Self::apply_type_bindings(return_type, bindings)),
2420            },
2421            TypeExpr::Never => TypeExpr::Never,
2422            TypeExpr::LitString(s) => TypeExpr::LitString(s.clone()),
2423            TypeExpr::LitInt(v) => TypeExpr::LitInt(*v),
2424        }
2425    }
2426
2427    fn applied_type_or_name(name: &str, args: Vec<TypeExpr>) -> TypeExpr {
2428        if args.is_empty() {
2429            TypeExpr::Named(name.to_string())
2430        } else {
2431            TypeExpr::Applied {
2432                name: name.to_string(),
2433                args,
2434            }
2435        }
2436    }
2437
2438    fn infer_struct_bindings(
2439        &self,
2440        struct_info: &StructDeclInfo,
2441        fields: &[DictEntry],
2442        scope: &TypeScope,
2443    ) -> BTreeMap<String, TypeExpr> {
2444        let type_param_set: std::collections::BTreeSet<String> = struct_info
2445            .type_params
2446            .iter()
2447            .map(|tp| tp.name.clone())
2448            .collect();
2449        let mut bindings = BTreeMap::new();
2450        for field in &struct_info.fields {
2451            let Some(expected_type) = &field.type_expr else {
2452                continue;
2453            };
2454            let Some(entry) = fields.iter().find(|entry| {
2455                matches!(&entry.key.node, Node::StringLiteral(key) | Node::Identifier(key) if key == &field.name)
2456            }) else {
2457                continue;
2458            };
2459            let Some(actual_type) = self.infer_type(&entry.value, scope) else {
2460                continue;
2461            };
2462            let _ = Self::extract_type_bindings(
2463                expected_type,
2464                &actual_type,
2465                &type_param_set,
2466                &mut bindings,
2467            );
2468        }
2469        bindings
2470    }
2471
2472    fn infer_struct_type(
2473        &self,
2474        struct_name: &str,
2475        struct_info: &StructDeclInfo,
2476        fields: &[DictEntry],
2477        scope: &TypeScope,
2478    ) -> TypeExpr {
2479        let bindings = self.infer_struct_bindings(struct_info, fields, scope);
2480        let args = struct_info
2481            .type_params
2482            .iter()
2483            .map(|tp| {
2484                bindings
2485                    .get(&tp.name)
2486                    .cloned()
2487                    .unwrap_or_else(Self::wildcard_type)
2488            })
2489            .collect();
2490        Self::applied_type_or_name(struct_name, args)
2491    }
2492
2493    fn infer_enum_type(
2494        &self,
2495        enum_name: &str,
2496        enum_info: &EnumDeclInfo,
2497        variant_name: &str,
2498        args: &[SNode],
2499        scope: &TypeScope,
2500    ) -> TypeExpr {
2501        let type_param_set: std::collections::BTreeSet<String> = enum_info
2502            .type_params
2503            .iter()
2504            .map(|tp| tp.name.clone())
2505            .collect();
2506        let mut bindings = BTreeMap::new();
2507        if let Some(variant) = enum_info
2508            .variants
2509            .iter()
2510            .find(|variant| variant.name == variant_name)
2511        {
2512            for (field, arg) in variant.fields.iter().zip(args.iter()) {
2513                let Some(expected_type) = &field.type_expr else {
2514                    continue;
2515                };
2516                let Some(actual_type) = self.infer_type(arg, scope) else {
2517                    continue;
2518                };
2519                let _ = Self::extract_type_bindings(
2520                    expected_type,
2521                    &actual_type,
2522                    &type_param_set,
2523                    &mut bindings,
2524                );
2525            }
2526        }
2527        let args = enum_info
2528            .type_params
2529            .iter()
2530            .map(|tp| {
2531                bindings
2532                    .get(&tp.name)
2533                    .cloned()
2534                    .unwrap_or_else(Self::wildcard_type)
2535            })
2536            .collect();
2537        Self::applied_type_or_name(enum_name, args)
2538    }
2539
2540    fn infer_try_error_type(&self, stmts: &[SNode], scope: &TypeScope) -> InferredType {
2541        let mut inferred: Vec<TypeExpr> = Vec::new();
2542        for stmt in stmts {
2543            match &stmt.node {
2544                Node::ThrowStmt { value } => {
2545                    if let Some(ty) = self.infer_type(value, scope) {
2546                        inferred.push(ty);
2547                    }
2548                }
2549                Node::TryOperator { operand } => {
2550                    if let Some(TypeExpr::Applied { name, args }) = self.infer_type(operand, scope)
2551                    {
2552                        if name == "Result" && args.len() == 2 {
2553                            inferred.push(args[1].clone());
2554                        }
2555                    }
2556                }
2557                Node::IfElse {
2558                    then_body,
2559                    else_body,
2560                    ..
2561                } => {
2562                    if let Some(ty) = self.infer_try_error_type(then_body, scope) {
2563                        inferred.push(ty);
2564                    }
2565                    if let Some(else_body) = else_body {
2566                        if let Some(ty) = self.infer_try_error_type(else_body, scope) {
2567                            inferred.push(ty);
2568                        }
2569                    }
2570                }
2571                Node::Block(body)
2572                | Node::TryExpr { body }
2573                | Node::SpawnExpr { body }
2574                | Node::Retry { body, .. }
2575                | Node::WhileLoop { body, .. }
2576                | Node::DeferStmt { body }
2577                | Node::MutexBlock { body }
2578                | Node::DeadlineBlock { body, .. }
2579                | Node::Pipeline { body, .. }
2580                | Node::OverrideDecl { body, .. } => {
2581                    if let Some(ty) = self.infer_try_error_type(body, scope) {
2582                        inferred.push(ty);
2583                    }
2584                }
2585                _ => {}
2586            }
2587        }
2588        if inferred.is_empty() {
2589            None
2590        } else {
2591            Some(simplify_union(inferred))
2592        }
2593    }
2594
2595    fn infer_list_literal_type(&self, items: &[SNode], scope: &TypeScope) -> TypeExpr {
2596        let mut inferred: Option<TypeExpr> = None;
2597        for item in items {
2598            let Some(item_type) = self.infer_type(item, scope) else {
2599                return TypeExpr::Named("list".into());
2600            };
2601            inferred = Some(match inferred {
2602                None => item_type,
2603                Some(current) if current == item_type => current,
2604                Some(TypeExpr::Union(mut members)) => {
2605                    if !members.contains(&item_type) {
2606                        members.push(item_type);
2607                    }
2608                    TypeExpr::Union(members)
2609                }
2610                Some(current) => TypeExpr::Union(vec![current, item_type]),
2611            });
2612        }
2613        inferred
2614            .map(|item_type| TypeExpr::List(Box::new(item_type)))
2615            .unwrap_or_else(|| TypeExpr::Named("list".into()))
2616    }
2617
2618    /// Extract bidirectional type refinements from a condition expression.
2619    fn extract_refinements(condition: &SNode, scope: &TypeScope) -> Refinements {
2620        match &condition.node {
2621            Node::BinaryOp { op, left, right } if op == "!=" || op == "==" => {
2622                let nil_ref = Self::extract_nil_refinements(op, left, right, scope);
2623                if !nil_ref.truthy.is_empty() || !nil_ref.falsy.is_empty() {
2624                    return nil_ref;
2625                }
2626                let typeof_ref = Self::extract_typeof_refinements(op, left, right, scope);
2627                if !typeof_ref.truthy.is_empty() || !typeof_ref.falsy.is_empty() {
2628                    return typeof_ref;
2629                }
2630                Refinements::empty()
2631            }
2632
2633            // Logical AND: both operands must be truthy, so truthy refinements compose.
2634            Node::BinaryOp { op, left, right } if op == "&&" => {
2635                let left_ref = Self::extract_refinements(left, scope);
2636                let right_ref = Self::extract_refinements(right, scope);
2637                let mut truthy = left_ref.truthy;
2638                truthy.extend(right_ref.truthy);
2639                let mut truthy_ruled_out = left_ref.truthy_ruled_out;
2640                truthy_ruled_out.extend(right_ref.truthy_ruled_out);
2641                Refinements {
2642                    truthy,
2643                    falsy: vec![],
2644                    truthy_ruled_out,
2645                    falsy_ruled_out: vec![],
2646                }
2647            }
2648
2649            // Logical OR: both operands must be falsy for the whole to be falsy.
2650            Node::BinaryOp { op, left, right } if op == "||" => {
2651                let left_ref = Self::extract_refinements(left, scope);
2652                let right_ref = Self::extract_refinements(right, scope);
2653                let mut falsy = left_ref.falsy;
2654                falsy.extend(right_ref.falsy);
2655                let mut falsy_ruled_out = left_ref.falsy_ruled_out;
2656                falsy_ruled_out.extend(right_ref.falsy_ruled_out);
2657                Refinements {
2658                    truthy: vec![],
2659                    falsy,
2660                    truthy_ruled_out: vec![],
2661                    falsy_ruled_out,
2662                }
2663            }
2664
2665            Node::UnaryOp { op, operand } if op == "!" => {
2666                Self::extract_refinements(operand, scope).inverted()
2667            }
2668
2669            // Bare identifier in condition position: narrow `T | nil` to `T`.
2670            Node::Identifier(name) => {
2671                if let Some(Some(TypeExpr::Union(members))) = scope.get_var(name) {
2672                    if members
2673                        .iter()
2674                        .any(|m| matches!(m, TypeExpr::Named(n) if n == "nil"))
2675                    {
2676                        if let Some(narrowed) = remove_from_union(members, "nil") {
2677                            return Refinements {
2678                                truthy: vec![(name.clone(), Some(narrowed))],
2679                                falsy: vec![(name.clone(), Some(TypeExpr::Named("nil".into())))],
2680                                truthy_ruled_out: vec![],
2681                                falsy_ruled_out: vec![],
2682                            };
2683                        }
2684                    }
2685                }
2686                Refinements::empty()
2687            }
2688
2689            Node::MethodCall {
2690                object,
2691                method,
2692                args,
2693            } if method == "has" && args.len() == 1 => {
2694                Self::extract_has_refinements(object, args, scope)
2695            }
2696
2697            Node::FunctionCall { name, args }
2698                if (name == "schema_is" || name == "is_type") && args.len() == 2 =>
2699            {
2700                Self::extract_schema_refinements(args, scope)
2701            }
2702
2703            _ => Refinements::empty(),
2704        }
2705    }
2706
2707    /// Extract nil-check refinements from `x != nil` / `x == nil` patterns.
2708    fn extract_nil_refinements(
2709        op: &str,
2710        left: &SNode,
2711        right: &SNode,
2712        scope: &TypeScope,
2713    ) -> Refinements {
2714        let var_node = if matches!(right.node, Node::NilLiteral) {
2715            left
2716        } else if matches!(left.node, Node::NilLiteral) {
2717            right
2718        } else {
2719            return Refinements::empty();
2720        };
2721
2722        if let Node::Identifier(name) = &var_node.node {
2723            let var_type = scope.get_var(name).cloned().flatten();
2724            match var_type {
2725                Some(TypeExpr::Union(ref members)) => {
2726                    if let Some(narrowed) = remove_from_union(members, "nil") {
2727                        let neq_refs = Refinements {
2728                            truthy: vec![(name.clone(), Some(narrowed))],
2729                            falsy: vec![(name.clone(), Some(TypeExpr::Named("nil".into())))],
2730                            ..Refinements::default()
2731                        };
2732                        return if op == "!=" {
2733                            neq_refs
2734                        } else {
2735                            neq_refs.inverted()
2736                        };
2737                    }
2738                }
2739                Some(TypeExpr::Named(ref n)) if n == "nil" => {
2740                    // Single nil type: == nil is always true, != nil narrows to never.
2741                    let eq_refs = Refinements {
2742                        truthy: vec![(name.clone(), Some(TypeExpr::Named("nil".into())))],
2743                        falsy: vec![(name.clone(), Some(TypeExpr::Never))],
2744                        ..Refinements::default()
2745                    };
2746                    return if op == "==" {
2747                        eq_refs
2748                    } else {
2749                        eq_refs.inverted()
2750                    };
2751                }
2752                _ => {}
2753            }
2754        }
2755        Refinements::empty()
2756    }
2757
2758    /// Extract type_of refinements from `type_of(x) == "typename"` patterns.
2759    fn extract_typeof_refinements(
2760        op: &str,
2761        left: &SNode,
2762        right: &SNode,
2763        scope: &TypeScope,
2764    ) -> Refinements {
2765        let (var_name, type_name) = if let (Some(var), Node::StringLiteral(tn)) =
2766            (extract_type_of_var(left), &right.node)
2767        {
2768            (var, tn.clone())
2769        } else if let (Node::StringLiteral(tn), Some(var)) =
2770            (&left.node, extract_type_of_var(right))
2771        {
2772            (var, tn.clone())
2773        } else {
2774            return Refinements::empty();
2775        };
2776
2777        const KNOWN_TYPES: &[&str] = &[
2778            "int", "string", "float", "bool", "nil", "list", "dict", "closure",
2779        ];
2780        if !KNOWN_TYPES.contains(&type_name.as_str()) {
2781            return Refinements::empty();
2782        }
2783
2784        let var_type = scope.get_var(&var_name).cloned().flatten();
2785        match var_type {
2786            Some(TypeExpr::Union(ref members)) => {
2787                let narrowed = narrow_to_single(members, &type_name);
2788                let remaining = remove_from_union(members, &type_name);
2789                if narrowed.is_some() || remaining.is_some() {
2790                    let eq_refs = Refinements {
2791                        truthy: narrowed
2792                            .map(|n| vec![(var_name.clone(), Some(n))])
2793                            .unwrap_or_default(),
2794                        falsy: remaining
2795                            .map(|r| vec![(var_name.clone(), Some(r))])
2796                            .unwrap_or_default(),
2797                        ..Refinements::default()
2798                    };
2799                    return if op == "==" {
2800                        eq_refs
2801                    } else {
2802                        eq_refs.inverted()
2803                    };
2804                }
2805            }
2806            Some(TypeExpr::Named(ref n)) if n == &type_name => {
2807                // Single named type matches the typeof check:
2808                // truthy = same type, falsy = never (type is fully ruled out).
2809                let eq_refs = Refinements {
2810                    truthy: vec![(var_name.clone(), Some(TypeExpr::Named(type_name)))],
2811                    falsy: vec![(var_name.clone(), Some(TypeExpr::Never))],
2812                    ..Refinements::default()
2813                };
2814                return if op == "==" {
2815                    eq_refs
2816                } else {
2817                    eq_refs.inverted()
2818                };
2819            }
2820            Some(TypeExpr::Named(ref n)) if n == "unknown" => {
2821                // `unknown` narrows to the tested concrete type on the truthy
2822                // branch. The falsy branch keeps `unknown` — subtracting one
2823                // concrete type from an open top still leaves an open top —
2824                // but we remember which concrete variants have been ruled
2825                // out so `unreachable()` / `throw` can detect incomplete
2826                // exhaustive-narrowing chains.
2827                let eq_refs = Refinements {
2828                    truthy: vec![(var_name.clone(), Some(TypeExpr::Named(type_name.clone())))],
2829                    falsy: vec![],
2830                    truthy_ruled_out: vec![],
2831                    falsy_ruled_out: vec![(var_name.clone(), type_name)],
2832                };
2833                return if op == "==" {
2834                    eq_refs
2835                } else {
2836                    eq_refs.inverted()
2837                };
2838            }
2839            _ => {}
2840        }
2841        Refinements::empty()
2842    }
2843
2844    /// Extract .has("key") refinements on shape types.
2845    fn extract_has_refinements(object: &SNode, args: &[SNode], scope: &TypeScope) -> Refinements {
2846        if let Node::Identifier(var_name) = &object.node {
2847            if let Node::StringLiteral(key) = &args[0].node {
2848                if let Some(Some(TypeExpr::Shape(fields))) = scope.get_var(var_name) {
2849                    if fields.iter().any(|f| f.name == *key && f.optional) {
2850                        let narrowed_fields: Vec<ShapeField> = fields
2851                            .iter()
2852                            .map(|f| {
2853                                if f.name == *key {
2854                                    ShapeField {
2855                                        name: f.name.clone(),
2856                                        type_expr: f.type_expr.clone(),
2857                                        optional: false,
2858                                    }
2859                                } else {
2860                                    f.clone()
2861                                }
2862                            })
2863                            .collect();
2864                        return Refinements {
2865                            truthy: vec![(
2866                                var_name.clone(),
2867                                Some(TypeExpr::Shape(narrowed_fields)),
2868                            )],
2869                            falsy: vec![],
2870                            ..Refinements::default()
2871                        };
2872                    }
2873                }
2874            }
2875        }
2876        Refinements::empty()
2877    }
2878
2879    fn extract_schema_refinements(args: &[SNode], scope: &TypeScope) -> Refinements {
2880        let Node::Identifier(var_name) = &args[0].node else {
2881            return Refinements::empty();
2882        };
2883        let Some(schema_type) = schema_type_expr_from_node(&args[1], scope) else {
2884            return Refinements::empty();
2885        };
2886        let Some(Some(var_type)) = scope.get_var(var_name).cloned() else {
2887            return Refinements::empty();
2888        };
2889
2890        let truthy = intersect_types(&var_type, &schema_type)
2891            .map(|ty| vec![(var_name.clone(), Some(ty))])
2892            .unwrap_or_default();
2893        let falsy = subtract_type(&var_type, &schema_type)
2894            .map(|ty| vec![(var_name.clone(), Some(ty))])
2895            .unwrap_or_default();
2896
2897        Refinements {
2898            truthy,
2899            falsy,
2900            ..Refinements::default()
2901        }
2902    }
2903
2904    /// Check whether a block definitely exits (delegates to the free function).
2905    fn block_definitely_exits(stmts: &[SNode]) -> bool {
2906        block_definitely_exits(stmts)
2907    }
2908
2909    fn check_match_exhaustiveness(
2910        &mut self,
2911        value: &SNode,
2912        arms: &[MatchArm],
2913        scope: &TypeScope,
2914        span: Span,
2915    ) {
2916        // Detect pattern: match <expr>.variant { "VariantA" -> ... }
2917        let enum_name = match &value.node {
2918            Node::PropertyAccess { object, property } if property == "variant" => {
2919                // Infer the type of the object
2920                match self.infer_type(object, scope) {
2921                    Some(TypeExpr::Named(name)) => {
2922                        if scope.get_enum(&name).is_some() {
2923                            Some(name)
2924                        } else {
2925                            None
2926                        }
2927                    }
2928                    _ => None,
2929                }
2930            }
2931            _ => {
2932                // Direct match on an enum value: match <expr> { ... }
2933                match self.infer_type(value, scope) {
2934                    Some(TypeExpr::Named(name)) if scope.get_enum(&name).is_some() => Some(name),
2935                    _ => None,
2936                }
2937            }
2938        };
2939
2940        let Some(enum_name) = enum_name else {
2941            // Try union type exhaustiveness instead
2942            self.check_match_exhaustiveness_union(value, arms, scope, span);
2943            return;
2944        };
2945        let Some(variants) = scope.get_enum(&enum_name) else {
2946            return;
2947        };
2948
2949        // Collect variant names covered by match arms
2950        let mut covered: Vec<String> = Vec::new();
2951        let mut has_wildcard = false;
2952
2953        for arm in arms {
2954            match &arm.pattern.node {
2955                // String literal pattern (matching on .variant): "VariantA"
2956                Node::StringLiteral(s) => covered.push(s.clone()),
2957                // Identifier pattern acts as a wildcard/catch-all
2958                Node::Identifier(name)
2959                    if name == "_"
2960                        || !variants
2961                            .variants
2962                            .iter()
2963                            .any(|variant| variant.name == *name) =>
2964                {
2965                    has_wildcard = true;
2966                }
2967                // Direct enum construct pattern: EnumName.Variant
2968                Node::EnumConstruct { variant, .. } => covered.push(variant.clone()),
2969                // PropertyAccess pattern: EnumName.Variant (no args)
2970                Node::PropertyAccess { property, .. } => covered.push(property.clone()),
2971                _ => {
2972                    // Unknown pattern shape — conservatively treat as wildcard
2973                    has_wildcard = true;
2974                }
2975            }
2976        }
2977
2978        if has_wildcard {
2979            return;
2980        }
2981
2982        let missing: Vec<&String> = variants
2983            .variants
2984            .iter()
2985            .map(|variant| &variant.name)
2986            .filter(|variant| !covered.contains(variant))
2987            .collect();
2988        if !missing.is_empty() {
2989            let missing_str = missing
2990                .iter()
2991                .map(|s| format!("\"{}\"", s))
2992                .collect::<Vec<_>>()
2993                .join(", ");
2994            self.warning_at(
2995                format!(
2996                    "Non-exhaustive match on enum {}: missing variants {}",
2997                    enum_name, missing_str
2998                ),
2999                span,
3000            );
3001        }
3002    }
3003
3004    /// Check exhaustiveness for match on union types (e.g. `string | int | nil`).
3005    fn check_match_exhaustiveness_union(
3006        &mut self,
3007        value: &SNode,
3008        arms: &[MatchArm],
3009        scope: &TypeScope,
3010        span: Span,
3011    ) {
3012        let Some(TypeExpr::Union(members)) = self.infer_type(value, scope) else {
3013            return;
3014        };
3015        // Only check unions of named types (string, int, nil, bool, etc.)
3016        if !members.iter().all(|m| matches!(m, TypeExpr::Named(_))) {
3017            return;
3018        }
3019
3020        let mut has_wildcard = false;
3021        let mut covered_types: Vec<String> = Vec::new();
3022
3023        for arm in arms {
3024            match &arm.pattern.node {
3025                // type_of(x) == "string" style patterns are common but hard to detect here
3026                // Literal patterns cover specific types
3027                Node::NilLiteral => covered_types.push("nil".into()),
3028                Node::BoolLiteral(_) => {
3029                    if !covered_types.contains(&"bool".into()) {
3030                        covered_types.push("bool".into());
3031                    }
3032                }
3033                Node::IntLiteral(_) => {
3034                    if !covered_types.contains(&"int".into()) {
3035                        covered_types.push("int".into());
3036                    }
3037                }
3038                Node::FloatLiteral(_) => {
3039                    if !covered_types.contains(&"float".into()) {
3040                        covered_types.push("float".into());
3041                    }
3042                }
3043                Node::StringLiteral(_) => {
3044                    if !covered_types.contains(&"string".into()) {
3045                        covered_types.push("string".into());
3046                    }
3047                }
3048                Node::Identifier(name) if name == "_" => {
3049                    has_wildcard = true;
3050                }
3051                _ => {
3052                    has_wildcard = true;
3053                }
3054            }
3055        }
3056
3057        if has_wildcard {
3058            return;
3059        }
3060
3061        let type_names: Vec<&str> = members
3062            .iter()
3063            .filter_map(|m| match m {
3064                TypeExpr::Named(n) => Some(n.as_str()),
3065                _ => None,
3066            })
3067            .collect();
3068        let missing: Vec<&&str> = type_names
3069            .iter()
3070            .filter(|t| !covered_types.iter().any(|c| c == **t))
3071            .collect();
3072        if !missing.is_empty() {
3073            let missing_str = missing
3074                .iter()
3075                .map(|s| s.to_string())
3076                .collect::<Vec<_>>()
3077                .join(", ");
3078            self.warning_at(
3079                format!(
3080                    "Non-exhaustive match on union type: missing {}",
3081                    missing_str
3082                ),
3083                span,
3084            );
3085        }
3086    }
3087
3088    /// Complete set of concrete variants that `type_of` may return, used as
3089    /// the reference for exhaustive-narrowing warnings on `unknown`.
3090    const UNKNOWN_CONCRETE_TYPES: &'static [&'static str] = &[
3091        "int", "string", "float", "bool", "nil", "list", "dict", "closure",
3092    ];
3093
3094    /// Emit a warning if any `unknown`-typed variable in scope has been
3095    /// partially narrowed via `type_of(v) == "T"` checks but the current
3096    /// control-flow path reaches a never-returning site (`unreachable()`,
3097    /// a function with `Never` return, or a `throw`) without covering every
3098    /// concrete `type_of` variant.
3099    ///
3100    /// The ruled-out set must be non-empty — reaching `throw`/`unreachable`
3101    /// without any narrowing isn't an exhaustiveness claim, so it stays
3102    /// silent and avoids false positives on plain error paths.
3103    fn check_unknown_exhaustiveness(&mut self, scope: &TypeScope, span: Span, site_label: &str) {
3104        let entries = scope.collect_unknown_ruled_out();
3105        for (var_name, covered) in entries {
3106            if covered.is_empty() {
3107                continue;
3108            }
3109            // Only warn if `v` is still typed `unknown` at this point —
3110            // if it was fully narrowed elsewhere the ruled-out set is stale.
3111            if !matches!(
3112                scope.get_var(&var_name),
3113                Some(Some(TypeExpr::Named(n))) if n == "unknown"
3114            ) {
3115                continue;
3116            }
3117            let missing: Vec<&str> = Self::UNKNOWN_CONCRETE_TYPES
3118                .iter()
3119                .copied()
3120                .filter(|t| !covered.iter().any(|c| c == t))
3121                .collect();
3122            if missing.is_empty() {
3123                continue;
3124            }
3125            let missing_str = missing
3126                .iter()
3127                .map(|s| s.to_string())
3128                .collect::<Vec<_>>()
3129                .join(", ");
3130            self.warning_at(
3131                format!(
3132                    "`{site}` reached but `{var}: unknown` was not fully narrowed — uncovered concrete type(s): {missing}",
3133                    site = site_label,
3134                    var = var_name,
3135                    missing = missing_str,
3136                ),
3137                span,
3138            );
3139        }
3140    }
3141
3142    fn check_call(&mut self, name: &str, args: &[SNode], scope: &mut TypeScope, span: Span) {
3143        // Special-case: unreachable(x) — when the argument is a variable,
3144        // verify it has been narrowed to `never` (exhaustiveness check).
3145        if name == "unreachable" {
3146            if let Some(arg) = args.first() {
3147                if matches!(&arg.node, Node::Identifier(_)) {
3148                    let arg_type = self.infer_type(arg, scope);
3149                    if let Some(ref ty) = arg_type {
3150                        if !matches!(ty, TypeExpr::Never) {
3151                            self.error_at(
3152                                format!(
3153                                    "unreachable() argument has type `{}` — not all cases are handled",
3154                                    format_type(ty)
3155                                ),
3156                                span,
3157                            );
3158                        }
3159                    }
3160                }
3161            }
3162            self.check_unknown_exhaustiveness(scope, span, "unreachable()");
3163            for arg in args {
3164                self.check_node(arg, scope);
3165            }
3166            return;
3167        }
3168
3169        // Calls to user-defined functions with a `never` return type also
3170        // signal "this path claims exhaustiveness" — apply the same check.
3171        if let Some(sig) = scope.get_fn(name).cloned() {
3172            if matches!(sig.return_type, Some(TypeExpr::Never)) {
3173                self.check_unknown_exhaustiveness(scope, span, &format!("{}()", name));
3174            }
3175        }
3176
3177        // Check against known function signatures
3178        let has_spread = args.iter().any(|a| matches!(&a.node, Node::Spread(_)));
3179        if let Some(sig) = scope.get_fn(name).cloned() {
3180            if !has_spread
3181                && !is_builtin(name)
3182                && !sig.has_rest
3183                && (args.len() < sig.required_params || args.len() > sig.params.len())
3184            {
3185                let expected = if sig.required_params == sig.params.len() {
3186                    format!("{}", sig.params.len())
3187                } else {
3188                    format!("{}-{}", sig.required_params, sig.params.len())
3189                };
3190                self.warning_at(
3191                    format!(
3192                        "Function '{}' expects {} arguments, got {}",
3193                        name,
3194                        expected,
3195                        args.len()
3196                    ),
3197                    span,
3198                );
3199            }
3200            // Build a scope that includes the function's generic type params
3201            // so they are treated as compatible with any concrete type.
3202            let call_scope = if sig.type_param_names.is_empty() {
3203                scope.clone()
3204            } else {
3205                let mut s = scope.child();
3206                for tp_name in &sig.type_param_names {
3207                    s.generic_type_params.insert(tp_name.clone());
3208                }
3209                s
3210            };
3211            let mut type_bindings: BTreeMap<String, TypeExpr> = BTreeMap::new();
3212            let type_param_set: std::collections::BTreeSet<String> =
3213                sig.type_param_names.iter().cloned().collect();
3214            for (arg, (_param_name, param_type)) in args.iter().zip(sig.params.iter()) {
3215                if let Some(param_ty) = param_type {
3216                    if let Err(message) = self.bind_from_arg_node(
3217                        param_ty,
3218                        arg,
3219                        &type_param_set,
3220                        &mut type_bindings,
3221                        scope,
3222                    ) {
3223                        self.error_at(message, arg.span);
3224                    }
3225                }
3226            }
3227            for (i, (arg, (param_name, param_type))) in
3228                args.iter().zip(sig.params.iter()).enumerate()
3229            {
3230                if let Some(expected) = param_type {
3231                    let actual = self.infer_type(arg, scope);
3232                    if let Some(actual) = &actual {
3233                        let expected = Self::apply_type_bindings(expected, &type_bindings);
3234                        if !self.types_compatible(&expected, actual, &call_scope) {
3235                            self.error_at(
3236                                format!(
3237                                    "Argument {} ('{}'): expected {}, got {}",
3238                                    i + 1,
3239                                    param_name,
3240                                    format_type(&expected),
3241                                    format_type(actual)
3242                                ),
3243                                arg.span,
3244                            );
3245                        }
3246                    }
3247                }
3248            }
3249            if !sig.where_clauses.is_empty() {
3250                for (type_param, bound) in &sig.where_clauses {
3251                    if let Some(concrete_type) = type_bindings.get(type_param) {
3252                        let concrete_name = format_type(concrete_type);
3253                        let Some(base_type_name) = Self::base_type_name(concrete_type) else {
3254                            self.error_at(
3255                                format!(
3256                                    "Type '{}' does not satisfy interface '{}': only named types can satisfy interfaces (required by constraint `where {}: {}`)",
3257                                    concrete_name, bound, type_param, bound
3258                                ),
3259                                span,
3260                            );
3261                            continue;
3262                        };
3263                        if let Some(reason) = self.interface_mismatch_reason(
3264                            base_type_name,
3265                            bound,
3266                            &BTreeMap::new(),
3267                            scope,
3268                        ) {
3269                            self.error_at(
3270                                format!(
3271                                    "Type '{}' does not satisfy interface '{}': {} \
3272                                     (required by constraint `where {}: {}`)",
3273                                    concrete_name, bound, reason, type_param, bound
3274                                ),
3275                                span,
3276                            );
3277                        }
3278                    }
3279                }
3280            }
3281        }
3282        // Check args recursively
3283        for arg in args {
3284            self.check_node(arg, scope);
3285        }
3286    }
3287
3288    /// Infer the type of an expression.
3289    fn infer_type(&self, snode: &SNode, scope: &TypeScope) -> InferredType {
3290        match &snode.node {
3291            Node::IntLiteral(_) => Some(TypeExpr::Named("int".into())),
3292            Node::FloatLiteral(_) => Some(TypeExpr::Named("float".into())),
3293            Node::StringLiteral(_) | Node::InterpolatedString(_) => {
3294                Some(TypeExpr::Named("string".into()))
3295            }
3296            Node::BoolLiteral(_) => Some(TypeExpr::Named("bool".into())),
3297            Node::NilLiteral => Some(TypeExpr::Named("nil".into())),
3298            Node::ListLiteral(items) => Some(self.infer_list_literal_type(items, scope)),
3299            // `a to b` (and `a to b exclusive`) produce a lazy Range value.
3300            // Expose it as a named `range` type; for-in and method resolution
3301            // special-case this type where needed.
3302            Node::RangeExpr { .. } => Some(TypeExpr::Named("range".into())),
3303            Node::DictLiteral(entries) => {
3304                // Infer shape type when all keys are string literals
3305                let mut fields = Vec::new();
3306                for entry in entries {
3307                    let key = match &entry.key.node {
3308                        Node::StringLiteral(key) | Node::Identifier(key) => key.clone(),
3309                        _ => return Some(TypeExpr::Named("dict".into())),
3310                    };
3311                    let val_type = self
3312                        .infer_type(&entry.value, scope)
3313                        .unwrap_or(TypeExpr::Named("nil".into()));
3314                    fields.push(ShapeField {
3315                        name: key,
3316                        type_expr: val_type,
3317                        optional: false,
3318                    });
3319                }
3320                if !fields.is_empty() {
3321                    Some(TypeExpr::Shape(fields))
3322                } else {
3323                    Some(TypeExpr::Named("dict".into()))
3324                }
3325            }
3326            Node::Closure { params, body, .. } => {
3327                // If all params are typed and we can infer a return type, produce FnType
3328                let all_typed = params.iter().all(|p| p.type_expr.is_some());
3329                if all_typed && !params.is_empty() {
3330                    let param_types: Vec<TypeExpr> =
3331                        params.iter().filter_map(|p| p.type_expr.clone()).collect();
3332                    // Try to infer return type from last expression in body
3333                    let ret = body.last().and_then(|last| self.infer_type(last, scope));
3334                    if let Some(ret_type) = ret {
3335                        return Some(TypeExpr::FnType {
3336                            params: param_types,
3337                            return_type: Box::new(ret_type),
3338                        });
3339                    }
3340                }
3341                Some(TypeExpr::Named("closure".into()))
3342            }
3343
3344            Node::Identifier(name) => scope.get_var(name).cloned().flatten(),
3345
3346            Node::FunctionCall { name, args } => {
3347                // Struct constructor calls return the struct type
3348                if let Some(struct_info) = scope.get_struct(name) {
3349                    return Some(Self::applied_type_or_name(
3350                        name,
3351                        struct_info
3352                            .type_params
3353                            .iter()
3354                            .map(|_| Self::wildcard_type())
3355                            .collect(),
3356                    ));
3357                }
3358                if name == "Ok" {
3359                    let ok_type = args
3360                        .first()
3361                        .and_then(|arg| self.infer_type(arg, scope))
3362                        .unwrap_or_else(Self::wildcard_type);
3363                    return Some(TypeExpr::Applied {
3364                        name: "Result".into(),
3365                        args: vec![ok_type, Self::wildcard_type()],
3366                    });
3367                }
3368                if name == "Err" {
3369                    let err_type = args
3370                        .first()
3371                        .and_then(|arg| self.infer_type(arg, scope))
3372                        .unwrap_or_else(Self::wildcard_type);
3373                    return Some(TypeExpr::Applied {
3374                        name: "Result".into(),
3375                        args: vec![Self::wildcard_type(), err_type],
3376                    });
3377                }
3378                // Check user-defined function return types
3379                if let Some(sig) = scope.get_fn(name).cloned() {
3380                    let mut return_type = sig.return_type.clone();
3381                    if let Some(ty) = return_type.take() {
3382                        if sig.type_param_names.is_empty() {
3383                            return Some(ty);
3384                        }
3385                        let mut bindings = BTreeMap::new();
3386                        let type_param_set: std::collections::BTreeSet<String> =
3387                            sig.type_param_names.iter().cloned().collect();
3388                        for (arg, (_param_name, param_type)) in args.iter().zip(sig.params.iter()) {
3389                            if let Some(param_ty) = param_type {
3390                                let _ = self.bind_from_arg_node(
3391                                    param_ty,
3392                                    arg,
3393                                    &type_param_set,
3394                                    &mut bindings,
3395                                    scope,
3396                                );
3397                            }
3398                        }
3399                        return Some(Self::apply_type_bindings(&ty, &bindings));
3400                    }
3401                    return None;
3402                }
3403                // Generic builtins (llm_call, schema_parse/check/expect):
3404                // bind T by walking each arg node against the param
3405                // TypeExpr, then apply bindings to the declared return
3406                // type. Falls through to `builtin_return_type` when no T
3407                // could be bound (e.g. llm_call without an output_schema
3408                // option), preserving the historical `dict` return.
3409                if let Some(sig) = builtin_signatures::lookup_generic_builtin_sig(name) {
3410                    let type_param_set: std::collections::BTreeSet<String> =
3411                        sig.type_params.iter().cloned().collect();
3412                    let mut bindings: BTreeMap<String, TypeExpr> = BTreeMap::new();
3413                    for (arg, param_ty) in args.iter().zip(sig.params.iter()) {
3414                        let _ = self.bind_from_arg_node(
3415                            param_ty,
3416                            arg,
3417                            &type_param_set,
3418                            &mut bindings,
3419                            scope,
3420                        );
3421                    }
3422                    let all_bound = sig.type_params.iter().all(|tp| bindings.contains_key(tp));
3423                    if all_bound {
3424                        return Some(Self::apply_type_bindings(&sig.return_type, &bindings));
3425                    }
3426                }
3427                // Check builtin return types
3428                builtin_return_type(name)
3429            }
3430
3431            Node::BinaryOp { op, left, right } => {
3432                let lt = self.infer_type(left, scope);
3433                let rt = self.infer_type(right, scope);
3434                infer_binary_op_type(op, &lt, &rt)
3435            }
3436
3437            Node::UnaryOp { op, operand } => {
3438                let t = self.infer_type(operand, scope);
3439                match op.as_str() {
3440                    "!" => Some(TypeExpr::Named("bool".into())),
3441                    "-" => t, // negation preserves type
3442                    _ => None,
3443                }
3444            }
3445
3446            Node::Ternary {
3447                condition,
3448                true_expr,
3449                false_expr,
3450            } => {
3451                let refs = Self::extract_refinements(condition, scope);
3452
3453                let mut true_scope = scope.child();
3454                refs.apply_truthy(&mut true_scope);
3455                let tt = self.infer_type(true_expr, &true_scope);
3456
3457                let mut false_scope = scope.child();
3458                refs.apply_falsy(&mut false_scope);
3459                let ft = self.infer_type(false_expr, &false_scope);
3460
3461                match (&tt, &ft) {
3462                    (Some(a), Some(b)) if a == b => tt,
3463                    (Some(a), Some(b)) => Some(TypeExpr::Union(vec![a.clone(), b.clone()])),
3464                    (Some(_), None) => tt,
3465                    (None, Some(_)) => ft,
3466                    (None, None) => None,
3467                }
3468            }
3469
3470            Node::EnumConstruct {
3471                enum_name,
3472                variant,
3473                args,
3474            } => {
3475                if let Some(enum_info) = scope.get_enum(enum_name) {
3476                    Some(self.infer_enum_type(enum_name, enum_info, variant, args, scope))
3477                } else {
3478                    Some(TypeExpr::Named(enum_name.clone()))
3479                }
3480            }
3481
3482            Node::PropertyAccess { object, property } => {
3483                // EnumName.Variant → infer as the enum type
3484                if let Node::Identifier(name) = &object.node {
3485                    if let Some(enum_info) = scope.get_enum(name) {
3486                        return Some(self.infer_enum_type(name, enum_info, property, &[], scope));
3487                    }
3488                }
3489                // .variant on an enum value → string
3490                if property == "variant" {
3491                    let obj_type = self.infer_type(object, scope);
3492                    if let Some(name) = obj_type.as_ref().and_then(Self::base_type_name) {
3493                        if scope.get_enum(name).is_some() {
3494                            return Some(TypeExpr::Named("string".into()));
3495                        }
3496                    }
3497                }
3498                // Shape field access: obj.field → field type
3499                let obj_type = self.infer_type(object, scope);
3500                // Pair<K, V> has `.first` and `.second` accessors.
3501                if let Some(TypeExpr::Applied { name, args }) = &obj_type {
3502                    if name == "Pair" && args.len() == 2 {
3503                        if property == "first" {
3504                            return Some(args[0].clone());
3505                        } else if property == "second" {
3506                            return Some(args[1].clone());
3507                        }
3508                    }
3509                }
3510                if let Some(TypeExpr::Shape(fields)) = &obj_type {
3511                    if let Some(field) = fields.iter().find(|f| f.name == *property) {
3512                        return Some(field.type_expr.clone());
3513                    }
3514                }
3515                None
3516            }
3517
3518            Node::SubscriptAccess { object, index } => {
3519                let obj_type = self.infer_type(object, scope);
3520                match &obj_type {
3521                    Some(TypeExpr::List(inner)) => Some(*inner.clone()),
3522                    Some(TypeExpr::DictType(_, v)) => Some(*v.clone()),
3523                    Some(TypeExpr::Shape(fields)) => {
3524                        // If index is a string literal, look up the field type
3525                        if let Node::StringLiteral(key) = &index.node {
3526                            fields
3527                                .iter()
3528                                .find(|f| &f.name == key)
3529                                .map(|f| f.type_expr.clone())
3530                        } else {
3531                            None
3532                        }
3533                    }
3534                    Some(TypeExpr::Named(n)) if n == "list" => None,
3535                    Some(TypeExpr::Named(n)) if n == "dict" => None,
3536                    Some(TypeExpr::Named(n)) if n == "string" => {
3537                        Some(TypeExpr::Named("string".into()))
3538                    }
3539                    _ => None,
3540                }
3541            }
3542            Node::SliceAccess { object, .. } => {
3543                // Slicing a list returns the same list type; slicing a string returns string
3544                let obj_type = self.infer_type(object, scope);
3545                match &obj_type {
3546                    Some(TypeExpr::List(_)) => obj_type,
3547                    Some(TypeExpr::Named(n)) if n == "list" => obj_type,
3548                    Some(TypeExpr::Named(n)) if n == "string" => {
3549                        Some(TypeExpr::Named("string".into()))
3550                    }
3551                    _ => None,
3552                }
3553            }
3554            Node::MethodCall {
3555                object,
3556                method,
3557                args,
3558            }
3559            | Node::OptionalMethodCall {
3560                object,
3561                method,
3562                args,
3563            } => {
3564                if let Node::Identifier(name) = &object.node {
3565                    if let Some(enum_info) = scope.get_enum(name) {
3566                        return Some(self.infer_enum_type(name, enum_info, method, args, scope));
3567                    }
3568                    if name == "Result" && (method == "Ok" || method == "Err") {
3569                        let ok_type = if method == "Ok" {
3570                            args.first()
3571                                .and_then(|arg| self.infer_type(arg, scope))
3572                                .unwrap_or_else(Self::wildcard_type)
3573                        } else {
3574                            Self::wildcard_type()
3575                        };
3576                        let err_type = if method == "Err" {
3577                            args.first()
3578                                .and_then(|arg| self.infer_type(arg, scope))
3579                                .unwrap_or_else(Self::wildcard_type)
3580                        } else {
3581                            Self::wildcard_type()
3582                        };
3583                        return Some(TypeExpr::Applied {
3584                            name: "Result".into(),
3585                            args: vec![ok_type, err_type],
3586                        });
3587                    }
3588                }
3589                let obj_type = self.infer_type(object, scope);
3590                // Iter<T> receiver: combinators preserve or transform T; sinks
3591                // materialize. This must come before the shared-method match
3592                // below so `.map` / `.filter` / etc. on an iter return Iter,
3593                // not list.
3594                let iter_elem_type: Option<TypeExpr> = match &obj_type {
3595                    Some(TypeExpr::Iter(inner)) => Some((**inner).clone()),
3596                    Some(TypeExpr::Named(n)) if n == "iter" => Some(TypeExpr::Named("any".into())),
3597                    _ => None,
3598                };
3599                if let Some(t) = iter_elem_type {
3600                    let pair = |k: TypeExpr, v: TypeExpr| TypeExpr::Applied {
3601                        name: "Pair".into(),
3602                        args: vec![k, v],
3603                    };
3604                    let iter_of = |ty: TypeExpr| TypeExpr::Iter(Box::new(ty));
3605                    match method.as_str() {
3606                        "iter" => return Some(iter_of(t)),
3607                        "map" | "flat_map" => {
3608                            // Closure-return inference is not threaded here;
3609                            // fall back to a coarse `iter<any>` — matches the
3610                            // list-return style the rest of the checker uses.
3611                            return Some(TypeExpr::Named("iter".into()));
3612                        }
3613                        "filter" | "take" | "skip" | "take_while" | "skip_while" => {
3614                            return Some(iter_of(t));
3615                        }
3616                        "zip" => {
3617                            return Some(iter_of(pair(t, TypeExpr::Named("any".into()))));
3618                        }
3619                        "enumerate" => {
3620                            return Some(iter_of(pair(TypeExpr::Named("int".into()), t)));
3621                        }
3622                        "chain" => return Some(iter_of(t)),
3623                        "chunks" | "windows" => {
3624                            return Some(iter_of(TypeExpr::List(Box::new(t))));
3625                        }
3626                        // Sinks
3627                        "to_list" => return Some(TypeExpr::List(Box::new(t))),
3628                        "to_set" => {
3629                            return Some(TypeExpr::Applied {
3630                                name: "set".into(),
3631                                args: vec![t],
3632                            })
3633                        }
3634                        "to_dict" => return Some(TypeExpr::Named("dict".into())),
3635                        "count" => return Some(TypeExpr::Named("int".into())),
3636                        "sum" => {
3637                            return Some(TypeExpr::Union(vec![
3638                                TypeExpr::Named("int".into()),
3639                                TypeExpr::Named("float".into()),
3640                            ]))
3641                        }
3642                        "min" | "max" | "first" | "last" | "find" => {
3643                            return Some(TypeExpr::Union(vec![t, TypeExpr::Named("nil".into())]));
3644                        }
3645                        "any" | "all" => return Some(TypeExpr::Named("bool".into())),
3646                        "for_each" => return Some(TypeExpr::Named("nil".into())),
3647                        "reduce" => return None,
3648                        _ => {}
3649                    }
3650                }
3651                // list<T> / dict / set / string .iter() → iter<T>. Other
3652                // combinator methods on list/dict/set/string keep their
3653                // existing eager typings (the runtime still materializes
3654                // them). Only the explicit .iter() bridge returns Iter.
3655                if method == "iter" {
3656                    match &obj_type {
3657                        Some(TypeExpr::List(inner)) => {
3658                            return Some(TypeExpr::Iter(Box::new((**inner).clone())));
3659                        }
3660                        Some(TypeExpr::DictType(k, v)) => {
3661                            return Some(TypeExpr::Iter(Box::new(TypeExpr::Applied {
3662                                name: "Pair".into(),
3663                                args: vec![(**k).clone(), (**v).clone()],
3664                            })));
3665                        }
3666                        Some(TypeExpr::Named(n))
3667                            if n == "list" || n == "dict" || n == "set" || n == "string" =>
3668                        {
3669                            return Some(TypeExpr::Named("iter".into()));
3670                        }
3671                        _ => {}
3672                    }
3673                }
3674                let is_dict = matches!(&obj_type, Some(TypeExpr::Named(n)) if n == "dict")
3675                    || matches!(&obj_type, Some(TypeExpr::DictType(..)))
3676                    || matches!(&obj_type, Some(TypeExpr::Shape(_)));
3677                match method.as_str() {
3678                    // Shared: bool-returning methods
3679                    "contains" | "starts_with" | "ends_with" | "empty" | "has" | "any" | "all" => {
3680                        Some(TypeExpr::Named("bool".into()))
3681                    }
3682                    // Shared: int-returning methods
3683                    "count" | "index_of" => Some(TypeExpr::Named("int".into())),
3684                    // String methods
3685                    "trim" | "lowercase" | "uppercase" | "reverse" | "replace" | "substring"
3686                    | "pad_left" | "pad_right" | "repeat" | "join" => {
3687                        Some(TypeExpr::Named("string".into()))
3688                    }
3689                    "split" | "chars" => Some(TypeExpr::Named("list".into())),
3690                    // filter returns dict for dicts, list for lists
3691                    "filter" => {
3692                        if is_dict {
3693                            Some(TypeExpr::Named("dict".into()))
3694                        } else {
3695                            Some(TypeExpr::Named("list".into()))
3696                        }
3697                    }
3698                    // List methods
3699                    "map" | "flat_map" | "sort" => Some(TypeExpr::Named("list".into())),
3700                    "window" | "each_cons" | "sliding_window" => match &obj_type {
3701                        Some(TypeExpr::List(inner)) => Some(TypeExpr::List(Box::new(
3702                            TypeExpr::List(Box::new((**inner).clone())),
3703                        ))),
3704                        _ => Some(TypeExpr::Named("list".into())),
3705                    },
3706                    "reduce" | "find" | "first" | "last" => None,
3707                    // Dict methods
3708                    "keys" | "values" | "entries" => Some(TypeExpr::Named("list".into())),
3709                    "merge" | "map_values" | "rekey" | "map_keys" => {
3710                        // Rekey/map_keys transform keys; resulting dict still keys-by-string.
3711                        // Preserve the value-type parameter when known so downstream code can
3712                        // still rely on dict<string, V> typing after a key-rename.
3713                        if let Some(TypeExpr::DictType(_, v)) = &obj_type {
3714                            Some(TypeExpr::DictType(
3715                                Box::new(TypeExpr::Named("string".into())),
3716                                v.clone(),
3717                            ))
3718                        } else {
3719                            Some(TypeExpr::Named("dict".into()))
3720                        }
3721                    }
3722                    // Conversions
3723                    "to_string" => Some(TypeExpr::Named("string".into())),
3724                    "to_int" => Some(TypeExpr::Named("int".into())),
3725                    "to_float" => Some(TypeExpr::Named("float".into())),
3726                    _ => None,
3727                }
3728            }
3729
3730            // TryOperator on Result<T, E> produces T
3731            Node::TryOperator { operand } => match self.infer_type(operand, scope) {
3732                Some(TypeExpr::Applied { name, args }) if name == "Result" && args.len() == 2 => {
3733                    Some(args[0].clone())
3734                }
3735                Some(TypeExpr::Named(name)) if name == "Result" => None,
3736                _ => None,
3737            },
3738
3739            // Exit expressions produce the bottom type.
3740            Node::ThrowStmt { .. }
3741            | Node::ReturnStmt { .. }
3742            | Node::BreakStmt
3743            | Node::ContinueStmt => Some(TypeExpr::Never),
3744
3745            // If/else as expression: merge branch types.
3746            Node::IfElse {
3747                then_body,
3748                else_body,
3749                ..
3750            } => {
3751                let then_type = self.infer_block_type(then_body, scope);
3752                let else_type = else_body
3753                    .as_ref()
3754                    .and_then(|eb| self.infer_block_type(eb, scope));
3755                match (then_type, else_type) {
3756                    (Some(TypeExpr::Never), Some(TypeExpr::Never)) => Some(TypeExpr::Never),
3757                    (Some(TypeExpr::Never), Some(other)) | (Some(other), Some(TypeExpr::Never)) => {
3758                        Some(other)
3759                    }
3760                    (Some(t), Some(e)) if t == e => Some(t),
3761                    (Some(t), Some(e)) => Some(simplify_union(vec![t, e])),
3762                    (Some(t), None) => Some(t),
3763                    (None, _) => None,
3764                }
3765            }
3766
3767            Node::TryExpr { body } => {
3768                let ok_type = self
3769                    .infer_block_type(body, scope)
3770                    .unwrap_or_else(Self::wildcard_type);
3771                let err_type = self
3772                    .infer_try_error_type(body, scope)
3773                    .unwrap_or_else(Self::wildcard_type);
3774                Some(TypeExpr::Applied {
3775                    name: "Result".into(),
3776                    args: vec![ok_type, err_type],
3777                })
3778            }
3779
3780            // `try* EXPR` evaluates to EXPR's value on success; rethrow on
3781            // error never returns. Type is therefore EXPR's inferred type.
3782            Node::TryStar { operand } => self.infer_type(operand, scope),
3783
3784            Node::StructConstruct {
3785                struct_name,
3786                fields,
3787            } => scope
3788                .get_struct(struct_name)
3789                .map(|struct_info| self.infer_struct_type(struct_name, struct_info, fields, scope))
3790                .or_else(|| Some(TypeExpr::Named(struct_name.clone()))),
3791
3792            _ => None,
3793        }
3794    }
3795
3796    /// Infer the type of a block (last expression, or `never` if the block definitely exits).
3797    fn infer_block_type(&self, stmts: &[SNode], scope: &TypeScope) -> InferredType {
3798        if Self::block_definitely_exits(stmts) {
3799            return Some(TypeExpr::Never);
3800        }
3801        stmts.last().and_then(|s| self.infer_type(s, scope))
3802    }
3803
3804    /// Check if `actual` can be assigned to `expected` (covariant subtype).
3805    ///
3806    /// This is a thin wrapper over [`types_compatible_at`] that enters
3807    /// the relation at covariant polarity. Callers that want to check
3808    /// invariance or contravariance directly can use
3809    /// [`types_compatible_at`] with the appropriate [`Polarity`].
3810    fn types_compatible(&self, expected: &TypeExpr, actual: &TypeExpr, scope: &TypeScope) -> bool {
3811        self.types_compatible_at(Polarity::Covariant, expected, actual, scope)
3812    }
3813
3814    /// Polarity-aware subtype check.
3815    ///
3816    /// - `Covariant`: `actual <: expected` under ordinary widening
3817    ///   (this is the public entry point behavior).
3818    /// - `Contravariant`: swaps the arguments and recurses covariantly.
3819    /// - `Invariant`: both directions must hold covariantly. This
3820    ///   disables the asymmetric numeric widening (`int <: float`)
3821    ///   that we rely on in covariant positions, so mutable container
3822    ///   slots do not accept a narrower element type.
3823    fn types_compatible_at(
3824        &self,
3825        polarity: Polarity,
3826        expected: &TypeExpr,
3827        actual: &TypeExpr,
3828        scope: &TypeScope,
3829    ) -> bool {
3830        match polarity {
3831            Polarity::Covariant => {}
3832            Polarity::Contravariant => {
3833                return self.types_compatible_at(Polarity::Covariant, actual, expected, scope);
3834            }
3835            Polarity::Invariant => {
3836                return self.types_compatible_at(Polarity::Covariant, expected, actual, scope)
3837                    && self.types_compatible_at(Polarity::Covariant, actual, expected, scope);
3838            }
3839        }
3840
3841        // From here on we are in the covariant case.
3842        if Self::is_wildcard_type(expected) || Self::is_wildcard_type(actual) {
3843            return true;
3844        }
3845        // Generic type parameters match anything.
3846        if let TypeExpr::Named(name) = expected {
3847            if scope.is_generic_type_param(name) {
3848                return true;
3849            }
3850        }
3851        if let TypeExpr::Named(name) = actual {
3852            if scope.is_generic_type_param(name) {
3853                return true;
3854            }
3855        }
3856        let expected = self.resolve_alias(expected, scope);
3857        let actual = self.resolve_alias(actual, scope);
3858
3859        // Interface satisfaction: if expected names an interface, check method compatibility.
3860        if let Some(iface_name) = Self::base_type_name(&expected) {
3861            if let Some(interface_info) = scope.get_interface(iface_name) {
3862                let mut interface_bindings = BTreeMap::new();
3863                if let TypeExpr::Applied { args, .. } = &expected {
3864                    for (type_param, arg) in interface_info.type_params.iter().zip(args.iter()) {
3865                        interface_bindings.insert(type_param.name.clone(), arg.clone());
3866                    }
3867                }
3868                if let Some(type_name) = Self::base_type_name(&actual) {
3869                    return self.satisfies_interface(
3870                        type_name,
3871                        iface_name,
3872                        &interface_bindings,
3873                        scope,
3874                    );
3875                }
3876                return false;
3877            }
3878        }
3879
3880        match (&expected, &actual) {
3881            // never is the bottom type: assignable to any type.
3882            (_, TypeExpr::Never) => true,
3883            // Nothing is assignable to never (except never itself, handled above).
3884            (TypeExpr::Never, _) => false,
3885            // `any` is the top type (escape hatch): every type flows into `any`,
3886            // and `any` flows back out to any concrete type with no narrowing required.
3887            (TypeExpr::Named(n), _) if n == "any" => true,
3888            (_, TypeExpr::Named(n)) if n == "any" => true,
3889            // `unknown` is the safe top: every type flows into `unknown`, but
3890            // `unknown` only flows back out to `unknown` itself (or `any`, via the
3891            // arm above). Concrete uses require narrowing via `type_of` / `schema_is`.
3892            (TypeExpr::Named(n), _) if n == "unknown" => true,
3893            // Reverse direction: `unknown` is not assignable to anything concrete.
3894            // The `(_, Named("unknown"))` arm deliberately falls through to `=> false`
3895            // below, producing a "expected T, got unknown" diagnostic.
3896            (TypeExpr::Named(a), TypeExpr::Named(b)) => a == b || (a == "float" && b == "int"),
3897            (TypeExpr::Named(a), TypeExpr::Applied { name: b, .. })
3898            | (TypeExpr::Applied { name: a, .. }, TypeExpr::Named(b)) => a == b,
3899            (
3900                TypeExpr::Applied {
3901                    name: expected_name,
3902                    args: expected_args,
3903                },
3904                TypeExpr::Applied {
3905                    name: actual_name,
3906                    args: actual_args,
3907                },
3908            ) => {
3909                if expected_name != actual_name || expected_args.len() != actual_args.len() {
3910                    return false;
3911                }
3912                // Consult the declared variance for each type parameter
3913                // of this constructor. User-declared generics default
3914                // to `Invariant` for any parameter without a marker,
3915                // which is enforced by the per-TypeParam default set in
3916                // the parser and AST. Unknown constructors (e.g. an
3917                // inferred schema-driven wrapper whose decl has not
3918                // been registered yet) fall back to invariance — that
3919                // is strictly safer than the previous implicit
3920                // covariance.
3921                let variances = scope.variance_of(expected_name);
3922                for (idx, (expected_arg, actual_arg)) in
3923                    expected_args.iter().zip(actual_args.iter()).enumerate()
3924                {
3925                    let child_variance = variances
3926                        .as_ref()
3927                        .and_then(|v| v.get(idx).copied())
3928                        .unwrap_or(Variance::Invariant);
3929                    let arg_polarity = Polarity::Covariant.compose(child_variance);
3930                    if !self.types_compatible_at(arg_polarity, expected_arg, actual_arg, scope) {
3931                        return false;
3932                    }
3933                }
3934                true
3935            }
3936            // Union-to-Union: every member of actual must be compatible with
3937            // at least one member of expected.
3938            (TypeExpr::Union(exp_members), TypeExpr::Union(act_members)) => {
3939                act_members.iter().all(|am| {
3940                    exp_members
3941                        .iter()
3942                        .any(|em| self.types_compatible(em, am, scope))
3943                })
3944            }
3945            (TypeExpr::Union(members), actual_type) => members
3946                .iter()
3947                .any(|m| self.types_compatible(m, actual_type, scope)),
3948            (expected_type, TypeExpr::Union(members)) => members
3949                .iter()
3950                .all(|m| self.types_compatible(expected_type, m, scope)),
3951            (TypeExpr::Shape(_), TypeExpr::Named(n)) if n == "dict" => true,
3952            (TypeExpr::Named(n), TypeExpr::Shape(_)) if n == "dict" => true,
3953            (TypeExpr::Shape(ef), TypeExpr::Shape(af)) => ef.iter().all(|expected_field| {
3954                if expected_field.optional {
3955                    return true;
3956                }
3957                af.iter().any(|actual_field| {
3958                    actual_field.name == expected_field.name
3959                        && self.types_compatible(
3960                            &expected_field.type_expr,
3961                            &actual_field.type_expr,
3962                            scope,
3963                        )
3964                })
3965            }),
3966            // dict<K, V> expected, Shape actual → all field values must match V
3967            (TypeExpr::DictType(ek, ev), TypeExpr::Shape(af)) => {
3968                let keys_ok = matches!(ek.as_ref(), TypeExpr::Named(n) if n == "string");
3969                keys_ok
3970                    && af
3971                        .iter()
3972                        .all(|f| self.types_compatible(ev, &f.type_expr, scope))
3973            }
3974            // Shape expected, dict<K, V> actual → gradual: allow since dict may have the fields
3975            (TypeExpr::Shape(_), TypeExpr::DictType(_, _)) => true,
3976            // list<T> is invariant: the element type must match exactly
3977            // (no int→float widening) because lists are mutable
3978            // (`push`, index assignment). Covariant lists are unsound
3979            // on write — a `list<int>` flowing into a `list<float>`
3980            // slot would let a `float` be pushed and later observed
3981            // as an `int`.
3982            (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
3983                self.types_compatible_at(Polarity::Invariant, expected_inner, actual_inner, scope)
3984            }
3985            (TypeExpr::Named(n), TypeExpr::List(_)) if n == "list" => true,
3986            (TypeExpr::List(_), TypeExpr::Named(n)) if n == "list" => true,
3987            // iter<T> is covariant: it is a read-only sequence with no
3988            // mutating projection, so widening its element type is
3989            // sound.
3990            (TypeExpr::Iter(expected_inner), TypeExpr::Iter(actual_inner)) => {
3991                self.types_compatible(expected_inner, actual_inner, scope)
3992            }
3993            (TypeExpr::Named(n), TypeExpr::Iter(_)) if n == "iter" => true,
3994            (TypeExpr::Iter(_), TypeExpr::Named(n)) if n == "iter" => true,
3995            // dict<K, V> is invariant in both K and V: dicts are
3996            // mutable (key/value assignment). See the `list` comment
3997            // above for the soundness argument.
3998            (TypeExpr::DictType(ek, ev), TypeExpr::DictType(ak, av)) => {
3999                self.types_compatible_at(Polarity::Invariant, ek, ak, scope)
4000                    && self.types_compatible_at(Polarity::Invariant, ev, av, scope)
4001            }
4002            (TypeExpr::Named(n), TypeExpr::DictType(_, _)) if n == "dict" => true,
4003            (TypeExpr::DictType(_, _), TypeExpr::Named(n)) if n == "dict" => true,
4004            // FnType subtyping: parameters are contravariant (an
4005            // `fn(float)` can stand in for an expected `fn(int)`
4006            // because floats contain ints); return types remain
4007            // covariant. Previously params were checked covariantly,
4008            // which let `fn(int)` stand in for `fn(float)` — an
4009            // unsound callback substitution.
4010            (
4011                TypeExpr::FnType {
4012                    params: ep,
4013                    return_type: er,
4014                },
4015                TypeExpr::FnType {
4016                    params: ap,
4017                    return_type: ar,
4018                },
4019            ) => {
4020                ep.len() == ap.len()
4021                    && ep.iter().zip(ap.iter()).all(|(e, a)| {
4022                        self.types_compatible_at(Polarity::Contravariant, e, a, scope)
4023                    })
4024                    && self.types_compatible(er, ar, scope)
4025            }
4026            // FnType is compatible with Named("closure") for backward compat
4027            (TypeExpr::FnType { .. }, TypeExpr::Named(n)) if n == "closure" => true,
4028            (TypeExpr::Named(n), TypeExpr::FnType { .. }) if n == "closure" => true,
4029            // Literal types: identical literals match; a literal flows
4030            // into its base type (`"pass"` → `string`); and — as a gradual
4031            // concession — a base type flows into a literal-typed slot
4032            // (`string` → `"pass" | "fail"`) so that existing
4033            // string/int-typed data can populate discriminated unions
4034            // without per-call-site widening. Runtime schema validation
4035            // (emitted for typed params and `schema_is`/`schema_expect`
4036            // guards) catches values that violate the literal set.
4037            (TypeExpr::LitString(a), TypeExpr::LitString(b)) => a == b,
4038            (TypeExpr::LitInt(a), TypeExpr::LitInt(b)) => a == b,
4039            (TypeExpr::Named(n), TypeExpr::LitString(_)) if n == "string" => true,
4040            (TypeExpr::Named(n), TypeExpr::LitInt(_)) if n == "int" || n == "float" => true,
4041            (TypeExpr::LitString(_), TypeExpr::Named(n)) if n == "string" => true,
4042            (TypeExpr::LitInt(_), TypeExpr::Named(n)) if n == "int" => true,
4043            _ => false,
4044        }
4045    }
4046
4047    fn resolve_alias<'a>(&self, ty: &'a TypeExpr, scope: &'a TypeScope) -> TypeExpr {
4048        match ty {
4049            TypeExpr::Named(name) => {
4050                if let Some(resolved) = scope.resolve_type(name) {
4051                    return self.resolve_alias(resolved, scope);
4052                }
4053                ty.clone()
4054            }
4055            TypeExpr::Union(types) => TypeExpr::Union(
4056                types
4057                    .iter()
4058                    .map(|ty| self.resolve_alias(ty, scope))
4059                    .collect(),
4060            ),
4061            TypeExpr::Shape(fields) => TypeExpr::Shape(
4062                fields
4063                    .iter()
4064                    .map(|field| ShapeField {
4065                        name: field.name.clone(),
4066                        type_expr: self.resolve_alias(&field.type_expr, scope),
4067                        optional: field.optional,
4068                    })
4069                    .collect(),
4070            ),
4071            TypeExpr::List(inner) => TypeExpr::List(Box::new(self.resolve_alias(inner, scope))),
4072            TypeExpr::Iter(inner) => TypeExpr::Iter(Box::new(self.resolve_alias(inner, scope))),
4073            TypeExpr::DictType(key, value) => TypeExpr::DictType(
4074                Box::new(self.resolve_alias(key, scope)),
4075                Box::new(self.resolve_alias(value, scope)),
4076            ),
4077            TypeExpr::FnType {
4078                params,
4079                return_type,
4080            } => TypeExpr::FnType {
4081                params: params
4082                    .iter()
4083                    .map(|param| self.resolve_alias(param, scope))
4084                    .collect(),
4085                return_type: Box::new(self.resolve_alias(return_type, scope)),
4086            },
4087            TypeExpr::Applied { name, args } => TypeExpr::Applied {
4088                name: name.clone(),
4089                args: args
4090                    .iter()
4091                    .map(|arg| self.resolve_alias(arg, scope))
4092                    .collect(),
4093            },
4094            TypeExpr::Never => TypeExpr::Never,
4095            TypeExpr::LitString(s) => TypeExpr::LitString(s.clone()),
4096            TypeExpr::LitInt(v) => TypeExpr::LitInt(*v),
4097        }
4098    }
4099
4100    /// Check declared variance on a `fn` declaration. Parameter
4101    /// types are contravariant positions; the return type is a
4102    /// covariant position.
4103    fn check_fn_decl_variance(
4104        &mut self,
4105        type_params: &[TypeParam],
4106        params: &[TypedParam],
4107        return_type: Option<&TypeExpr>,
4108        name: &str,
4109        span: Span,
4110    ) {
4111        let mut positions: Vec<(&TypeExpr, Polarity)> = Vec::new();
4112        for p in params {
4113            if let Some(te) = &p.type_expr {
4114                positions.push((te, Polarity::Contravariant));
4115            }
4116        }
4117        if let Some(rt) = return_type {
4118            positions.push((rt, Polarity::Covariant));
4119        }
4120        let kind = format!("function '{name}'");
4121        self.check_decl_variance(&kind, type_params, &positions, span);
4122    }
4123
4124    /// Check declared variance on a `type` alias. The alias body is
4125    /// treated as a covariant position (what the alias "produces").
4126    fn check_type_alias_decl_variance(
4127        &mut self,
4128        type_params: &[TypeParam],
4129        type_expr: &TypeExpr,
4130        name: &str,
4131        span: Span,
4132    ) {
4133        let positions = [(type_expr, Polarity::Covariant)];
4134        let kind = format!("type alias '{name}'");
4135        self.check_decl_variance(&kind, type_params, &positions, span);
4136    }
4137
4138    /// Check declared variance on an `enum` declaration. Variant
4139    /// field types are covariant positions (enums are produced,
4140    /// not mutated in place).
4141    fn check_enum_decl_variance(
4142        &mut self,
4143        type_params: &[TypeParam],
4144        variants: &[EnumVariant],
4145        name: &str,
4146        span: Span,
4147    ) {
4148        let mut positions: Vec<(&TypeExpr, Polarity)> = Vec::new();
4149        for variant in variants {
4150            for field in &variant.fields {
4151                if let Some(te) = &field.type_expr {
4152                    positions.push((te, Polarity::Covariant));
4153                }
4154            }
4155        }
4156        let kind = format!("enum '{name}'");
4157        self.check_decl_variance(&kind, type_params, &positions, span);
4158    }
4159
4160    /// Check declared variance on a `struct` declaration. Field
4161    /// types are invariant positions because struct fields are
4162    /// mutable in Harn. (If Harn ever gains read-only fields, those
4163    /// could be covariant.)
4164    fn check_struct_decl_variance(
4165        &mut self,
4166        type_params: &[TypeParam],
4167        fields: &[StructField],
4168        name: &str,
4169        span: Span,
4170    ) {
4171        let positions: Vec<(&TypeExpr, Polarity)> = fields
4172            .iter()
4173            .filter_map(|f| f.type_expr.as_ref().map(|te| (te, Polarity::Invariant)))
4174            .collect();
4175        let kind = format!("struct '{name}'");
4176        self.check_decl_variance(&kind, type_params, &positions, span);
4177    }
4178
4179    /// Check declared variance on an `interface` declaration.
4180    /// Method parameter types are contravariant; method return types
4181    /// are covariant. Associated types are invariant positions.
4182    fn check_interface_decl_variance(
4183        &mut self,
4184        type_params: &[TypeParam],
4185        methods: &[InterfaceMethod],
4186        name: &str,
4187        span: Span,
4188    ) {
4189        let mut positions: Vec<(&TypeExpr, Polarity)> = Vec::new();
4190        for method in methods {
4191            for p in &method.params {
4192                if let Some(te) = &p.type_expr {
4193                    positions.push((te, Polarity::Contravariant));
4194                }
4195            }
4196            if let Some(rt) = &method.return_type {
4197                positions.push((rt, Polarity::Covariant));
4198            }
4199        }
4200        let kind = format!("interface '{name}'");
4201        self.check_decl_variance(&kind, type_params, &positions, span);
4202    }
4203
4204    /// Declaration-site variance check.
4205    ///
4206    /// Given a set of declared type parameters (with their `Variance`
4207    /// annotations) and a list of positions in the declaration body
4208    /// where those parameters may appear, walk each position tracking
4209    /// polarity. A parameter declared `out T` (`Covariant`) may appear
4210    /// only in covariant positions; `in T` (`Contravariant`) only in
4211    /// contravariant positions; unannotated (`Invariant`) may appear
4212    /// anywhere.
4213    ///
4214    /// Callers pass each top-level expression together with its
4215    /// starting polarity. For example, a function's return type
4216    /// starts at `Covariant`, each parameter starts at `Contravariant`,
4217    /// an enum variant field starts at `Covariant`, etc.
4218    fn check_decl_variance(
4219        &mut self,
4220        decl_kind: &str,
4221        type_params: &[TypeParam],
4222        positions: &[(&TypeExpr, Polarity)],
4223        span: Span,
4224    ) {
4225        // Build a quick lookup: param name -> declared variance.
4226        // If no parameter has a non-invariant marker, skip the walk —
4227        // invariant params can appear in any polarity.
4228        if type_params
4229            .iter()
4230            .all(|tp| tp.variance == Variance::Invariant)
4231        {
4232            return;
4233        }
4234        let declared: BTreeMap<String, Variance> = type_params
4235            .iter()
4236            .map(|tp| (tp.name.clone(), tp.variance))
4237            .collect();
4238        for (ty, polarity) in positions {
4239            self.walk_variance(decl_kind, ty, *polarity, &declared, span);
4240        }
4241    }
4242
4243    /// Recursive walker used by [`check_decl_variance`].
4244    #[allow(clippy::only_used_in_recursion)]
4245    fn walk_variance(
4246        &mut self,
4247        decl_kind: &str,
4248        ty: &TypeExpr,
4249        polarity: Polarity,
4250        declared: &BTreeMap<String, Variance>,
4251        span: Span,
4252    ) {
4253        match ty {
4254            TypeExpr::Named(name) => {
4255                if let Some(&declared_variance) = declared.get(name) {
4256                    let ok = matches!(
4257                        (declared_variance, polarity),
4258                        (Variance::Invariant, _)
4259                            | (Variance::Covariant, Polarity::Covariant)
4260                            | (Variance::Contravariant, Polarity::Contravariant)
4261                    );
4262                    if !ok {
4263                        let (marker, declared_word) = match declared_variance {
4264                            Variance::Covariant => ("out", "covariant"),
4265                            Variance::Contravariant => ("in", "contravariant"),
4266                            Variance::Invariant => unreachable!(),
4267                        };
4268                        let position_word = match polarity {
4269                            Polarity::Covariant => "covariant",
4270                            Polarity::Contravariant => "contravariant",
4271                            Polarity::Invariant => "invariant",
4272                        };
4273                        self.error_at(
4274                            format!(
4275                                "type parameter '{name}' is declared '{marker}' \
4276                                 ({declared_word}) but appears in a \
4277                                 {position_word} position in {decl_kind}"
4278                            ),
4279                            span,
4280                        );
4281                    }
4282                }
4283            }
4284            TypeExpr::List(inner) | TypeExpr::Iter(inner) => {
4285                // `list<T>` is invariant; `iter<T>` is covariant.
4286                let sub = match ty {
4287                    TypeExpr::List(_) => Polarity::Invariant,
4288                    TypeExpr::Iter(_) => polarity,
4289                    _ => unreachable!(),
4290                };
4291                self.walk_variance(decl_kind, inner, sub, declared, span);
4292            }
4293            TypeExpr::DictType(k, v) => {
4294                // dict<K, V> is invariant in both.
4295                self.walk_variance(decl_kind, k, Polarity::Invariant, declared, span);
4296                self.walk_variance(decl_kind, v, Polarity::Invariant, declared, span);
4297            }
4298            TypeExpr::Shape(fields) => {
4299                for f in fields {
4300                    self.walk_variance(decl_kind, &f.type_expr, polarity, declared, span);
4301                }
4302            }
4303            TypeExpr::Union(members) => {
4304                for m in members {
4305                    self.walk_variance(decl_kind, m, polarity, declared, span);
4306                }
4307            }
4308            TypeExpr::FnType {
4309                params,
4310                return_type,
4311            } => {
4312                // Parameters are contravariant; return is covariant,
4313                // composed with the outer polarity.
4314                let param_polarity = polarity.compose(Variance::Contravariant);
4315                for p in params {
4316                    self.walk_variance(decl_kind, p, param_polarity, declared, span);
4317                }
4318                self.walk_variance(decl_kind, return_type, polarity, declared, span);
4319            }
4320            TypeExpr::Applied { name, args } => {
4321                // Consult the declared variance of this constructor.
4322                // Built-in `Result` has covariant-covariant; user
4323                // generics carry their own variance annotations;
4324                // unknown constructors fall back to invariance (safe).
4325                let variances: Option<Vec<Variance>> = self
4326                    .scope
4327                    .get_enum(name)
4328                    .map(|info| info.type_params.iter().map(|tp| tp.variance).collect())
4329                    .or_else(|| {
4330                        self.scope
4331                            .get_struct(name)
4332                            .map(|info| info.type_params.iter().map(|tp| tp.variance).collect())
4333                    })
4334                    .or_else(|| {
4335                        self.scope
4336                            .get_interface(name)
4337                            .map(|info| info.type_params.iter().map(|tp| tp.variance).collect())
4338                    });
4339                for (idx, arg) in args.iter().enumerate() {
4340                    let child_variance = variances
4341                        .as_ref()
4342                        .and_then(|v| v.get(idx).copied())
4343                        .unwrap_or(Variance::Invariant);
4344                    let sub = polarity.compose(child_variance);
4345                    self.walk_variance(decl_kind, arg, sub, declared, span);
4346                }
4347            }
4348            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => {}
4349        }
4350    }
4351
4352    fn error_at(&mut self, message: String, span: Span) {
4353        self.diagnostics.push(TypeDiagnostic {
4354            message,
4355            severity: DiagnosticSeverity::Error,
4356            span: Some(span),
4357            help: None,
4358            fix: None,
4359        });
4360    }
4361
4362    #[allow(dead_code)]
4363    fn error_at_with_help(&mut self, message: String, span: Span, help: String) {
4364        self.diagnostics.push(TypeDiagnostic {
4365            message,
4366            severity: DiagnosticSeverity::Error,
4367            span: Some(span),
4368            help: Some(help),
4369            fix: None,
4370        });
4371    }
4372
4373    fn error_at_with_fix(&mut self, message: String, span: Span, fix: Vec<FixEdit>) {
4374        self.diagnostics.push(TypeDiagnostic {
4375            message,
4376            severity: DiagnosticSeverity::Error,
4377            span: Some(span),
4378            help: None,
4379            fix: Some(fix),
4380        });
4381    }
4382
4383    fn warning_at(&mut self, message: String, span: Span) {
4384        self.diagnostics.push(TypeDiagnostic {
4385            message,
4386            severity: DiagnosticSeverity::Warning,
4387            span: Some(span),
4388            help: None,
4389            fix: None,
4390        });
4391    }
4392
4393    #[allow(dead_code)]
4394    fn warning_at_with_help(&mut self, message: String, span: Span, help: String) {
4395        self.diagnostics.push(TypeDiagnostic {
4396            message,
4397            severity: DiagnosticSeverity::Warning,
4398            span: Some(span),
4399            help: Some(help),
4400            fix: None,
4401        });
4402    }
4403
4404    /// Recursively validate binary operations in an expression tree.
4405    /// Unlike `check_node`, this only checks BinaryOp type compatibility
4406    /// without triggering other validations (e.g., function call arg checks).
4407    fn check_binops(&mut self, snode: &SNode, scope: &mut TypeScope) {
4408        match &snode.node {
4409            Node::BinaryOp { op, left, right } => {
4410                self.check_binops(left, scope);
4411                self.check_binops(right, scope);
4412                let lt = self.infer_type(left, scope);
4413                let rt = self.infer_type(right, scope);
4414                if let (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) = (&lt, &rt) {
4415                    let span = snode.span;
4416                    match op.as_str() {
4417                        "+" => {
4418                            let valid = matches!(
4419                                (l.as_str(), r.as_str()),
4420                                ("int" | "float", "int" | "float")
4421                                    | ("string", "string")
4422                                    | ("list", "list")
4423                                    | ("dict", "dict")
4424                            );
4425                            if !valid {
4426                                let msg = format!("can't add {} and {}", l, r);
4427                                let fix = if l == "string" || r == "string" {
4428                                    self.build_interpolation_fix(left, right, l == "string", span)
4429                                } else {
4430                                    None
4431                                };
4432                                if let Some(fix) = fix {
4433                                    self.error_at_with_fix(msg, span, fix);
4434                                } else {
4435                                    self.error_at(msg, span);
4436                                }
4437                            }
4438                        }
4439                        "-" | "/" | "%" | "**" => {
4440                            let numeric = ["int", "float"];
4441                            if !numeric.contains(&l.as_str()) || !numeric.contains(&r.as_str()) {
4442                                self.error_at(
4443                                    format!(
4444                                        "can't use '{}' on {} and {} (needs numeric operands)",
4445                                        op, l, r
4446                                    ),
4447                                    span,
4448                                );
4449                            }
4450                        }
4451                        "*" => {
4452                            let numeric = ["int", "float"];
4453                            let is_numeric =
4454                                numeric.contains(&l.as_str()) && numeric.contains(&r.as_str());
4455                            let is_string_repeat =
4456                                (l == "string" && r == "int") || (l == "int" && r == "string");
4457                            if !is_numeric && !is_string_repeat {
4458                                self.error_at(
4459                                    format!("can't multiply {} and {} (try string * int)", l, r),
4460                                    span,
4461                                );
4462                            }
4463                        }
4464                        _ => {}
4465                    }
4466                }
4467            }
4468            // Recurse into sub-expressions that might contain BinaryOps
4469            Node::UnaryOp { operand, .. } => self.check_binops(operand, scope),
4470            _ => {}
4471        }
4472    }
4473
4474    /// Build a fix that converts `"str" + expr` or `expr + "str"` to string interpolation.
4475    fn build_interpolation_fix(
4476        &self,
4477        left: &SNode,
4478        right: &SNode,
4479        left_is_string: bool,
4480        expr_span: Span,
4481    ) -> Option<Vec<FixEdit>> {
4482        let src = self.source.as_ref()?;
4483        let (str_node, other_node) = if left_is_string {
4484            (left, right)
4485        } else {
4486            (right, left)
4487        };
4488        let str_text = src.get(str_node.span.start..str_node.span.end)?;
4489        let other_text = src.get(other_node.span.start..other_node.span.end)?;
4490        // Only handle simple double-quoted strings (not multiline/raw)
4491        let inner = str_text.strip_prefix('"')?.strip_suffix('"')?;
4492        // Skip if the expression contains characters that would break interpolation
4493        if other_text.contains('}') || other_text.contains('"') {
4494            return None;
4495        }
4496        let replacement = if left_is_string {
4497            format!("\"{inner}${{{other_text}}}\"")
4498        } else {
4499            format!("\"${{{other_text}}}{inner}\"")
4500        };
4501        Some(vec![FixEdit {
4502            span: expr_span,
4503            replacement,
4504        }])
4505    }
4506}
4507
4508impl Default for TypeChecker {
4509    fn default() -> Self {
4510        Self::new()
4511    }
4512}
4513
4514/// Infer the result type of a binary operation.
4515fn infer_binary_op_type(op: &str, left: &InferredType, right: &InferredType) -> InferredType {
4516    match op {
4517        "==" | "!=" | "<" | ">" | "<=" | ">=" | "&&" | "||" | "in" | "not_in" => {
4518            Some(TypeExpr::Named("bool".into()))
4519        }
4520        "+" => match (left, right) {
4521            (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) => {
4522                match (l.as_str(), r.as_str()) {
4523                    ("int", "int") => Some(TypeExpr::Named("int".into())),
4524                    ("float", _) | (_, "float") => Some(TypeExpr::Named("float".into())),
4525                    ("string", "string") => Some(TypeExpr::Named("string".into())),
4526                    ("list", "list") => Some(TypeExpr::Named("list".into())),
4527                    ("dict", "dict") => Some(TypeExpr::Named("dict".into())),
4528                    _ => None,
4529                }
4530            }
4531            _ => None,
4532        },
4533        "-" | "/" | "%" => match (left, right) {
4534            (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) => {
4535                match (l.as_str(), r.as_str()) {
4536                    ("int", "int") => Some(TypeExpr::Named("int".into())),
4537                    ("float", _) | (_, "float") => Some(TypeExpr::Named("float".into())),
4538                    _ => None,
4539                }
4540            }
4541            _ => None,
4542        },
4543        "**" => match (left, right) {
4544            (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) => {
4545                match (l.as_str(), r.as_str()) {
4546                    ("int", "int") => Some(TypeExpr::Named("int".into())),
4547                    ("float", _) | (_, "float") => Some(TypeExpr::Named("float".into())),
4548                    _ => None,
4549                }
4550            }
4551            _ => None,
4552        },
4553        "*" => match (left, right) {
4554            (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) => {
4555                match (l.as_str(), r.as_str()) {
4556                    ("string", "int") | ("int", "string") => Some(TypeExpr::Named("string".into())),
4557                    ("int", "int") => Some(TypeExpr::Named("int".into())),
4558                    ("float", _) | (_, "float") => Some(TypeExpr::Named("float".into())),
4559                    _ => None,
4560                }
4561            }
4562            _ => None,
4563        },
4564        "??" => match (left, right) {
4565            // Union containing nil: strip nil, use non-nil members
4566            (Some(TypeExpr::Union(members)), _) => {
4567                let non_nil: Vec<_> = members
4568                    .iter()
4569                    .filter(|m| !matches!(m, TypeExpr::Named(n) if n == "nil"))
4570                    .cloned()
4571                    .collect();
4572                if non_nil.len() == 1 {
4573                    Some(non_nil[0].clone())
4574                } else if non_nil.is_empty() {
4575                    right.clone()
4576                } else {
4577                    Some(TypeExpr::Union(non_nil))
4578                }
4579            }
4580            // Left is nil: result is always the right side
4581            (Some(TypeExpr::Named(n)), _) if n == "nil" => right.clone(),
4582            // Left is a known non-nil type: right is unreachable, preserve left
4583            (Some(l), _) => Some(l.clone()),
4584            // Unknown left: use right as best guess
4585            (None, _) => right.clone(),
4586        },
4587        "|>" => None,
4588        _ => None,
4589    }
4590}
4591
4592/// Format a type expression for display in error messages.
4593/// Produce a detail string describing why a Shape type is incompatible with
4594/// another Shape type — e.g. "missing field 'age' (int)" or "field 'name'
4595/// has type int, expected string".  Returns `None` if both types are not shapes.
4596pub fn shape_mismatch_detail(expected: &TypeExpr, actual: &TypeExpr) -> Option<String> {
4597    if let (TypeExpr::Shape(ef), TypeExpr::Shape(af)) = (expected, actual) {
4598        let mut details = Vec::new();
4599        for field in ef {
4600            if field.optional {
4601                continue;
4602            }
4603            match af.iter().find(|f| f.name == field.name) {
4604                None => details.push(format!(
4605                    "missing field '{}' ({})",
4606                    field.name,
4607                    format_type(&field.type_expr)
4608                )),
4609                Some(actual_field) => {
4610                    let e_str = format_type(&field.type_expr);
4611                    let a_str = format_type(&actual_field.type_expr);
4612                    if e_str != a_str {
4613                        details.push(format!(
4614                            "field '{}' has type {}, expected {}",
4615                            field.name, a_str, e_str
4616                        ));
4617                    }
4618                }
4619            }
4620        }
4621        if details.is_empty() {
4622            None
4623        } else {
4624            Some(details.join("; "))
4625        }
4626    } else {
4627        None
4628    }
4629}
4630
4631/// Returns true when the type is obvious from the RHS expression
4632/// (e.g. `let x = 42` is obviously int — no hint needed).
4633fn is_obvious_type(value: &SNode, _ty: &TypeExpr) -> bool {
4634    matches!(
4635        &value.node,
4636        Node::IntLiteral(_)
4637            | Node::FloatLiteral(_)
4638            | Node::StringLiteral(_)
4639            | Node::BoolLiteral(_)
4640            | Node::NilLiteral
4641            | Node::ListLiteral(_)
4642            | Node::DictLiteral(_)
4643            | Node::InterpolatedString(_)
4644    )
4645}
4646
4647/// Check whether a single statement definitely exits (return/throw/break/continue
4648/// or an if/else where both branches exit).
4649pub fn stmt_definitely_exits(stmt: &SNode) -> bool {
4650    match &stmt.node {
4651        Node::ReturnStmt { .. } | Node::ThrowStmt { .. } | Node::BreakStmt | Node::ContinueStmt => {
4652            true
4653        }
4654        Node::IfElse {
4655            then_body,
4656            else_body: Some(else_body),
4657            ..
4658        } => block_definitely_exits(then_body) && block_definitely_exits(else_body),
4659        _ => false,
4660    }
4661}
4662
4663/// Check whether a block definitely exits (contains a terminating statement).
4664pub fn block_definitely_exits(stmts: &[SNode]) -> bool {
4665    stmts.iter().any(stmt_definitely_exits)
4666}
4667
4668pub fn format_type(ty: &TypeExpr) -> String {
4669    match ty {
4670        TypeExpr::Named(n) => n.clone(),
4671        TypeExpr::Union(types) => types
4672            .iter()
4673            .map(format_type)
4674            .collect::<Vec<_>>()
4675            .join(" | "),
4676        TypeExpr::Shape(fields) => {
4677            let inner: Vec<String> = fields
4678                .iter()
4679                .map(|f| {
4680                    let opt = if f.optional { "?" } else { "" };
4681                    format!("{}{opt}: {}", f.name, format_type(&f.type_expr))
4682                })
4683                .collect();
4684            format!("{{{}}}", inner.join(", "))
4685        }
4686        TypeExpr::List(inner) => format!("list<{}>", format_type(inner)),
4687        TypeExpr::Iter(inner) => format!("iter<{}>", format_type(inner)),
4688        TypeExpr::DictType(k, v) => format!("dict<{}, {}>", format_type(k), format_type(v)),
4689        TypeExpr::Applied { name, args } => {
4690            let args_str = args.iter().map(format_type).collect::<Vec<_>>().join(", ");
4691            format!("{name}<{args_str}>")
4692        }
4693        TypeExpr::FnType {
4694            params,
4695            return_type,
4696        } => {
4697            let params_str = params
4698                .iter()
4699                .map(format_type)
4700                .collect::<Vec<_>>()
4701                .join(", ");
4702            format!("fn({}) -> {}", params_str, format_type(return_type))
4703        }
4704        TypeExpr::Never => "never".to_string(),
4705        TypeExpr::LitString(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
4706        TypeExpr::LitInt(v) => v.to_string(),
4707    }
4708}
4709
4710/// Simplify a union by removing `Never` members and collapsing.
4711fn simplify_union(members: Vec<TypeExpr>) -> TypeExpr {
4712    let filtered: Vec<TypeExpr> = members
4713        .into_iter()
4714        .filter(|m| !matches!(m, TypeExpr::Never))
4715        .collect();
4716    match filtered.len() {
4717        0 => TypeExpr::Never,
4718        1 => filtered.into_iter().next().unwrap(),
4719        _ => TypeExpr::Union(filtered),
4720    }
4721}
4722
4723/// Remove a named type from a union, collapsing single-element unions.
4724/// Returns `Some(Never)` when all members are removed (exhausted).
4725fn remove_from_union(members: &[TypeExpr], to_remove: &str) -> InferredType {
4726    let remaining: Vec<TypeExpr> = members
4727        .iter()
4728        .filter(|m| !matches!(m, TypeExpr::Named(n) if n == to_remove))
4729        .cloned()
4730        .collect();
4731    match remaining.len() {
4732        0 => Some(TypeExpr::Never),
4733        1 => Some(remaining.into_iter().next().unwrap()),
4734        _ => Some(TypeExpr::Union(remaining)),
4735    }
4736}
4737
4738/// Narrow a union to just one named type, if that type is a member.
4739fn narrow_to_single(members: &[TypeExpr], target: &str) -> InferredType {
4740    if members
4741        .iter()
4742        .any(|m| matches!(m, TypeExpr::Named(n) if n == target))
4743    {
4744        Some(TypeExpr::Named(target.to_string()))
4745    } else {
4746        None
4747    }
4748}
4749
4750/// Extract the variable name from a `type_of(x)` call.
4751fn extract_type_of_var(node: &SNode) -> Option<String> {
4752    if let Node::FunctionCall { name, args } = &node.node {
4753        if name == "type_of" && args.len() == 1 {
4754            if let Node::Identifier(var) = &args[0].node {
4755                return Some(var.clone());
4756            }
4757        }
4758    }
4759    None
4760}
4761
4762fn schema_type_expr_from_node(node: &SNode, scope: &TypeScope) -> Option<TypeExpr> {
4763    match &node.node {
4764        Node::Identifier(name) => {
4765            // Prefer schema bindings (runtime dicts), then fall back to a
4766            // declared `type` alias with the same name. This powers
4767            // `schema_is(x, T)` / `schema_expect(x, T)` narrowing when `T`
4768            // is a type alias rather than a schema-dict binding.
4769            if let Some(schema) = scope.get_schema_binding(name).cloned().flatten() {
4770                return Some(schema);
4771            }
4772            scope.resolve_type(name).cloned()
4773        }
4774        Node::DictLiteral(entries) => schema_type_expr_from_dict(entries, scope),
4775        // `schema_of(T)` is a runtime function that returns the JSON-Schema
4776        // dict for a type alias. When its result is passed into a position
4777        // typed `Schema<T>`, we resolve the underlying alias to its
4778        // TypeExpr so the generic binding can extract `T`.
4779        Node::FunctionCall { name, args } if name == "schema_of" && args.len() == 1 => {
4780            if let Node::Identifier(alias) = &args[0].node {
4781                return scope.resolve_type(alias).cloned();
4782            }
4783            None
4784        }
4785        _ => None,
4786    }
4787}
4788
4789fn schema_type_expr_from_dict(entries: &[DictEntry], scope: &TypeScope) -> Option<TypeExpr> {
4790    let mut type_name: Option<String> = None;
4791    let mut properties: Option<&SNode> = None;
4792    let mut required: Option<Vec<String>> = None;
4793    let mut items: Option<&SNode> = None;
4794    let mut union: Option<&SNode> = None;
4795    let mut nullable = false;
4796    let mut additional_properties: Option<&SNode> = None;
4797
4798    for entry in entries {
4799        let key = schema_entry_key(&entry.key)?;
4800        match key.as_str() {
4801            "type" => match &entry.value.node {
4802                Node::StringLiteral(text) | Node::RawStringLiteral(text) => {
4803                    type_name = Some(normalize_schema_type_name(text));
4804                }
4805                Node::ListLiteral(items_list) => {
4806                    let union_members = items_list
4807                        .iter()
4808                        .filter_map(|item| match &item.node {
4809                            Node::StringLiteral(text) | Node::RawStringLiteral(text) => {
4810                                Some(TypeExpr::Named(normalize_schema_type_name(text)))
4811                            }
4812                            _ => None,
4813                        })
4814                        .collect::<Vec<_>>();
4815                    if !union_members.is_empty() {
4816                        return Some(TypeExpr::Union(union_members));
4817                    }
4818                }
4819                _ => {}
4820            },
4821            "properties" => properties = Some(&entry.value),
4822            "required" => {
4823                required = schema_required_names(&entry.value);
4824            }
4825            "items" => items = Some(&entry.value),
4826            "union" | "oneOf" | "anyOf" => union = Some(&entry.value),
4827            "nullable" => {
4828                nullable = matches!(entry.value.node, Node::BoolLiteral(true));
4829            }
4830            "additional_properties" | "additionalProperties" => {
4831                additional_properties = Some(&entry.value);
4832            }
4833            _ => {}
4834        }
4835    }
4836
4837    let mut schema_type = if let Some(union_node) = union {
4838        schema_union_type_expr(union_node, scope)?
4839    } else if let Some(properties_node) = properties {
4840        let property_entries = match &properties_node.node {
4841            Node::DictLiteral(entries) => entries,
4842            _ => return None,
4843        };
4844        let required_names = required.unwrap_or_default();
4845        let mut fields = Vec::new();
4846        for entry in property_entries {
4847            let field_name = schema_entry_key(&entry.key)?;
4848            let field_type = schema_type_expr_from_node(&entry.value, scope)?;
4849            fields.push(ShapeField {
4850                name: field_name.clone(),
4851                type_expr: field_type,
4852                optional: !required_names.contains(&field_name),
4853            });
4854        }
4855        TypeExpr::Shape(fields)
4856    } else if let Some(item_node) = items {
4857        TypeExpr::List(Box::new(schema_type_expr_from_node(item_node, scope)?))
4858    } else if let Some(type_name) = type_name {
4859        if type_name == "dict" {
4860            if let Some(extra_node) = additional_properties {
4861                let value_type = match &extra_node.node {
4862                    Node::BoolLiteral(_) => None,
4863                    _ => schema_type_expr_from_node(extra_node, scope),
4864                };
4865                if let Some(value_type) = value_type {
4866                    TypeExpr::DictType(
4867                        Box::new(TypeExpr::Named("string".into())),
4868                        Box::new(value_type),
4869                    )
4870                } else {
4871                    TypeExpr::Named(type_name)
4872                }
4873            } else {
4874                TypeExpr::Named(type_name)
4875            }
4876        } else {
4877            TypeExpr::Named(type_name)
4878        }
4879    } else {
4880        return None;
4881    };
4882
4883    if nullable {
4884        schema_type = match schema_type {
4885            TypeExpr::Union(mut members) => {
4886                if !members
4887                    .iter()
4888                    .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil"))
4889                {
4890                    members.push(TypeExpr::Named("nil".into()));
4891                }
4892                TypeExpr::Union(members)
4893            }
4894            other => TypeExpr::Union(vec![other, TypeExpr::Named("nil".into())]),
4895        };
4896    }
4897
4898    Some(schema_type)
4899}
4900
4901fn schema_union_type_expr(node: &SNode, scope: &TypeScope) -> Option<TypeExpr> {
4902    let Node::ListLiteral(items) = &node.node else {
4903        return None;
4904    };
4905    let members = items
4906        .iter()
4907        .filter_map(|item| schema_type_expr_from_node(item, scope))
4908        .collect::<Vec<_>>();
4909    match members.len() {
4910        0 => None,
4911        1 => members.into_iter().next(),
4912        _ => Some(TypeExpr::Union(members)),
4913    }
4914}
4915
4916fn schema_required_names(node: &SNode) -> Option<Vec<String>> {
4917    let Node::ListLiteral(items) = &node.node else {
4918        return None;
4919    };
4920    Some(
4921        items
4922            .iter()
4923            .filter_map(|item| match &item.node {
4924                Node::StringLiteral(text) | Node::RawStringLiteral(text) => Some(text.clone()),
4925                Node::Identifier(text) => Some(text.clone()),
4926                _ => None,
4927            })
4928            .collect(),
4929    )
4930}
4931
4932fn schema_entry_key(node: &SNode) -> Option<String> {
4933    match &node.node {
4934        Node::Identifier(name) => Some(name.clone()),
4935        Node::StringLiteral(name) | Node::RawStringLiteral(name) => Some(name.clone()),
4936        _ => None,
4937    }
4938}
4939
4940fn normalize_schema_type_name(text: &str) -> String {
4941    match text {
4942        "object" => "dict".into(),
4943        "array" => "list".into(),
4944        "integer" => "int".into(),
4945        "number" => "float".into(),
4946        "boolean" => "bool".into(),
4947        "null" => "nil".into(),
4948        other => other.into(),
4949    }
4950}
4951
4952fn intersect_types(current: &TypeExpr, schema_type: &TypeExpr) -> Option<TypeExpr> {
4953    match (current, schema_type) {
4954        // Literal intersections: two equal literals keep the literal.
4955        (TypeExpr::LitString(a), TypeExpr::LitString(b)) if a == b => {
4956            Some(TypeExpr::LitString(a.clone()))
4957        }
4958        (TypeExpr::LitInt(a), TypeExpr::LitInt(b)) if a == b => Some(TypeExpr::LitInt(*a)),
4959        // Intersecting a literal with its base type keeps the literal.
4960        (TypeExpr::LitString(s), TypeExpr::Named(n))
4961        | (TypeExpr::Named(n), TypeExpr::LitString(s))
4962            if n == "string" =>
4963        {
4964            Some(TypeExpr::LitString(s.clone()))
4965        }
4966        (TypeExpr::LitInt(v), TypeExpr::Named(n)) | (TypeExpr::Named(n), TypeExpr::LitInt(v))
4967            if n == "int" || n == "float" =>
4968        {
4969            Some(TypeExpr::LitInt(*v))
4970        }
4971        (TypeExpr::Union(members), other) => {
4972            let kept = members
4973                .iter()
4974                .filter_map(|member| intersect_types(member, other))
4975                .collect::<Vec<_>>();
4976            match kept.len() {
4977                0 => None,
4978                1 => kept.into_iter().next(),
4979                _ => Some(TypeExpr::Union(kept)),
4980            }
4981        }
4982        (other, TypeExpr::Union(members)) => {
4983            let kept = members
4984                .iter()
4985                .filter_map(|member| intersect_types(other, member))
4986                .collect::<Vec<_>>();
4987            match kept.len() {
4988                0 => None,
4989                1 => kept.into_iter().next(),
4990                _ => Some(TypeExpr::Union(kept)),
4991            }
4992        }
4993        (TypeExpr::Named(left), TypeExpr::Named(right)) if left == right => {
4994            Some(TypeExpr::Named(left.clone()))
4995        }
4996        (TypeExpr::Named(name), TypeExpr::Shape(fields)) if name == "dict" => {
4997            Some(TypeExpr::Shape(fields.clone()))
4998        }
4999        (TypeExpr::Shape(fields), TypeExpr::Named(name)) if name == "dict" => {
5000            Some(TypeExpr::Shape(fields.clone()))
5001        }
5002        (TypeExpr::Named(name), TypeExpr::List(inner)) if name == "list" => {
5003            Some(TypeExpr::List(inner.clone()))
5004        }
5005        (TypeExpr::List(inner), TypeExpr::Named(name)) if name == "list" => {
5006            Some(TypeExpr::List(inner.clone()))
5007        }
5008        (TypeExpr::Named(name), TypeExpr::DictType(key, value)) if name == "dict" => {
5009            Some(TypeExpr::DictType(key.clone(), value.clone()))
5010        }
5011        (TypeExpr::DictType(key, value), TypeExpr::Named(name)) if name == "dict" => {
5012            Some(TypeExpr::DictType(key.clone(), value.clone()))
5013        }
5014        (TypeExpr::Shape(_), TypeExpr::Shape(fields)) => Some(TypeExpr::Shape(fields.clone())),
5015        (TypeExpr::List(current_inner), TypeExpr::List(schema_inner)) => {
5016            intersect_types(current_inner, schema_inner)
5017                .map(|inner| TypeExpr::List(Box::new(inner)))
5018        }
5019        (
5020            TypeExpr::DictType(current_key, current_value),
5021            TypeExpr::DictType(schema_key, schema_value),
5022        ) => {
5023            let key = intersect_types(current_key, schema_key)?;
5024            let value = intersect_types(current_value, schema_value)?;
5025            Some(TypeExpr::DictType(Box::new(key), Box::new(value)))
5026        }
5027        _ => None,
5028    }
5029}
5030
5031fn subtract_type(current: &TypeExpr, schema_type: &TypeExpr) -> Option<TypeExpr> {
5032    match current {
5033        TypeExpr::Union(members) => {
5034            let remaining = members
5035                .iter()
5036                .filter(|member| intersect_types(member, schema_type).is_none())
5037                .cloned()
5038                .collect::<Vec<_>>();
5039            match remaining.len() {
5040                0 => None,
5041                1 => remaining.into_iter().next(),
5042                _ => Some(TypeExpr::Union(remaining)),
5043            }
5044        }
5045        other if intersect_types(other, schema_type).is_some() => None,
5046        other => Some(other.clone()),
5047    }
5048}
5049
5050/// Apply a list of refinements to a scope, tracking pre-narrowing types.
5051fn apply_refinements(scope: &mut TypeScope, refinements: &[(String, InferredType)]) {
5052    for (var_name, narrowed_type) in refinements {
5053        // Save the pre-narrowing type so we can restore it on reassignment
5054        if !scope.narrowed_vars.contains_key(var_name) {
5055            if let Some(original) = scope.get_var(var_name).cloned() {
5056                scope.narrowed_vars.insert(var_name.clone(), original);
5057            }
5058        }
5059        scope.define_var(var_name, narrowed_type.clone());
5060    }
5061}
5062
5063#[cfg(test)]
5064mod tests {
5065    use super::*;
5066    use crate::Parser;
5067    use harn_lexer::Lexer;
5068
5069    fn check_source(source: &str) -> Vec<TypeDiagnostic> {
5070        let mut lexer = Lexer::new(source);
5071        let tokens = lexer.tokenize().unwrap();
5072        let mut parser = Parser::new(tokens);
5073        let program = parser.parse().unwrap();
5074        TypeChecker::new().check(&program)
5075    }
5076
5077    fn errors(source: &str) -> Vec<String> {
5078        check_source(source)
5079            .into_iter()
5080            .filter(|d| d.severity == DiagnosticSeverity::Error)
5081            .map(|d| d.message)
5082            .collect()
5083    }
5084
5085    #[test]
5086    fn test_no_errors_for_untyped_code() {
5087        let errs = errors("pipeline t(task) { let x = 42\nlog(x) }");
5088        assert!(errs.is_empty());
5089    }
5090
5091    #[test]
5092    fn test_correct_typed_let() {
5093        let errs = errors("pipeline t(task) { let x: int = 42 }");
5094        assert!(errs.is_empty());
5095    }
5096
5097    #[test]
5098    fn test_type_mismatch_let() {
5099        let errs = errors(r#"pipeline t(task) { let x: int = "hello" }"#);
5100        assert_eq!(errs.len(), 1);
5101        assert!(errs[0].contains("declared as int"));
5102        assert!(errs[0].contains("assigned string"));
5103    }
5104
5105    #[test]
5106    fn test_correct_typed_fn() {
5107        let errs = errors(
5108            "pipeline t(task) { fn add(a: int, b: int) -> int { return a + b }\nadd(1, 2) }",
5109        );
5110        assert!(errs.is_empty());
5111    }
5112
5113    #[test]
5114    fn test_fn_arg_type_mismatch() {
5115        let errs = errors(
5116            r#"pipeline t(task) { fn add(a: int, b: int) -> int { return a + b }
5117add("hello", 2) }"#,
5118        );
5119        assert_eq!(errs.len(), 1);
5120        assert!(errs[0].contains("Argument 1"));
5121        assert!(errs[0].contains("expected int"));
5122    }
5123
5124    #[test]
5125    fn test_return_type_mismatch() {
5126        let errs = errors(r#"pipeline t(task) { fn get() -> int { return "hello" } }"#);
5127        assert_eq!(errs.len(), 1);
5128        assert!(errs[0].contains("return type doesn't match"));
5129    }
5130
5131    #[test]
5132    fn test_union_type_compatible() {
5133        let errs = errors(r#"pipeline t(task) { let x: string | nil = nil }"#);
5134        assert!(errs.is_empty());
5135    }
5136
5137    #[test]
5138    fn test_union_type_mismatch() {
5139        let errs = errors(r#"pipeline t(task) { let x: string | nil = 42 }"#);
5140        assert_eq!(errs.len(), 1);
5141        assert!(errs[0].contains("declared as"));
5142    }
5143
5144    #[test]
5145    fn test_type_inference_propagation() {
5146        let errs = errors(
5147            r#"pipeline t(task) {
5148  fn add(a: int, b: int) -> int { return a + b }
5149  let result: string = add(1, 2)
5150}"#,
5151        );
5152        assert_eq!(errs.len(), 1);
5153        assert!(errs[0].contains("declared as"));
5154        assert!(errs[0].contains("string"));
5155        assert!(errs[0].contains("int"));
5156    }
5157
5158    #[test]
5159    fn test_generic_return_type_instantiates_from_callsite() {
5160        let errs = errors(
5161            r#"pipeline t(task) {
5162  fn identity<T>(x: T) -> T { return x }
5163  fn first<T>(items: list<T>) -> T { return items[0] }
5164  let n: int = identity(42)
5165  let s: string = first(["a", "b"])
5166}"#,
5167        );
5168        assert!(errs.is_empty(), "unexpected type errors: {errs:?}");
5169    }
5170
5171    #[test]
5172    fn test_generic_type_param_must_bind_consistently() {
5173        let errs = errors(
5174            r#"pipeline t(task) {
5175  fn keep<T>(a: T, b: T) -> T { return a }
5176  keep(1, "x")
5177}"#,
5178        );
5179        assert_eq!(errs.len(), 2, "expected 2 errors, got: {:?}", errs);
5180        assert!(
5181            errs.iter()
5182                .any(|err| err.contains("type parameter 'T' was inferred as both int and string")),
5183            "missing generic binding conflict error: {:?}",
5184            errs
5185        );
5186        assert!(
5187            errs.iter()
5188                .any(|err| err.contains("Argument 2 ('b'): expected int, got string")),
5189            "missing instantiated argument mismatch error: {:?}",
5190            errs
5191        );
5192    }
5193
5194    #[test]
5195    fn test_generic_list_binding_propagates_element_type() {
5196        let errs = errors(
5197            r#"pipeline t(task) {
5198  fn first<T>(items: list<T>) -> T { return items[0] }
5199  let bad: string = first([1, 2, 3])
5200}"#,
5201        );
5202        assert_eq!(errs.len(), 1, "expected 1 error, got: {:?}", errs);
5203        assert!(errs[0].contains("declared as string, but assigned int"));
5204    }
5205
5206    #[test]
5207    fn test_generic_struct_literal_instantiates_type_arguments() {
5208        let errs = errors(
5209            r#"pipeline t(task) {
5210  struct Pair<A, B> {
5211    first: A
5212    second: B
5213  }
5214  let pair: Pair<int, string> = Pair { first: 1, second: "two" }
5215}"#,
5216        );
5217        assert!(errs.is_empty(), "unexpected type errors: {errs:?}");
5218    }
5219
5220    #[test]
5221    fn test_generic_enum_construct_instantiates_type_arguments() {
5222        let errs = errors(
5223            r#"pipeline t(task) {
5224  enum Option<T> {
5225    Some(value: T),
5226    None
5227  }
5228  let value: Option<int> = Option.Some(42)
5229}"#,
5230        );
5231        assert!(errs.is_empty(), "unexpected type errors: {errs:?}");
5232    }
5233
5234    #[test]
5235    fn test_result_generic_type_compatibility() {
5236        let errs = errors(
5237            r#"pipeline t(task) {
5238  let ok: Result<int, string> = Result.Ok(42)
5239  let err: Result<int, string> = Result.Err("oops")
5240}"#,
5241        );
5242        assert!(errs.is_empty(), "unexpected type errors: {errs:?}");
5243    }
5244
5245    #[test]
5246    fn test_result_generic_type_mismatch_reports_error() {
5247        let errs = errors(
5248            r#"pipeline t(task) {
5249  let bad: Result<int, string> = Result.Err(42)
5250}"#,
5251        );
5252        assert_eq!(errs.len(), 1, "expected 1 error, got: {errs:?}");
5253        assert!(errs[0].contains("Result<int, string>"));
5254        assert!(errs[0].contains("Result<_, int>"));
5255    }
5256
5257    #[test]
5258    fn test_builtin_return_type_inference() {
5259        let errs = errors(r#"pipeline t(task) { let x: string = to_int("42") }"#);
5260        assert_eq!(errs.len(), 1);
5261        assert!(errs[0].contains("string"));
5262        assert!(errs[0].contains("int"));
5263    }
5264
5265    #[test]
5266    fn test_workflow_and_transcript_builtins_are_known() {
5267        let errs = errors(
5268            r#"pipeline t(task) {
5269  let flow = workflow_graph({name: "demo", entry: "act", nodes: {act: {kind: "stage"}}})
5270  let report: dict = workflow_policy_report(flow, {tools: tool_registry(), capabilities: {workspace: ["read_text"]}})
5271  let run: dict = workflow_execute("task", flow, [], {})
5272  let tree: dict = load_run_tree("run.json")
5273  let fixture: dict = run_record_fixture(run?.run)
5274  let suite: dict = run_record_eval_suite([{run: run?.run, fixture: fixture}])
5275  let diff: dict = run_record_diff(run?.run, run?.run)
5276  let manifest: dict = eval_suite_manifest({cases: [{run_path: "run.json"}]})
5277  let suite_report: dict = eval_suite_run(manifest)
5278  let wf: dict = artifact_workspace_file("src/main.rs", "fn main() {}", {source: "host"})
5279  let snap: dict = artifact_workspace_snapshot(["src/main.rs"], "snapshot")
5280  let selection: dict = artifact_editor_selection("src/main.rs", "main")
5281  let verify: dict = artifact_verification_result("verify", "ok")
5282  let test_result: dict = artifact_test_result("tests", "pass")
5283  let cmd: dict = artifact_command_result("cargo test", {status: 0})
5284  let patch: dict = artifact_diff("src/main.rs", "old", "new")
5285  let git: dict = artifact_git_diff("diff --git a b")
5286  let review: dict = artifact_diff_review(patch, "review me")
5287  let decision: dict = artifact_review_decision(review, "accepted")
5288  let proposal: dict = artifact_patch_proposal(review, "*** Begin Patch")
5289  let bundle: dict = artifact_verification_bundle("checks", [{name: "fmt", ok: true}])
5290  let apply: dict = artifact_apply_intent(review, "apply")
5291  let transcript = transcript_reset({metadata: {source: "test"}})
5292  let visible: string = transcript_render_visible(transcript_archive(transcript))
5293  let events: list = transcript_events(transcript)
5294  let context: string = artifact_context([], {max_artifacts: 1})
5295  println(report)
5296  println(run)
5297  println(tree)
5298  println(fixture)
5299  println(suite)
5300  println(diff)
5301  println(manifest)
5302  println(suite_report)
5303  println(wf)
5304  println(snap)
5305  println(selection)
5306  println(verify)
5307  println(test_result)
5308  println(cmd)
5309  println(patch)
5310  println(git)
5311  println(review)
5312  println(decision)
5313  println(proposal)
5314  println(bundle)
5315  println(apply)
5316  println(visible)
5317  println(events)
5318  println(context)
5319}"#,
5320        );
5321        assert!(errs.is_empty(), "unexpected type errors: {errs:?}");
5322    }
5323
5324    #[test]
5325    fn test_binary_op_type_inference() {
5326        let errs = errors("pipeline t(task) { let x: string = 1 + 2 }");
5327        assert_eq!(errs.len(), 1);
5328    }
5329
5330    #[test]
5331    fn test_exponentiation_requires_numeric_operands() {
5332        let errs = errors(r#"pipeline t(task) { let x = "nope" ** 2 }"#);
5333        assert!(
5334            errs.iter().any(|err| err.contains("can't use '**'")),
5335            "missing exponentiation type error: {errs:?}"
5336        );
5337    }
5338
5339    #[test]
5340    fn test_comparison_returns_bool() {
5341        let errs = errors("pipeline t(task) { let x: bool = 1 < 2 }");
5342        assert!(errs.is_empty());
5343    }
5344
5345    #[test]
5346    fn test_int_float_promotion() {
5347        let errs = errors("pipeline t(task) { let x: float = 42 }");
5348        assert!(errs.is_empty());
5349    }
5350
5351    #[test]
5352    fn test_untyped_code_no_errors() {
5353        let errs = errors(
5354            r#"pipeline t(task) {
5355  fn process(data) {
5356    let result = data + " processed"
5357    return result
5358  }
5359  log(process("hello"))
5360}"#,
5361        );
5362        assert!(errs.is_empty());
5363    }
5364
5365    #[test]
5366    fn test_type_alias() {
5367        let errs = errors(
5368            r#"pipeline t(task) {
5369  type Name = string
5370  let x: Name = "hello"
5371}"#,
5372        );
5373        assert!(errs.is_empty());
5374    }
5375
5376    #[test]
5377    fn test_type_alias_mismatch() {
5378        let errs = errors(
5379            r#"pipeline t(task) {
5380  type Name = string
5381  let x: Name = 42
5382}"#,
5383        );
5384        assert_eq!(errs.len(), 1);
5385    }
5386
5387    #[test]
5388    fn test_assignment_type_check() {
5389        let errs = errors(
5390            r#"pipeline t(task) {
5391  var x: int = 0
5392  x = "hello"
5393}"#,
5394        );
5395        assert_eq!(errs.len(), 1);
5396        assert!(errs[0].contains("can't assign string"));
5397    }
5398
5399    #[test]
5400    fn test_covariance_int_to_float_in_fn() {
5401        let errs = errors(
5402            "pipeline t(task) { fn scale(x: float) -> float { return x * 2.0 }\nscale(42) }",
5403        );
5404        assert!(errs.is_empty());
5405    }
5406
5407    #[test]
5408    fn test_covariance_return_type() {
5409        let errs = errors("pipeline t(task) { fn get() -> float { return 42 } }");
5410        assert!(errs.is_empty());
5411    }
5412
5413    #[test]
5414    fn test_no_contravariance_float_to_int() {
5415        let errs = errors("pipeline t(task) { fn add(a: int) -> int { return a + 1 }\nadd(3.14) }");
5416        assert_eq!(errs.len(), 1);
5417    }
5418
5419    // --- Comprehensive variance (issue #34) --------------------------------
5420
5421    #[test]
5422    fn test_fn_param_contravariance_positive() {
5423        // A closure that accepts a float (a supertype of int) can
5424        // stand in for an expected `fn(int) -> int`: anything the
5425        // caller hands in (an int) the closure can still accept.
5426        let errs = errors(
5427            r#"pipeline t(task) {
5428                let wide = fn(x: float) { return 0 }
5429                let cb: fn(int) -> int = wide
5430            }"#,
5431        );
5432        assert!(
5433            errs.is_empty(),
5434            "expected fn(float)->int to satisfy fn(int)->int, got: {errs:?}"
5435        );
5436    }
5437
5438    #[test]
5439    fn test_fn_param_contravariance_negative() {
5440        // A closure that only accepts ints cannot stand in for an
5441        // expected `fn(float) -> int`: the caller may hand it a
5442        // float, which it is not prepared to receive.
5443        let errs = errors(
5444            r#"pipeline t(task) {
5445                let narrow = fn(x: int) { return 0 }
5446                let cb: fn(float) -> int = narrow
5447            }"#,
5448        );
5449        assert!(
5450            !errs.is_empty(),
5451            "expected fn(int)->int NOT to satisfy fn(float)->int, but type-check passed"
5452        );
5453    }
5454
5455    #[test]
5456    fn test_list_invariant_int_to_float_rejected() {
5457        // `list<int>` must not flow into `list<float>` — lists are
5458        // mutable, so a covariant assignment is unsound.
5459        let errs = errors(
5460            r#"pipeline t(task) {
5461                let xs: list<int> = [1, 2, 3]
5462                let ys: list<float> = xs
5463            }"#,
5464        );
5465        assert!(
5466            !errs.is_empty(),
5467            "expected list<int> NOT to flow into list<float>, but type-check passed"
5468        );
5469    }
5470
5471    #[test]
5472    fn test_iter_covariant_int_to_float_accepted() {
5473        // Iterators are read-only, so element-type widening is sound.
5474        let errs = errors(
5475            r#"pipeline t(task) {
5476                fn sink(ys: iter<float>) -> int { return 0 }
5477                fn pipe(xs: iter<int>) -> int { return sink(xs) }
5478            }"#,
5479        );
5480        assert!(
5481            errs.is_empty(),
5482            "expected iter<int> to flow into iter<float>, got: {errs:?}"
5483        );
5484    }
5485
5486    #[test]
5487    fn test_decl_site_out_used_in_contravariant_position_rejected() {
5488        // `type Box<out T> = fn(T) -> ()` — T is declared covariant
5489        // but appears only as an input (contravariant). Must be
5490        // rejected at declaration time.
5491        let errs = errors(
5492            r#"pipeline t(task) {
5493                type Box<out T> = fn(T) -> int
5494            }"#,
5495        );
5496        assert!(
5497            errs.iter().any(|e| e.contains("declared 'out'")),
5498            "expected 'out T' misuse diagnostic, got: {errs:?}"
5499        );
5500    }
5501
5502    #[test]
5503    fn test_decl_site_in_used_in_covariant_position_rejected() {
5504        // `interface Producer<in T> { fn next() -> T }` — T is declared
5505        // contravariant but appears only in output position.
5506        let errs = errors(
5507            r#"pipeline t(task) {
5508                interface Producer<in T> { fn next() -> T }
5509            }"#,
5510        );
5511        assert!(
5512            errs.iter().any(|e| e.contains("declared 'in'")),
5513            "expected 'in T' misuse diagnostic, got: {errs:?}"
5514        );
5515    }
5516
5517    #[test]
5518    fn test_decl_site_out_in_covariant_position_ok() {
5519        // `type Reader<out T> = fn() -> T` — T appears in a covariant
5520        // position, consistent with `out T`.
5521        let errs = errors(
5522            r#"pipeline t(task) {
5523                type Reader<out T> = fn() -> T
5524            }"#,
5525        );
5526        assert!(
5527            errs.iter().all(|e| !e.contains("declared 'out'")),
5528            "unexpected variance diagnostic: {errs:?}"
5529        );
5530    }
5531
5532    #[test]
5533    fn test_dict_invariant_int_to_float_rejected() {
5534        let errs = errors(
5535            r#"pipeline t(task) {
5536                let d: dict<string, int> = {"a": 1}
5537                let e: dict<string, float> = d
5538            }"#,
5539        );
5540        assert!(
5541            !errs.is_empty(),
5542            "expected dict<string, int> NOT to flow into dict<string, float>"
5543        );
5544    }
5545
5546    fn warnings(source: &str) -> Vec<String> {
5547        check_source(source)
5548            .into_iter()
5549            .filter(|d| d.severity == DiagnosticSeverity::Warning)
5550            .map(|d| d.message)
5551            .collect()
5552    }
5553
5554    #[test]
5555    fn test_exhaustive_match_no_warning() {
5556        let warns = warnings(
5557            r#"pipeline t(task) {
5558  enum Color { Red, Green, Blue }
5559  let c = Color.Red
5560  match c.variant {
5561    "Red" -> { log("r") }
5562    "Green" -> { log("g") }
5563    "Blue" -> { log("b") }
5564  }
5565}"#,
5566        );
5567        let exhaustive_warns: Vec<_> = warns
5568            .iter()
5569            .filter(|w| w.contains("Non-exhaustive"))
5570            .collect();
5571        assert!(exhaustive_warns.is_empty());
5572    }
5573
5574    #[test]
5575    fn test_non_exhaustive_match_warning() {
5576        let warns = warnings(
5577            r#"pipeline t(task) {
5578  enum Color { Red, Green, Blue }
5579  let c = Color.Red
5580  match c.variant {
5581    "Red" -> { log("r") }
5582    "Green" -> { log("g") }
5583  }
5584}"#,
5585        );
5586        let exhaustive_warns: Vec<_> = warns
5587            .iter()
5588            .filter(|w| w.contains("Non-exhaustive"))
5589            .collect();
5590        assert_eq!(exhaustive_warns.len(), 1);
5591        assert!(exhaustive_warns[0].contains("Blue"));
5592    }
5593
5594    #[test]
5595    fn test_non_exhaustive_multiple_missing() {
5596        let warns = warnings(
5597            r#"pipeline t(task) {
5598  enum Status { Active, Inactive, Pending }
5599  let s = Status.Active
5600  match s.variant {
5601    "Active" -> { log("a") }
5602  }
5603}"#,
5604        );
5605        let exhaustive_warns: Vec<_> = warns
5606            .iter()
5607            .filter(|w| w.contains("Non-exhaustive"))
5608            .collect();
5609        assert_eq!(exhaustive_warns.len(), 1);
5610        assert!(exhaustive_warns[0].contains("Inactive"));
5611        assert!(exhaustive_warns[0].contains("Pending"));
5612    }
5613
5614    fn exhaustive_warns(source: &str) -> Vec<String> {
5615        warnings(source)
5616            .into_iter()
5617            .filter(|w| w.contains("was not fully narrowed"))
5618            .collect()
5619    }
5620
5621    #[test]
5622    fn test_unknown_exhaustive_unreachable_happy_path() {
5623        // All eight concrete variants covered → no warning on unreachable().
5624        let source = r#"pipeline t(task) {
5625  fn describe(v: unknown) -> string {
5626    if type_of(v) == "string"  { return "s" }
5627    if type_of(v) == "int"     { return "i" }
5628    if type_of(v) == "float"   { return "f" }
5629    if type_of(v) == "bool"    { return "b" }
5630    if type_of(v) == "nil"     { return "n" }
5631    if type_of(v) == "list"    { return "l" }
5632    if type_of(v) == "dict"    { return "d" }
5633    if type_of(v) == "closure" { return "c" }
5634    unreachable("unknown type_of variant")
5635  }
5636  log(describe(1))
5637}"#;
5638        assert!(exhaustive_warns(source).is_empty());
5639    }
5640
5641    #[test]
5642    fn test_unknown_exhaustive_unreachable_incomplete_warns() {
5643        let source = r#"pipeline t(task) {
5644  fn describe(v: unknown) -> string {
5645    if type_of(v) == "string" { return "s" }
5646    if type_of(v) == "int"    { return "i" }
5647    unreachable("unknown type_of variant")
5648  }
5649  log(describe(1))
5650}"#;
5651        let warns = exhaustive_warns(source);
5652        assert_eq!(warns.len(), 1, "expected one warning, got: {:?}", warns);
5653        let w = &warns[0];
5654        for missing in &["float", "bool", "nil", "list", "dict", "closure"] {
5655            assert!(w.contains(missing), "missing {missing} in: {w}");
5656        }
5657        assert!(!w.contains("int"));
5658        assert!(!w.contains("string"));
5659        assert!(w.contains("unreachable"));
5660        assert!(w.contains("v: unknown"));
5661    }
5662
5663    #[test]
5664    fn test_unknown_incomplete_normal_return_no_warning() {
5665        // Normal `return` fallthrough is NOT an exhaustiveness claim.
5666        let source = r#"pipeline t(task) {
5667  fn describe(v: unknown) -> string {
5668    if type_of(v) == "string" { return "s" }
5669    if type_of(v) == "int"    { return "i" }
5670    return "other"
5671  }
5672  log(describe(1))
5673}"#;
5674        assert!(exhaustive_warns(source).is_empty());
5675    }
5676
5677    #[test]
5678    fn test_unknown_exhaustive_throw_incomplete_warns() {
5679        let source = r#"pipeline t(task) {
5680  fn describe(v: unknown) -> string {
5681    if type_of(v) == "string" { return "s" }
5682    throw "unhandled"
5683  }
5684  log(describe("x"))
5685}"#;
5686        let warns = exhaustive_warns(source);
5687        assert_eq!(warns.len(), 1, "expected one warning, got: {:?}", warns);
5688        assert!(warns[0].contains("throw"));
5689        assert!(warns[0].contains("int"));
5690    }
5691
5692    #[test]
5693    fn test_unknown_throw_without_narrowing_no_warning() {
5694        // A bare throw with no preceding `type_of` narrowing is not
5695        // an exhaustiveness claim — stay silent.
5696        let source = r#"pipeline t(task) {
5697  fn crash(v: unknown) -> string {
5698    throw "nope"
5699  }
5700  log(crash(1))
5701}"#;
5702        assert!(exhaustive_warns(source).is_empty());
5703    }
5704
5705    #[test]
5706    fn test_unknown_exhaustive_nested_branch() {
5707        // Nested if inside a single branch: inner exhaustiveness doesn't
5708        // escape to the outer scope, and incomplete outer coverage warns.
5709        let source = r#"pipeline t(task) {
5710  fn describe(v: unknown, x: int) -> string {
5711    if type_of(v) == "string" {
5712      if x > 0 { return v.upper() } else { return "s" }
5713    }
5714    if type_of(v) == "int" { return "i" }
5715    unreachable("unknown type_of variant")
5716  }
5717  log(describe(1, 1))
5718}"#;
5719        let warns = exhaustive_warns(source);
5720        assert_eq!(warns.len(), 1, "expected one warning, got: {:?}", warns);
5721        assert!(warns[0].contains("float"));
5722    }
5723
5724    #[test]
5725    fn test_unknown_exhaustive_negated_check() {
5726        // `type_of(v) != "T"` guards the happy path, so the then-branch
5727        // accumulates coverage on the truthy side via inversion.
5728        let source = r#"pipeline t(task) {
5729  fn describe(v: unknown) -> string {
5730    if type_of(v) != "string" {
5731      // v still unknown here, but "string" is NOT ruled out on this path
5732      return "non-string"
5733    }
5734    // v: string here
5735    return v.upper()
5736  }
5737  log(describe("x"))
5738}"#;
5739        // No unreachable/throw so no warning regardless.
5740        assert!(exhaustive_warns(source).is_empty());
5741    }
5742
5743    #[test]
5744    fn test_enum_construct_type_inference() {
5745        let errs = errors(
5746            r#"pipeline t(task) {
5747  enum Color { Red, Green, Blue }
5748  let c: Color = Color.Red
5749}"#,
5750        );
5751        assert!(errs.is_empty());
5752    }
5753
5754    #[test]
5755    fn test_nil_coalescing_strips_nil() {
5756        // After ??, nil should be stripped from the type
5757        let errs = errors(
5758            r#"pipeline t(task) {
5759  let x: string | nil = nil
5760  let y: string = x ?? "default"
5761}"#,
5762        );
5763        assert!(errs.is_empty());
5764    }
5765
5766    #[test]
5767    fn test_shape_mismatch_detail_missing_field() {
5768        let errs = errors(
5769            r#"pipeline t(task) {
5770  let x: {name: string, age: int} = {name: "hello"}
5771}"#,
5772        );
5773        assert_eq!(errs.len(), 1);
5774        assert!(
5775            errs[0].contains("missing field 'age'"),
5776            "expected detail about missing field, got: {}",
5777            errs[0]
5778        );
5779    }
5780
5781    #[test]
5782    fn test_shape_mismatch_detail_wrong_type() {
5783        let errs = errors(
5784            r#"pipeline t(task) {
5785  let x: {name: string, age: int} = {name: 42, age: 10}
5786}"#,
5787        );
5788        assert_eq!(errs.len(), 1);
5789        assert!(
5790            errs[0].contains("field 'name' has type int, expected string"),
5791            "expected detail about wrong type, got: {}",
5792            errs[0]
5793        );
5794    }
5795
5796    #[test]
5797    fn test_match_pattern_string_against_int() {
5798        let warns = warnings(
5799            r#"pipeline t(task) {
5800  let x: int = 42
5801  match x {
5802    "hello" -> { log("bad") }
5803    42 -> { log("ok") }
5804  }
5805}"#,
5806        );
5807        let pattern_warns: Vec<_> = warns
5808            .iter()
5809            .filter(|w| w.contains("Match pattern type mismatch"))
5810            .collect();
5811        assert_eq!(pattern_warns.len(), 1);
5812        assert!(pattern_warns[0].contains("matching int against string literal"));
5813    }
5814
5815    #[test]
5816    fn test_match_pattern_int_against_string() {
5817        let warns = warnings(
5818            r#"pipeline t(task) {
5819  let x: string = "hello"
5820  match x {
5821    42 -> { log("bad") }
5822    "hello" -> { log("ok") }
5823  }
5824}"#,
5825        );
5826        let pattern_warns: Vec<_> = warns
5827            .iter()
5828            .filter(|w| w.contains("Match pattern type mismatch"))
5829            .collect();
5830        assert_eq!(pattern_warns.len(), 1);
5831        assert!(pattern_warns[0].contains("matching string against int literal"));
5832    }
5833
5834    #[test]
5835    fn test_match_pattern_bool_against_int() {
5836        let warns = warnings(
5837            r#"pipeline t(task) {
5838  let x: int = 42
5839  match x {
5840    true -> { log("bad") }
5841    42 -> { log("ok") }
5842  }
5843}"#,
5844        );
5845        let pattern_warns: Vec<_> = warns
5846            .iter()
5847            .filter(|w| w.contains("Match pattern type mismatch"))
5848            .collect();
5849        assert_eq!(pattern_warns.len(), 1);
5850        assert!(pattern_warns[0].contains("matching int against bool literal"));
5851    }
5852
5853    #[test]
5854    fn test_match_pattern_float_against_string() {
5855        let warns = warnings(
5856            r#"pipeline t(task) {
5857  let x: string = "hello"
5858  match x {
5859    3.14 -> { log("bad") }
5860    "hello" -> { log("ok") }
5861  }
5862}"#,
5863        );
5864        let pattern_warns: Vec<_> = warns
5865            .iter()
5866            .filter(|w| w.contains("Match pattern type mismatch"))
5867            .collect();
5868        assert_eq!(pattern_warns.len(), 1);
5869        assert!(pattern_warns[0].contains("matching string against float literal"));
5870    }
5871
5872    #[test]
5873    fn test_match_pattern_int_against_float_ok() {
5874        // int and float are compatible for match patterns
5875        let warns = warnings(
5876            r#"pipeline t(task) {
5877  let x: float = 3.14
5878  match x {
5879    42 -> { log("ok") }
5880    _ -> { log("default") }
5881  }
5882}"#,
5883        );
5884        let pattern_warns: Vec<_> = warns
5885            .iter()
5886            .filter(|w| w.contains("Match pattern type mismatch"))
5887            .collect();
5888        assert!(pattern_warns.is_empty());
5889    }
5890
5891    #[test]
5892    fn test_match_pattern_float_against_int_ok() {
5893        // float and int are compatible for match patterns
5894        let warns = warnings(
5895            r#"pipeline t(task) {
5896  let x: int = 42
5897  match x {
5898    3.14 -> { log("close") }
5899    _ -> { log("default") }
5900  }
5901}"#,
5902        );
5903        let pattern_warns: Vec<_> = warns
5904            .iter()
5905            .filter(|w| w.contains("Match pattern type mismatch"))
5906            .collect();
5907        assert!(pattern_warns.is_empty());
5908    }
5909
5910    #[test]
5911    fn test_match_pattern_correct_types_no_warning() {
5912        let warns = warnings(
5913            r#"pipeline t(task) {
5914  let x: int = 42
5915  match x {
5916    1 -> { log("one") }
5917    2 -> { log("two") }
5918    _ -> { log("other") }
5919  }
5920}"#,
5921        );
5922        let pattern_warns: Vec<_> = warns
5923            .iter()
5924            .filter(|w| w.contains("Match pattern type mismatch"))
5925            .collect();
5926        assert!(pattern_warns.is_empty());
5927    }
5928
5929    #[test]
5930    fn test_match_pattern_wildcard_no_warning() {
5931        let warns = warnings(
5932            r#"pipeline t(task) {
5933  let x: int = 42
5934  match x {
5935    _ -> { log("catch all") }
5936  }
5937}"#,
5938        );
5939        let pattern_warns: Vec<_> = warns
5940            .iter()
5941            .filter(|w| w.contains("Match pattern type mismatch"))
5942            .collect();
5943        assert!(pattern_warns.is_empty());
5944    }
5945
5946    #[test]
5947    fn test_match_pattern_untyped_no_warning() {
5948        // When value has no known type, no warning should be emitted
5949        let warns = warnings(
5950            r#"pipeline t(task) {
5951  let x = some_unknown_fn()
5952  match x {
5953    "hello" -> { log("string") }
5954    42 -> { log("int") }
5955  }
5956}"#,
5957        );
5958        let pattern_warns: Vec<_> = warns
5959            .iter()
5960            .filter(|w| w.contains("Match pattern type mismatch"))
5961            .collect();
5962        assert!(pattern_warns.is_empty());
5963    }
5964
5965    fn iface_errors(source: &str) -> Vec<String> {
5966        errors(source)
5967            .into_iter()
5968            .filter(|message| message.contains("does not satisfy interface"))
5969            .collect()
5970    }
5971
5972    #[test]
5973    fn test_interface_constraint_return_type_mismatch() {
5974        let warns = iface_errors(
5975            r#"pipeline t(task) {
5976  interface Sizable {
5977    fn size(self) -> int
5978  }
5979  struct Box { width: int }
5980  impl Box {
5981    fn size(self) -> string { return "nope" }
5982  }
5983  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
5984  measure(Box({width: 3}))
5985}"#,
5986        );
5987        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
5988        assert!(
5989            warns[0].contains("method 'size' returns 'string', expected 'int'"),
5990            "unexpected message: {}",
5991            warns[0]
5992        );
5993    }
5994
5995    #[test]
5996    fn test_interface_constraint_param_type_mismatch() {
5997        let warns = iface_errors(
5998            r#"pipeline t(task) {
5999  interface Processor {
6000    fn process(self, x: int) -> string
6001  }
6002  struct MyProc { name: string }
6003  impl MyProc {
6004    fn process(self, x: string) -> string { return x }
6005  }
6006  fn run_proc<T>(p: T) where T: Processor { log(p.process(42)) }
6007  run_proc(MyProc({name: "a"}))
6008}"#,
6009        );
6010        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
6011        assert!(
6012            warns[0].contains("method 'process' parameter 1 has type 'string', expected 'int'"),
6013            "unexpected message: {}",
6014            warns[0]
6015        );
6016    }
6017
6018    #[test]
6019    fn test_interface_constraint_missing_method() {
6020        let warns = iface_errors(
6021            r#"pipeline t(task) {
6022  interface Sizable {
6023    fn size(self) -> int
6024  }
6025  struct Box { width: int }
6026  impl Box {
6027    fn area(self) -> int { return self.width }
6028  }
6029  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
6030  measure(Box({width: 3}))
6031}"#,
6032        );
6033        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
6034        assert!(
6035            warns[0].contains("missing method 'size'"),
6036            "unexpected message: {}",
6037            warns[0]
6038        );
6039    }
6040
6041    #[test]
6042    fn test_interface_constraint_param_count_mismatch() {
6043        let warns = iface_errors(
6044            r#"pipeline t(task) {
6045  interface Doubler {
6046    fn double(self, x: int) -> int
6047  }
6048  struct Bad { v: int }
6049  impl Bad {
6050    fn double(self) -> int { return self.v * 2 }
6051  }
6052  fn run_double<T>(d: T) where T: Doubler { log(d.double(3)) }
6053  run_double(Bad({v: 5}))
6054}"#,
6055        );
6056        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
6057        assert!(
6058            warns[0].contains("method 'double' has 0 parameter(s), expected 1"),
6059            "unexpected message: {}",
6060            warns[0]
6061        );
6062    }
6063
6064    #[test]
6065    fn test_interface_constraint_satisfied() {
6066        let warns = iface_errors(
6067            r#"pipeline t(task) {
6068  interface Sizable {
6069    fn size(self) -> int
6070  }
6071  struct Box { width: int, height: int }
6072  impl Box {
6073    fn size(self) -> int { return self.width * self.height }
6074  }
6075  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
6076  measure(Box({width: 3, height: 4}))
6077}"#,
6078        );
6079        assert!(warns.is_empty(), "expected no warnings, got: {:?}", warns);
6080    }
6081
6082    #[test]
6083    fn test_interface_constraint_untyped_impl_compatible() {
6084        // Gradual typing: untyped impl return should not trigger warning
6085        let warns = iface_errors(
6086            r#"pipeline t(task) {
6087  interface Sizable {
6088    fn size(self) -> int
6089  }
6090  struct Box { width: int }
6091  impl Box {
6092    fn size(self) { return self.width }
6093  }
6094  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
6095  measure(Box({width: 3}))
6096}"#,
6097        );
6098        assert!(warns.is_empty(), "expected no warnings, got: {:?}", warns);
6099    }
6100
6101    #[test]
6102    fn test_interface_constraint_int_float_covariance() {
6103        // int is compatible with float (covariance)
6104        let warns = iface_errors(
6105            r#"pipeline t(task) {
6106  interface Measurable {
6107    fn value(self) -> float
6108  }
6109  struct Gauge { v: int }
6110  impl Gauge {
6111    fn value(self) -> int { return self.v }
6112  }
6113  fn read_val<T>(g: T) where T: Measurable { log(g.value()) }
6114  read_val(Gauge({v: 42}))
6115}"#,
6116        );
6117        assert!(warns.is_empty(), "expected no warnings, got: {:?}", warns);
6118    }
6119
6120    #[test]
6121    fn test_interface_associated_type_constraint_satisfied() {
6122        let warns = iface_errors(
6123            r#"pipeline t(task) {
6124  interface Collection {
6125    type Item
6126    fn get(self, index: int) -> Item
6127  }
6128  struct Names {}
6129  impl Names {
6130    fn get(self, index: int) -> string { return "ada" }
6131  }
6132  fn first<C>(collection: C) where C: Collection {
6133    log(collection.get(0))
6134  }
6135  first(Names {})
6136}"#,
6137        );
6138        assert!(warns.is_empty(), "expected no warnings, got: {:?}", warns);
6139    }
6140
6141    #[test]
6142    fn test_interface_associated_type_default_mismatch() {
6143        let warns = iface_errors(
6144            r#"pipeline t(task) {
6145  interface IntCollection {
6146    type Item = int
6147    fn get(self, index: int) -> Item
6148  }
6149  struct Labels {}
6150  impl Labels {
6151    fn get(self, index: int) -> string { return "oops" }
6152  }
6153  fn first<C>(collection: C) where C: IntCollection {
6154    log(collection.get(0))
6155  }
6156  first(Labels {})
6157}"#,
6158        );
6159        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
6160        assert!(
6161            warns[0].contains("associated type 'Item' resolves to 'string', expected 'int'"),
6162            "unexpected message: {}",
6163            warns[0]
6164        );
6165    }
6166
6167    #[test]
6168    fn test_nil_narrowing_then_branch() {
6169        // Existing behavior: x != nil narrows to string in then-branch
6170        let errs = errors(
6171            r#"pipeline t(task) {
6172  fn greet(name: string | nil) {
6173    if name != nil {
6174      let s: string = name
6175    }
6176  }
6177}"#,
6178        );
6179        assert!(errs.is_empty(), "got: {:?}", errs);
6180    }
6181
6182    #[test]
6183    fn test_nil_narrowing_else_branch() {
6184        // NEW: x != nil narrows to nil in else-branch
6185        let errs = errors(
6186            r#"pipeline t(task) {
6187  fn check(x: string | nil) {
6188    if x != nil {
6189      let s: string = x
6190    } else {
6191      let n: nil = x
6192    }
6193  }
6194}"#,
6195        );
6196        assert!(errs.is_empty(), "got: {:?}", errs);
6197    }
6198
6199    #[test]
6200    fn test_nil_equality_narrows_both() {
6201        // x == nil narrows then to nil, else to non-nil
6202        let errs = errors(
6203            r#"pipeline t(task) {
6204  fn check(x: string | nil) {
6205    if x == nil {
6206      let n: nil = x
6207    } else {
6208      let s: string = x
6209    }
6210  }
6211}"#,
6212        );
6213        assert!(errs.is_empty(), "got: {:?}", errs);
6214    }
6215
6216    #[test]
6217    fn test_truthiness_narrowing() {
6218        // Bare identifier in condition removes nil
6219        let errs = errors(
6220            r#"pipeline t(task) {
6221  fn check(x: string | nil) {
6222    if x {
6223      let s: string = x
6224    }
6225  }
6226}"#,
6227        );
6228        assert!(errs.is_empty(), "got: {:?}", errs);
6229    }
6230
6231    #[test]
6232    fn test_negation_narrowing() {
6233        // !x swaps truthy/falsy
6234        let errs = errors(
6235            r#"pipeline t(task) {
6236  fn check(x: string | nil) {
6237    if !x {
6238      let n: nil = x
6239    } else {
6240      let s: string = x
6241    }
6242  }
6243}"#,
6244        );
6245        assert!(errs.is_empty(), "got: {:?}", errs);
6246    }
6247
6248    #[test]
6249    fn test_typeof_narrowing() {
6250        // type_of(x) == "string" narrows to string
6251        let errs = errors(
6252            r#"pipeline t(task) {
6253  fn check(x: string | int) {
6254    if type_of(x) == "string" {
6255      let s: string = x
6256    }
6257  }
6258}"#,
6259        );
6260        assert!(errs.is_empty(), "got: {:?}", errs);
6261    }
6262
6263    #[test]
6264    fn test_typeof_narrowing_else() {
6265        // else removes the tested type
6266        let errs = errors(
6267            r#"pipeline t(task) {
6268  fn check(x: string | int) {
6269    if type_of(x) == "string" {
6270      let s: string = x
6271    } else {
6272      let i: int = x
6273    }
6274  }
6275}"#,
6276        );
6277        assert!(errs.is_empty(), "got: {:?}", errs);
6278    }
6279
6280    #[test]
6281    fn test_typeof_neq_narrowing() {
6282        // type_of(x) != "string" removes string in then, narrows to string in else
6283        let errs = errors(
6284            r#"pipeline t(task) {
6285  fn check(x: string | int) {
6286    if type_of(x) != "string" {
6287      let i: int = x
6288    } else {
6289      let s: string = x
6290    }
6291  }
6292}"#,
6293        );
6294        assert!(errs.is_empty(), "got: {:?}", errs);
6295    }
6296
6297    #[test]
6298    fn test_and_combines_narrowing() {
6299        // && combines truthy refinements
6300        let errs = errors(
6301            r#"pipeline t(task) {
6302  fn check(x: string | int | nil) {
6303    if x != nil && type_of(x) == "string" {
6304      let s: string = x
6305    }
6306  }
6307}"#,
6308        );
6309        assert!(errs.is_empty(), "got: {:?}", errs);
6310    }
6311
6312    #[test]
6313    fn test_or_falsy_narrowing() {
6314        // || combines falsy refinements
6315        let errs = errors(
6316            r#"pipeline t(task) {
6317  fn check(x: string | nil, y: int | nil) {
6318    if x || y {
6319      // conservative: can't narrow
6320    } else {
6321      let xn: nil = x
6322      let yn: nil = y
6323    }
6324  }
6325}"#,
6326        );
6327        assert!(errs.is_empty(), "got: {:?}", errs);
6328    }
6329
6330    #[test]
6331    fn test_guard_narrows_outer_scope() {
6332        let errs = errors(
6333            r#"pipeline t(task) {
6334  fn check(x: string | nil) {
6335    guard x != nil else { return }
6336    let s: string = x
6337  }
6338}"#,
6339        );
6340        assert!(errs.is_empty(), "got: {:?}", errs);
6341    }
6342
6343    #[test]
6344    fn test_while_narrows_body() {
6345        let errs = errors(
6346            r#"pipeline t(task) {
6347  fn check(x: string | nil) {
6348    while x != nil {
6349      let s: string = x
6350      break
6351    }
6352  }
6353}"#,
6354        );
6355        assert!(errs.is_empty(), "got: {:?}", errs);
6356    }
6357
6358    #[test]
6359    fn test_early_return_narrows_after_if() {
6360        // if then-body returns, falsy refinements apply after
6361        let errs = errors(
6362            r#"pipeline t(task) {
6363  fn check(x: string | nil) -> string {
6364    if x == nil {
6365      return "default"
6366    }
6367    let s: string = x
6368    return s
6369  }
6370}"#,
6371        );
6372        assert!(errs.is_empty(), "got: {:?}", errs);
6373    }
6374
6375    #[test]
6376    fn test_early_throw_narrows_after_if() {
6377        let errs = errors(
6378            r#"pipeline t(task) {
6379  fn check(x: string | nil) {
6380    if x == nil {
6381      throw "missing"
6382    }
6383    let s: string = x
6384  }
6385}"#,
6386        );
6387        assert!(errs.is_empty(), "got: {:?}", errs);
6388    }
6389
6390    #[test]
6391    fn test_no_narrowing_unknown_type() {
6392        // Gradual typing: untyped vars don't get narrowed
6393        let errs = errors(
6394            r#"pipeline t(task) {
6395  fn check(x) {
6396    if x != nil {
6397      let s: string = x
6398    }
6399  }
6400}"#,
6401        );
6402        // No narrowing possible, so assigning untyped x to string should be fine
6403        // (gradual typing allows it)
6404        assert!(errs.is_empty(), "got: {:?}", errs);
6405    }
6406
6407    #[test]
6408    fn test_reassignment_invalidates_narrowing() {
6409        // After reassigning a narrowed var, the original type should be restored
6410        let errs = errors(
6411            r#"pipeline t(task) {
6412  fn check(x: string | nil) {
6413    var y: string | nil = x
6414    if y != nil {
6415      let s: string = y
6416      y = nil
6417      let s2: string = y
6418    }
6419  }
6420}"#,
6421        );
6422        // s2 should fail because y was reassigned, invalidating the narrowing
6423        assert_eq!(errs.len(), 1, "expected 1 error, got: {:?}", errs);
6424        assert!(
6425            errs[0].contains("declared as"),
6426            "expected type mismatch, got: {}",
6427            errs[0]
6428        );
6429    }
6430
6431    #[test]
6432    fn test_let_immutable_warning() {
6433        let all = check_source(
6434            r#"pipeline t(task) {
6435  let x = 42
6436  x = 43
6437}"#,
6438        );
6439        let warnings: Vec<_> = all
6440            .iter()
6441            .filter(|d| d.severity == DiagnosticSeverity::Warning)
6442            .collect();
6443        assert!(
6444            warnings.iter().any(|w| w.message.contains("immutable")),
6445            "expected immutability warning, got: {:?}",
6446            warnings
6447        );
6448    }
6449
6450    #[test]
6451    fn test_nested_narrowing() {
6452        let errs = errors(
6453            r#"pipeline t(task) {
6454  fn check(x: string | int | nil) {
6455    if x != nil {
6456      if type_of(x) == "int" {
6457        let i: int = x
6458      }
6459    }
6460  }
6461}"#,
6462        );
6463        assert!(errs.is_empty(), "got: {:?}", errs);
6464    }
6465
6466    #[test]
6467    fn test_match_narrows_arms() {
6468        let errs = errors(
6469            r#"pipeline t(task) {
6470  fn check(x: string | int) {
6471    match x {
6472      "hello" -> {
6473        let s: string = x
6474      }
6475      42 -> {
6476        let i: int = x
6477      }
6478      _ -> {}
6479    }
6480  }
6481}"#,
6482        );
6483        assert!(errs.is_empty(), "got: {:?}", errs);
6484    }
6485
6486    #[test]
6487    fn test_has_narrows_optional_field() {
6488        let errs = errors(
6489            r#"pipeline t(task) {
6490  fn check(x: {name?: string, age: int}) {
6491    if x.has("name") {
6492      let n: {name: string, age: int} = x
6493    }
6494  }
6495}"#,
6496        );
6497        assert!(errs.is_empty(), "got: {:?}", errs);
6498    }
6499
6500    // -----------------------------------------------------------------------
6501    // Autofix tests
6502    // -----------------------------------------------------------------------
6503
6504    fn check_source_with_source(source: &str) -> Vec<TypeDiagnostic> {
6505        let mut lexer = Lexer::new(source);
6506        let tokens = lexer.tokenize().unwrap();
6507        let mut parser = Parser::new(tokens);
6508        let program = parser.parse().unwrap();
6509        TypeChecker::new().check_with_source(&program, source)
6510    }
6511
6512    #[test]
6513    fn test_fix_string_plus_int_literal() {
6514        let source = "pipeline t(task) {\n  let x = \"hello \" + 42\n  log(x)\n}";
6515        let diags = check_source_with_source(source);
6516        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
6517        assert_eq!(fixable.len(), 1, "expected 1 fixable diagnostic");
6518        let fix = fixable[0].fix.as_ref().unwrap();
6519        assert_eq!(fix.len(), 1);
6520        assert_eq!(fix[0].replacement, "\"hello ${42}\"");
6521    }
6522
6523    #[test]
6524    fn test_fix_int_plus_string_literal() {
6525        let source = "pipeline t(task) {\n  let x = 42 + \"hello\"\n  log(x)\n}";
6526        let diags = check_source_with_source(source);
6527        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
6528        assert_eq!(fixable.len(), 1, "expected 1 fixable diagnostic");
6529        let fix = fixable[0].fix.as_ref().unwrap();
6530        assert_eq!(fix[0].replacement, "\"${42}hello\"");
6531    }
6532
6533    #[test]
6534    fn test_fix_string_plus_variable() {
6535        let source = "pipeline t(task) {\n  let n: int = 5\n  let x = \"count: \" + n\n  log(x)\n}";
6536        let diags = check_source_with_source(source);
6537        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
6538        assert_eq!(fixable.len(), 1, "expected 1 fixable diagnostic");
6539        let fix = fixable[0].fix.as_ref().unwrap();
6540        assert_eq!(fix[0].replacement, "\"count: ${n}\"");
6541    }
6542
6543    #[test]
6544    fn test_no_fix_int_plus_int() {
6545        // int + float should error but no interpolation fix
6546        let source = "pipeline t(task) {\n  let x: int = 5\n  let y: float = 3.0\n  let z = x - y\n  log(z)\n}";
6547        let diags = check_source_with_source(source);
6548        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
6549        assert!(
6550            fixable.is_empty(),
6551            "no fix expected for numeric ops, got: {fixable:?}"
6552        );
6553    }
6554
6555    #[test]
6556    fn test_no_fix_without_source() {
6557        let source = "pipeline t(task) {\n  let x = \"hello \" + 42\n  log(x)\n}";
6558        let diags = check_source(source);
6559        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
6560        assert!(
6561            fixable.is_empty(),
6562            "without source, no fix should be generated"
6563        );
6564    }
6565
6566    #[test]
6567    fn test_union_exhaustive_match_no_warning() {
6568        let warns = warnings(
6569            r#"pipeline t(task) {
6570  let x: string | int | nil = nil
6571  match x {
6572    "hello" -> { log("s") }
6573    42 -> { log("i") }
6574    nil -> { log("n") }
6575  }
6576}"#,
6577        );
6578        let union_warns: Vec<_> = warns
6579            .iter()
6580            .filter(|w| w.contains("Non-exhaustive match on union"))
6581            .collect();
6582        assert!(union_warns.is_empty());
6583    }
6584
6585    #[test]
6586    fn test_union_non_exhaustive_match_warning() {
6587        let warns = warnings(
6588            r#"pipeline t(task) {
6589  let x: string | int | nil = nil
6590  match x {
6591    "hello" -> { log("s") }
6592    42 -> { log("i") }
6593  }
6594}"#,
6595        );
6596        let union_warns: Vec<_> = warns
6597            .iter()
6598            .filter(|w| w.contains("Non-exhaustive match on union"))
6599            .collect();
6600        assert_eq!(union_warns.len(), 1);
6601        assert!(union_warns[0].contains("nil"));
6602    }
6603
6604    #[test]
6605    fn test_nil_coalesce_non_union_preserves_left_type() {
6606        // When left is a known non-nil type, ?? should preserve it
6607        let errs = errors(
6608            r#"pipeline t(task) {
6609  let x: int = 42
6610  let y: int = x ?? 0
6611}"#,
6612        );
6613        assert!(errs.is_empty());
6614    }
6615
6616    #[test]
6617    fn test_nil_coalesce_nil_returns_right_type() {
6618        let errs = errors(
6619            r#"pipeline t(task) {
6620  let x: string = nil ?? "fallback"
6621}"#,
6622        );
6623        assert!(errs.is_empty());
6624    }
6625
6626    #[test]
6627    fn test_never_is_subtype_of_everything() {
6628        let tc = TypeChecker::new();
6629        let scope = TypeScope::new();
6630        assert!(tc.types_compatible(&TypeExpr::Named("string".into()), &TypeExpr::Never, &scope));
6631        assert!(tc.types_compatible(&TypeExpr::Named("int".into()), &TypeExpr::Never, &scope));
6632        assert!(tc.types_compatible(
6633            &TypeExpr::Union(vec![
6634                TypeExpr::Named("string".into()),
6635                TypeExpr::Named("nil".into()),
6636            ]),
6637            &TypeExpr::Never,
6638            &scope,
6639        ));
6640    }
6641
6642    #[test]
6643    fn test_nothing_is_subtype_of_never() {
6644        let tc = TypeChecker::new();
6645        let scope = TypeScope::new();
6646        assert!(!tc.types_compatible(&TypeExpr::Never, &TypeExpr::Named("string".into()), &scope));
6647        assert!(!tc.types_compatible(&TypeExpr::Never, &TypeExpr::Named("int".into()), &scope));
6648    }
6649
6650    #[test]
6651    fn test_never_never_compatible() {
6652        let tc = TypeChecker::new();
6653        let scope = TypeScope::new();
6654        assert!(tc.types_compatible(&TypeExpr::Never, &TypeExpr::Never, &scope));
6655    }
6656
6657    #[test]
6658    fn test_any_is_top_type_bidirectional() {
6659        let tc = TypeChecker::new();
6660        let scope = TypeScope::new();
6661        let any = TypeExpr::Named("any".into());
6662        // Every concrete type flows into any.
6663        assert!(tc.types_compatible(&any, &TypeExpr::Named("string".into()), &scope));
6664        assert!(tc.types_compatible(&any, &TypeExpr::Named("int".into()), &scope));
6665        assert!(tc.types_compatible(&any, &TypeExpr::Named("nil".into()), &scope));
6666        assert!(tc.types_compatible(
6667            &any,
6668            &TypeExpr::List(Box::new(TypeExpr::Named("int".into()))),
6669            &scope
6670        ));
6671        // any flows back out to every concrete type (escape hatch).
6672        assert!(tc.types_compatible(&TypeExpr::Named("string".into()), &any, &scope));
6673        assert!(tc.types_compatible(&TypeExpr::Named("nil".into()), &any, &scope));
6674    }
6675
6676    #[test]
6677    fn test_unknown_is_safe_top_one_way() {
6678        let tc = TypeChecker::new();
6679        let scope = TypeScope::new();
6680        let unknown = TypeExpr::Named("unknown".into());
6681        // Every concrete type flows into unknown.
6682        assert!(tc.types_compatible(&unknown, &TypeExpr::Named("string".into()), &scope));
6683        assert!(tc.types_compatible(&unknown, &TypeExpr::Named("nil".into()), &scope));
6684        assert!(tc.types_compatible(
6685            &unknown,
6686            &TypeExpr::List(Box::new(TypeExpr::Named("int".into()))),
6687            &scope
6688        ));
6689        // unknown does NOT flow back out to concrete types without narrowing.
6690        assert!(!tc.types_compatible(&TypeExpr::Named("string".into()), &unknown, &scope));
6691        assert!(!tc.types_compatible(&TypeExpr::Named("int".into()), &unknown, &scope));
6692        // unknown is compatible with itself.
6693        assert!(tc.types_compatible(&unknown, &unknown, &scope));
6694        // unknown flows into any (any accepts everything).
6695        assert!(tc.types_compatible(&TypeExpr::Named("any".into()), &unknown, &scope));
6696    }
6697
6698    #[test]
6699    fn test_unknown_narrows_via_type_of() {
6700        // Concrete narrowing behavior is covered end-to-end by the conformance
6701        // test `unknown_narrowing.harn`; this unit test guards against the
6702        // refinement path silently regressing to "no narrowing" for named
6703        // unknown types.
6704        let errs = errors(
6705            r#"pipeline t(task) {
6706  fn f(v: unknown) -> string {
6707    if type_of(v) == "string" {
6708      return v
6709    }
6710    return "other"
6711  }
6712  log(f("hi"))
6713}"#,
6714        );
6715        assert!(
6716            errs.is_empty(),
6717            "unknown should narrow to string inside type_of guard: {errs:?}"
6718        );
6719    }
6720
6721    #[test]
6722    fn test_unknown_without_narrowing_errors() {
6723        let errs = errors(
6724            r#"pipeline t(task) {
6725  let u: unknown = "hello"
6726  let s: string = u
6727}"#,
6728        );
6729        assert!(
6730            errs.iter().any(|e| e.contains("unknown")),
6731            "expected an error mentioning unknown, got: {errs:?}"
6732        );
6733    }
6734
6735    #[test]
6736    fn test_simplify_union_removes_never() {
6737        assert_eq!(
6738            simplify_union(vec![TypeExpr::Never, TypeExpr::Named("string".into())]),
6739            TypeExpr::Named("string".into()),
6740        );
6741        assert_eq!(
6742            simplify_union(vec![TypeExpr::Never, TypeExpr::Never]),
6743            TypeExpr::Never,
6744        );
6745        assert_eq!(
6746            simplify_union(vec![
6747                TypeExpr::Named("string".into()),
6748                TypeExpr::Never,
6749                TypeExpr::Named("int".into()),
6750            ]),
6751            TypeExpr::Union(vec![
6752                TypeExpr::Named("string".into()),
6753                TypeExpr::Named("int".into()),
6754            ]),
6755        );
6756    }
6757
6758    #[test]
6759    fn test_remove_from_union_exhausted_returns_never() {
6760        let result = remove_from_union(&[TypeExpr::Named("string".into())], "string");
6761        assert_eq!(result, Some(TypeExpr::Never));
6762    }
6763
6764    #[test]
6765    fn test_if_else_one_branch_throws_infers_other() {
6766        // if/else where else throws — result should be int (from then-branch)
6767        let errs = errors(
6768            r#"pipeline t(task) {
6769  fn foo(x: bool) -> int {
6770    let result: int = if x { 42 } else { throw "err" }
6771    return result
6772  }
6773}"#,
6774        );
6775        assert!(errs.is_empty(), "unexpected errors: {errs:?}");
6776    }
6777
6778    #[test]
6779    fn test_if_else_both_branches_throw_infers_never() {
6780        // Both branches exit — should infer never, which is assignable to anything
6781        let errs = errors(
6782            r#"pipeline t(task) {
6783  fn foo(x: bool) -> string {
6784    let result: string = if x { throw "a" } else { throw "b" }
6785    return result
6786  }
6787}"#,
6788        );
6789        assert!(errs.is_empty(), "unexpected errors: {errs:?}");
6790    }
6791
6792    #[test]
6793    fn test_unreachable_after_return() {
6794        let warns = warnings(
6795            r#"pipeline t(task) {
6796  fn foo() -> int {
6797    return 1
6798    let x = 2
6799  }
6800}"#,
6801        );
6802        assert!(
6803            warns.iter().any(|w| w.contains("unreachable")),
6804            "expected unreachable warning: {warns:?}"
6805        );
6806    }
6807
6808    #[test]
6809    fn test_unreachable_after_throw() {
6810        let warns = warnings(
6811            r#"pipeline t(task) {
6812  fn foo() {
6813    throw "err"
6814    let x = 2
6815  }
6816}"#,
6817        );
6818        assert!(
6819            warns.iter().any(|w| w.contains("unreachable")),
6820            "expected unreachable warning: {warns:?}"
6821        );
6822    }
6823
6824    #[test]
6825    fn test_unreachable_after_composite_exit() {
6826        let warns = warnings(
6827            r#"pipeline t(task) {
6828  fn foo(x: bool) {
6829    if x { return 1 } else { throw "err" }
6830    let y = 2
6831  }
6832}"#,
6833        );
6834        assert!(
6835            warns.iter().any(|w| w.contains("unreachable")),
6836            "expected unreachable warning: {warns:?}"
6837        );
6838    }
6839
6840    #[test]
6841    fn test_no_unreachable_warning_when_reachable() {
6842        let warns = warnings(
6843            r#"pipeline t(task) {
6844  fn foo(x: bool) {
6845    if x { return 1 }
6846    let y = 2
6847  }
6848}"#,
6849        );
6850        assert!(
6851            !warns.iter().any(|w| w.contains("unreachable")),
6852            "unexpected unreachable warning: {warns:?}"
6853        );
6854    }
6855
6856    #[test]
6857    fn test_catch_typed_error_variable() {
6858        // When catch has a type annotation, the error var should be typed
6859        let errs = errors(
6860            r#"pipeline t(task) {
6861  enum AppError { NotFound, Timeout }
6862  try {
6863    throw AppError.NotFound
6864  } catch (e: AppError) {
6865    let x: AppError = e
6866  }
6867}"#,
6868        );
6869        assert!(errs.is_empty(), "unexpected errors: {errs:?}");
6870    }
6871
6872    #[test]
6873    fn test_unreachable_with_never_arg_no_error() {
6874        // After exhaustive narrowing, unreachable(x) should pass
6875        let errs = errors(
6876            r#"pipeline t(task) {
6877  fn foo(x: string | int) {
6878    if type_of(x) == "string" { return }
6879    if type_of(x) == "int" { return }
6880    unreachable(x)
6881  }
6882}"#,
6883        );
6884        assert!(
6885            !errs.iter().any(|e| e.contains("unreachable")),
6886            "unexpected unreachable error: {errs:?}"
6887        );
6888    }
6889
6890    #[test]
6891    fn test_unreachable_with_remaining_types_errors() {
6892        // Non-exhaustive narrowing — unreachable(x) should error
6893        let errs = errors(
6894            r#"pipeline t(task) {
6895  fn foo(x: string | int | nil) {
6896    if type_of(x) == "string" { return }
6897    unreachable(x)
6898  }
6899}"#,
6900        );
6901        assert!(
6902            errs.iter()
6903                .any(|e| e.contains("unreachable") && e.contains("not all cases")),
6904            "expected unreachable error about remaining types: {errs:?}"
6905        );
6906    }
6907
6908    #[test]
6909    fn test_unreachable_no_args_no_compile_error() {
6910        let errs = errors(
6911            r#"pipeline t(task) {
6912  fn foo() {
6913    unreachable()
6914  }
6915}"#,
6916        );
6917        assert!(
6918            !errs
6919                .iter()
6920                .any(|e| e.contains("unreachable") && e.contains("not all cases")),
6921            "unreachable() with no args should not produce type error: {errs:?}"
6922        );
6923    }
6924
6925    #[test]
6926    fn test_never_type_annotation_parses() {
6927        let errs = errors(
6928            r#"pipeline t(task) {
6929  fn foo() -> never {
6930    throw "always throws"
6931  }
6932}"#,
6933        );
6934        assert!(errs.is_empty(), "unexpected errors: {errs:?}");
6935    }
6936
6937    #[test]
6938    fn test_format_type_never() {
6939        assert_eq!(format_type(&TypeExpr::Never), "never");
6940    }
6941
6942    // ── Strict types mode tests ──────────────────────────────────────
6943
6944    fn check_source_strict(source: &str) -> Vec<TypeDiagnostic> {
6945        let mut lexer = Lexer::new(source);
6946        let tokens = lexer.tokenize().unwrap();
6947        let mut parser = Parser::new(tokens);
6948        let program = parser.parse().unwrap();
6949        TypeChecker::with_strict_types(true).check(&program)
6950    }
6951
6952    fn strict_warnings(source: &str) -> Vec<String> {
6953        check_source_strict(source)
6954            .into_iter()
6955            .filter(|d| d.severity == DiagnosticSeverity::Warning)
6956            .map(|d| d.message)
6957            .collect()
6958    }
6959
6960    #[test]
6961    fn test_strict_types_json_parse_property_access() {
6962        let warns = strict_warnings(
6963            r#"pipeline t(task) {
6964  let data = json_parse("{}")
6965  log(data.name)
6966}"#,
6967        );
6968        assert!(
6969            warns.iter().any(|w| w.contains("unvalidated")),
6970            "expected unvalidated warning, got: {warns:?}"
6971        );
6972    }
6973
6974    #[test]
6975    fn test_strict_types_direct_chain_access() {
6976        let warns = strict_warnings(
6977            r#"pipeline t(task) {
6978  log(json_parse("{}").name)
6979}"#,
6980        );
6981        assert!(
6982            warns.iter().any(|w| w.contains("Direct property access")),
6983            "expected direct access warning, got: {warns:?}"
6984        );
6985    }
6986
6987    #[test]
6988    fn test_strict_types_schema_expect_clears() {
6989        let warns = strict_warnings(
6990            r#"pipeline t(task) {
6991  let my_schema = {type: "object", properties: {name: {type: "string"}}}
6992  let data = json_parse("{}")
6993  schema_expect(data, my_schema)
6994  log(data.name)
6995}"#,
6996        );
6997        assert!(
6998            !warns.iter().any(|w| w.contains("unvalidated")),
6999            "expected no unvalidated warning after schema_expect, got: {warns:?}"
7000        );
7001    }
7002
7003    #[test]
7004    fn test_strict_types_schema_is_if_guard() {
7005        let warns = strict_warnings(
7006            r#"pipeline t(task) {
7007  let my_schema = {type: "object", properties: {name: {type: "string"}}}
7008  let data = json_parse("{}")
7009  if schema_is(data, my_schema) {
7010    log(data.name)
7011  }
7012}"#,
7013        );
7014        assert!(
7015            !warns.iter().any(|w| w.contains("unvalidated")),
7016            "expected no unvalidated warning inside schema_is guard, got: {warns:?}"
7017        );
7018    }
7019
7020    #[test]
7021    fn test_strict_types_shape_annotation_clears() {
7022        let warns = strict_warnings(
7023            r#"pipeline t(task) {
7024  let data: {name: string, age: int} = json_parse("{}")
7025  log(data.name)
7026}"#,
7027        );
7028        assert!(
7029            !warns.iter().any(|w| w.contains("unvalidated")),
7030            "expected no warning with shape annotation, got: {warns:?}"
7031        );
7032    }
7033
7034    #[test]
7035    fn test_strict_types_propagation() {
7036        let warns = strict_warnings(
7037            r#"pipeline t(task) {
7038  let data = json_parse("{}")
7039  let x = data
7040  log(x.name)
7041}"#,
7042        );
7043        assert!(
7044            warns
7045                .iter()
7046                .any(|w| w.contains("unvalidated") && w.contains("'x'")),
7047            "expected propagation warning for x, got: {warns:?}"
7048        );
7049    }
7050
7051    #[test]
7052    fn test_strict_types_non_boundary_no_warning() {
7053        let warns = strict_warnings(
7054            r#"pipeline t(task) {
7055  let x = len("hello")
7056  log(x)
7057}"#,
7058        );
7059        assert!(
7060            !warns.iter().any(|w| w.contains("unvalidated")),
7061            "non-boundary function should not be flagged, got: {warns:?}"
7062        );
7063    }
7064
7065    #[test]
7066    fn test_strict_types_subscript_access() {
7067        let warns = strict_warnings(
7068            r#"pipeline t(task) {
7069  let data = json_parse("{}")
7070  log(data["name"])
7071}"#,
7072        );
7073        assert!(
7074            warns.iter().any(|w| w.contains("unvalidated")),
7075            "expected subscript warning, got: {warns:?}"
7076        );
7077    }
7078
7079    #[test]
7080    fn test_strict_types_disabled_by_default() {
7081        let diags = check_source(
7082            r#"pipeline t(task) {
7083  let data = json_parse("{}")
7084  log(data.name)
7085}"#,
7086        );
7087        assert!(
7088            !diags.iter().any(|d| d.message.contains("unvalidated")),
7089            "strict types should be off by default, got: {diags:?}"
7090        );
7091    }
7092
7093    #[test]
7094    fn test_strict_types_llm_call_without_schema() {
7095        let warns = strict_warnings(
7096            r#"pipeline t(task) {
7097  let result = llm_call("prompt", "system")
7098  log(result.text)
7099}"#,
7100        );
7101        assert!(
7102            warns.iter().any(|w| w.contains("unvalidated")),
7103            "llm_call without schema should warn, got: {warns:?}"
7104        );
7105    }
7106
7107    #[test]
7108    fn test_strict_types_llm_call_with_schema_clean() {
7109        let warns = strict_warnings(
7110            r#"pipeline t(task) {
7111  let result = llm_call("prompt", "system", {
7112    schema: {type: "object", properties: {name: {type: "string"}}}
7113  })
7114  log(result.data)
7115  log(result.text)
7116}"#,
7117        );
7118        assert!(
7119            !warns.iter().any(|w| w.contains("unvalidated")),
7120            "llm_call with schema should not warn, got: {warns:?}"
7121        );
7122    }
7123
7124    #[test]
7125    fn test_strict_types_schema_expect_result_typed() {
7126        let warns = strict_warnings(
7127            r#"pipeline t(task) {
7128  let my_schema = {type: "object", properties: {name: {type: "string"}}}
7129  let validated = schema_expect(json_parse("{}"), my_schema)
7130  log(validated.name)
7131}"#,
7132        );
7133        assert!(
7134            !warns.iter().any(|w| w.contains("unvalidated")),
7135            "schema_expect result should be typed, got: {warns:?}"
7136        );
7137    }
7138
7139    #[test]
7140    fn test_strict_types_realistic_orchestration() {
7141        let warns = strict_warnings(
7142            r#"pipeline t(task) {
7143  let payload_schema = {type: "object", properties: {
7144    name: {type: "string"},
7145    steps: {type: "list", items: {type: "string"}}
7146  }}
7147
7148  // Good: schema-aware llm_call
7149  let result = llm_call("generate a workflow", "system", {
7150    schema: payload_schema
7151  })
7152  let workflow_name = result.data.name
7153
7154  // Good: validate then access
7155  let raw = json_parse("{}")
7156  schema_expect(raw, payload_schema)
7157  let steps = raw.steps
7158
7159  log(workflow_name)
7160  log(steps)
7161}"#,
7162        );
7163        assert!(
7164            !warns.iter().any(|w| w.contains("unvalidated")),
7165            "validated orchestration should be clean, got: {warns:?}"
7166        );
7167    }
7168
7169    #[test]
7170    fn test_strict_types_llm_call_with_schema_via_variable() {
7171        let warns = strict_warnings(
7172            r#"pipeline t(task) {
7173  let my_schema = {type: "object", properties: {score: {type: "float"}}}
7174  let result = llm_call("rate this", "system", {
7175    schema: my_schema
7176  })
7177  log(result.data.score)
7178}"#,
7179        );
7180        assert!(
7181            !warns.iter().any(|w| w.contains("unvalidated")),
7182            "llm_call with schema variable should not warn, got: {warns:?}"
7183        );
7184    }
7185}