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