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