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