Skip to main content

harn_parser/
typechecker.rs

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