Skip to main content

harn_parser/
typechecker.rs

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