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