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