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