Skip to main content

harn_parser/
typechecker.rs

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