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/// Scope for tracking variable types.
38#[derive(Debug, Clone)]
39struct TypeScope {
40    /// Variable name → inferred type.
41    vars: BTreeMap<String, InferredType>,
42    /// Function name → (param types, return type).
43    functions: BTreeMap<String, FnSignature>,
44    /// Named type aliases.
45    type_aliases: BTreeMap<String, TypeExpr>,
46    /// Enum declarations: name → variant names.
47    enums: BTreeMap<String, Vec<String>>,
48    /// Interface declarations: name → method signatures.
49    interfaces: BTreeMap<String, Vec<InterfaceMethod>>,
50    /// Struct declarations: name → field types.
51    structs: BTreeMap<String, Vec<(String, InferredType)>>,
52    /// Impl block methods: type_name → method signatures.
53    impl_methods: BTreeMap<String, Vec<ImplMethodSig>>,
54    /// Generic type parameter names in scope (treated as compatible with any type).
55    generic_type_params: std::collections::BTreeSet<String>,
56    /// Where-clause constraints: type_param → interface_bound.
57    /// Used for definition-site checking of generic function bodies.
58    where_constraints: BTreeMap<String, String>,
59    /// Variables declared with `var` (mutable). Variables not in this set
60    /// are immutable (`let`, function params, loop vars, etc.).
61    mutable_vars: std::collections::BTreeSet<String>,
62    /// Variables that have been narrowed by flow-sensitive refinement.
63    /// Maps var name → pre-narrowing type (used to restore on reassignment).
64    narrowed_vars: BTreeMap<String, InferredType>,
65    /// Schema literals bound to variables, reduced to a TypeExpr subset so
66    /// `schema_is(x, some_schema)` can participate in flow refinement.
67    schema_bindings: BTreeMap<String, InferredType>,
68    parent: Option<Box<TypeScope>>,
69}
70
71/// Method signature extracted from an impl block (for interface checking).
72#[derive(Debug, Clone)]
73struct ImplMethodSig {
74    name: String,
75    /// Number of parameters excluding `self`.
76    param_count: usize,
77    /// Parameter types (excluding `self`), None means untyped.
78    param_types: Vec<Option<TypeExpr>>,
79    /// Return type, None means untyped.
80    return_type: Option<TypeExpr>,
81}
82
83#[derive(Debug, Clone)]
84struct FnSignature {
85    params: Vec<(String, InferredType)>,
86    return_type: InferredType,
87    /// Generic type parameter names declared on the function.
88    type_param_names: Vec<String>,
89    /// Number of required parameters (those without defaults).
90    required_params: usize,
91    /// Where-clause constraints: (type_param_name, interface_bound).
92    where_clauses: Vec<(String, String)>,
93    /// True if the last parameter is a rest parameter.
94    has_rest: bool,
95}
96
97impl TypeScope {
98    fn new() -> Self {
99        Self {
100            vars: BTreeMap::new(),
101            functions: BTreeMap::new(),
102            type_aliases: BTreeMap::new(),
103            enums: BTreeMap::new(),
104            interfaces: BTreeMap::new(),
105            structs: BTreeMap::new(),
106            impl_methods: BTreeMap::new(),
107            generic_type_params: std::collections::BTreeSet::new(),
108            where_constraints: BTreeMap::new(),
109            mutable_vars: std::collections::BTreeSet::new(),
110            narrowed_vars: BTreeMap::new(),
111            schema_bindings: BTreeMap::new(),
112            parent: None,
113        }
114    }
115
116    fn child(&self) -> Self {
117        Self {
118            vars: BTreeMap::new(),
119            functions: BTreeMap::new(),
120            type_aliases: BTreeMap::new(),
121            enums: BTreeMap::new(),
122            interfaces: BTreeMap::new(),
123            structs: BTreeMap::new(),
124            impl_methods: BTreeMap::new(),
125            generic_type_params: std::collections::BTreeSet::new(),
126            where_constraints: BTreeMap::new(),
127            mutable_vars: std::collections::BTreeSet::new(),
128            narrowed_vars: BTreeMap::new(),
129            schema_bindings: BTreeMap::new(),
130            parent: Some(Box::new(self.clone())),
131        }
132    }
133
134    fn get_var(&self, name: &str) -> Option<&InferredType> {
135        self.vars
136            .get(name)
137            .or_else(|| self.parent.as_ref()?.get_var(name))
138    }
139
140    fn get_fn(&self, name: &str) -> Option<&FnSignature> {
141        self.functions
142            .get(name)
143            .or_else(|| self.parent.as_ref()?.get_fn(name))
144    }
145
146    fn get_schema_binding(&self, name: &str) -> Option<&InferredType> {
147        self.schema_bindings
148            .get(name)
149            .or_else(|| self.parent.as_ref()?.get_schema_binding(name))
150    }
151
152    fn resolve_type(&self, name: &str) -> Option<&TypeExpr> {
153        self.type_aliases
154            .get(name)
155            .or_else(|| self.parent.as_ref()?.resolve_type(name))
156    }
157
158    fn is_generic_type_param(&self, name: &str) -> bool {
159        self.generic_type_params.contains(name)
160            || self
161                .parent
162                .as_ref()
163                .is_some_and(|p| p.is_generic_type_param(name))
164    }
165
166    fn get_where_constraint(&self, type_param: &str) -> Option<&str> {
167        self.where_constraints
168            .get(type_param)
169            .map(|s| s.as_str())
170            .or_else(|| {
171                self.parent
172                    .as_ref()
173                    .and_then(|p| p.get_where_constraint(type_param))
174            })
175    }
176
177    fn get_enum(&self, name: &str) -> Option<&Vec<String>> {
178        self.enums
179            .get(name)
180            .or_else(|| self.parent.as_ref()?.get_enum(name))
181    }
182
183    fn get_interface(&self, name: &str) -> Option<&Vec<InterfaceMethod>> {
184        self.interfaces
185            .get(name)
186            .or_else(|| self.parent.as_ref()?.get_interface(name))
187    }
188
189    fn get_struct(&self, name: &str) -> Option<&Vec<(String, InferredType)>> {
190        self.structs
191            .get(name)
192            .or_else(|| self.parent.as_ref()?.get_struct(name))
193    }
194
195    fn get_impl_methods(&self, name: &str) -> Option<&Vec<ImplMethodSig>> {
196        self.impl_methods
197            .get(name)
198            .or_else(|| self.parent.as_ref()?.get_impl_methods(name))
199    }
200
201    fn define_var(&mut self, name: &str, ty: InferredType) {
202        self.vars.insert(name.to_string(), ty);
203    }
204
205    fn define_var_mutable(&mut self, name: &str, ty: InferredType) {
206        self.vars.insert(name.to_string(), ty);
207        self.mutable_vars.insert(name.to_string());
208    }
209
210    fn define_schema_binding(&mut self, name: &str, ty: InferredType) {
211        self.schema_bindings.insert(name.to_string(), ty);
212    }
213
214    /// Check if a variable is mutable (declared with `var`).
215    fn is_mutable(&self, name: &str) -> bool {
216        self.mutable_vars.contains(name) || self.parent.as_ref().is_some_and(|p| p.is_mutable(name))
217    }
218
219    fn define_fn(&mut self, name: &str, sig: FnSignature) {
220        self.functions.insert(name.to_string(), sig);
221    }
222}
223
224/// Bidirectional type refinements extracted from a condition.
225/// Each path contains a list of (variable_name, narrowed_type) pairs.
226#[derive(Debug, Clone, Default)]
227struct Refinements {
228    /// Narrowings when the condition evaluates to true/truthy.
229    truthy: Vec<(String, InferredType)>,
230    /// Narrowings when the condition evaluates to false/falsy.
231    falsy: Vec<(String, InferredType)>,
232}
233
234impl Refinements {
235    fn empty() -> Self {
236        Self::default()
237    }
238
239    /// Swap truthy and falsy (used for negation).
240    fn inverted(self) -> Self {
241        Self {
242            truthy: self.falsy,
243            falsy: self.truthy,
244        }
245    }
246}
247
248/// Known return types for builtin functions. Delegates to the shared
249/// [`builtin_signatures`] registry — see that module for the full table.
250fn builtin_return_type(name: &str) -> InferredType {
251    builtin_signatures::builtin_return_type(name)
252}
253
254/// Check if a name is a known builtin. Delegates to the shared
255/// [`builtin_signatures`] registry.
256fn is_builtin(name: &str) -> bool {
257    builtin_signatures::is_builtin(name)
258}
259
260/// The static type checker.
261pub struct TypeChecker {
262    diagnostics: Vec<TypeDiagnostic>,
263    scope: TypeScope,
264    source: Option<String>,
265    hints: Vec<InlayHintInfo>,
266}
267
268impl TypeChecker {
269    pub fn new() -> Self {
270        Self {
271            diagnostics: Vec::new(),
272            scope: TypeScope::new(),
273            source: None,
274            hints: Vec::new(),
275        }
276    }
277
278    /// Check a program with source text for autofix generation.
279    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
280        self.source = Some(source.to_string());
281        self.check_inner(program).0
282    }
283
284    /// Check a program and return diagnostics.
285    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
286        self.check_inner(program).0
287    }
288
289    /// Check a program and return both diagnostics and inlay hints.
290    pub fn check_with_hints(
291        mut self,
292        program: &[SNode],
293        source: &str,
294    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
295        self.source = Some(source.to_string());
296        self.check_inner(program)
297    }
298
299    fn check_inner(mut self, program: &[SNode]) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
300        // First pass: register type and enum declarations into root scope
301        Self::register_declarations_into(&mut self.scope, program);
302
303        // Also scan pipeline bodies for declarations
304        for snode in program {
305            if let Node::Pipeline { body, .. } = &snode.node {
306                Self::register_declarations_into(&mut self.scope, body);
307            }
308        }
309
310        // Check each top-level node
311        for snode in program {
312            match &snode.node {
313                Node::Pipeline { params, body, .. } => {
314                    let mut child = self.scope.child();
315                    for p in params {
316                        child.define_var(p, None);
317                    }
318                    self.check_block(body, &mut child);
319                }
320                Node::FnDecl {
321                    name,
322                    type_params,
323                    params,
324                    return_type,
325                    where_clauses,
326                    body,
327                    ..
328                } => {
329                    let required_params =
330                        params.iter().filter(|p| p.default_value.is_none()).count();
331                    let sig = FnSignature {
332                        params: params
333                            .iter()
334                            .map(|p| (p.name.clone(), p.type_expr.clone()))
335                            .collect(),
336                        return_type: return_type.clone(),
337                        type_param_names: type_params.iter().map(|tp| tp.name.clone()).collect(),
338                        required_params,
339                        where_clauses: where_clauses
340                            .iter()
341                            .map(|wc| (wc.type_name.clone(), wc.bound.clone()))
342                            .collect(),
343                        has_rest: params.last().is_some_and(|p| p.rest),
344                    };
345                    self.scope.define_fn(name, sig);
346                    self.check_fn_body(type_params, params, return_type, body, where_clauses);
347                }
348                _ => {
349                    let mut scope = self.scope.clone();
350                    self.check_node(snode, &mut scope);
351                    // Merge any new definitions back into the top-level scope
352                    for (name, ty) in scope.vars {
353                        self.scope.vars.entry(name).or_insert(ty);
354                    }
355                    for name in scope.mutable_vars {
356                        self.scope.mutable_vars.insert(name);
357                    }
358                }
359            }
360        }
361
362        (self.diagnostics, self.hints)
363    }
364
365    /// Register type, enum, interface, and struct declarations from AST nodes into a scope.
366    fn register_declarations_into(scope: &mut TypeScope, nodes: &[SNode]) {
367        for snode in nodes {
368            match &snode.node {
369                Node::TypeDecl { name, type_expr } => {
370                    scope.type_aliases.insert(name.clone(), type_expr.clone());
371                }
372                Node::EnumDecl { name, variants, .. } => {
373                    let variant_names: Vec<String> =
374                        variants.iter().map(|v| v.name.clone()).collect();
375                    scope.enums.insert(name.clone(), variant_names);
376                }
377                Node::InterfaceDecl { name, methods, .. } => {
378                    scope.interfaces.insert(name.clone(), methods.clone());
379                }
380                Node::StructDecl { name, fields, .. } => {
381                    let field_types: Vec<(String, InferredType)> = fields
382                        .iter()
383                        .map(|f| (f.name.clone(), f.type_expr.clone()))
384                        .collect();
385                    scope.structs.insert(name.clone(), field_types);
386                }
387                Node::ImplBlock {
388                    type_name, methods, ..
389                } => {
390                    let sigs: Vec<ImplMethodSig> = methods
391                        .iter()
392                        .filter_map(|m| {
393                            if let Node::FnDecl {
394                                name,
395                                params,
396                                return_type,
397                                ..
398                            } = &m.node
399                            {
400                                let non_self: Vec<_> =
401                                    params.iter().filter(|p| p.name != "self").collect();
402                                let param_count = non_self.len();
403                                let param_types: Vec<Option<TypeExpr>> =
404                                    non_self.iter().map(|p| p.type_expr.clone()).collect();
405                                Some(ImplMethodSig {
406                                    name: name.clone(),
407                                    param_count,
408                                    param_types,
409                                    return_type: return_type.clone(),
410                                })
411                            } else {
412                                None
413                            }
414                        })
415                        .collect();
416                    scope.impl_methods.insert(type_name.clone(), sigs);
417                }
418                _ => {}
419            }
420        }
421    }
422
423    fn check_block(&mut self, stmts: &[SNode], scope: &mut TypeScope) {
424        for stmt in stmts {
425            self.check_node(stmt, scope);
426        }
427    }
428
429    /// Define variables from a destructuring pattern in the given scope (as unknown type).
430    fn define_pattern_vars(pattern: &BindingPattern, scope: &mut TypeScope, mutable: bool) {
431        let define = |scope: &mut TypeScope, name: &str| {
432            if mutable {
433                scope.define_var_mutable(name, None);
434            } else {
435                scope.define_var(name, None);
436            }
437        };
438        match pattern {
439            BindingPattern::Identifier(name) => {
440                define(scope, name);
441            }
442            BindingPattern::Dict(fields) => {
443                for field in fields {
444                    let name = field.alias.as_deref().unwrap_or(&field.key);
445                    define(scope, name);
446                }
447            }
448            BindingPattern::List(elements) => {
449                for elem in elements {
450                    define(scope, &elem.name);
451                }
452            }
453        }
454    }
455
456    /// Type-check default value expressions within a destructuring pattern.
457    fn check_pattern_defaults(&mut self, pattern: &BindingPattern, scope: &mut TypeScope) {
458        match pattern {
459            BindingPattern::Identifier(_) => {}
460            BindingPattern::Dict(fields) => {
461                for field in fields {
462                    if let Some(default) = &field.default_value {
463                        self.check_binops(default, scope);
464                    }
465                }
466            }
467            BindingPattern::List(elements) => {
468                for elem in elements {
469                    if let Some(default) = &elem.default_value {
470                        self.check_binops(default, scope);
471                    }
472                }
473            }
474        }
475    }
476
477    fn check_node(&mut self, snode: &SNode, scope: &mut TypeScope) {
478        let span = snode.span;
479        match &snode.node {
480            Node::LetBinding {
481                pattern,
482                type_ann,
483                value,
484            } => {
485                self.check_binops(value, scope);
486                let inferred = self.infer_type(value, scope);
487                if let BindingPattern::Identifier(name) = pattern {
488                    if let Some(expected) = type_ann {
489                        if let Some(actual) = &inferred {
490                            if !self.types_compatible(expected, actual, scope) {
491                                let mut msg = format!(
492                                    "Type mismatch: '{}' declared as {}, but assigned {}",
493                                    name,
494                                    format_type(expected),
495                                    format_type(actual)
496                                );
497                                if let Some(detail) = shape_mismatch_detail(expected, actual) {
498                                    msg.push_str(&format!(" ({})", detail));
499                                }
500                                self.error_at(msg, span);
501                            }
502                        }
503                    }
504                    // Collect inlay hint when type is inferred (no annotation)
505                    if type_ann.is_none() {
506                        if let Some(ref ty) = inferred {
507                            if !is_obvious_type(value, ty) {
508                                self.hints.push(InlayHintInfo {
509                                    line: span.line,
510                                    column: span.column + "let ".len() + name.len(),
511                                    label: format!(": {}", format_type(ty)),
512                                });
513                            }
514                        }
515                    }
516                    let ty = type_ann.clone().or(inferred);
517                    scope.define_var(name, ty);
518                    scope.define_schema_binding(name, schema_type_expr_from_node(value, scope));
519                } else {
520                    self.check_pattern_defaults(pattern, scope);
521                    Self::define_pattern_vars(pattern, scope, false);
522                }
523            }
524
525            Node::VarBinding {
526                pattern,
527                type_ann,
528                value,
529            } => {
530                self.check_binops(value, scope);
531                let inferred = self.infer_type(value, scope);
532                if let BindingPattern::Identifier(name) = pattern {
533                    if let Some(expected) = type_ann {
534                        if let Some(actual) = &inferred {
535                            if !self.types_compatible(expected, actual, scope) {
536                                let mut msg = format!(
537                                    "Type mismatch: '{}' declared as {}, but assigned {}",
538                                    name,
539                                    format_type(expected),
540                                    format_type(actual)
541                                );
542                                if let Some(detail) = shape_mismatch_detail(expected, actual) {
543                                    msg.push_str(&format!(" ({})", detail));
544                                }
545                                self.error_at(msg, span);
546                            }
547                        }
548                    }
549                    if type_ann.is_none() {
550                        if let Some(ref ty) = inferred {
551                            if !is_obvious_type(value, ty) {
552                                self.hints.push(InlayHintInfo {
553                                    line: span.line,
554                                    column: span.column + "var ".len() + name.len(),
555                                    label: format!(": {}", format_type(ty)),
556                                });
557                            }
558                        }
559                    }
560                    let ty = type_ann.clone().or(inferred);
561                    scope.define_var_mutable(name, ty);
562                    scope.define_schema_binding(name, schema_type_expr_from_node(value, scope));
563                } else {
564                    self.check_pattern_defaults(pattern, scope);
565                    Self::define_pattern_vars(pattern, scope, true);
566                }
567            }
568
569            Node::FnDecl {
570                name,
571                type_params,
572                params,
573                return_type,
574                where_clauses,
575                body,
576                ..
577            } => {
578                let required_params = params.iter().filter(|p| p.default_value.is_none()).count();
579                let sig = FnSignature {
580                    params: params
581                        .iter()
582                        .map(|p| (p.name.clone(), p.type_expr.clone()))
583                        .collect(),
584                    return_type: return_type.clone(),
585                    type_param_names: type_params.iter().map(|tp| tp.name.clone()).collect(),
586                    required_params,
587                    where_clauses: where_clauses
588                        .iter()
589                        .map(|wc| (wc.type_name.clone(), wc.bound.clone()))
590                        .collect(),
591                    has_rest: params.last().is_some_and(|p| p.rest),
592                };
593                scope.define_fn(name, sig.clone());
594                scope.define_var(name, None);
595                self.check_fn_body(type_params, params, return_type, body, where_clauses);
596            }
597
598            Node::ToolDecl {
599                name,
600                params,
601                return_type,
602                body,
603                ..
604            } => {
605                // Register the tool like a function for type checking purposes
606                let required_params = params.iter().filter(|p| p.default_value.is_none()).count();
607                let sig = FnSignature {
608                    params: params
609                        .iter()
610                        .map(|p| (p.name.clone(), p.type_expr.clone()))
611                        .collect(),
612                    return_type: return_type.clone(),
613                    type_param_names: Vec::new(),
614                    required_params,
615                    where_clauses: Vec::new(),
616                    has_rest: params.last().is_some_and(|p| p.rest),
617                };
618                scope.define_fn(name, sig);
619                scope.define_var(name, None);
620                self.check_fn_body(&[], params, return_type, body, &[]);
621            }
622
623            Node::FunctionCall { name, args } => {
624                self.check_call(name, args, scope, span);
625            }
626
627            Node::IfElse {
628                condition,
629                then_body,
630                else_body,
631            } => {
632                self.check_node(condition, scope);
633                let refs = Self::extract_refinements(condition, scope);
634
635                let mut then_scope = scope.child();
636                apply_refinements(&mut then_scope, &refs.truthy);
637                self.check_block(then_body, &mut then_scope);
638
639                if let Some(else_body) = else_body {
640                    let mut else_scope = scope.child();
641                    apply_refinements(&mut else_scope, &refs.falsy);
642                    self.check_block(else_body, &mut else_scope);
643
644                    // Post-branch narrowing: if one branch definitely exits,
645                    // apply the other branch's refinements to the outer scope
646                    if Self::block_definitely_exits(then_body)
647                        && !Self::block_definitely_exits(else_body)
648                    {
649                        apply_refinements(scope, &refs.falsy);
650                    } else if Self::block_definitely_exits(else_body)
651                        && !Self::block_definitely_exits(then_body)
652                    {
653                        apply_refinements(scope, &refs.truthy);
654                    }
655                } else {
656                    // No else: if then-body always exits, apply falsy after
657                    if Self::block_definitely_exits(then_body) {
658                        apply_refinements(scope, &refs.falsy);
659                    }
660                }
661            }
662
663            Node::ForIn {
664                pattern,
665                iterable,
666                body,
667            } => {
668                self.check_node(iterable, scope);
669                let mut loop_scope = scope.child();
670                if let BindingPattern::Identifier(variable) = pattern {
671                    // Infer loop variable type from iterable
672                    let elem_type = match self.infer_type(iterable, scope) {
673                        Some(TypeExpr::List(inner)) => Some(*inner),
674                        Some(TypeExpr::Named(n)) if n == "string" => {
675                            Some(TypeExpr::Named("string".into()))
676                        }
677                        _ => None,
678                    };
679                    loop_scope.define_var(variable, elem_type);
680                } else {
681                    self.check_pattern_defaults(pattern, &mut loop_scope);
682                    Self::define_pattern_vars(pattern, &mut loop_scope, false);
683                }
684                self.check_block(body, &mut loop_scope);
685            }
686
687            Node::WhileLoop { condition, body } => {
688                self.check_node(condition, scope);
689                let refs = Self::extract_refinements(condition, scope);
690                let mut loop_scope = scope.child();
691                apply_refinements(&mut loop_scope, &refs.truthy);
692                self.check_block(body, &mut loop_scope);
693            }
694
695            Node::RequireStmt { condition, message } => {
696                self.check_node(condition, scope);
697                if let Some(message) = message {
698                    self.check_node(message, scope);
699                }
700            }
701
702            Node::TryCatch {
703                body,
704                error_var,
705                catch_body,
706                finally_body,
707                ..
708            } => {
709                let mut try_scope = scope.child();
710                self.check_block(body, &mut try_scope);
711                let mut catch_scope = scope.child();
712                if let Some(var) = error_var {
713                    catch_scope.define_var(var, None);
714                }
715                self.check_block(catch_body, &mut catch_scope);
716                if let Some(fb) = finally_body {
717                    let mut finally_scope = scope.child();
718                    self.check_block(fb, &mut finally_scope);
719                }
720            }
721
722            Node::TryExpr { body } => {
723                let mut try_scope = scope.child();
724                self.check_block(body, &mut try_scope);
725            }
726
727            Node::ReturnStmt {
728                value: Some(val), ..
729            } => {
730                self.check_node(val, scope);
731            }
732
733            Node::Assignment {
734                target, value, op, ..
735            } => {
736                self.check_node(value, scope);
737                if let Node::Identifier(name) = &target.node {
738                    // Compile-time immutability check
739                    if scope.get_var(name).is_some() && !scope.is_mutable(name) {
740                        self.warning_at(
741                            format!(
742                                "Cannot assign to '{}': variable is immutable (declared with 'let')",
743                                name
744                            ),
745                            span,
746                        );
747                    }
748
749                    if let Some(Some(var_type)) = scope.get_var(name) {
750                        let value_type = self.infer_type(value, scope);
751                        let assigned = if let Some(op) = op {
752                            let var_inferred = scope.get_var(name).cloned().flatten();
753                            infer_binary_op_type(op, &var_inferred, &value_type)
754                        } else {
755                            value_type
756                        };
757                        if let Some(actual) = &assigned {
758                            // Check against the original (pre-narrowing) type if narrowed
759                            let check_type = scope
760                                .narrowed_vars
761                                .get(name)
762                                .and_then(|t| t.as_ref())
763                                .unwrap_or(var_type);
764                            if !self.types_compatible(check_type, actual, scope) {
765                                self.error_at(
766                                    format!(
767                                        "Type mismatch: cannot assign {} to '{}' (declared as {})",
768                                        format_type(actual),
769                                        name,
770                                        format_type(check_type)
771                                    ),
772                                    span,
773                                );
774                            }
775                        }
776                    }
777
778                    // Invalidate narrowing on reassignment: restore original type
779                    if let Some(original) = scope.narrowed_vars.remove(name) {
780                        scope.define_var(name, original);
781                    }
782                    scope.define_schema_binding(name, None);
783                }
784            }
785
786            Node::TypeDecl { name, type_expr } => {
787                scope.type_aliases.insert(name.clone(), type_expr.clone());
788            }
789
790            Node::EnumDecl { name, variants, .. } => {
791                let variant_names: Vec<String> = variants.iter().map(|v| v.name.clone()).collect();
792                scope.enums.insert(name.clone(), variant_names);
793            }
794
795            Node::StructDecl { name, fields, .. } => {
796                let field_types: Vec<(String, InferredType)> = fields
797                    .iter()
798                    .map(|f| (f.name.clone(), f.type_expr.clone()))
799                    .collect();
800                scope.structs.insert(name.clone(), field_types);
801            }
802
803            Node::InterfaceDecl { name, methods, .. } => {
804                scope.interfaces.insert(name.clone(), methods.clone());
805            }
806
807            Node::ImplBlock {
808                type_name, methods, ..
809            } => {
810                // Register impl methods for interface satisfaction checking
811                let sigs: Vec<ImplMethodSig> = methods
812                    .iter()
813                    .filter_map(|m| {
814                        if let Node::FnDecl {
815                            name,
816                            params,
817                            return_type,
818                            ..
819                        } = &m.node
820                        {
821                            let non_self: Vec<_> =
822                                params.iter().filter(|p| p.name != "self").collect();
823                            let param_count = non_self.len();
824                            let param_types: Vec<Option<TypeExpr>> =
825                                non_self.iter().map(|p| p.type_expr.clone()).collect();
826                            Some(ImplMethodSig {
827                                name: name.clone(),
828                                param_count,
829                                param_types,
830                                return_type: return_type.clone(),
831                            })
832                        } else {
833                            None
834                        }
835                    })
836                    .collect();
837                scope.impl_methods.insert(type_name.clone(), sigs);
838                for method_sn in methods {
839                    self.check_node(method_sn, scope);
840                }
841            }
842
843            Node::TryOperator { operand } => {
844                self.check_node(operand, scope);
845            }
846
847            Node::MatchExpr { value, arms } => {
848                self.check_node(value, scope);
849                let value_type = self.infer_type(value, scope);
850                for arm in arms {
851                    self.check_node(&arm.pattern, scope);
852                    // Check for incompatible literal pattern types
853                    if let Some(ref vt) = value_type {
854                        let value_type_name = format_type(vt);
855                        let mismatch = match &arm.pattern.node {
856                            Node::StringLiteral(_) => {
857                                !self.types_compatible(vt, &TypeExpr::Named("string".into()), scope)
858                            }
859                            Node::IntLiteral(_) => {
860                                !self.types_compatible(vt, &TypeExpr::Named("int".into()), scope)
861                                    && !self.types_compatible(
862                                        vt,
863                                        &TypeExpr::Named("float".into()),
864                                        scope,
865                                    )
866                            }
867                            Node::FloatLiteral(_) => {
868                                !self.types_compatible(vt, &TypeExpr::Named("float".into()), scope)
869                                    && !self.types_compatible(
870                                        vt,
871                                        &TypeExpr::Named("int".into()),
872                                        scope,
873                                    )
874                            }
875                            Node::BoolLiteral(_) => {
876                                !self.types_compatible(vt, &TypeExpr::Named("bool".into()), scope)
877                            }
878                            _ => false,
879                        };
880                        if mismatch {
881                            let pattern_type = match &arm.pattern.node {
882                                Node::StringLiteral(_) => "string",
883                                Node::IntLiteral(_) => "int",
884                                Node::FloatLiteral(_) => "float",
885                                Node::BoolLiteral(_) => "bool",
886                                _ => unreachable!(),
887                            };
888                            self.warning_at(
889                                format!(
890                                    "Match pattern type mismatch: matching {} against {} literal",
891                                    value_type_name, pattern_type
892                                ),
893                                arm.pattern.span,
894                            );
895                        }
896                    }
897                    let mut arm_scope = scope.child();
898                    // Narrow the matched value's type in each arm
899                    if let Node::Identifier(var_name) = &value.node {
900                        if let Some(Some(TypeExpr::Union(members))) = scope.get_var(var_name) {
901                            let narrowed = match &arm.pattern.node {
902                                Node::NilLiteral => narrow_to_single(members, "nil"),
903                                Node::StringLiteral(_) => narrow_to_single(members, "string"),
904                                Node::IntLiteral(_) => narrow_to_single(members, "int"),
905                                Node::FloatLiteral(_) => narrow_to_single(members, "float"),
906                                Node::BoolLiteral(_) => narrow_to_single(members, "bool"),
907                                _ => None,
908                            };
909                            if let Some(narrowed_type) = narrowed {
910                                arm_scope.define_var(var_name, Some(narrowed_type));
911                            }
912                        }
913                    }
914                    self.check_block(&arm.body, &mut arm_scope);
915                }
916                self.check_match_exhaustiveness(value, arms, scope, span);
917            }
918
919            // Recurse into nested expressions + validate binary op types
920            Node::BinaryOp { op, left, right } => {
921                self.check_node(left, scope);
922                self.check_node(right, scope);
923                // Validate operator/type compatibility
924                let lt = self.infer_type(left, scope);
925                let rt = self.infer_type(right, scope);
926                if let (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) = (&lt, &rt) {
927                    match op.as_str() {
928                        "-" | "/" | "%" => {
929                            let numeric = ["int", "float"];
930                            if !numeric.contains(&l.as_str()) || !numeric.contains(&r.as_str()) {
931                                self.error_at(
932                                    format!(
933                                        "Operator '{}' requires numeric operands, got {} and {}",
934                                        op, l, r
935                                    ),
936                                    span,
937                                );
938                            }
939                        }
940                        "*" => {
941                            let numeric = ["int", "float"];
942                            let is_numeric =
943                                numeric.contains(&l.as_str()) && numeric.contains(&r.as_str());
944                            let is_string_repeat =
945                                (l == "string" && r == "int") || (l == "int" && r == "string");
946                            if !is_numeric && !is_string_repeat {
947                                self.error_at(
948                                    format!(
949                                        "Operator '*' requires numeric operands or string * int, got {} and {}",
950                                        l, r
951                                    ),
952                                    span,
953                                );
954                            }
955                        }
956                        "+" => {
957                            let valid = matches!(
958                                (l.as_str(), r.as_str()),
959                                ("int" | "float", "int" | "float")
960                                    | ("string", "string")
961                                    | ("list", "list")
962                                    | ("dict", "dict")
963                            );
964                            if !valid {
965                                let msg =
966                                    format!("Operator '+' is not valid for types {} and {}", l, r);
967                                // Offer interpolation fix when one side is string
968                                let fix = if l == "string" || r == "string" {
969                                    self.build_interpolation_fix(left, right, l == "string", span)
970                                } else {
971                                    None
972                                };
973                                if let Some(fix) = fix {
974                                    self.error_at_with_fix(msg, span, fix);
975                                } else {
976                                    self.error_at(msg, span);
977                                }
978                            }
979                        }
980                        "<" | ">" | "<=" | ">=" => {
981                            let comparable = ["int", "float", "string"];
982                            if !comparable.contains(&l.as_str())
983                                || !comparable.contains(&r.as_str())
984                            {
985                                self.warning_at(
986                                    format!(
987                                        "Comparison '{}' may not be meaningful for types {} and {}",
988                                        op, l, r
989                                    ),
990                                    span,
991                                );
992                            } else if (l == "string") != (r == "string") {
993                                self.warning_at(
994                                    format!(
995                                        "Comparing {} with {} using '{}' may give unexpected results",
996                                        l, r, op
997                                    ),
998                                    span,
999                                );
1000                            }
1001                        }
1002                        _ => {}
1003                    }
1004                }
1005            }
1006            Node::UnaryOp { operand, .. } => {
1007                self.check_node(operand, scope);
1008            }
1009            Node::MethodCall {
1010                object,
1011                method,
1012                args,
1013                ..
1014            }
1015            | Node::OptionalMethodCall {
1016                object,
1017                method,
1018                args,
1019                ..
1020            } => {
1021                self.check_node(object, scope);
1022                for arg in args {
1023                    self.check_node(arg, scope);
1024                }
1025                // Definition-site generic checking: if the object's type is a
1026                // constrained generic param (where T: Interface), verify the
1027                // method exists in the bound interface.
1028                if let Some(TypeExpr::Named(type_name)) = self.infer_type(object, scope) {
1029                    if scope.is_generic_type_param(&type_name) {
1030                        if let Some(iface_name) = scope.get_where_constraint(&type_name) {
1031                            if let Some(iface_methods) = scope.get_interface(iface_name) {
1032                                let has_method = iface_methods.iter().any(|m| m.name == *method);
1033                                if !has_method {
1034                                    self.warning_at(
1035                                        format!(
1036                                            "Method '{}' not found in interface '{}' (constraint on '{}')",
1037                                            method, iface_name, type_name
1038                                        ),
1039                                        span,
1040                                    );
1041                                }
1042                            }
1043                        }
1044                    }
1045                }
1046            }
1047            Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
1048                self.check_node(object, scope);
1049            }
1050            Node::SubscriptAccess { object, index } => {
1051                self.check_node(object, scope);
1052                self.check_node(index, scope);
1053            }
1054            Node::SliceAccess { object, start, end } => {
1055                self.check_node(object, scope);
1056                if let Some(s) = start {
1057                    self.check_node(s, scope);
1058                }
1059                if let Some(e) = end {
1060                    self.check_node(e, scope);
1061                }
1062            }
1063
1064            // --- Compound nodes: recurse into children ---
1065            Node::Ternary {
1066                condition,
1067                true_expr,
1068                false_expr,
1069            } => {
1070                self.check_node(condition, scope);
1071                let refs = Self::extract_refinements(condition, scope);
1072
1073                let mut true_scope = scope.child();
1074                apply_refinements(&mut true_scope, &refs.truthy);
1075                self.check_node(true_expr, &mut true_scope);
1076
1077                let mut false_scope = scope.child();
1078                apply_refinements(&mut false_scope, &refs.falsy);
1079                self.check_node(false_expr, &mut false_scope);
1080            }
1081
1082            Node::ThrowStmt { value } => {
1083                self.check_node(value, scope);
1084            }
1085
1086            Node::GuardStmt {
1087                condition,
1088                else_body,
1089            } => {
1090                self.check_node(condition, scope);
1091                let refs = Self::extract_refinements(condition, scope);
1092
1093                let mut else_scope = scope.child();
1094                apply_refinements(&mut else_scope, &refs.falsy);
1095                self.check_block(else_body, &mut else_scope);
1096
1097                // After guard, condition is true — apply truthy refinements
1098                // to the OUTER scope (guard's else-body must exit)
1099                apply_refinements(scope, &refs.truthy);
1100            }
1101
1102            Node::SpawnExpr { body } => {
1103                let mut spawn_scope = scope.child();
1104                self.check_block(body, &mut spawn_scope);
1105            }
1106
1107            Node::Parallel {
1108                count,
1109                variable,
1110                body,
1111            } => {
1112                self.check_node(count, scope);
1113                let mut par_scope = scope.child();
1114                if let Some(var) = variable {
1115                    par_scope.define_var(var, Some(TypeExpr::Named("int".into())));
1116                }
1117                self.check_block(body, &mut par_scope);
1118            }
1119
1120            Node::ParallelMap {
1121                list,
1122                variable,
1123                body,
1124            }
1125            | Node::ParallelSettle {
1126                list,
1127                variable,
1128                body,
1129            } => {
1130                self.check_node(list, scope);
1131                let mut par_scope = scope.child();
1132                let elem_type = match self.infer_type(list, scope) {
1133                    Some(TypeExpr::List(inner)) => Some(*inner),
1134                    _ => None,
1135                };
1136                par_scope.define_var(variable, elem_type);
1137                self.check_block(body, &mut par_scope);
1138            }
1139
1140            Node::SelectExpr {
1141                cases,
1142                timeout,
1143                default_body,
1144            } => {
1145                for case in cases {
1146                    self.check_node(&case.channel, scope);
1147                    let mut case_scope = scope.child();
1148                    case_scope.define_var(&case.variable, None);
1149                    self.check_block(&case.body, &mut case_scope);
1150                }
1151                if let Some((dur, body)) = timeout {
1152                    self.check_node(dur, scope);
1153                    let mut timeout_scope = scope.child();
1154                    self.check_block(body, &mut timeout_scope);
1155                }
1156                if let Some(body) = default_body {
1157                    let mut default_scope = scope.child();
1158                    self.check_block(body, &mut default_scope);
1159                }
1160            }
1161
1162            Node::DeadlineBlock { duration, body } => {
1163                self.check_node(duration, scope);
1164                let mut block_scope = scope.child();
1165                self.check_block(body, &mut block_scope);
1166            }
1167
1168            Node::MutexBlock { body } => {
1169                let mut block_scope = scope.child();
1170                self.check_block(body, &mut block_scope);
1171            }
1172
1173            Node::Retry { count, body } => {
1174                self.check_node(count, scope);
1175                let mut retry_scope = scope.child();
1176                self.check_block(body, &mut retry_scope);
1177            }
1178
1179            Node::Closure { params, body, .. } => {
1180                let mut closure_scope = scope.child();
1181                for p in params {
1182                    closure_scope.define_var(&p.name, p.type_expr.clone());
1183                }
1184                self.check_block(body, &mut closure_scope);
1185            }
1186
1187            Node::ListLiteral(elements) => {
1188                for elem in elements {
1189                    self.check_node(elem, scope);
1190                }
1191            }
1192
1193            Node::DictLiteral(entries) | Node::AskExpr { fields: entries } => {
1194                for entry in entries {
1195                    self.check_node(&entry.key, scope);
1196                    self.check_node(&entry.value, scope);
1197                }
1198            }
1199
1200            Node::RangeExpr { start, end, .. } => {
1201                self.check_node(start, scope);
1202                self.check_node(end, scope);
1203            }
1204
1205            Node::Spread(inner) => {
1206                self.check_node(inner, scope);
1207            }
1208
1209            Node::Block(stmts) => {
1210                let mut block_scope = scope.child();
1211                self.check_block(stmts, &mut block_scope);
1212            }
1213
1214            Node::YieldExpr { value } => {
1215                if let Some(v) = value {
1216                    self.check_node(v, scope);
1217                }
1218            }
1219
1220            // --- Struct construction: validate fields against declaration ---
1221            Node::StructConstruct {
1222                struct_name,
1223                fields,
1224            } => {
1225                for entry in fields {
1226                    self.check_node(&entry.key, scope);
1227                    self.check_node(&entry.value, scope);
1228                }
1229                if let Some(declared_fields) = scope.get_struct(struct_name).cloned() {
1230                    // Warn on unknown fields
1231                    for entry in fields {
1232                        if let Node::StringLiteral(key) | Node::Identifier(key) = &entry.key.node {
1233                            if !declared_fields.iter().any(|(name, _)| name == key) {
1234                                self.warning_at(
1235                                    format!("Unknown field '{}' in struct '{}'", key, struct_name),
1236                                    entry.key.span,
1237                                );
1238                            }
1239                        }
1240                    }
1241                    // Warn on missing required fields
1242                    let provided: Vec<String> = fields
1243                        .iter()
1244                        .filter_map(|e| match &e.key.node {
1245                            Node::StringLiteral(k) | Node::Identifier(k) => Some(k.clone()),
1246                            _ => None,
1247                        })
1248                        .collect();
1249                    for (name, _) in &declared_fields {
1250                        if !provided.contains(name) {
1251                            self.warning_at(
1252                                format!(
1253                                    "Missing field '{}' in struct '{}' construction",
1254                                    name, struct_name
1255                                ),
1256                                span,
1257                            );
1258                        }
1259                    }
1260                }
1261            }
1262
1263            // --- Enum construction: validate variant exists ---
1264            Node::EnumConstruct {
1265                enum_name,
1266                variant,
1267                args,
1268            } => {
1269                for arg in args {
1270                    self.check_node(arg, scope);
1271                }
1272                if let Some(variants) = scope.get_enum(enum_name) {
1273                    if !variants.contains(variant) {
1274                        self.warning_at(
1275                            format!("Unknown variant '{}' in enum '{}'", variant, enum_name),
1276                            span,
1277                        );
1278                    }
1279                }
1280            }
1281
1282            // --- InterpolatedString: segments are lexer-level, no SNode children ---
1283            Node::InterpolatedString(_) => {}
1284
1285            // --- Terminals: no children to check ---
1286            Node::StringLiteral(_)
1287            | Node::RawStringLiteral(_)
1288            | Node::IntLiteral(_)
1289            | Node::FloatLiteral(_)
1290            | Node::BoolLiteral(_)
1291            | Node::NilLiteral
1292            | Node::Identifier(_)
1293            | Node::DurationLiteral(_)
1294            | Node::BreakStmt
1295            | Node::ContinueStmt
1296            | Node::ReturnStmt { value: None }
1297            | Node::ImportDecl { .. }
1298            | Node::SelectiveImport { .. } => {}
1299
1300            // Declarations already handled above; catch remaining variants
1301            // that have no meaningful type-check behavior.
1302            Node::Pipeline { body, .. } | Node::OverrideDecl { body, .. } => {
1303                let mut decl_scope = scope.child();
1304                self.check_block(body, &mut decl_scope);
1305            }
1306        }
1307    }
1308
1309    fn check_fn_body(
1310        &mut self,
1311        type_params: &[TypeParam],
1312        params: &[TypedParam],
1313        return_type: &Option<TypeExpr>,
1314        body: &[SNode],
1315        where_clauses: &[WhereClause],
1316    ) {
1317        let mut fn_scope = self.scope.child();
1318        // Register generic type parameters so they are treated as compatible
1319        // with any concrete type during type checking.
1320        for tp in type_params {
1321            fn_scope.generic_type_params.insert(tp.name.clone());
1322        }
1323        // Store where-clause constraints for definition-site checking
1324        for wc in where_clauses {
1325            fn_scope
1326                .where_constraints
1327                .insert(wc.type_name.clone(), wc.bound.clone());
1328        }
1329        for param in params {
1330            fn_scope.define_var(&param.name, param.type_expr.clone());
1331            if let Some(default) = &param.default_value {
1332                self.check_node(default, &mut fn_scope);
1333            }
1334        }
1335        // Snapshot scope before main pass (which may mutate it with narrowing)
1336        // so that return-type checking starts from the original parameter types.
1337        let ret_scope_base = if return_type.is_some() {
1338            Some(fn_scope.child())
1339        } else {
1340            None
1341        };
1342
1343        self.check_block(body, &mut fn_scope);
1344
1345        // Check return statements against declared return type
1346        if let Some(ret_type) = return_type {
1347            let mut ret_scope = ret_scope_base.unwrap();
1348            for stmt in body {
1349                self.check_return_type(stmt, ret_type, &mut ret_scope);
1350            }
1351        }
1352    }
1353
1354    fn check_return_type(&mut self, snode: &SNode, expected: &TypeExpr, scope: &mut TypeScope) {
1355        let span = snode.span;
1356        match &snode.node {
1357            Node::ReturnStmt { value: Some(val) } => {
1358                let inferred = self.infer_type(val, scope);
1359                if let Some(actual) = &inferred {
1360                    if !self.types_compatible(expected, actual, scope) {
1361                        self.error_at(
1362                            format!(
1363                                "Return type mismatch: expected {}, got {}",
1364                                format_type(expected),
1365                                format_type(actual)
1366                            ),
1367                            span,
1368                        );
1369                    }
1370                }
1371            }
1372            Node::IfElse {
1373                condition,
1374                then_body,
1375                else_body,
1376            } => {
1377                let refs = Self::extract_refinements(condition, scope);
1378                let mut then_scope = scope.child();
1379                apply_refinements(&mut then_scope, &refs.truthy);
1380                for stmt in then_body {
1381                    self.check_return_type(stmt, expected, &mut then_scope);
1382                }
1383                if let Some(else_body) = else_body {
1384                    let mut else_scope = scope.child();
1385                    apply_refinements(&mut else_scope, &refs.falsy);
1386                    for stmt in else_body {
1387                        self.check_return_type(stmt, expected, &mut else_scope);
1388                    }
1389                    // Post-branch narrowing for return type checking
1390                    if Self::block_definitely_exits(then_body)
1391                        && !Self::block_definitely_exits(else_body)
1392                    {
1393                        apply_refinements(scope, &refs.falsy);
1394                    } else if Self::block_definitely_exits(else_body)
1395                        && !Self::block_definitely_exits(then_body)
1396                    {
1397                        apply_refinements(scope, &refs.truthy);
1398                    }
1399                } else {
1400                    // No else: if then-body always exits, apply falsy after
1401                    if Self::block_definitely_exits(then_body) {
1402                        apply_refinements(scope, &refs.falsy);
1403                    }
1404                }
1405            }
1406            _ => {}
1407        }
1408    }
1409
1410    /// Check if a match expression on an enum's `.variant` property covers all variants.
1411    /// Extract narrowing info from nil-check conditions like `x != nil`.
1412    /// Returns (var_name, narrowed_type) where narrowed_type removes nil from a union.
1413    /// Check if a type satisfies an interface (Go-style implicit satisfaction).
1414    /// A type satisfies an interface if its impl block has all the required methods.
1415    fn satisfies_interface(
1416        &self,
1417        type_name: &str,
1418        interface_name: &str,
1419        scope: &TypeScope,
1420    ) -> bool {
1421        self.interface_mismatch_reason(type_name, interface_name, scope)
1422            .is_none()
1423    }
1424
1425    /// Return a detailed reason why a type does not satisfy an interface, or None
1426    /// if it does satisfy it.  Used for producing actionable warning messages.
1427    fn interface_mismatch_reason(
1428        &self,
1429        type_name: &str,
1430        interface_name: &str,
1431        scope: &TypeScope,
1432    ) -> Option<String> {
1433        let interface_methods = match scope.get_interface(interface_name) {
1434            Some(methods) => methods,
1435            None => return Some(format!("interface '{}' not found", interface_name)),
1436        };
1437        let impl_methods = match scope.get_impl_methods(type_name) {
1438            Some(methods) => methods,
1439            None => {
1440                if interface_methods.is_empty() {
1441                    return None;
1442                }
1443                let names: Vec<_> = interface_methods.iter().map(|m| m.name.as_str()).collect();
1444                return Some(format!("missing method(s): {}", names.join(", ")));
1445            }
1446        };
1447        for iface_method in interface_methods {
1448            let iface_params: Vec<_> = iface_method
1449                .params
1450                .iter()
1451                .filter(|p| p.name != "self")
1452                .collect();
1453            let iface_param_count = iface_params.len();
1454            let matching_impl = impl_methods.iter().find(|im| im.name == iface_method.name);
1455            let impl_method = match matching_impl {
1456                Some(m) => m,
1457                None => {
1458                    return Some(format!("missing method '{}'", iface_method.name));
1459                }
1460            };
1461            if impl_method.param_count != iface_param_count {
1462                return Some(format!(
1463                    "method '{}' has {} parameter(s), expected {}",
1464                    iface_method.name, impl_method.param_count, iface_param_count
1465                ));
1466            }
1467            // Check parameter types where both sides specify them
1468            for (i, iface_param) in iface_params.iter().enumerate() {
1469                if let (Some(expected), Some(actual)) = (
1470                    &iface_param.type_expr,
1471                    impl_method.param_types.get(i).and_then(|t| t.as_ref()),
1472                ) {
1473                    if !self.types_compatible(expected, actual, scope) {
1474                        return Some(format!(
1475                            "method '{}' parameter {} has type '{}', expected '{}'",
1476                            iface_method.name,
1477                            i + 1,
1478                            format_type(actual),
1479                            format_type(expected),
1480                        ));
1481                    }
1482                }
1483            }
1484            // Check return type where both sides specify it
1485            if let (Some(expected_ret), Some(actual_ret)) =
1486                (&iface_method.return_type, &impl_method.return_type)
1487            {
1488                if !self.types_compatible(expected_ret, actual_ret, scope) {
1489                    return Some(format!(
1490                        "method '{}' returns '{}', expected '{}'",
1491                        iface_method.name,
1492                        format_type(actual_ret),
1493                        format_type(expected_ret),
1494                    ));
1495                }
1496            }
1497        }
1498        None
1499    }
1500
1501    fn bind_type_param(
1502        param_name: &str,
1503        concrete: &TypeExpr,
1504        bindings: &mut BTreeMap<String, TypeExpr>,
1505    ) -> Result<(), String> {
1506        if let Some(existing) = bindings.get(param_name) {
1507            if existing != concrete {
1508                return Err(format!(
1509                    "type parameter '{}' was inferred as both {} and {}",
1510                    param_name,
1511                    format_type(existing),
1512                    format_type(concrete)
1513                ));
1514            }
1515            return Ok(());
1516        }
1517        bindings.insert(param_name.to_string(), concrete.clone());
1518        Ok(())
1519    }
1520
1521    /// Recursively extract type parameter bindings from matching param/arg types.
1522    /// E.g., param_type=list<T> + arg_type=list<Dog> → binds T=Dog.
1523    fn extract_type_bindings(
1524        param_type: &TypeExpr,
1525        arg_type: &TypeExpr,
1526        type_params: &std::collections::BTreeSet<String>,
1527        bindings: &mut BTreeMap<String, TypeExpr>,
1528    ) -> Result<(), String> {
1529        match (param_type, arg_type) {
1530            (TypeExpr::Named(param_name), concrete) if type_params.contains(param_name) => {
1531                Self::bind_type_param(param_name, concrete, bindings)
1532            }
1533            (TypeExpr::List(p_inner), TypeExpr::List(a_inner)) => {
1534                Self::extract_type_bindings(p_inner, a_inner, type_params, bindings)
1535            }
1536            (TypeExpr::DictType(pk, pv), TypeExpr::DictType(ak, av)) => {
1537                Self::extract_type_bindings(pk, ak, type_params, bindings)?;
1538                Self::extract_type_bindings(pv, av, type_params, bindings)
1539            }
1540            (TypeExpr::Shape(param_fields), TypeExpr::Shape(arg_fields)) => {
1541                for param_field in param_fields {
1542                    if let Some(arg_field) = arg_fields
1543                        .iter()
1544                        .find(|field| field.name == param_field.name)
1545                    {
1546                        Self::extract_type_bindings(
1547                            &param_field.type_expr,
1548                            &arg_field.type_expr,
1549                            type_params,
1550                            bindings,
1551                        )?;
1552                    }
1553                }
1554                Ok(())
1555            }
1556            (
1557                TypeExpr::FnType {
1558                    params: p_params,
1559                    return_type: p_ret,
1560                },
1561                TypeExpr::FnType {
1562                    params: a_params,
1563                    return_type: a_ret,
1564                },
1565            ) => {
1566                for (param, arg) in p_params.iter().zip(a_params.iter()) {
1567                    Self::extract_type_bindings(param, arg, type_params, bindings)?;
1568                }
1569                Self::extract_type_bindings(p_ret, a_ret, type_params, bindings)
1570            }
1571            _ => Ok(()),
1572        }
1573    }
1574
1575    fn apply_type_bindings(ty: &TypeExpr, bindings: &BTreeMap<String, TypeExpr>) -> TypeExpr {
1576        match ty {
1577            TypeExpr::Named(name) => bindings
1578                .get(name)
1579                .cloned()
1580                .unwrap_or_else(|| TypeExpr::Named(name.clone())),
1581            TypeExpr::Union(items) => TypeExpr::Union(
1582                items
1583                    .iter()
1584                    .map(|item| Self::apply_type_bindings(item, bindings))
1585                    .collect(),
1586            ),
1587            TypeExpr::Shape(fields) => TypeExpr::Shape(
1588                fields
1589                    .iter()
1590                    .map(|field| ShapeField {
1591                        name: field.name.clone(),
1592                        type_expr: Self::apply_type_bindings(&field.type_expr, bindings),
1593                        optional: field.optional,
1594                    })
1595                    .collect(),
1596            ),
1597            TypeExpr::List(inner) => {
1598                TypeExpr::List(Box::new(Self::apply_type_bindings(inner, bindings)))
1599            }
1600            TypeExpr::DictType(key, value) => TypeExpr::DictType(
1601                Box::new(Self::apply_type_bindings(key, bindings)),
1602                Box::new(Self::apply_type_bindings(value, bindings)),
1603            ),
1604            TypeExpr::FnType {
1605                params,
1606                return_type,
1607            } => TypeExpr::FnType {
1608                params: params
1609                    .iter()
1610                    .map(|param| Self::apply_type_bindings(param, bindings))
1611                    .collect(),
1612                return_type: Box::new(Self::apply_type_bindings(return_type, bindings)),
1613            },
1614        }
1615    }
1616
1617    fn infer_list_literal_type(&self, items: &[SNode], scope: &TypeScope) -> TypeExpr {
1618        let mut inferred: Option<TypeExpr> = None;
1619        for item in items {
1620            let Some(item_type) = self.infer_type(item, scope) else {
1621                return TypeExpr::Named("list".into());
1622            };
1623            inferred = Some(match inferred {
1624                None => item_type,
1625                Some(current) if current == item_type => current,
1626                Some(TypeExpr::Union(mut members)) => {
1627                    if !members.contains(&item_type) {
1628                        members.push(item_type);
1629                    }
1630                    TypeExpr::Union(members)
1631                }
1632                Some(current) => TypeExpr::Union(vec![current, item_type]),
1633            });
1634        }
1635        inferred
1636            .map(|item_type| TypeExpr::List(Box::new(item_type)))
1637            .unwrap_or_else(|| TypeExpr::Named("list".into()))
1638    }
1639
1640    /// Extract bidirectional type refinements from a condition expression.
1641    fn extract_refinements(condition: &SNode, scope: &TypeScope) -> Refinements {
1642        match &condition.node {
1643            // --- Nil checks and type_of checks ---
1644            Node::BinaryOp { op, left, right } if op == "!=" || op == "==" => {
1645                let nil_ref = Self::extract_nil_refinements(op, left, right, scope);
1646                if !nil_ref.truthy.is_empty() || !nil_ref.falsy.is_empty() {
1647                    return nil_ref;
1648                }
1649                let typeof_ref = Self::extract_typeof_refinements(op, left, right, scope);
1650                if !typeof_ref.truthy.is_empty() || !typeof_ref.falsy.is_empty() {
1651                    return typeof_ref;
1652                }
1653                Refinements::empty()
1654            }
1655
1656            // --- Logical AND: both must be true on truthy path ---
1657            Node::BinaryOp { op, left, right } if op == "&&" => {
1658                let left_ref = Self::extract_refinements(left, scope);
1659                let right_ref = Self::extract_refinements(right, scope);
1660                let mut truthy = left_ref.truthy;
1661                truthy.extend(right_ref.truthy);
1662                Refinements {
1663                    truthy,
1664                    falsy: vec![],
1665                }
1666            }
1667
1668            // --- Logical OR: both must be false on falsy path ---
1669            Node::BinaryOp { op, left, right } if op == "||" => {
1670                let left_ref = Self::extract_refinements(left, scope);
1671                let right_ref = Self::extract_refinements(right, scope);
1672                let mut falsy = left_ref.falsy;
1673                falsy.extend(right_ref.falsy);
1674                Refinements {
1675                    truthy: vec![],
1676                    falsy,
1677                }
1678            }
1679
1680            // --- Negation: swap truthy/falsy ---
1681            Node::UnaryOp { op, operand } if op == "!" => {
1682                Self::extract_refinements(operand, scope).inverted()
1683            }
1684
1685            // --- Truthiness: bare identifier in condition position ---
1686            Node::Identifier(name) => {
1687                if let Some(Some(TypeExpr::Union(members))) = scope.get_var(name) {
1688                    if members
1689                        .iter()
1690                        .any(|m| matches!(m, TypeExpr::Named(n) if n == "nil"))
1691                    {
1692                        if let Some(narrowed) = remove_from_union(members, "nil") {
1693                            return Refinements {
1694                                truthy: vec![(name.clone(), Some(narrowed))],
1695                                falsy: vec![(name.clone(), Some(TypeExpr::Named("nil".into())))],
1696                            };
1697                        }
1698                    }
1699                }
1700                Refinements::empty()
1701            }
1702
1703            // --- .has("key") on shapes ---
1704            Node::MethodCall {
1705                object,
1706                method,
1707                args,
1708            } if method == "has" && args.len() == 1 => {
1709                Self::extract_has_refinements(object, args, scope)
1710            }
1711
1712            Node::FunctionCall { name, args }
1713                if (name == "schema_is" || name == "is_type") && args.len() == 2 =>
1714            {
1715                Self::extract_schema_refinements(args, scope)
1716            }
1717
1718            _ => Refinements::empty(),
1719        }
1720    }
1721
1722    /// Extract nil-check refinements from `x != nil` / `x == nil` patterns.
1723    fn extract_nil_refinements(
1724        op: &str,
1725        left: &SNode,
1726        right: &SNode,
1727        scope: &TypeScope,
1728    ) -> Refinements {
1729        let var_node = if matches!(right.node, Node::NilLiteral) {
1730            left
1731        } else if matches!(left.node, Node::NilLiteral) {
1732            right
1733        } else {
1734            return Refinements::empty();
1735        };
1736
1737        if let Node::Identifier(name) = &var_node.node {
1738            if let Some(Some(TypeExpr::Union(members))) = scope.get_var(name) {
1739                if let Some(narrowed) = remove_from_union(members, "nil") {
1740                    let neq_refs = Refinements {
1741                        truthy: vec![(name.clone(), Some(narrowed))],
1742                        falsy: vec![(name.clone(), Some(TypeExpr::Named("nil".into())))],
1743                    };
1744                    return if op == "!=" {
1745                        neq_refs
1746                    } else {
1747                        neq_refs.inverted()
1748                    };
1749                }
1750            }
1751        }
1752        Refinements::empty()
1753    }
1754
1755    /// Extract type_of refinements from `type_of(x) == "typename"` patterns.
1756    fn extract_typeof_refinements(
1757        op: &str,
1758        left: &SNode,
1759        right: &SNode,
1760        scope: &TypeScope,
1761    ) -> Refinements {
1762        let (var_name, type_name) = if let (Some(var), Node::StringLiteral(tn)) =
1763            (extract_type_of_var(left), &right.node)
1764        {
1765            (var, tn.clone())
1766        } else if let (Node::StringLiteral(tn), Some(var)) =
1767            (&left.node, extract_type_of_var(right))
1768        {
1769            (var, tn.clone())
1770        } else {
1771            return Refinements::empty();
1772        };
1773
1774        const KNOWN_TYPES: &[&str] = &[
1775            "int", "string", "float", "bool", "nil", "list", "dict", "closure",
1776        ];
1777        if !KNOWN_TYPES.contains(&type_name.as_str()) {
1778            return Refinements::empty();
1779        }
1780
1781        if let Some(Some(TypeExpr::Union(members))) = scope.get_var(&var_name) {
1782            let narrowed = narrow_to_single(members, &type_name);
1783            let remaining = remove_from_union(members, &type_name);
1784            if narrowed.is_some() || remaining.is_some() {
1785                let eq_refs = Refinements {
1786                    truthy: narrowed
1787                        .map(|n| vec![(var_name.clone(), Some(n))])
1788                        .unwrap_or_default(),
1789                    falsy: remaining
1790                        .map(|r| vec![(var_name.clone(), Some(r))])
1791                        .unwrap_or_default(),
1792                };
1793                return if op == "==" {
1794                    eq_refs
1795                } else {
1796                    eq_refs.inverted()
1797                };
1798            }
1799        }
1800        Refinements::empty()
1801    }
1802
1803    /// Extract .has("key") refinements on shape types.
1804    fn extract_has_refinements(object: &SNode, args: &[SNode], scope: &TypeScope) -> Refinements {
1805        if let Node::Identifier(var_name) = &object.node {
1806            if let Node::StringLiteral(key) = &args[0].node {
1807                if let Some(Some(TypeExpr::Shape(fields))) = scope.get_var(var_name) {
1808                    if fields.iter().any(|f| f.name == *key && f.optional) {
1809                        let narrowed_fields: Vec<ShapeField> = fields
1810                            .iter()
1811                            .map(|f| {
1812                                if f.name == *key {
1813                                    ShapeField {
1814                                        name: f.name.clone(),
1815                                        type_expr: f.type_expr.clone(),
1816                                        optional: false,
1817                                    }
1818                                } else {
1819                                    f.clone()
1820                                }
1821                            })
1822                            .collect();
1823                        return Refinements {
1824                            truthy: vec![(
1825                                var_name.clone(),
1826                                Some(TypeExpr::Shape(narrowed_fields)),
1827                            )],
1828                            falsy: vec![],
1829                        };
1830                    }
1831                }
1832            }
1833        }
1834        Refinements::empty()
1835    }
1836
1837    fn extract_schema_refinements(args: &[SNode], scope: &TypeScope) -> Refinements {
1838        let Node::Identifier(var_name) = &args[0].node else {
1839            return Refinements::empty();
1840        };
1841        let Some(schema_type) = schema_type_expr_from_node(&args[1], scope) else {
1842            return Refinements::empty();
1843        };
1844        let Some(Some(var_type)) = scope.get_var(var_name).cloned() else {
1845            return Refinements::empty();
1846        };
1847
1848        let truthy = intersect_types(&var_type, &schema_type)
1849            .map(|ty| vec![(var_name.clone(), Some(ty))])
1850            .unwrap_or_default();
1851        let falsy = subtract_type(&var_type, &schema_type)
1852            .map(|ty| vec![(var_name.clone(), Some(ty))])
1853            .unwrap_or_default();
1854
1855        Refinements { truthy, falsy }
1856    }
1857
1858    /// Check whether a block definitely exits (return/throw/break/continue).
1859    fn block_definitely_exits(stmts: &[SNode]) -> bool {
1860        stmts.iter().any(|s| match &s.node {
1861            Node::ReturnStmt { .. }
1862            | Node::ThrowStmt { .. }
1863            | Node::BreakStmt
1864            | Node::ContinueStmt => true,
1865            Node::IfElse {
1866                then_body,
1867                else_body: Some(else_body),
1868                ..
1869            } => Self::block_definitely_exits(then_body) && Self::block_definitely_exits(else_body),
1870            _ => false,
1871        })
1872    }
1873
1874    fn check_match_exhaustiveness(
1875        &mut self,
1876        value: &SNode,
1877        arms: &[MatchArm],
1878        scope: &TypeScope,
1879        span: Span,
1880    ) {
1881        // Detect pattern: match <expr>.variant { "VariantA" -> ... }
1882        let enum_name = match &value.node {
1883            Node::PropertyAccess { object, property } if property == "variant" => {
1884                // Infer the type of the object
1885                match self.infer_type(object, scope) {
1886                    Some(TypeExpr::Named(name)) => {
1887                        if scope.get_enum(&name).is_some() {
1888                            Some(name)
1889                        } else {
1890                            None
1891                        }
1892                    }
1893                    _ => None,
1894                }
1895            }
1896            _ => {
1897                // Direct match on an enum value: match <expr> { ... }
1898                match self.infer_type(value, scope) {
1899                    Some(TypeExpr::Named(name)) if scope.get_enum(&name).is_some() => Some(name),
1900                    _ => None,
1901                }
1902            }
1903        };
1904
1905        let Some(enum_name) = enum_name else {
1906            // Try union type exhaustiveness instead
1907            self.check_match_exhaustiveness_union(value, arms, scope, span);
1908            return;
1909        };
1910        let Some(variants) = scope.get_enum(&enum_name) else {
1911            return;
1912        };
1913
1914        // Collect variant names covered by match arms
1915        let mut covered: Vec<String> = Vec::new();
1916        let mut has_wildcard = false;
1917
1918        for arm in arms {
1919            match &arm.pattern.node {
1920                // String literal pattern (matching on .variant): "VariantA"
1921                Node::StringLiteral(s) => covered.push(s.clone()),
1922                // Identifier pattern acts as a wildcard/catch-all
1923                Node::Identifier(name) if name == "_" || !variants.contains(name) => {
1924                    has_wildcard = true;
1925                }
1926                // Direct enum construct pattern: EnumName.Variant
1927                Node::EnumConstruct { variant, .. } => covered.push(variant.clone()),
1928                // PropertyAccess pattern: EnumName.Variant (no args)
1929                Node::PropertyAccess { property, .. } => covered.push(property.clone()),
1930                _ => {
1931                    // Unknown pattern shape — conservatively treat as wildcard
1932                    has_wildcard = true;
1933                }
1934            }
1935        }
1936
1937        if has_wildcard {
1938            return;
1939        }
1940
1941        let missing: Vec<&String> = variants.iter().filter(|v| !covered.contains(v)).collect();
1942        if !missing.is_empty() {
1943            let missing_str = missing
1944                .iter()
1945                .map(|s| format!("\"{}\"", s))
1946                .collect::<Vec<_>>()
1947                .join(", ");
1948            self.warning_at(
1949                format!(
1950                    "Non-exhaustive match on enum {}: missing variants {}",
1951                    enum_name, missing_str
1952                ),
1953                span,
1954            );
1955        }
1956    }
1957
1958    /// Check exhaustiveness for match on union types (e.g. `string | int | nil`).
1959    fn check_match_exhaustiveness_union(
1960        &mut self,
1961        value: &SNode,
1962        arms: &[MatchArm],
1963        scope: &TypeScope,
1964        span: Span,
1965    ) {
1966        let Some(TypeExpr::Union(members)) = self.infer_type(value, scope) else {
1967            return;
1968        };
1969        // Only check unions of named types (string, int, nil, bool, etc.)
1970        if !members.iter().all(|m| matches!(m, TypeExpr::Named(_))) {
1971            return;
1972        }
1973
1974        let mut has_wildcard = false;
1975        let mut covered_types: Vec<String> = Vec::new();
1976
1977        for arm in arms {
1978            match &arm.pattern.node {
1979                // type_of(x) == "string" style patterns are common but hard to detect here
1980                // Literal patterns cover specific types
1981                Node::NilLiteral => covered_types.push("nil".into()),
1982                Node::BoolLiteral(_) => {
1983                    if !covered_types.contains(&"bool".into()) {
1984                        covered_types.push("bool".into());
1985                    }
1986                }
1987                Node::IntLiteral(_) => {
1988                    if !covered_types.contains(&"int".into()) {
1989                        covered_types.push("int".into());
1990                    }
1991                }
1992                Node::FloatLiteral(_) => {
1993                    if !covered_types.contains(&"float".into()) {
1994                        covered_types.push("float".into());
1995                    }
1996                }
1997                Node::StringLiteral(_) => {
1998                    if !covered_types.contains(&"string".into()) {
1999                        covered_types.push("string".into());
2000                    }
2001                }
2002                Node::Identifier(name) if name == "_" => {
2003                    has_wildcard = true;
2004                }
2005                _ => {
2006                    has_wildcard = true;
2007                }
2008            }
2009        }
2010
2011        if has_wildcard {
2012            return;
2013        }
2014
2015        let type_names: Vec<&str> = members
2016            .iter()
2017            .filter_map(|m| match m {
2018                TypeExpr::Named(n) => Some(n.as_str()),
2019                _ => None,
2020            })
2021            .collect();
2022        let missing: Vec<&&str> = type_names
2023            .iter()
2024            .filter(|t| !covered_types.iter().any(|c| c == **t))
2025            .collect();
2026        if !missing.is_empty() {
2027            let missing_str = missing
2028                .iter()
2029                .map(|s| s.to_string())
2030                .collect::<Vec<_>>()
2031                .join(", ");
2032            self.warning_at(
2033                format!(
2034                    "Non-exhaustive match on union type: missing {}",
2035                    missing_str
2036                ),
2037                span,
2038            );
2039        }
2040    }
2041
2042    fn check_call(&mut self, name: &str, args: &[SNode], scope: &mut TypeScope, span: Span) {
2043        // Check against known function signatures
2044        let has_spread = args.iter().any(|a| matches!(&a.node, Node::Spread(_)));
2045        if let Some(sig) = scope.get_fn(name).cloned() {
2046            if !has_spread
2047                && !is_builtin(name)
2048                && !sig.has_rest
2049                && (args.len() < sig.required_params || args.len() > sig.params.len())
2050            {
2051                let expected = if sig.required_params == sig.params.len() {
2052                    format!("{}", sig.params.len())
2053                } else {
2054                    format!("{}-{}", sig.required_params, sig.params.len())
2055                };
2056                self.warning_at(
2057                    format!(
2058                        "Function '{}' expects {} arguments, got {}",
2059                        name,
2060                        expected,
2061                        args.len()
2062                    ),
2063                    span,
2064                );
2065            }
2066            // Build a scope that includes the function's generic type params
2067            // so they are treated as compatible with any concrete type.
2068            let call_scope = if sig.type_param_names.is_empty() {
2069                scope.clone()
2070            } else {
2071                let mut s = scope.child();
2072                for tp_name in &sig.type_param_names {
2073                    s.generic_type_params.insert(tp_name.clone());
2074                }
2075                s
2076            };
2077            let mut type_bindings: BTreeMap<String, TypeExpr> = BTreeMap::new();
2078            let type_param_set: std::collections::BTreeSet<String> =
2079                sig.type_param_names.iter().cloned().collect();
2080            for (arg, (_param_name, param_type)) in args.iter().zip(sig.params.iter()) {
2081                if let Some(param_ty) = param_type {
2082                    if let Some(arg_ty) = self.infer_type(arg, scope) {
2083                        if let Err(message) = Self::extract_type_bindings(
2084                            param_ty,
2085                            &arg_ty,
2086                            &type_param_set,
2087                            &mut type_bindings,
2088                        ) {
2089                            self.error_at(message, arg.span);
2090                        }
2091                    }
2092                }
2093            }
2094            for (i, (arg, (param_name, param_type))) in
2095                args.iter().zip(sig.params.iter()).enumerate()
2096            {
2097                if let Some(expected) = param_type {
2098                    let actual = self.infer_type(arg, scope);
2099                    if let Some(actual) = &actual {
2100                        let expected = Self::apply_type_bindings(expected, &type_bindings);
2101                        if !self.types_compatible(&expected, actual, &call_scope) {
2102                            self.error_at(
2103                                format!(
2104                                    "Argument {} ('{}'): expected {}, got {}",
2105                                    i + 1,
2106                                    param_name,
2107                                    format_type(&expected),
2108                                    format_type(actual)
2109                                ),
2110                                arg.span,
2111                            );
2112                        }
2113                    }
2114                }
2115            }
2116            if !sig.where_clauses.is_empty() {
2117                for (type_param, bound) in &sig.where_clauses {
2118                    if let Some(concrete_type) = type_bindings.get(type_param) {
2119                        let concrete_name = format_type(concrete_type);
2120                        if let Some(reason) =
2121                            self.interface_mismatch_reason(&concrete_name, bound, scope)
2122                        {
2123                            self.error_at(
2124                                format!(
2125                                    "Type '{}' does not satisfy interface '{}': {} \
2126                                     (required by constraint `where {}: {}`)",
2127                                    concrete_name, bound, reason, type_param, bound
2128                                ),
2129                                span,
2130                            );
2131                        }
2132                    }
2133                }
2134            }
2135        }
2136        // Check args recursively
2137        for arg in args {
2138            self.check_node(arg, scope);
2139        }
2140    }
2141
2142    /// Infer the type of an expression.
2143    fn infer_type(&self, snode: &SNode, scope: &TypeScope) -> InferredType {
2144        match &snode.node {
2145            Node::IntLiteral(_) => Some(TypeExpr::Named("int".into())),
2146            Node::FloatLiteral(_) => Some(TypeExpr::Named("float".into())),
2147            Node::StringLiteral(_) | Node::InterpolatedString(_) => {
2148                Some(TypeExpr::Named("string".into()))
2149            }
2150            Node::BoolLiteral(_) => Some(TypeExpr::Named("bool".into())),
2151            Node::NilLiteral => Some(TypeExpr::Named("nil".into())),
2152            Node::ListLiteral(items) => Some(self.infer_list_literal_type(items, scope)),
2153            Node::DictLiteral(entries) => {
2154                // Infer shape type when all keys are string literals
2155                let mut fields = Vec::new();
2156                for entry in entries {
2157                    let key = match &entry.key.node {
2158                        Node::StringLiteral(key) | Node::Identifier(key) => key.clone(),
2159                        _ => return Some(TypeExpr::Named("dict".into())),
2160                    };
2161                    let val_type = self
2162                        .infer_type(&entry.value, scope)
2163                        .unwrap_or(TypeExpr::Named("nil".into()));
2164                    fields.push(ShapeField {
2165                        name: key,
2166                        type_expr: val_type,
2167                        optional: false,
2168                    });
2169                }
2170                if !fields.is_empty() {
2171                    Some(TypeExpr::Shape(fields))
2172                } else {
2173                    Some(TypeExpr::Named("dict".into()))
2174                }
2175            }
2176            Node::Closure { params, body, .. } => {
2177                // If all params are typed and we can infer a return type, produce FnType
2178                let all_typed = params.iter().all(|p| p.type_expr.is_some());
2179                if all_typed && !params.is_empty() {
2180                    let param_types: Vec<TypeExpr> =
2181                        params.iter().filter_map(|p| p.type_expr.clone()).collect();
2182                    // Try to infer return type from last expression in body
2183                    let ret = body.last().and_then(|last| self.infer_type(last, scope));
2184                    if let Some(ret_type) = ret {
2185                        return Some(TypeExpr::FnType {
2186                            params: param_types,
2187                            return_type: Box::new(ret_type),
2188                        });
2189                    }
2190                }
2191                Some(TypeExpr::Named("closure".into()))
2192            }
2193
2194            Node::Identifier(name) => scope.get_var(name).cloned().flatten(),
2195
2196            Node::FunctionCall { name, args } => {
2197                // Struct constructor calls return the struct type
2198                if scope.get_struct(name).is_some() {
2199                    return Some(TypeExpr::Named(name.clone()));
2200                }
2201                // Check user-defined function return types
2202                if let Some(sig) = scope.get_fn(name) {
2203                    let mut return_type = sig.return_type.clone();
2204                    if let Some(ty) = return_type.take() {
2205                        if sig.type_param_names.is_empty() {
2206                            return Some(ty);
2207                        }
2208                        let mut bindings = BTreeMap::new();
2209                        let type_param_set: std::collections::BTreeSet<String> =
2210                            sig.type_param_names.iter().cloned().collect();
2211                        for (arg, (_param_name, param_type)) in args.iter().zip(sig.params.iter()) {
2212                            if let Some(param_ty) = param_type {
2213                                if let Some(arg_ty) = self.infer_type(arg, scope) {
2214                                    let _ = Self::extract_type_bindings(
2215                                        param_ty,
2216                                        &arg_ty,
2217                                        &type_param_set,
2218                                        &mut bindings,
2219                                    );
2220                                }
2221                            }
2222                        }
2223                        return Some(Self::apply_type_bindings(&ty, &bindings));
2224                    }
2225                    return None;
2226                }
2227                // Check builtin return types
2228                builtin_return_type(name)
2229            }
2230
2231            Node::BinaryOp { op, left, right } => {
2232                let lt = self.infer_type(left, scope);
2233                let rt = self.infer_type(right, scope);
2234                infer_binary_op_type(op, &lt, &rt)
2235            }
2236
2237            Node::UnaryOp { op, operand } => {
2238                let t = self.infer_type(operand, scope);
2239                match op.as_str() {
2240                    "!" => Some(TypeExpr::Named("bool".into())),
2241                    "-" => t, // negation preserves type
2242                    _ => None,
2243                }
2244            }
2245
2246            Node::Ternary {
2247                condition,
2248                true_expr,
2249                false_expr,
2250            } => {
2251                let refs = Self::extract_refinements(condition, scope);
2252
2253                let mut true_scope = scope.child();
2254                apply_refinements(&mut true_scope, &refs.truthy);
2255                let tt = self.infer_type(true_expr, &true_scope);
2256
2257                let mut false_scope = scope.child();
2258                apply_refinements(&mut false_scope, &refs.falsy);
2259                let ft = self.infer_type(false_expr, &false_scope);
2260
2261                match (&tt, &ft) {
2262                    (Some(a), Some(b)) if a == b => tt,
2263                    (Some(a), Some(b)) => Some(TypeExpr::Union(vec![a.clone(), b.clone()])),
2264                    (Some(_), None) => tt,
2265                    (None, Some(_)) => ft,
2266                    (None, None) => None,
2267                }
2268            }
2269
2270            Node::EnumConstruct { enum_name, .. } => Some(TypeExpr::Named(enum_name.clone())),
2271
2272            Node::PropertyAccess { object, property } => {
2273                // EnumName.Variant → infer as the enum type
2274                if let Node::Identifier(name) = &object.node {
2275                    if scope.get_enum(name).is_some() {
2276                        return Some(TypeExpr::Named(name.clone()));
2277                    }
2278                }
2279                // .variant on an enum value → string
2280                if property == "variant" {
2281                    let obj_type = self.infer_type(object, scope);
2282                    if let Some(TypeExpr::Named(name)) = &obj_type {
2283                        if scope.get_enum(name).is_some() {
2284                            return Some(TypeExpr::Named("string".into()));
2285                        }
2286                    }
2287                }
2288                // Shape field access: obj.field → field type
2289                let obj_type = self.infer_type(object, scope);
2290                if let Some(TypeExpr::Shape(fields)) = &obj_type {
2291                    if let Some(field) = fields.iter().find(|f| f.name == *property) {
2292                        return Some(field.type_expr.clone());
2293                    }
2294                }
2295                None
2296            }
2297
2298            Node::SubscriptAccess { object, index } => {
2299                let obj_type = self.infer_type(object, scope);
2300                match &obj_type {
2301                    Some(TypeExpr::List(inner)) => Some(*inner.clone()),
2302                    Some(TypeExpr::DictType(_, v)) => Some(*v.clone()),
2303                    Some(TypeExpr::Shape(fields)) => {
2304                        // If index is a string literal, look up the field type
2305                        if let Node::StringLiteral(key) = &index.node {
2306                            fields
2307                                .iter()
2308                                .find(|f| &f.name == key)
2309                                .map(|f| f.type_expr.clone())
2310                        } else {
2311                            None
2312                        }
2313                    }
2314                    Some(TypeExpr::Named(n)) if n == "list" => None,
2315                    Some(TypeExpr::Named(n)) if n == "dict" => None,
2316                    Some(TypeExpr::Named(n)) if n == "string" => {
2317                        Some(TypeExpr::Named("string".into()))
2318                    }
2319                    _ => None,
2320                }
2321            }
2322            Node::SliceAccess { object, .. } => {
2323                // Slicing a list returns the same list type; slicing a string returns string
2324                let obj_type = self.infer_type(object, scope);
2325                match &obj_type {
2326                    Some(TypeExpr::List(_)) => obj_type,
2327                    Some(TypeExpr::Named(n)) if n == "list" => obj_type,
2328                    Some(TypeExpr::Named(n)) if n == "string" => {
2329                        Some(TypeExpr::Named("string".into()))
2330                    }
2331                    _ => None,
2332                }
2333            }
2334            Node::MethodCall { object, method, .. }
2335            | Node::OptionalMethodCall { object, method, .. } => {
2336                let obj_type = self.infer_type(object, scope);
2337                let is_dict = matches!(&obj_type, Some(TypeExpr::Named(n)) if n == "dict")
2338                    || matches!(&obj_type, Some(TypeExpr::DictType(..)))
2339                    || matches!(&obj_type, Some(TypeExpr::Shape(_)));
2340                match method.as_str() {
2341                    // Shared: bool-returning methods
2342                    "contains" | "starts_with" | "ends_with" | "empty" | "has" | "any" | "all" => {
2343                        Some(TypeExpr::Named("bool".into()))
2344                    }
2345                    // Shared: int-returning methods
2346                    "count" | "index_of" => Some(TypeExpr::Named("int".into())),
2347                    // String methods
2348                    "trim" | "lowercase" | "uppercase" | "reverse" | "replace" | "substring"
2349                    | "pad_left" | "pad_right" | "repeat" | "join" => {
2350                        Some(TypeExpr::Named("string".into()))
2351                    }
2352                    "split" | "chars" => Some(TypeExpr::Named("list".into())),
2353                    // filter returns dict for dicts, list for lists
2354                    "filter" => {
2355                        if is_dict {
2356                            Some(TypeExpr::Named("dict".into()))
2357                        } else {
2358                            Some(TypeExpr::Named("list".into()))
2359                        }
2360                    }
2361                    // List methods
2362                    "map" | "flat_map" | "sort" => Some(TypeExpr::Named("list".into())),
2363                    "reduce" | "find" | "first" | "last" => None,
2364                    // Dict methods
2365                    "keys" | "values" | "entries" => Some(TypeExpr::Named("list".into())),
2366                    "merge" | "map_values" | "rekey" | "map_keys" => {
2367                        // Rekey/map_keys transform keys; resulting dict still keys-by-string.
2368                        // Preserve the value-type parameter when known so downstream code can
2369                        // still rely on dict<string, V> typing after a key-rename.
2370                        if let Some(TypeExpr::DictType(_, v)) = &obj_type {
2371                            Some(TypeExpr::DictType(
2372                                Box::new(TypeExpr::Named("string".into())),
2373                                v.clone(),
2374                            ))
2375                        } else {
2376                            Some(TypeExpr::Named("dict".into()))
2377                        }
2378                    }
2379                    // Conversions
2380                    "to_string" => Some(TypeExpr::Named("string".into())),
2381                    "to_int" => Some(TypeExpr::Named("int".into())),
2382                    "to_float" => Some(TypeExpr::Named("float".into())),
2383                    _ => None,
2384                }
2385            }
2386
2387            // TryOperator on Result<T, E> produces T
2388            Node::TryOperator { operand } => {
2389                match self.infer_type(operand, scope) {
2390                    Some(TypeExpr::Named(name)) if name == "Result" => None, // unknown inner type
2391                    _ => None,
2392                }
2393            }
2394
2395            _ => None,
2396        }
2397    }
2398
2399    /// Check if two types are compatible (actual can be assigned to expected).
2400    fn types_compatible(&self, expected: &TypeExpr, actual: &TypeExpr, scope: &TypeScope) -> bool {
2401        // Generic type parameters match anything.
2402        if let TypeExpr::Named(name) = expected {
2403            if scope.is_generic_type_param(name) {
2404                return true;
2405            }
2406        }
2407        if let TypeExpr::Named(name) = actual {
2408            if scope.is_generic_type_param(name) {
2409                return true;
2410            }
2411        }
2412        let expected = self.resolve_alias(expected, scope);
2413        let actual = self.resolve_alias(actual, scope);
2414
2415        // Interface satisfaction: if expected is an interface name, check if actual type
2416        // has all required methods (Go-style implicit satisfaction).
2417        if let TypeExpr::Named(iface_name) = &expected {
2418            if scope.get_interface(iface_name).is_some() {
2419                if let TypeExpr::Named(type_name) = &actual {
2420                    return self.satisfies_interface(type_name, iface_name, scope);
2421                }
2422                return false;
2423            }
2424        }
2425
2426        match (&expected, &actual) {
2427            (TypeExpr::Named(a), TypeExpr::Named(b)) => a == b || (a == "float" && b == "int"),
2428            // Union-to-Union: every member of actual must be compatible with
2429            // at least one member of expected.
2430            (TypeExpr::Union(exp_members), TypeExpr::Union(act_members)) => {
2431                act_members.iter().all(|am| {
2432                    exp_members
2433                        .iter()
2434                        .any(|em| self.types_compatible(em, am, scope))
2435                })
2436            }
2437            (TypeExpr::Union(members), actual_type) => members
2438                .iter()
2439                .any(|m| self.types_compatible(m, actual_type, scope)),
2440            (expected_type, TypeExpr::Union(members)) => members
2441                .iter()
2442                .all(|m| self.types_compatible(expected_type, m, scope)),
2443            (TypeExpr::Shape(_), TypeExpr::Named(n)) if n == "dict" => true,
2444            (TypeExpr::Named(n), TypeExpr::Shape(_)) if n == "dict" => true,
2445            (TypeExpr::Shape(ef), TypeExpr::Shape(af)) => ef.iter().all(|expected_field| {
2446                if expected_field.optional {
2447                    return true;
2448                }
2449                af.iter().any(|actual_field| {
2450                    actual_field.name == expected_field.name
2451                        && self.types_compatible(
2452                            &expected_field.type_expr,
2453                            &actual_field.type_expr,
2454                            scope,
2455                        )
2456                })
2457            }),
2458            // dict<K, V> expected, Shape actual → all field values must match V
2459            (TypeExpr::DictType(ek, ev), TypeExpr::Shape(af)) => {
2460                let keys_ok = matches!(ek.as_ref(), TypeExpr::Named(n) if n == "string");
2461                keys_ok
2462                    && af
2463                        .iter()
2464                        .all(|f| self.types_compatible(ev, &f.type_expr, scope))
2465            }
2466            // Shape expected, dict<K, V> actual → gradual: allow since dict may have the fields
2467            (TypeExpr::Shape(_), TypeExpr::DictType(_, _)) => true,
2468            (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
2469                self.types_compatible(expected_inner, actual_inner, scope)
2470            }
2471            (TypeExpr::Named(n), TypeExpr::List(_)) if n == "list" => true,
2472            (TypeExpr::List(_), TypeExpr::Named(n)) if n == "list" => true,
2473            (TypeExpr::DictType(ek, ev), TypeExpr::DictType(ak, av)) => {
2474                self.types_compatible(ek, ak, scope) && self.types_compatible(ev, av, scope)
2475            }
2476            (TypeExpr::Named(n), TypeExpr::DictType(_, _)) if n == "dict" => true,
2477            (TypeExpr::DictType(_, _), TypeExpr::Named(n)) if n == "dict" => true,
2478            // FnType compatibility: params match positionally and return types match
2479            (
2480                TypeExpr::FnType {
2481                    params: ep,
2482                    return_type: er,
2483                },
2484                TypeExpr::FnType {
2485                    params: ap,
2486                    return_type: ar,
2487                },
2488            ) => {
2489                ep.len() == ap.len()
2490                    && ep
2491                        .iter()
2492                        .zip(ap.iter())
2493                        .all(|(e, a)| self.types_compatible(e, a, scope))
2494                    && self.types_compatible(er, ar, scope)
2495            }
2496            // FnType is compatible with Named("closure") for backward compat
2497            (TypeExpr::FnType { .. }, TypeExpr::Named(n)) if n == "closure" => true,
2498            (TypeExpr::Named(n), TypeExpr::FnType { .. }) if n == "closure" => true,
2499            _ => false,
2500        }
2501    }
2502
2503    fn resolve_alias<'a>(&self, ty: &'a TypeExpr, scope: &'a TypeScope) -> TypeExpr {
2504        if let TypeExpr::Named(name) = ty {
2505            if let Some(resolved) = scope.resolve_type(name) {
2506                return resolved.clone();
2507            }
2508        }
2509        ty.clone()
2510    }
2511
2512    fn error_at(&mut self, message: String, span: Span) {
2513        self.diagnostics.push(TypeDiagnostic {
2514            message,
2515            severity: DiagnosticSeverity::Error,
2516            span: Some(span),
2517            help: None,
2518            fix: None,
2519        });
2520    }
2521
2522    #[allow(dead_code)]
2523    fn error_at_with_help(&mut self, message: String, span: Span, help: String) {
2524        self.diagnostics.push(TypeDiagnostic {
2525            message,
2526            severity: DiagnosticSeverity::Error,
2527            span: Some(span),
2528            help: Some(help),
2529            fix: None,
2530        });
2531    }
2532
2533    fn error_at_with_fix(&mut self, message: String, span: Span, fix: Vec<FixEdit>) {
2534        self.diagnostics.push(TypeDiagnostic {
2535            message,
2536            severity: DiagnosticSeverity::Error,
2537            span: Some(span),
2538            help: None,
2539            fix: Some(fix),
2540        });
2541    }
2542
2543    fn warning_at(&mut self, message: String, span: Span) {
2544        self.diagnostics.push(TypeDiagnostic {
2545            message,
2546            severity: DiagnosticSeverity::Warning,
2547            span: Some(span),
2548            help: None,
2549            fix: None,
2550        });
2551    }
2552
2553    #[allow(dead_code)]
2554    fn warning_at_with_help(&mut self, message: String, span: Span, help: String) {
2555        self.diagnostics.push(TypeDiagnostic {
2556            message,
2557            severity: DiagnosticSeverity::Warning,
2558            span: Some(span),
2559            help: Some(help),
2560            fix: None,
2561        });
2562    }
2563
2564    /// Recursively validate binary operations in an expression tree.
2565    /// Unlike `check_node`, this only checks BinaryOp type compatibility
2566    /// without triggering other validations (e.g., function call arg checks).
2567    fn check_binops(&mut self, snode: &SNode, scope: &mut TypeScope) {
2568        match &snode.node {
2569            Node::BinaryOp { op, left, right } => {
2570                self.check_binops(left, scope);
2571                self.check_binops(right, scope);
2572                let lt = self.infer_type(left, scope);
2573                let rt = self.infer_type(right, scope);
2574                if let (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) = (&lt, &rt) {
2575                    let span = snode.span;
2576                    match op.as_str() {
2577                        "+" => {
2578                            let valid = matches!(
2579                                (l.as_str(), r.as_str()),
2580                                ("int" | "float", "int" | "float")
2581                                    | ("string", "string")
2582                                    | ("list", "list")
2583                                    | ("dict", "dict")
2584                            );
2585                            if !valid {
2586                                let msg =
2587                                    format!("Operator '+' is not valid for types {} and {}", l, r);
2588                                let fix = if l == "string" || r == "string" {
2589                                    self.build_interpolation_fix(left, right, l == "string", span)
2590                                } else {
2591                                    None
2592                                };
2593                                if let Some(fix) = fix {
2594                                    self.error_at_with_fix(msg, span, fix);
2595                                } else {
2596                                    self.error_at(msg, span);
2597                                }
2598                            }
2599                        }
2600                        "-" | "/" | "%" => {
2601                            let numeric = ["int", "float"];
2602                            if !numeric.contains(&l.as_str()) || !numeric.contains(&r.as_str()) {
2603                                self.error_at(
2604                                    format!(
2605                                        "Operator '{}' requires numeric operands, got {} and {}",
2606                                        op, l, r
2607                                    ),
2608                                    span,
2609                                );
2610                            }
2611                        }
2612                        "*" => {
2613                            let numeric = ["int", "float"];
2614                            let is_numeric =
2615                                numeric.contains(&l.as_str()) && numeric.contains(&r.as_str());
2616                            let is_string_repeat =
2617                                (l == "string" && r == "int") || (l == "int" && r == "string");
2618                            if !is_numeric && !is_string_repeat {
2619                                self.error_at(
2620                                    format!(
2621                                        "Operator '*' requires numeric operands or string * int, got {} and {}",
2622                                        l, r
2623                                    ),
2624                                    span,
2625                                );
2626                            }
2627                        }
2628                        _ => {}
2629                    }
2630                }
2631            }
2632            // Recurse into sub-expressions that might contain BinaryOps
2633            Node::UnaryOp { operand, .. } => self.check_binops(operand, scope),
2634            _ => {}
2635        }
2636    }
2637
2638    /// Build a fix that converts `"str" + expr` or `expr + "str"` to string interpolation.
2639    fn build_interpolation_fix(
2640        &self,
2641        left: &SNode,
2642        right: &SNode,
2643        left_is_string: bool,
2644        expr_span: Span,
2645    ) -> Option<Vec<FixEdit>> {
2646        let src = self.source.as_ref()?;
2647        let (str_node, other_node) = if left_is_string {
2648            (left, right)
2649        } else {
2650            (right, left)
2651        };
2652        let str_text = src.get(str_node.span.start..str_node.span.end)?;
2653        let other_text = src.get(other_node.span.start..other_node.span.end)?;
2654        // Only handle simple double-quoted strings (not multiline/raw)
2655        let inner = str_text.strip_prefix('"')?.strip_suffix('"')?;
2656        // Skip if the expression contains characters that would break interpolation
2657        if other_text.contains('}') || other_text.contains('"') {
2658            return None;
2659        }
2660        let replacement = if left_is_string {
2661            format!("\"{inner}${{{other_text}}}\"")
2662        } else {
2663            format!("\"${{{other_text}}}{inner}\"")
2664        };
2665        Some(vec![FixEdit {
2666            span: expr_span,
2667            replacement,
2668        }])
2669    }
2670}
2671
2672impl Default for TypeChecker {
2673    fn default() -> Self {
2674        Self::new()
2675    }
2676}
2677
2678/// Infer the result type of a binary operation.
2679fn infer_binary_op_type(op: &str, left: &InferredType, right: &InferredType) -> InferredType {
2680    match op {
2681        "==" | "!=" | "<" | ">" | "<=" | ">=" | "&&" | "||" | "in" | "not_in" => {
2682            Some(TypeExpr::Named("bool".into()))
2683        }
2684        "+" => match (left, right) {
2685            (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) => {
2686                match (l.as_str(), r.as_str()) {
2687                    ("int", "int") => Some(TypeExpr::Named("int".into())),
2688                    ("float", _) | (_, "float") => Some(TypeExpr::Named("float".into())),
2689                    ("string", "string") => Some(TypeExpr::Named("string".into())),
2690                    ("list", "list") => Some(TypeExpr::Named("list".into())),
2691                    ("dict", "dict") => Some(TypeExpr::Named("dict".into())),
2692                    _ => None,
2693                }
2694            }
2695            _ => None,
2696        },
2697        "-" | "/" | "%" => match (left, right) {
2698            (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) => {
2699                match (l.as_str(), r.as_str()) {
2700                    ("int", "int") => Some(TypeExpr::Named("int".into())),
2701                    ("float", _) | (_, "float") => Some(TypeExpr::Named("float".into())),
2702                    _ => None,
2703                }
2704            }
2705            _ => None,
2706        },
2707        "*" => match (left, right) {
2708            (Some(TypeExpr::Named(l)), Some(TypeExpr::Named(r))) => {
2709                match (l.as_str(), r.as_str()) {
2710                    ("string", "int") | ("int", "string") => Some(TypeExpr::Named("string".into())),
2711                    ("int", "int") => Some(TypeExpr::Named("int".into())),
2712                    ("float", _) | (_, "float") => Some(TypeExpr::Named("float".into())),
2713                    _ => None,
2714                }
2715            }
2716            _ => None,
2717        },
2718        "??" => match (left, right) {
2719            // Union containing nil: strip nil, use non-nil members
2720            (Some(TypeExpr::Union(members)), _) => {
2721                let non_nil: Vec<_> = members
2722                    .iter()
2723                    .filter(|m| !matches!(m, TypeExpr::Named(n) if n == "nil"))
2724                    .cloned()
2725                    .collect();
2726                if non_nil.len() == 1 {
2727                    Some(non_nil[0].clone())
2728                } else if non_nil.is_empty() {
2729                    right.clone()
2730                } else {
2731                    Some(TypeExpr::Union(non_nil))
2732                }
2733            }
2734            // Left is nil: result is always the right side
2735            (Some(TypeExpr::Named(n)), _) if n == "nil" => right.clone(),
2736            // Left is a known non-nil type: right is unreachable, preserve left
2737            (Some(l), _) => Some(l.clone()),
2738            // Unknown left: use right as best guess
2739            (None, _) => right.clone(),
2740        },
2741        "|>" => None,
2742        _ => None,
2743    }
2744}
2745
2746/// Format a type expression for display in error messages.
2747/// Produce a detail string describing why a Shape type is incompatible with
2748/// another Shape type — e.g. "missing field 'age' (int)" or "field 'name'
2749/// has type int, expected string".  Returns `None` if both types are not shapes.
2750pub fn shape_mismatch_detail(expected: &TypeExpr, actual: &TypeExpr) -> Option<String> {
2751    if let (TypeExpr::Shape(ef), TypeExpr::Shape(af)) = (expected, actual) {
2752        let mut details = Vec::new();
2753        for field in ef {
2754            if field.optional {
2755                continue;
2756            }
2757            match af.iter().find(|f| f.name == field.name) {
2758                None => details.push(format!(
2759                    "missing field '{}' ({})",
2760                    field.name,
2761                    format_type(&field.type_expr)
2762                )),
2763                Some(actual_field) => {
2764                    let e_str = format_type(&field.type_expr);
2765                    let a_str = format_type(&actual_field.type_expr);
2766                    if e_str != a_str {
2767                        details.push(format!(
2768                            "field '{}' has type {}, expected {}",
2769                            field.name, a_str, e_str
2770                        ));
2771                    }
2772                }
2773            }
2774        }
2775        if details.is_empty() {
2776            None
2777        } else {
2778            Some(details.join("; "))
2779        }
2780    } else {
2781        None
2782    }
2783}
2784
2785/// Returns true when the type is obvious from the RHS expression
2786/// (e.g. `let x = 42` is obviously int — no hint needed).
2787fn is_obvious_type(value: &SNode, _ty: &TypeExpr) -> bool {
2788    matches!(
2789        &value.node,
2790        Node::IntLiteral(_)
2791            | Node::FloatLiteral(_)
2792            | Node::StringLiteral(_)
2793            | Node::BoolLiteral(_)
2794            | Node::NilLiteral
2795            | Node::ListLiteral(_)
2796            | Node::DictLiteral(_)
2797            | Node::InterpolatedString(_)
2798    )
2799}
2800
2801pub fn format_type(ty: &TypeExpr) -> String {
2802    match ty {
2803        TypeExpr::Named(n) => n.clone(),
2804        TypeExpr::Union(types) => types
2805            .iter()
2806            .map(format_type)
2807            .collect::<Vec<_>>()
2808            .join(" | "),
2809        TypeExpr::Shape(fields) => {
2810            let inner: Vec<String> = fields
2811                .iter()
2812                .map(|f| {
2813                    let opt = if f.optional { "?" } else { "" };
2814                    format!("{}{opt}: {}", f.name, format_type(&f.type_expr))
2815                })
2816                .collect();
2817            format!("{{{}}}", inner.join(", "))
2818        }
2819        TypeExpr::List(inner) => format!("list<{}>", format_type(inner)),
2820        TypeExpr::DictType(k, v) => format!("dict<{}, {}>", format_type(k), format_type(v)),
2821        TypeExpr::FnType {
2822            params,
2823            return_type,
2824        } => {
2825            let params_str = params
2826                .iter()
2827                .map(format_type)
2828                .collect::<Vec<_>>()
2829                .join(", ");
2830            format!("fn({}) -> {}", params_str, format_type(return_type))
2831        }
2832    }
2833}
2834
2835/// Remove a named type from a union, collapsing single-element unions.
2836fn remove_from_union(members: &[TypeExpr], to_remove: &str) -> InferredType {
2837    let remaining: Vec<TypeExpr> = members
2838        .iter()
2839        .filter(|m| !matches!(m, TypeExpr::Named(n) if n == to_remove))
2840        .cloned()
2841        .collect();
2842    match remaining.len() {
2843        0 => None,
2844        1 => Some(remaining.into_iter().next().unwrap()),
2845        _ => Some(TypeExpr::Union(remaining)),
2846    }
2847}
2848
2849/// Narrow a union to just one named type, if that type is a member.
2850fn narrow_to_single(members: &[TypeExpr], target: &str) -> InferredType {
2851    if members
2852        .iter()
2853        .any(|m| matches!(m, TypeExpr::Named(n) if n == target))
2854    {
2855        Some(TypeExpr::Named(target.to_string()))
2856    } else {
2857        None
2858    }
2859}
2860
2861/// Extract the variable name from a `type_of(x)` call.
2862fn extract_type_of_var(node: &SNode) -> Option<String> {
2863    if let Node::FunctionCall { name, args } = &node.node {
2864        if name == "type_of" && args.len() == 1 {
2865            if let Node::Identifier(var) = &args[0].node {
2866                return Some(var.clone());
2867            }
2868        }
2869    }
2870    None
2871}
2872
2873fn schema_type_expr_from_node(node: &SNode, scope: &TypeScope) -> Option<TypeExpr> {
2874    match &node.node {
2875        Node::Identifier(name) => scope.get_schema_binding(name).cloned().flatten(),
2876        Node::DictLiteral(entries) => schema_type_expr_from_dict(entries, scope),
2877        _ => None,
2878    }
2879}
2880
2881fn schema_type_expr_from_dict(entries: &[DictEntry], scope: &TypeScope) -> Option<TypeExpr> {
2882    let mut type_name: Option<String> = None;
2883    let mut properties: Option<&SNode> = None;
2884    let mut required: Option<Vec<String>> = None;
2885    let mut items: Option<&SNode> = None;
2886    let mut union: Option<&SNode> = None;
2887    let mut nullable = false;
2888    let mut additional_properties: Option<&SNode> = None;
2889
2890    for entry in entries {
2891        let key = schema_entry_key(&entry.key)?;
2892        match key.as_str() {
2893            "type" => match &entry.value.node {
2894                Node::StringLiteral(text) | Node::RawStringLiteral(text) => {
2895                    type_name = Some(normalize_schema_type_name(text));
2896                }
2897                Node::ListLiteral(items_list) => {
2898                    let union_members = items_list
2899                        .iter()
2900                        .filter_map(|item| match &item.node {
2901                            Node::StringLiteral(text) | Node::RawStringLiteral(text) => {
2902                                Some(TypeExpr::Named(normalize_schema_type_name(text)))
2903                            }
2904                            _ => None,
2905                        })
2906                        .collect::<Vec<_>>();
2907                    if !union_members.is_empty() {
2908                        return Some(TypeExpr::Union(union_members));
2909                    }
2910                }
2911                _ => {}
2912            },
2913            "properties" => properties = Some(&entry.value),
2914            "required" => {
2915                required = schema_required_names(&entry.value);
2916            }
2917            "items" => items = Some(&entry.value),
2918            "union" | "oneOf" | "anyOf" => union = Some(&entry.value),
2919            "nullable" => {
2920                nullable = matches!(entry.value.node, Node::BoolLiteral(true));
2921            }
2922            "additional_properties" | "additionalProperties" => {
2923                additional_properties = Some(&entry.value);
2924            }
2925            _ => {}
2926        }
2927    }
2928
2929    let mut schema_type = if let Some(union_node) = union {
2930        schema_union_type_expr(union_node, scope)?
2931    } else if let Some(properties_node) = properties {
2932        let property_entries = match &properties_node.node {
2933            Node::DictLiteral(entries) => entries,
2934            _ => return None,
2935        };
2936        let required_names = required.unwrap_or_default();
2937        let mut fields = Vec::new();
2938        for entry in property_entries {
2939            let field_name = schema_entry_key(&entry.key)?;
2940            let field_type = schema_type_expr_from_node(&entry.value, scope)?;
2941            fields.push(ShapeField {
2942                name: field_name.clone(),
2943                type_expr: field_type,
2944                optional: !required_names.contains(&field_name),
2945            });
2946        }
2947        TypeExpr::Shape(fields)
2948    } else if let Some(item_node) = items {
2949        TypeExpr::List(Box::new(schema_type_expr_from_node(item_node, scope)?))
2950    } else if let Some(type_name) = type_name {
2951        if type_name == "dict" {
2952            if let Some(extra_node) = additional_properties {
2953                let value_type = match &extra_node.node {
2954                    Node::BoolLiteral(_) => None,
2955                    _ => schema_type_expr_from_node(extra_node, scope),
2956                };
2957                if let Some(value_type) = value_type {
2958                    TypeExpr::DictType(
2959                        Box::new(TypeExpr::Named("string".into())),
2960                        Box::new(value_type),
2961                    )
2962                } else {
2963                    TypeExpr::Named(type_name)
2964                }
2965            } else {
2966                TypeExpr::Named(type_name)
2967            }
2968        } else {
2969            TypeExpr::Named(type_name)
2970        }
2971    } else {
2972        return None;
2973    };
2974
2975    if nullable {
2976        schema_type = match schema_type {
2977            TypeExpr::Union(mut members) => {
2978                if !members
2979                    .iter()
2980                    .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil"))
2981                {
2982                    members.push(TypeExpr::Named("nil".into()));
2983                }
2984                TypeExpr::Union(members)
2985            }
2986            other => TypeExpr::Union(vec![other, TypeExpr::Named("nil".into())]),
2987        };
2988    }
2989
2990    Some(schema_type)
2991}
2992
2993fn schema_union_type_expr(node: &SNode, scope: &TypeScope) -> Option<TypeExpr> {
2994    let Node::ListLiteral(items) = &node.node else {
2995        return None;
2996    };
2997    let members = items
2998        .iter()
2999        .filter_map(|item| schema_type_expr_from_node(item, scope))
3000        .collect::<Vec<_>>();
3001    match members.len() {
3002        0 => None,
3003        1 => members.into_iter().next(),
3004        _ => Some(TypeExpr::Union(members)),
3005    }
3006}
3007
3008fn schema_required_names(node: &SNode) -> Option<Vec<String>> {
3009    let Node::ListLiteral(items) = &node.node else {
3010        return None;
3011    };
3012    Some(
3013        items
3014            .iter()
3015            .filter_map(|item| match &item.node {
3016                Node::StringLiteral(text) | Node::RawStringLiteral(text) => Some(text.clone()),
3017                Node::Identifier(text) => Some(text.clone()),
3018                _ => None,
3019            })
3020            .collect(),
3021    )
3022}
3023
3024fn schema_entry_key(node: &SNode) -> Option<String> {
3025    match &node.node {
3026        Node::Identifier(name) => Some(name.clone()),
3027        Node::StringLiteral(name) | Node::RawStringLiteral(name) => Some(name.clone()),
3028        _ => None,
3029    }
3030}
3031
3032fn normalize_schema_type_name(text: &str) -> String {
3033    match text {
3034        "object" => "dict".into(),
3035        "array" => "list".into(),
3036        "integer" => "int".into(),
3037        "number" => "float".into(),
3038        "boolean" => "bool".into(),
3039        "null" => "nil".into(),
3040        other => other.into(),
3041    }
3042}
3043
3044fn intersect_types(current: &TypeExpr, schema_type: &TypeExpr) -> Option<TypeExpr> {
3045    match (current, schema_type) {
3046        (TypeExpr::Union(members), other) => {
3047            let kept = members
3048                .iter()
3049                .filter_map(|member| intersect_types(member, other))
3050                .collect::<Vec<_>>();
3051            match kept.len() {
3052                0 => None,
3053                1 => kept.into_iter().next(),
3054                _ => Some(TypeExpr::Union(kept)),
3055            }
3056        }
3057        (other, TypeExpr::Union(members)) => {
3058            let kept = members
3059                .iter()
3060                .filter_map(|member| intersect_types(other, member))
3061                .collect::<Vec<_>>();
3062            match kept.len() {
3063                0 => None,
3064                1 => kept.into_iter().next(),
3065                _ => Some(TypeExpr::Union(kept)),
3066            }
3067        }
3068        (TypeExpr::Named(left), TypeExpr::Named(right)) if left == right => {
3069            Some(TypeExpr::Named(left.clone()))
3070        }
3071        (TypeExpr::Named(name), TypeExpr::Shape(fields)) if name == "dict" => {
3072            Some(TypeExpr::Shape(fields.clone()))
3073        }
3074        (TypeExpr::Shape(fields), TypeExpr::Named(name)) if name == "dict" => {
3075            Some(TypeExpr::Shape(fields.clone()))
3076        }
3077        (TypeExpr::Named(name), TypeExpr::List(inner)) if name == "list" => {
3078            Some(TypeExpr::List(inner.clone()))
3079        }
3080        (TypeExpr::List(inner), TypeExpr::Named(name)) if name == "list" => {
3081            Some(TypeExpr::List(inner.clone()))
3082        }
3083        (TypeExpr::Named(name), TypeExpr::DictType(key, value)) if name == "dict" => {
3084            Some(TypeExpr::DictType(key.clone(), value.clone()))
3085        }
3086        (TypeExpr::DictType(key, value), TypeExpr::Named(name)) if name == "dict" => {
3087            Some(TypeExpr::DictType(key.clone(), value.clone()))
3088        }
3089        (TypeExpr::Shape(_), TypeExpr::Shape(fields)) => Some(TypeExpr::Shape(fields.clone())),
3090        (TypeExpr::List(current_inner), TypeExpr::List(schema_inner)) => {
3091            intersect_types(current_inner, schema_inner)
3092                .map(|inner| TypeExpr::List(Box::new(inner)))
3093        }
3094        (
3095            TypeExpr::DictType(current_key, current_value),
3096            TypeExpr::DictType(schema_key, schema_value),
3097        ) => {
3098            let key = intersect_types(current_key, schema_key)?;
3099            let value = intersect_types(current_value, schema_value)?;
3100            Some(TypeExpr::DictType(Box::new(key), Box::new(value)))
3101        }
3102        _ => None,
3103    }
3104}
3105
3106fn subtract_type(current: &TypeExpr, schema_type: &TypeExpr) -> Option<TypeExpr> {
3107    match current {
3108        TypeExpr::Union(members) => {
3109            let remaining = members
3110                .iter()
3111                .filter(|member| intersect_types(member, schema_type).is_none())
3112                .cloned()
3113                .collect::<Vec<_>>();
3114            match remaining.len() {
3115                0 => None,
3116                1 => remaining.into_iter().next(),
3117                _ => Some(TypeExpr::Union(remaining)),
3118            }
3119        }
3120        other if intersect_types(other, schema_type).is_some() => None,
3121        other => Some(other.clone()),
3122    }
3123}
3124
3125/// Apply a list of refinements to a scope, tracking pre-narrowing types.
3126fn apply_refinements(scope: &mut TypeScope, refinements: &[(String, InferredType)]) {
3127    for (var_name, narrowed_type) in refinements {
3128        // Save the pre-narrowing type so we can restore it on reassignment
3129        if !scope.narrowed_vars.contains_key(var_name) {
3130            if let Some(original) = scope.get_var(var_name).cloned() {
3131                scope.narrowed_vars.insert(var_name.clone(), original);
3132            }
3133        }
3134        scope.define_var(var_name, narrowed_type.clone());
3135    }
3136}
3137
3138#[cfg(test)]
3139mod tests {
3140    use super::*;
3141    use crate::Parser;
3142    use harn_lexer::Lexer;
3143
3144    fn check_source(source: &str) -> Vec<TypeDiagnostic> {
3145        let mut lexer = Lexer::new(source);
3146        let tokens = lexer.tokenize().unwrap();
3147        let mut parser = Parser::new(tokens);
3148        let program = parser.parse().unwrap();
3149        TypeChecker::new().check(&program)
3150    }
3151
3152    fn errors(source: &str) -> Vec<String> {
3153        check_source(source)
3154            .into_iter()
3155            .filter(|d| d.severity == DiagnosticSeverity::Error)
3156            .map(|d| d.message)
3157            .collect()
3158    }
3159
3160    #[test]
3161    fn test_no_errors_for_untyped_code() {
3162        let errs = errors("pipeline t(task) { let x = 42\nlog(x) }");
3163        assert!(errs.is_empty());
3164    }
3165
3166    #[test]
3167    fn test_correct_typed_let() {
3168        let errs = errors("pipeline t(task) { let x: int = 42 }");
3169        assert!(errs.is_empty());
3170    }
3171
3172    #[test]
3173    fn test_type_mismatch_let() {
3174        let errs = errors(r#"pipeline t(task) { let x: int = "hello" }"#);
3175        assert_eq!(errs.len(), 1);
3176        assert!(errs[0].contains("Type mismatch"));
3177        assert!(errs[0].contains("int"));
3178        assert!(errs[0].contains("string"));
3179    }
3180
3181    #[test]
3182    fn test_correct_typed_fn() {
3183        let errs = errors(
3184            "pipeline t(task) { fn add(a: int, b: int) -> int { return a + b }\nadd(1, 2) }",
3185        );
3186        assert!(errs.is_empty());
3187    }
3188
3189    #[test]
3190    fn test_fn_arg_type_mismatch() {
3191        let errs = errors(
3192            r#"pipeline t(task) { fn add(a: int, b: int) -> int { return a + b }
3193add("hello", 2) }"#,
3194        );
3195        assert_eq!(errs.len(), 1);
3196        assert!(errs[0].contains("Argument 1"));
3197        assert!(errs[0].contains("expected int"));
3198    }
3199
3200    #[test]
3201    fn test_return_type_mismatch() {
3202        let errs = errors(r#"pipeline t(task) { fn get() -> int { return "hello" } }"#);
3203        assert_eq!(errs.len(), 1);
3204        assert!(errs[0].contains("Return type mismatch"));
3205    }
3206
3207    #[test]
3208    fn test_union_type_compatible() {
3209        let errs = errors(r#"pipeline t(task) { let x: string | nil = nil }"#);
3210        assert!(errs.is_empty());
3211    }
3212
3213    #[test]
3214    fn test_union_type_mismatch() {
3215        let errs = errors(r#"pipeline t(task) { let x: string | nil = 42 }"#);
3216        assert_eq!(errs.len(), 1);
3217        assert!(errs[0].contains("Type mismatch"));
3218    }
3219
3220    #[test]
3221    fn test_type_inference_propagation() {
3222        let errs = errors(
3223            r#"pipeline t(task) {
3224  fn add(a: int, b: int) -> int { return a + b }
3225  let result: string = add(1, 2)
3226}"#,
3227        );
3228        assert_eq!(errs.len(), 1);
3229        assert!(errs[0].contains("Type mismatch"));
3230        assert!(errs[0].contains("string"));
3231        assert!(errs[0].contains("int"));
3232    }
3233
3234    #[test]
3235    fn test_generic_return_type_instantiates_from_callsite() {
3236        let errs = errors(
3237            r#"pipeline t(task) {
3238  fn identity<T>(x: T) -> T { return x }
3239  fn first<T>(items: list<T>) -> T { return items[0] }
3240  let n: int = identity(42)
3241  let s: string = first(["a", "b"])
3242}"#,
3243        );
3244        assert!(errs.is_empty(), "unexpected type errors: {errs:?}");
3245    }
3246
3247    #[test]
3248    fn test_generic_type_param_must_bind_consistently() {
3249        let errs = errors(
3250            r#"pipeline t(task) {
3251  fn keep<T>(a: T, b: T) -> T { return a }
3252  keep(1, "x")
3253}"#,
3254        );
3255        assert_eq!(errs.len(), 2, "expected 2 errors, got: {:?}", errs);
3256        assert!(
3257            errs.iter()
3258                .any(|err| err.contains("type parameter 'T' was inferred as both int and string")),
3259            "missing generic binding conflict error: {:?}",
3260            errs
3261        );
3262        assert!(
3263            errs.iter()
3264                .any(|err| err.contains("Argument 2 ('b'): expected int, got string")),
3265            "missing instantiated argument mismatch error: {:?}",
3266            errs
3267        );
3268    }
3269
3270    #[test]
3271    fn test_generic_list_binding_propagates_element_type() {
3272        let errs = errors(
3273            r#"pipeline t(task) {
3274  fn first<T>(items: list<T>) -> T { return items[0] }
3275  let bad: string = first([1, 2, 3])
3276}"#,
3277        );
3278        assert_eq!(errs.len(), 1, "expected 1 error, got: {:?}", errs);
3279        assert!(errs[0].contains("declared as string, but assigned int"));
3280    }
3281
3282    #[test]
3283    fn test_builtin_return_type_inference() {
3284        let errs = errors(r#"pipeline t(task) { let x: string = to_int("42") }"#);
3285        assert_eq!(errs.len(), 1);
3286        assert!(errs[0].contains("string"));
3287        assert!(errs[0].contains("int"));
3288    }
3289
3290    #[test]
3291    fn test_workflow_and_transcript_builtins_are_known() {
3292        let errs = errors(
3293            r#"pipeline t(task) {
3294  let flow = workflow_graph({name: "demo", entry: "act", nodes: {act: {kind: "stage"}}})
3295  let report: dict = workflow_policy_report(flow, {tools: tool_registry(), capabilities: {workspace: ["read_text"]}})
3296  let run: dict = workflow_execute("task", flow, [], {})
3297  let tree: dict = load_run_tree("run.json")
3298  let fixture: dict = run_record_fixture(run?.run)
3299  let suite: dict = run_record_eval_suite([{run: run?.run, fixture: fixture}])
3300  let diff: dict = run_record_diff(run?.run, run?.run)
3301  let manifest: dict = eval_suite_manifest({cases: [{run_path: "run.json"}]})
3302  let suite_report: dict = eval_suite_run(manifest)
3303  let wf: dict = artifact_workspace_file("src/main.rs", "fn main() {}", {source: "host"})
3304  let snap: dict = artifact_workspace_snapshot(["src/main.rs"], "snapshot")
3305  let selection: dict = artifact_editor_selection("src/main.rs", "main")
3306  let verify: dict = artifact_verification_result("verify", "ok")
3307  let test_result: dict = artifact_test_result("tests", "pass")
3308  let cmd: dict = artifact_command_result("cargo test", {status: 0})
3309  let patch: dict = artifact_diff("src/main.rs", "old", "new")
3310  let git: dict = artifact_git_diff("diff --git a b")
3311  let review: dict = artifact_diff_review(patch, "review me")
3312  let decision: dict = artifact_review_decision(review, "accepted")
3313  let proposal: dict = artifact_patch_proposal(review, "*** Begin Patch")
3314  let bundle: dict = artifact_verification_bundle("checks", [{name: "fmt", ok: true}])
3315  let apply: dict = artifact_apply_intent(review, "apply")
3316  let transcript = transcript_reset({metadata: {source: "test"}})
3317  let visible: string = transcript_render_visible(transcript_archive(transcript))
3318  let events: list = transcript_events(transcript)
3319  let context: string = artifact_context([], {max_artifacts: 1})
3320  println(report)
3321  println(run)
3322  println(tree)
3323  println(fixture)
3324  println(suite)
3325  println(diff)
3326  println(manifest)
3327  println(suite_report)
3328  println(wf)
3329  println(snap)
3330  println(selection)
3331  println(verify)
3332  println(test_result)
3333  println(cmd)
3334  println(patch)
3335  println(git)
3336  println(review)
3337  println(decision)
3338  println(proposal)
3339  println(bundle)
3340  println(apply)
3341  println(visible)
3342  println(events)
3343  println(context)
3344}"#,
3345        );
3346        assert!(errs.is_empty(), "unexpected type errors: {errs:?}");
3347    }
3348
3349    #[test]
3350    fn test_binary_op_type_inference() {
3351        let errs = errors("pipeline t(task) { let x: string = 1 + 2 }");
3352        assert_eq!(errs.len(), 1);
3353    }
3354
3355    #[test]
3356    fn test_comparison_returns_bool() {
3357        let errs = errors("pipeline t(task) { let x: bool = 1 < 2 }");
3358        assert!(errs.is_empty());
3359    }
3360
3361    #[test]
3362    fn test_int_float_promotion() {
3363        let errs = errors("pipeline t(task) { let x: float = 42 }");
3364        assert!(errs.is_empty());
3365    }
3366
3367    #[test]
3368    fn test_untyped_code_no_errors() {
3369        let errs = errors(
3370            r#"pipeline t(task) {
3371  fn process(data) {
3372    let result = data + " processed"
3373    return result
3374  }
3375  log(process("hello"))
3376}"#,
3377        );
3378        assert!(errs.is_empty());
3379    }
3380
3381    #[test]
3382    fn test_type_alias() {
3383        let errs = errors(
3384            r#"pipeline t(task) {
3385  type Name = string
3386  let x: Name = "hello"
3387}"#,
3388        );
3389        assert!(errs.is_empty());
3390    }
3391
3392    #[test]
3393    fn test_type_alias_mismatch() {
3394        let errs = errors(
3395            r#"pipeline t(task) {
3396  type Name = string
3397  let x: Name = 42
3398}"#,
3399        );
3400        assert_eq!(errs.len(), 1);
3401    }
3402
3403    #[test]
3404    fn test_assignment_type_check() {
3405        let errs = errors(
3406            r#"pipeline t(task) {
3407  var x: int = 0
3408  x = "hello"
3409}"#,
3410        );
3411        assert_eq!(errs.len(), 1);
3412        assert!(errs[0].contains("cannot assign string"));
3413    }
3414
3415    #[test]
3416    fn test_covariance_int_to_float_in_fn() {
3417        let errs = errors(
3418            "pipeline t(task) { fn scale(x: float) -> float { return x * 2.0 }\nscale(42) }",
3419        );
3420        assert!(errs.is_empty());
3421    }
3422
3423    #[test]
3424    fn test_covariance_return_type() {
3425        let errs = errors("pipeline t(task) { fn get() -> float { return 42 } }");
3426        assert!(errs.is_empty());
3427    }
3428
3429    #[test]
3430    fn test_no_contravariance_float_to_int() {
3431        let errs = errors("pipeline t(task) { fn add(a: int) -> int { return a + 1 }\nadd(3.14) }");
3432        assert_eq!(errs.len(), 1);
3433    }
3434
3435    // --- Exhaustiveness checking tests ---
3436
3437    fn warnings(source: &str) -> Vec<String> {
3438        check_source(source)
3439            .into_iter()
3440            .filter(|d| d.severity == DiagnosticSeverity::Warning)
3441            .map(|d| d.message)
3442            .collect()
3443    }
3444
3445    #[test]
3446    fn test_exhaustive_match_no_warning() {
3447        let warns = warnings(
3448            r#"pipeline t(task) {
3449  enum Color { Red, Green, Blue }
3450  let c = Color.Red
3451  match c.variant {
3452    "Red" -> { log("r") }
3453    "Green" -> { log("g") }
3454    "Blue" -> { log("b") }
3455  }
3456}"#,
3457        );
3458        let exhaustive_warns: Vec<_> = warns
3459            .iter()
3460            .filter(|w| w.contains("Non-exhaustive"))
3461            .collect();
3462        assert!(exhaustive_warns.is_empty());
3463    }
3464
3465    #[test]
3466    fn test_non_exhaustive_match_warning() {
3467        let warns = warnings(
3468            r#"pipeline t(task) {
3469  enum Color { Red, Green, Blue }
3470  let c = Color.Red
3471  match c.variant {
3472    "Red" -> { log("r") }
3473    "Green" -> { log("g") }
3474  }
3475}"#,
3476        );
3477        let exhaustive_warns: Vec<_> = warns
3478            .iter()
3479            .filter(|w| w.contains("Non-exhaustive"))
3480            .collect();
3481        assert_eq!(exhaustive_warns.len(), 1);
3482        assert!(exhaustive_warns[0].contains("Blue"));
3483    }
3484
3485    #[test]
3486    fn test_non_exhaustive_multiple_missing() {
3487        let warns = warnings(
3488            r#"pipeline t(task) {
3489  enum Status { Active, Inactive, Pending }
3490  let s = Status.Active
3491  match s.variant {
3492    "Active" -> { log("a") }
3493  }
3494}"#,
3495        );
3496        let exhaustive_warns: Vec<_> = warns
3497            .iter()
3498            .filter(|w| w.contains("Non-exhaustive"))
3499            .collect();
3500        assert_eq!(exhaustive_warns.len(), 1);
3501        assert!(exhaustive_warns[0].contains("Inactive"));
3502        assert!(exhaustive_warns[0].contains("Pending"));
3503    }
3504
3505    #[test]
3506    fn test_enum_construct_type_inference() {
3507        let errs = errors(
3508            r#"pipeline t(task) {
3509  enum Color { Red, Green, Blue }
3510  let c: Color = Color.Red
3511}"#,
3512        );
3513        assert!(errs.is_empty());
3514    }
3515
3516    // --- Type narrowing tests ---
3517
3518    #[test]
3519    fn test_nil_coalescing_strips_nil() {
3520        // After ??, nil should be stripped from the type
3521        let errs = errors(
3522            r#"pipeline t(task) {
3523  let x: string | nil = nil
3524  let y: string = x ?? "default"
3525}"#,
3526        );
3527        assert!(errs.is_empty());
3528    }
3529
3530    #[test]
3531    fn test_shape_mismatch_detail_missing_field() {
3532        let errs = errors(
3533            r#"pipeline t(task) {
3534  let x: {name: string, age: int} = {name: "hello"}
3535}"#,
3536        );
3537        assert_eq!(errs.len(), 1);
3538        assert!(
3539            errs[0].contains("missing field 'age'"),
3540            "expected detail about missing field, got: {}",
3541            errs[0]
3542        );
3543    }
3544
3545    #[test]
3546    fn test_shape_mismatch_detail_wrong_type() {
3547        let errs = errors(
3548            r#"pipeline t(task) {
3549  let x: {name: string, age: int} = {name: 42, age: 10}
3550}"#,
3551        );
3552        assert_eq!(errs.len(), 1);
3553        assert!(
3554            errs[0].contains("field 'name' has type int, expected string"),
3555            "expected detail about wrong type, got: {}",
3556            errs[0]
3557        );
3558    }
3559
3560    // --- Match pattern type validation tests ---
3561
3562    #[test]
3563    fn test_match_pattern_string_against_int() {
3564        let warns = warnings(
3565            r#"pipeline t(task) {
3566  let x: int = 42
3567  match x {
3568    "hello" -> { log("bad") }
3569    42 -> { log("ok") }
3570  }
3571}"#,
3572        );
3573        let pattern_warns: Vec<_> = warns
3574            .iter()
3575            .filter(|w| w.contains("Match pattern type mismatch"))
3576            .collect();
3577        assert_eq!(pattern_warns.len(), 1);
3578        assert!(pattern_warns[0].contains("matching int against string literal"));
3579    }
3580
3581    #[test]
3582    fn test_match_pattern_int_against_string() {
3583        let warns = warnings(
3584            r#"pipeline t(task) {
3585  let x: string = "hello"
3586  match x {
3587    42 -> { log("bad") }
3588    "hello" -> { log("ok") }
3589  }
3590}"#,
3591        );
3592        let pattern_warns: Vec<_> = warns
3593            .iter()
3594            .filter(|w| w.contains("Match pattern type mismatch"))
3595            .collect();
3596        assert_eq!(pattern_warns.len(), 1);
3597        assert!(pattern_warns[0].contains("matching string against int literal"));
3598    }
3599
3600    #[test]
3601    fn test_match_pattern_bool_against_int() {
3602        let warns = warnings(
3603            r#"pipeline t(task) {
3604  let x: int = 42
3605  match x {
3606    true -> { log("bad") }
3607    42 -> { log("ok") }
3608  }
3609}"#,
3610        );
3611        let pattern_warns: Vec<_> = warns
3612            .iter()
3613            .filter(|w| w.contains("Match pattern type mismatch"))
3614            .collect();
3615        assert_eq!(pattern_warns.len(), 1);
3616        assert!(pattern_warns[0].contains("matching int against bool literal"));
3617    }
3618
3619    #[test]
3620    fn test_match_pattern_float_against_string() {
3621        let warns = warnings(
3622            r#"pipeline t(task) {
3623  let x: string = "hello"
3624  match x {
3625    3.14 -> { log("bad") }
3626    "hello" -> { log("ok") }
3627  }
3628}"#,
3629        );
3630        let pattern_warns: Vec<_> = warns
3631            .iter()
3632            .filter(|w| w.contains("Match pattern type mismatch"))
3633            .collect();
3634        assert_eq!(pattern_warns.len(), 1);
3635        assert!(pattern_warns[0].contains("matching string against float literal"));
3636    }
3637
3638    #[test]
3639    fn test_match_pattern_int_against_float_ok() {
3640        // int and float are compatible for match patterns
3641        let warns = warnings(
3642            r#"pipeline t(task) {
3643  let x: float = 3.14
3644  match x {
3645    42 -> { log("ok") }
3646    _ -> { log("default") }
3647  }
3648}"#,
3649        );
3650        let pattern_warns: Vec<_> = warns
3651            .iter()
3652            .filter(|w| w.contains("Match pattern type mismatch"))
3653            .collect();
3654        assert!(pattern_warns.is_empty());
3655    }
3656
3657    #[test]
3658    fn test_match_pattern_float_against_int_ok() {
3659        // float and int are compatible for match patterns
3660        let warns = warnings(
3661            r#"pipeline t(task) {
3662  let x: int = 42
3663  match x {
3664    3.14 -> { log("close") }
3665    _ -> { log("default") }
3666  }
3667}"#,
3668        );
3669        let pattern_warns: Vec<_> = warns
3670            .iter()
3671            .filter(|w| w.contains("Match pattern type mismatch"))
3672            .collect();
3673        assert!(pattern_warns.is_empty());
3674    }
3675
3676    #[test]
3677    fn test_match_pattern_correct_types_no_warning() {
3678        let warns = warnings(
3679            r#"pipeline t(task) {
3680  let x: int = 42
3681  match x {
3682    1 -> { log("one") }
3683    2 -> { log("two") }
3684    _ -> { log("other") }
3685  }
3686}"#,
3687        );
3688        let pattern_warns: Vec<_> = warns
3689            .iter()
3690            .filter(|w| w.contains("Match pattern type mismatch"))
3691            .collect();
3692        assert!(pattern_warns.is_empty());
3693    }
3694
3695    #[test]
3696    fn test_match_pattern_wildcard_no_warning() {
3697        let warns = warnings(
3698            r#"pipeline t(task) {
3699  let x: int = 42
3700  match x {
3701    _ -> { log("catch all") }
3702  }
3703}"#,
3704        );
3705        let pattern_warns: Vec<_> = warns
3706            .iter()
3707            .filter(|w| w.contains("Match pattern type mismatch"))
3708            .collect();
3709        assert!(pattern_warns.is_empty());
3710    }
3711
3712    #[test]
3713    fn test_match_pattern_untyped_no_warning() {
3714        // When value has no known type, no warning should be emitted
3715        let warns = warnings(
3716            r#"pipeline t(task) {
3717  let x = some_unknown_fn()
3718  match x {
3719    "hello" -> { log("string") }
3720    42 -> { log("int") }
3721  }
3722}"#,
3723        );
3724        let pattern_warns: Vec<_> = warns
3725            .iter()
3726            .filter(|w| w.contains("Match pattern type mismatch"))
3727            .collect();
3728        assert!(pattern_warns.is_empty());
3729    }
3730
3731    // --- Interface constraint type checking tests ---
3732
3733    fn iface_errors(source: &str) -> Vec<String> {
3734        errors(source)
3735            .into_iter()
3736            .filter(|message| message.contains("does not satisfy interface"))
3737            .collect()
3738    }
3739
3740    #[test]
3741    fn test_interface_constraint_return_type_mismatch() {
3742        let warns = iface_errors(
3743            r#"pipeline t(task) {
3744  interface Sizable {
3745    fn size(self) -> int
3746  }
3747  struct Box { width: int }
3748  impl Box {
3749    fn size(self) -> string { return "nope" }
3750  }
3751  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
3752  measure(Box({width: 3}))
3753}"#,
3754        );
3755        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
3756        assert!(
3757            warns[0].contains("method 'size' returns 'string', expected 'int'"),
3758            "unexpected message: {}",
3759            warns[0]
3760        );
3761    }
3762
3763    #[test]
3764    fn test_interface_constraint_param_type_mismatch() {
3765        let warns = iface_errors(
3766            r#"pipeline t(task) {
3767  interface Processor {
3768    fn process(self, x: int) -> string
3769  }
3770  struct MyProc { name: string }
3771  impl MyProc {
3772    fn process(self, x: string) -> string { return x }
3773  }
3774  fn run_proc<T>(p: T) where T: Processor { log(p.process(42)) }
3775  run_proc(MyProc({name: "a"}))
3776}"#,
3777        );
3778        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
3779        assert!(
3780            warns[0].contains("method 'process' parameter 1 has type 'string', expected 'int'"),
3781            "unexpected message: {}",
3782            warns[0]
3783        );
3784    }
3785
3786    #[test]
3787    fn test_interface_constraint_missing_method() {
3788        let warns = iface_errors(
3789            r#"pipeline t(task) {
3790  interface Sizable {
3791    fn size(self) -> int
3792  }
3793  struct Box { width: int }
3794  impl Box {
3795    fn area(self) -> int { return self.width }
3796  }
3797  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
3798  measure(Box({width: 3}))
3799}"#,
3800        );
3801        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
3802        assert!(
3803            warns[0].contains("missing method 'size'"),
3804            "unexpected message: {}",
3805            warns[0]
3806        );
3807    }
3808
3809    #[test]
3810    fn test_interface_constraint_param_count_mismatch() {
3811        let warns = iface_errors(
3812            r#"pipeline t(task) {
3813  interface Doubler {
3814    fn double(self, x: int) -> int
3815  }
3816  struct Bad { v: int }
3817  impl Bad {
3818    fn double(self) -> int { return self.v * 2 }
3819  }
3820  fn run_double<T>(d: T) where T: Doubler { log(d.double(3)) }
3821  run_double(Bad({v: 5}))
3822}"#,
3823        );
3824        assert_eq!(warns.len(), 1, "expected 1 warning, got: {:?}", warns);
3825        assert!(
3826            warns[0].contains("method 'double' has 0 parameter(s), expected 1"),
3827            "unexpected message: {}",
3828            warns[0]
3829        );
3830    }
3831
3832    #[test]
3833    fn test_interface_constraint_satisfied() {
3834        let warns = iface_errors(
3835            r#"pipeline t(task) {
3836  interface Sizable {
3837    fn size(self) -> int
3838  }
3839  struct Box { width: int, height: int }
3840  impl Box {
3841    fn size(self) -> int { return self.width * self.height }
3842  }
3843  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
3844  measure(Box({width: 3, height: 4}))
3845}"#,
3846        );
3847        assert!(warns.is_empty(), "expected no warnings, got: {:?}", warns);
3848    }
3849
3850    #[test]
3851    fn test_interface_constraint_untyped_impl_compatible() {
3852        // Gradual typing: untyped impl return should not trigger warning
3853        let warns = iface_errors(
3854            r#"pipeline t(task) {
3855  interface Sizable {
3856    fn size(self) -> int
3857  }
3858  struct Box { width: int }
3859  impl Box {
3860    fn size(self) { return self.width }
3861  }
3862  fn measure<T>(item: T) where T: Sizable { log(item.size()) }
3863  measure(Box({width: 3}))
3864}"#,
3865        );
3866        assert!(warns.is_empty(), "expected no warnings, got: {:?}", warns);
3867    }
3868
3869    #[test]
3870    fn test_interface_constraint_int_float_covariance() {
3871        // int is compatible with float (covariance)
3872        let warns = iface_errors(
3873            r#"pipeline t(task) {
3874  interface Measurable {
3875    fn value(self) -> float
3876  }
3877  struct Gauge { v: int }
3878  impl Gauge {
3879    fn value(self) -> int { return self.v }
3880  }
3881  fn read_val<T>(g: T) where T: Measurable { log(g.value()) }
3882  read_val(Gauge({v: 42}))
3883}"#,
3884        );
3885        assert!(warns.is_empty(), "expected no warnings, got: {:?}", warns);
3886    }
3887
3888    // --- Flow-sensitive type refinement tests ---
3889
3890    #[test]
3891    fn test_nil_narrowing_then_branch() {
3892        // Existing behavior: x != nil narrows to string in then-branch
3893        let errs = errors(
3894            r#"pipeline t(task) {
3895  fn greet(name: string | nil) {
3896    if name != nil {
3897      let s: string = name
3898    }
3899  }
3900}"#,
3901        );
3902        assert!(errs.is_empty(), "got: {:?}", errs);
3903    }
3904
3905    #[test]
3906    fn test_nil_narrowing_else_branch() {
3907        // NEW: x != nil narrows to nil in else-branch
3908        let errs = errors(
3909            r#"pipeline t(task) {
3910  fn check(x: string | nil) {
3911    if x != nil {
3912      let s: string = x
3913    } else {
3914      let n: nil = x
3915    }
3916  }
3917}"#,
3918        );
3919        assert!(errs.is_empty(), "got: {:?}", errs);
3920    }
3921
3922    #[test]
3923    fn test_nil_equality_narrows_both() {
3924        // x == nil narrows then to nil, else to non-nil
3925        let errs = errors(
3926            r#"pipeline t(task) {
3927  fn check(x: string | nil) {
3928    if x == nil {
3929      let n: nil = x
3930    } else {
3931      let s: string = x
3932    }
3933  }
3934}"#,
3935        );
3936        assert!(errs.is_empty(), "got: {:?}", errs);
3937    }
3938
3939    #[test]
3940    fn test_truthiness_narrowing() {
3941        // Bare identifier in condition removes nil
3942        let errs = errors(
3943            r#"pipeline t(task) {
3944  fn check(x: string | nil) {
3945    if x {
3946      let s: string = x
3947    }
3948  }
3949}"#,
3950        );
3951        assert!(errs.is_empty(), "got: {:?}", errs);
3952    }
3953
3954    #[test]
3955    fn test_negation_narrowing() {
3956        // !x swaps truthy/falsy
3957        let errs = errors(
3958            r#"pipeline t(task) {
3959  fn check(x: string | nil) {
3960    if !x {
3961      let n: nil = x
3962    } else {
3963      let s: string = x
3964    }
3965  }
3966}"#,
3967        );
3968        assert!(errs.is_empty(), "got: {:?}", errs);
3969    }
3970
3971    #[test]
3972    fn test_typeof_narrowing() {
3973        // type_of(x) == "string" narrows to string
3974        let errs = errors(
3975            r#"pipeline t(task) {
3976  fn check(x: string | int) {
3977    if type_of(x) == "string" {
3978      let s: string = x
3979    }
3980  }
3981}"#,
3982        );
3983        assert!(errs.is_empty(), "got: {:?}", errs);
3984    }
3985
3986    #[test]
3987    fn test_typeof_narrowing_else() {
3988        // else removes the tested type
3989        let errs = errors(
3990            r#"pipeline t(task) {
3991  fn check(x: string | int) {
3992    if type_of(x) == "string" {
3993      let s: string = x
3994    } else {
3995      let i: int = x
3996    }
3997  }
3998}"#,
3999        );
4000        assert!(errs.is_empty(), "got: {:?}", errs);
4001    }
4002
4003    #[test]
4004    fn test_typeof_neq_narrowing() {
4005        // type_of(x) != "string" removes string in then, narrows to string in else
4006        let errs = errors(
4007            r#"pipeline t(task) {
4008  fn check(x: string | int) {
4009    if type_of(x) != "string" {
4010      let i: int = x
4011    } else {
4012      let s: string = x
4013    }
4014  }
4015}"#,
4016        );
4017        assert!(errs.is_empty(), "got: {:?}", errs);
4018    }
4019
4020    #[test]
4021    fn test_and_combines_narrowing() {
4022        // && combines truthy refinements
4023        let errs = errors(
4024            r#"pipeline t(task) {
4025  fn check(x: string | int | nil) {
4026    if x != nil && type_of(x) == "string" {
4027      let s: string = x
4028    }
4029  }
4030}"#,
4031        );
4032        assert!(errs.is_empty(), "got: {:?}", errs);
4033    }
4034
4035    #[test]
4036    fn test_or_falsy_narrowing() {
4037        // || combines falsy refinements
4038        let errs = errors(
4039            r#"pipeline t(task) {
4040  fn check(x: string | nil, y: int | nil) {
4041    if x || y {
4042      // conservative: can't narrow
4043    } else {
4044      let xn: nil = x
4045      let yn: nil = y
4046    }
4047  }
4048}"#,
4049        );
4050        assert!(errs.is_empty(), "got: {:?}", errs);
4051    }
4052
4053    #[test]
4054    fn test_guard_narrows_outer_scope() {
4055        let errs = errors(
4056            r#"pipeline t(task) {
4057  fn check(x: string | nil) {
4058    guard x != nil else { return }
4059    let s: string = x
4060  }
4061}"#,
4062        );
4063        assert!(errs.is_empty(), "got: {:?}", errs);
4064    }
4065
4066    #[test]
4067    fn test_while_narrows_body() {
4068        let errs = errors(
4069            r#"pipeline t(task) {
4070  fn check(x: string | nil) {
4071    while x != nil {
4072      let s: string = x
4073      break
4074    }
4075  }
4076}"#,
4077        );
4078        assert!(errs.is_empty(), "got: {:?}", errs);
4079    }
4080
4081    #[test]
4082    fn test_early_return_narrows_after_if() {
4083        // if then-body returns, falsy refinements apply after
4084        let errs = errors(
4085            r#"pipeline t(task) {
4086  fn check(x: string | nil) -> string {
4087    if x == nil {
4088      return "default"
4089    }
4090    let s: string = x
4091    return s
4092  }
4093}"#,
4094        );
4095        assert!(errs.is_empty(), "got: {:?}", errs);
4096    }
4097
4098    #[test]
4099    fn test_early_throw_narrows_after_if() {
4100        let errs = errors(
4101            r#"pipeline t(task) {
4102  fn check(x: string | nil) {
4103    if x == nil {
4104      throw "missing"
4105    }
4106    let s: string = x
4107  }
4108}"#,
4109        );
4110        assert!(errs.is_empty(), "got: {:?}", errs);
4111    }
4112
4113    #[test]
4114    fn test_no_narrowing_unknown_type() {
4115        // Gradual typing: untyped vars don't get narrowed
4116        let errs = errors(
4117            r#"pipeline t(task) {
4118  fn check(x) {
4119    if x != nil {
4120      let s: string = x
4121    }
4122  }
4123}"#,
4124        );
4125        // No narrowing possible, so assigning untyped x to string should be fine
4126        // (gradual typing allows it)
4127        assert!(errs.is_empty(), "got: {:?}", errs);
4128    }
4129
4130    #[test]
4131    fn test_reassignment_invalidates_narrowing() {
4132        // After reassigning a narrowed var, the original type should be restored
4133        let errs = errors(
4134            r#"pipeline t(task) {
4135  fn check(x: string | nil) {
4136    var y: string | nil = x
4137    if y != nil {
4138      let s: string = y
4139      y = nil
4140      let s2: string = y
4141    }
4142  }
4143}"#,
4144        );
4145        // s2 should fail because y was reassigned, invalidating the narrowing
4146        assert_eq!(errs.len(), 1, "expected 1 error, got: {:?}", errs);
4147        assert!(
4148            errs[0].contains("Type mismatch"),
4149            "expected type mismatch, got: {}",
4150            errs[0]
4151        );
4152    }
4153
4154    #[test]
4155    fn test_let_immutable_warning() {
4156        let all = check_source(
4157            r#"pipeline t(task) {
4158  let x = 42
4159  x = 43
4160}"#,
4161        );
4162        let warnings: Vec<_> = all
4163            .iter()
4164            .filter(|d| d.severity == DiagnosticSeverity::Warning)
4165            .collect();
4166        assert!(
4167            warnings.iter().any(|w| w.message.contains("immutable")),
4168            "expected immutability warning, got: {:?}",
4169            warnings
4170        );
4171    }
4172
4173    #[test]
4174    fn test_nested_narrowing() {
4175        let errs = errors(
4176            r#"pipeline t(task) {
4177  fn check(x: string | int | nil) {
4178    if x != nil {
4179      if type_of(x) == "int" {
4180        let i: int = x
4181      }
4182    }
4183  }
4184}"#,
4185        );
4186        assert!(errs.is_empty(), "got: {:?}", errs);
4187    }
4188
4189    #[test]
4190    fn test_match_narrows_arms() {
4191        let errs = errors(
4192            r#"pipeline t(task) {
4193  fn check(x: string | int) {
4194    match x {
4195      "hello" -> {
4196        let s: string = x
4197      }
4198      42 -> {
4199        let i: int = x
4200      }
4201      _ -> {}
4202    }
4203  }
4204}"#,
4205        );
4206        assert!(errs.is_empty(), "got: {:?}", errs);
4207    }
4208
4209    #[test]
4210    fn test_has_narrows_optional_field() {
4211        let errs = errors(
4212            r#"pipeline t(task) {
4213  fn check(x: {name?: string, age: int}) {
4214    if x.has("name") {
4215      let n: {name: string, age: int} = x
4216    }
4217  }
4218}"#,
4219        );
4220        assert!(errs.is_empty(), "got: {:?}", errs);
4221    }
4222
4223    // -----------------------------------------------------------------------
4224    // Autofix tests
4225    // -----------------------------------------------------------------------
4226
4227    fn check_source_with_source(source: &str) -> Vec<TypeDiagnostic> {
4228        let mut lexer = Lexer::new(source);
4229        let tokens = lexer.tokenize().unwrap();
4230        let mut parser = Parser::new(tokens);
4231        let program = parser.parse().unwrap();
4232        TypeChecker::new().check_with_source(&program, source)
4233    }
4234
4235    #[test]
4236    fn test_fix_string_plus_int_literal() {
4237        let source = "pipeline t(task) {\n  let x = \"hello \" + 42\n  log(x)\n}";
4238        let diags = check_source_with_source(source);
4239        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
4240        assert_eq!(fixable.len(), 1, "expected 1 fixable diagnostic");
4241        let fix = fixable[0].fix.as_ref().unwrap();
4242        assert_eq!(fix.len(), 1);
4243        assert_eq!(fix[0].replacement, "\"hello ${42}\"");
4244    }
4245
4246    #[test]
4247    fn test_fix_int_plus_string_literal() {
4248        let source = "pipeline t(task) {\n  let x = 42 + \"hello\"\n  log(x)\n}";
4249        let diags = check_source_with_source(source);
4250        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
4251        assert_eq!(fixable.len(), 1, "expected 1 fixable diagnostic");
4252        let fix = fixable[0].fix.as_ref().unwrap();
4253        assert_eq!(fix[0].replacement, "\"${42}hello\"");
4254    }
4255
4256    #[test]
4257    fn test_fix_string_plus_variable() {
4258        let source = "pipeline t(task) {\n  let n: int = 5\n  let x = \"count: \" + n\n  log(x)\n}";
4259        let diags = check_source_with_source(source);
4260        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
4261        assert_eq!(fixable.len(), 1, "expected 1 fixable diagnostic");
4262        let fix = fixable[0].fix.as_ref().unwrap();
4263        assert_eq!(fix[0].replacement, "\"count: ${n}\"");
4264    }
4265
4266    #[test]
4267    fn test_no_fix_int_plus_int() {
4268        // int + float should error but no interpolation fix
4269        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}";
4270        let diags = check_source_with_source(source);
4271        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
4272        assert!(
4273            fixable.is_empty(),
4274            "no fix expected for numeric ops, got: {fixable:?}"
4275        );
4276    }
4277
4278    #[test]
4279    fn test_no_fix_without_source() {
4280        let source = "pipeline t(task) {\n  let x = \"hello \" + 42\n  log(x)\n}";
4281        let diags = check_source(source);
4282        let fixable: Vec<_> = diags.iter().filter(|d| d.fix.is_some()).collect();
4283        assert!(
4284            fixable.is_empty(),
4285            "without source, no fix should be generated"
4286        );
4287    }
4288
4289    // --- Union exhaustiveness tests ---
4290
4291    #[test]
4292    fn test_union_exhaustive_match_no_warning() {
4293        let warns = warnings(
4294            r#"pipeline t(task) {
4295  let x: string | int | nil = nil
4296  match x {
4297    "hello" -> { log("s") }
4298    42 -> { log("i") }
4299    nil -> { log("n") }
4300  }
4301}"#,
4302        );
4303        let union_warns: Vec<_> = warns
4304            .iter()
4305            .filter(|w| w.contains("Non-exhaustive match on union"))
4306            .collect();
4307        assert!(union_warns.is_empty());
4308    }
4309
4310    #[test]
4311    fn test_union_non_exhaustive_match_warning() {
4312        let warns = warnings(
4313            r#"pipeline t(task) {
4314  let x: string | int | nil = nil
4315  match x {
4316    "hello" -> { log("s") }
4317    42 -> { log("i") }
4318  }
4319}"#,
4320        );
4321        let union_warns: Vec<_> = warns
4322            .iter()
4323            .filter(|w| w.contains("Non-exhaustive match on union"))
4324            .collect();
4325        assert_eq!(union_warns.len(), 1);
4326        assert!(union_warns[0].contains("nil"));
4327    }
4328
4329    // --- Nil-coalescing type inference tests ---
4330
4331    #[test]
4332    fn test_nil_coalesce_non_union_preserves_left_type() {
4333        // When left is a known non-nil type, ?? should preserve it
4334        let errs = errors(
4335            r#"pipeline t(task) {
4336  let x: int = 42
4337  let y: int = x ?? 0
4338}"#,
4339        );
4340        assert!(errs.is_empty());
4341    }
4342
4343    #[test]
4344    fn test_nil_coalesce_nil_returns_right_type() {
4345        let errs = errors(
4346            r#"pipeline t(task) {
4347  let x: string = nil ?? "fallback"
4348}"#,
4349        );
4350        assert!(errs.is_empty());
4351    }
4352}