Skip to main content

harn_parser/typechecker/
mod.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::rc::Rc;
3
4use crate::ast::*;
5use crate::builtin_signatures;
6use crate::diagnostic_codes::{Code, Repair};
7use harn_lexer::{FixEdit, Span};
8
9type TypeMismatchEvidence = (Option<(Span, String)>, Option<Span>);
10
11mod binary_ops;
12mod exits;
13mod format;
14mod inference;
15pub mod method_registry;
16mod predicate;
17mod schema_inference;
18mod scope;
19mod union;
20
21pub use exits::{block_definitely_exits, stmt_definitely_exits};
22pub use format::{format_type, shape_mismatch_detail};
23pub use predicate::{
24    canonical_type as canonical_predicate_type, PredicateModelRoute, PredicateSite,
25};
26
27/// Substitute generic bindings with the same open-row folding used by type
28/// inference. Schema compilation calls this instead of carrying a second type
29/// expression rewriter.
30pub fn substitute_type_expr(ty: &TypeExpr, bindings: &BTreeMap<String, TypeExpr>) -> TypeExpr {
31    TypeChecker::apply_type_bindings(ty, bindings)
32}
33
34use schema_inference::output_schema_type_expr_from_node;
35use scope::TypeScope;
36
37/// An inlay hint produced during type checking.
38#[derive(Debug, Clone)]
39pub struct InlayHintInfo {
40    /// Position (line, column) where the hint should be displayed (after the variable name).
41    pub line: usize,
42    pub column: usize,
43    /// The type label to display (e.g. ": string").
44    pub label: String,
45}
46
47/// Semantic type inferred or declared for one plain local binding.
48///
49/// Consumers such as codemods use the declaration span as stable identity;
50/// the display-oriented inlay-hint stream intentionally omits obvious types
51/// and therefore is not a semantic analysis surface.
52#[derive(Debug, Clone)]
53pub struct BindingTypeInfo {
54    pub name: String,
55    pub span: Span,
56    pub type_expr: TypeExpr,
57}
58
59/// Typed facts produced by one complete checker walk.
60#[derive(Debug, Clone)]
61pub struct TypeCheckFacts {
62    pub diagnostics: Vec<TypeDiagnostic>,
63    pub inlay_hints: Vec<InlayHintInfo>,
64    pub binding_types: Vec<BindingTypeInfo>,
65    /// Validated model-evaluation sites, including sites inside helper bodies.
66    pub predicate_sites: Vec<PredicateSite>,
67}
68
69/// Static info for one `import * as alias from "path"` binding.
70#[derive(Debug, Clone)]
71pub struct NamespaceImportBinding {
72    /// Module path as written / resolved display string for diagnostics.
73    pub module_path: String,
74    /// Public export names from the target module.
75    pub members: BTreeSet<String>,
76    /// Call signature for each callable member, as a self-contained
77    /// [`TypeExpr::FnType`] whose named types the producer already resolved
78    /// against the defining module.
79    ///
80    /// A namespace import does not flatten the target's type names into this
81    /// module, so a signature that still referenced `Request` by name would be
82    /// unresolvable here and could not be checked. Members absent from this
83    /// map stay `any`, which is what keeps an unlowerable signature (generic,
84    /// rest parameter, row-polymorphic) from being checked wrongly rather than
85    /// gradually (#6172).
86    pub member_types: std::collections::BTreeMap<String, TypeExpr>,
87    /// Declared parameter names per member, positional. `TypeExpr::FnType` is
88    /// positional only, so without these a mismatch would report `arg2` where
89    /// the named-import path reports the real parameter name.
90    pub member_param_names: std::collections::BTreeMap<String, Vec<String>>,
91    /// Required argument count per member. Defaulted trailing parameters are
92    /// omissible, so this is not the parameter count.
93    pub member_required_params: std::collections::BTreeMap<String, usize>,
94    /// Validated narrowing contract for each callable namespace member.
95    pub member_type_predicates: std::collections::BTreeMap<String, TypePredicate>,
96}
97
98/// A diagnostic produced by the type checker.
99#[derive(Debug, Clone)]
100pub struct TypeDiagnostic {
101    pub code: Code,
102    pub message: String,
103    pub severity: DiagnosticSeverity,
104    pub span: Option<Span>,
105    pub help: Option<String>,
106    pub related: Vec<RelatedDiagnostic>,
107    /// Concrete fix edits. The structured repair safety class decides whether
108    /// bulk autofix may apply them.
109    pub fix: Option<Vec<FixEdit>>,
110    /// Optional structured payload that higher-level tooling (e.g. the
111    /// LSP code-action provider) can consume to synthesise fixes that
112    /// need more than a static `FixEdit`. Out-of-band from `fix` so the
113    /// string-based rendering pipeline doesn't have to care.
114    pub details: Option<DiagnosticDetails>,
115    /// Structured repair classifier — id, summary, and safety class.
116    /// Agents and IDEs dispatch on `repair.safety` to decide whether to
117    /// auto-apply, propose, or escalate. `None` when no repair shape is
118    /// registered for this code; populated automatically from
119    /// [`Code::repair_template`] by the builder helpers.
120    pub repair: Option<Repair>,
121}
122
123impl TypeDiagnostic {
124    /// Return concrete edits only when their structured safety class permits
125    /// automatic application. Unclassified legacy edits retain their existing
126    /// machine-applicable contract.
127    pub fn machine_applicable_fix(&self) -> Option<&[FixEdit]> {
128        let fix = self.fix.as_deref()?;
129        self.repair
130            .as_ref()
131            .is_none_or(|repair| repair.safety.is_machine_applicable())
132            .then_some(fix)
133    }
134}
135
136#[derive(Debug, Clone)]
137pub struct RelatedDiagnostic {
138    pub span: Span,
139    pub message: String,
140}
141
142/// Optional structured companion data on a `TypeDiagnostic`. The
143/// variants map one-to-one with diagnostics that have specific
144/// tooling-consumable state beyond the human-readable message; each
145/// variant is attached only by the sites that produce its
146/// corresponding diagnostic, so a consumer can pattern-match on the
147/// variant without parsing the error string.
148#[derive(Debug, Clone)]
149pub enum DiagnosticDetails {
150    /// A concrete expected/found mismatch. Renderers can use this to
151    /// provide stable labels without scraping human-readable text.
152    TypeMismatch {
153        expected: TypeExpr,
154        actual: TypeExpr,
155    },
156    /// A value-position name that failed resolution. Tooling must consume
157    /// this field instead of parsing the display message, whose wording and
158    /// suggestions are free to evolve.
159    UnresolvedName { name: String },
160    /// A call supplied the wrong number of positional arguments. Parameter
161    /// types let migration tooling repair omitted leading capability grants
162    /// without parsing the human-readable arity message.
163    CallArity {
164        callee: String,
165        parameter_types: Vec<Option<TypeExpr>>,
166        required: usize,
167        actual: usize,
168    },
169    /// A Flow predicate requested authority outside the evaluator's injected
170    /// contract. Tooling can report or migrate the exact parameter and
171    /// capability set without parsing the human-readable diagnostic.
172    FlowCapabilityBoundary {
173        parameter: String,
174        capabilities: Vec<String>,
175        allowed: Vec<String>,
176    },
177    /// A `match` expression with missing variant coverage. `missing`
178    /// holds the formatted literal values of each uncovered variant
179    /// (quoted for strings, bare for ints), ready to drop into a new
180    /// arm prefix. The diagnostic's `span` covers the whole `match`
181    /// expression, so a code-action can locate the closing `}` by
182    /// reading the source at `span.end`.
183    NonExhaustiveMatch { missing: Vec<String> },
184    /// A type-aware lint diagnostic. These diagnostics are produced by
185    /// the type checker because the rule depends on flow-sensitive type
186    /// information, but `harn lint` should surface and filter them like
187    /// ordinary lint rules.
188    LintRule { rule: &'static str },
189    /// A declared parameter with no type annotation. The owning declaration
190    /// and the parameter name let a migration tool find the site without
191    /// parsing the human-readable message.
192    ImplicitAnyParameter { owner: String, parameter: String },
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum DiagnosticSeverity {
197    Error,
198    Warning,
199}
200
201/// The static type checker.
202pub struct TypeChecker {
203    diagnostics: Vec<TypeDiagnostic>,
204    /// Root scope shared by every child scope created during the walk.
205    /// `Rc` lets fn/pipeline body entries take a refcount bump instead of
206    /// deep-cloning the entire scope chain. Mutations during the pre-pass
207    /// (and the top-level non-callable arm) go through `Rc::make_mut`,
208    /// which is O(1) while the refcount is 1.
209    scope: Rc<TypeScope>,
210    source: Option<String>,
211    hints: Vec<InlayHintInfo>,
212    binding_types: Vec<BindingTypeInfo>,
213    predicate_sites: Vec<PredicateSite>,
214    predicate_bindings: Vec<(crate::lexical::BindingId, Span)>,
215    /// When true, flag unvalidated boundary-API values used in field access.
216    strict_types: bool,
217    /// Explicit process-bound compatibility mode for pre-Harness callers.
218    /// Snapshotted when the checker is created so one check cannot change
219    /// semantics midway through an import graph.
220    legacy_ambient_capabilities: bool,
221    /// Explicit authority for embedder-owned host-dispatch source. Unlike the
222    /// legacy compatibility mode, this exposes only `PrivilegedWire` builtins.
223    privileged_wire_builtins: bool,
224    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
225    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
226    fn_depth: usize,
227    /// Lexical depth of enclosing `gen fn` bodies. `emit` is only valid here.
228    stream_fn_depth: usize,
229    /// Expected emitted value type for each enclosing `gen fn`.
230    stream_emit_types: Vec<Option<TypeExpr>>,
231    /// Declared return type for the current function-like body. `None`
232    /// entries deliberately break propagation across untyped closures, where
233    /// an inner `return` belongs to the closure rather than the enclosing fn.
234    expected_return_types: Vec<Option<TypeExpr>>,
235    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
236    /// when an `@deprecated` attribute is encountered on a top-level fn decl
237    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
238    /// site to emit a warning + help line.
239    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
240    /// Names statically known to be introduced by cross-module imports
241    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
242    /// strict cross-module mode: an unresolved callable name is reported as
243    /// an error instead of silently passing through. `None` preserves the
244    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
245    /// diagnostics).
246    imported_names: Option<HashSet<String>>,
247    /// Type-like declarations imported from other modules. These are registered
248    /// into the scope before local checking so imported type aliases and tagged
249    /// unions participate in normal field access and narrowing.
250    imported_type_decls: Vec<SNode>,
251    /// Callable declarations imported from other modules. Only their
252    /// signatures are registered; bodies stay owned by the defining module.
253    imported_callable_decls: Vec<SNode>,
254    /// Namespace imports (`import * as alias from "..."`). The alias is bound
255    /// as an annotated shape whose fields are the target module's exports.
256    namespace_imports: std::collections::HashMap<String, NamespaceImportBinding>,
257    /// Local predicate functions whose bodies have passed contract checking.
258    validated_type_predicates: HashSet<(usize, usize)>,
259    /// Coinductive guard for recursive-type subtype checks. Holds the
260    /// pre-unfolding `(expected, actual)` pairs currently on the
261    /// `types_compatible_at` stack. Re-encountering a pair means the walk has
262    /// cycled through a recursive type alias (`type Tree = {children: [Tree]}`);
263    /// we then assume compatibility (greatest-fixpoint / equirecursive
264    /// subtyping) instead of recursing forever. Interior mutability because
265    /// `types_compatible_at` runs behind `&self`.
266    subtype_cycle_guard: std::cell::RefCell<Vec<(TypeExpr, TypeExpr)>>,
267}
268
269impl TypeChecker {
270    pub(in crate::typechecker) fn wildcard_type() -> TypeExpr {
271        TypeExpr::Named("_".into())
272    }
273
274    pub(in crate::typechecker) fn is_wildcard_type(ty: &TypeExpr) -> bool {
275        matches!(ty, TypeExpr::Named(name) if name == "_")
276    }
277
278    pub(in crate::typechecker) fn contains_wildcard_type(ty: &TypeExpr) -> bool {
279        match ty {
280            TypeExpr::Named(name) => name == "_",
281            TypeExpr::Union(members) | TypeExpr::Intersection(members) => {
282                members.iter().any(Self::contains_wildcard_type)
283            }
284            TypeExpr::Tuple(items) => items.iter().any(Self::contains_wildcard_type),
285            TypeExpr::Shape(fields) => fields
286                .iter()
287                .any(|field| Self::contains_wildcard_type(&field.type_expr)),
288            TypeExpr::OpenShape { fields, rests } => {
289                fields
290                    .iter()
291                    .any(|field| Self::contains_wildcard_type(&field.type_expr))
292                    || rests.iter().any(Self::contains_wildcard_type)
293            }
294            TypeExpr::List(inner)
295            | TypeExpr::Iter(inner)
296            | TypeExpr::Generator(inner)
297            | TypeExpr::Stream(inner)
298            | TypeExpr::Owned(inner) => Self::contains_wildcard_type(inner),
299            TypeExpr::DictType(key, value) => {
300                Self::contains_wildcard_type(key) || Self::contains_wildcard_type(value)
301            }
302            TypeExpr::Applied { args, .. } => args.iter().any(Self::contains_wildcard_type),
303            TypeExpr::FnType {
304                params,
305                return_type,
306            } => {
307                params.iter().any(Self::contains_wildcard_type)
308                    || Self::contains_wildcard_type(return_type)
309            }
310            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => false,
311        }
312    }
313
314    pub(in crate::typechecker) fn contains_type_param(
315        ty: &TypeExpr,
316        type_params: &BTreeSet<String>,
317    ) -> bool {
318        match ty {
319            TypeExpr::Named(name) => type_params.contains(name),
320            TypeExpr::Union(members) | TypeExpr::Intersection(members) => members
321                .iter()
322                .any(|member| Self::contains_type_param(member, type_params)),
323            TypeExpr::Tuple(items) => items
324                .iter()
325                .any(|item| Self::contains_type_param(item, type_params)),
326            TypeExpr::Shape(fields) => fields
327                .iter()
328                .any(|field| Self::contains_type_param(&field.type_expr, type_params)),
329            TypeExpr::OpenShape { fields, rests } => {
330                fields
331                    .iter()
332                    .any(|field| Self::contains_type_param(&field.type_expr, type_params))
333                    || rests
334                        .iter()
335                        .any(|rest| Self::contains_type_param(rest, type_params))
336            }
337            TypeExpr::List(inner)
338            | TypeExpr::Iter(inner)
339            | TypeExpr::Generator(inner)
340            | TypeExpr::Stream(inner)
341            | TypeExpr::Owned(inner) => Self::contains_type_param(inner, type_params),
342            TypeExpr::DictType(key, value) => {
343                Self::contains_type_param(key, type_params)
344                    || Self::contains_type_param(value, type_params)
345            }
346            TypeExpr::Applied { args, .. } => args
347                .iter()
348                .any(|arg| Self::contains_type_param(arg, type_params)),
349            TypeExpr::FnType {
350                params,
351                return_type,
352            } => {
353                params
354                    .iter()
355                    .any(|param| Self::contains_type_param(param, type_params))
356                    || Self::contains_type_param(return_type, type_params)
357            }
358            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => false,
359        }
360    }
361
362    pub(in crate::typechecker) fn contains_abstract_type(
363        &self,
364        ty: &TypeExpr,
365        scope: &TypeScope,
366    ) -> bool {
367        match ty {
368            TypeExpr::Named(name) => {
369                matches!(name.as_str(), "_" | "any" | "unknown")
370                    || scope.is_generic_type_param(name)
371            }
372            TypeExpr::Union(members) | TypeExpr::Intersection(members) => members
373                .iter()
374                .any(|member| self.contains_abstract_type(member, scope)),
375            TypeExpr::Tuple(items) => items
376                .iter()
377                .any(|item| self.contains_abstract_type(item, scope)),
378            TypeExpr::Shape(fields) => fields
379                .iter()
380                .any(|field| self.contains_abstract_type(&field.type_expr, scope)),
381            TypeExpr::OpenShape { fields, rests } => {
382                fields
383                    .iter()
384                    .any(|field| self.contains_abstract_type(&field.type_expr, scope))
385                    || rests
386                        .iter()
387                        .any(|rest| self.contains_abstract_type(rest, scope))
388            }
389            TypeExpr::List(inner)
390            | TypeExpr::Iter(inner)
391            | TypeExpr::Generator(inner)
392            | TypeExpr::Stream(inner)
393            | TypeExpr::Owned(inner) => self.contains_abstract_type(inner, scope),
394            TypeExpr::DictType(key, value) => {
395                self.contains_abstract_type(key, scope) || self.contains_abstract_type(value, scope)
396            }
397            TypeExpr::Applied { args, .. } => args
398                .iter()
399                .any(|arg| self.contains_abstract_type(arg, scope)),
400            TypeExpr::FnType {
401                params,
402                return_type,
403            } => {
404                params
405                    .iter()
406                    .any(|param| self.contains_abstract_type(param, scope))
407                    || self.contains_abstract_type(return_type, scope)
408            }
409            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => false,
410        }
411    }
412
413    pub(in crate::typechecker) fn base_type_name(ty: &TypeExpr) -> Option<&str> {
414        match ty {
415            TypeExpr::Named(name) => Some(name.as_str()),
416            TypeExpr::Applied { name, .. } => Some(name.as_str()),
417            _ => None,
418        }
419    }
420
421    pub fn new() -> Self {
422        Self {
423            diagnostics: Vec::new(),
424            scope: Rc::new(TypeScope::new()),
425            source: None,
426            hints: Vec::new(),
427            binding_types: Vec::new(),
428            predicate_sites: Vec::new(),
429            predicate_bindings: Vec::new(),
430            strict_types: false,
431            legacy_ambient_capabilities: crate::legacy_ambient_capabilities_enabled(),
432            privileged_wire_builtins: false,
433            fn_depth: 0,
434            stream_fn_depth: 0,
435            stream_emit_types: Vec::new(),
436            expected_return_types: Vec::new(),
437            deprecated_fns: std::collections::HashMap::new(),
438            imported_names: None,
439            imported_type_decls: Vec::new(),
440            imported_callable_decls: Vec::new(),
441            namespace_imports: std::collections::HashMap::new(),
442            validated_type_predicates: HashSet::new(),
443            subtype_cycle_guard: std::cell::RefCell::new(Vec::new()),
444        }
445    }
446
447    /// Create a type checker with strict types mode.
448    /// When enabled, flags unvalidated boundary-API values used in field access.
449    pub fn with_strict_types(strict: bool) -> Self {
450        Self {
451            diagnostics: Vec::new(),
452            scope: Rc::new(TypeScope::new()),
453            source: None,
454            hints: Vec::new(),
455            binding_types: Vec::new(),
456            predicate_sites: Vec::new(),
457            predicate_bindings: Vec::new(),
458            strict_types: strict,
459            legacy_ambient_capabilities: crate::legacy_ambient_capabilities_enabled(),
460            privileged_wire_builtins: false,
461            fn_depth: 0,
462            stream_fn_depth: 0,
463            stream_emit_types: Vec::new(),
464            expected_return_types: Vec::new(),
465            deprecated_fns: std::collections::HashMap::new(),
466            imported_names: None,
467            imported_type_decls: Vec::new(),
468            imported_callable_decls: Vec::new(),
469            namespace_imports: std::collections::HashMap::new(),
470            validated_type_predicates: HashSet::new(),
471            subtype_cycle_guard: std::cell::RefCell::new(Vec::new()),
472        }
473    }
474
475    /// Attach the set of names statically introduced by cross-module imports.
476    ///
477    /// Enables strict cross-module undefined-call errors: call sites that are
478    /// not builtins, not local declarations, not struct constructors, not
479    /// callable variables, and not in `imported` will produce a type error.
480    ///
481    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
482    /// unresolved call names only surface via lint diagnostics. Callers
483    /// should only pass `Some(set)` when every import in the file resolved
484    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
485    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
486        self.imported_names = Some(imported);
487        self
488    }
489
490    #[cfg(test)]
491    pub(crate) fn with_legacy_ambient_capabilities(mut self) -> Self {
492        self.legacy_ambient_capabilities = true;
493        self
494    }
495
496    pub fn with_privileged_wire_builtins(mut self, enabled: bool) -> Self {
497        self.privileged_wire_builtins = enabled;
498        self
499    }
500
501    pub(in crate::typechecker) fn lookup_builtin(
502        &self,
503        name: &str,
504    ) -> Option<&'static crate::builtin_signatures::BuiltinSignature> {
505        crate::builtin_signatures::lookup_with_privileged_wire(name, self.privileged_wire_builtins)
506    }
507
508    pub(in crate::typechecker) fn is_builtin(&self, name: &str) -> bool {
509        crate::builtin_signatures::is_builtin_with_privileged_wire(
510            name,
511            self.privileged_wire_builtins,
512        )
513    }
514
515    /// Attach imported type / struct / enum / interface declarations. The
516    /// caller is responsible for resolving module imports and filtering the
517    /// visible declarations before passing them in.
518    pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
519        self.imported_type_decls = imported;
520        self
521    }
522
523    /// Attach imported function / pipeline / tool declarations. The checker
524    /// registers only call signatures so imported pure-Harn functions enforce
525    /// their parameter annotations at the caller without checking the imported
526    /// body in the caller's scope.
527    pub fn with_imported_callable_decls(mut self, imported: Vec<SNode>) -> Self {
528        self.imported_callable_decls = imported;
529        self
530    }
531
532    /// Attach namespace imports (`import * as alias from "..."`).
533    ///
534    /// Each alias is registered in `imported_names` (when that set is present)
535    /// and bound as an annotated closed shape so `alias.member` / `alias.member()`
536    /// are validated against the target module's export set.
537    pub fn with_namespace_imports(
538        mut self,
539        imports: impl IntoIterator<Item = (String, NamespaceImportBinding)>,
540    ) -> Self {
541        let imports: std::collections::HashMap<String, NamespaceImportBinding> =
542            imports.into_iter().collect();
543        if let Some(names) = self.imported_names.as_mut() {
544            for alias in imports.keys() {
545                names.insert(alias.clone());
546            }
547        }
548        self.namespace_imports = imports;
549        self
550    }
551
552    /// Check a program with source text for autofix generation.
553    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
554        self.source = Some(source.to_string());
555        self.check_inner(program).diagnostics
556    }
557
558    /// Check a program with strict types mode and source text.
559    pub fn check_strict_with_source(
560        mut self,
561        program: &[SNode],
562        source: &str,
563    ) -> Vec<TypeDiagnostic> {
564        self.source = Some(source.to_string());
565        self.strict_types = true;
566        self.check_inner(program).diagnostics
567    }
568
569    /// Check a program and return diagnostics.
570    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
571        self.check_inner(program).diagnostics
572    }
573
574    /// Check whether a function call value is a boundary source that produces
575    /// unvalidated data.  Returns `None` if the value is type-safe
576    /// (e.g. llm_call with a schema option, or a non-boundary function).
577    pub(in crate::typechecker) fn detect_boundary_source(
578        value: &SNode,
579        scope: &TypeScope,
580    ) -> Option<String> {
581        match &value.node {
582            Node::FunctionCall { name, args, .. } => {
583                if !builtin_signatures::is_untyped_boundary_source(name) {
584                    return None;
585                }
586                // llm_call/llm_completion with a schema option are type-safe
587                if (name == "llm_call" || name == "llm_completion")
588                    && Self::llm_call_has_typed_schema_option(args, scope)
589                {
590                    return None;
591                }
592                Some(name.clone())
593            }
594            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
595            _ => None,
596        }
597    }
598
599    /// True if an `llm_call` / `llm_completion` options dict names a
600    /// resolvable output schema. Used by the strict-types boundary checks
601    /// to suppress "unvalidated" warnings when the call site is typed.
602    /// Actual return-type narrowing is driven by the generic-builtin
603    /// dispatch path in `infer_type`, not this helper.
604    pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
605        args: &[SNode],
606        scope: &TypeScope,
607    ) -> bool {
608        let Some(opts) = args.get(2) else {
609            return false;
610        };
611        let Node::DictLiteral(entries) = &opts.node else {
612            return false;
613        };
614        entries.iter().any(|entry| {
615            let key = match &entry.key.node {
616                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
617                _ => return false,
618            };
619            key == "output" && output_schema_type_expr_from_node(&entry.value, scope).is_some()
620        })
621    }
622
623    /// Check whether a type annotation is a concrete shape/struct type
624    /// (as opposed to bare `dict` or no annotation).
625    pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
626        matches!(
627            ty,
628            TypeExpr::Shape(_)
629                | TypeExpr::Applied { .. }
630                | TypeExpr::FnType { .. }
631                | TypeExpr::List(_)
632                | TypeExpr::Iter(_)
633                | TypeExpr::Generator(_)
634                | TypeExpr::Stream(_)
635                | TypeExpr::DictType(_, _)
636        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
637    }
638
639    /// Check a program and return both diagnostics and inlay hints.
640    pub fn check_with_hints(
641        mut self,
642        program: &[SNode],
643        source: &str,
644    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
645        self.source = Some(source.to_string());
646        let facts = self.check_inner(program);
647        (facts.diagnostics, facts.inlay_hints)
648    }
649
650    /// Check a program and retain semantic binding types for typed consumers.
651    pub fn check_with_facts(mut self, program: &[SNode], source: &str) -> TypeCheckFacts {
652        self.source = Some(source.to_string());
653        self.check_inner(program)
654    }
655
656    pub(in crate::typechecker) fn error_at(&mut self, code: Code, message: String, span: Span) {
657        self.diagnostics.push(TypeDiagnostic {
658            code,
659            message,
660            severity: DiagnosticSeverity::Error,
661            span: Some(span),
662            help: None,
663            related: Vec::new(),
664            fix: None,
665            details: None,
666            repair: default_repair(code),
667        });
668    }
669
670    #[allow(dead_code)]
671    pub(in crate::typechecker) fn error_at_with_help(
672        &mut self,
673        code: Code,
674        message: String,
675        span: Span,
676        help: String,
677    ) {
678        self.diagnostics.push(TypeDiagnostic {
679            code,
680            message,
681            severity: DiagnosticSeverity::Error,
682            span: Some(span),
683            help: Some(help),
684            related: Vec::new(),
685            fix: None,
686            details: None,
687            repair: default_repair(code),
688        });
689    }
690
691    pub(in crate::typechecker) fn unresolved_name_error_at(
692        &mut self,
693        name: &str,
694        message: String,
695        span: Span,
696        help: Option<String>,
697    ) {
698        self.diagnostics.push(TypeDiagnostic {
699            code: Code::UndefinedVariable,
700            message,
701            severity: DiagnosticSeverity::Error,
702            span: Some(span),
703            help,
704            related: Vec::new(),
705            fix: None,
706            details: Some(DiagnosticDetails::UnresolvedName {
707                name: name.to_string(),
708            }),
709            repair: default_repair(Code::UndefinedVariable),
710        });
711    }
712
713    pub(in crate::typechecker) fn flow_capability_boundary_error_at(
714        &mut self,
715        parameter: &str,
716        capabilities: Vec<String>,
717        span: Span,
718    ) {
719        let capabilities_display = capabilities.join(", ");
720        self.diagnostics.push(TypeDiagnostic {
721            code: Code::FlowInvariantAttributeInvalid,
722            message: format!(
723                "Flow `@invariant` parameter `{parameter}` requests unsupported capability authority: {capabilities_display}; Flow evaluation injects only a leading `HarnessAst`"
724            ),
725            severity: DiagnosticSeverity::Error,
726            span: Some(span),
727            help: Some(
728                "move the effect outside the predicate or accept the injected `HarnessAst` as its first parameter"
729                    .to_string(),
730            ),
731            related: Vec::new(),
732            fix: None,
733            details: Some(DiagnosticDetails::FlowCapabilityBoundary {
734                parameter: parameter.to_string(),
735                capabilities,
736                allowed: vec!["HarnessAst".to_string()],
737            }),
738            repair: default_repair(Code::FlowInvariantAttributeInvalid),
739        });
740    }
741
742    pub(in crate::typechecker) fn type_mismatch_at(
743        &mut self,
744        code: Code,
745        context: impl Into<String>,
746        expected: &TypeExpr,
747        actual: &TypeExpr,
748        span: Span,
749        evidence: TypeMismatchEvidence,
750        scope: &TypeScope,
751    ) {
752        let (expected_origin, value_span) = evidence;
753        let nested_mismatch = first_nested_mismatch(expected, actual, scope);
754        let mut message = format!(
755            "{}: expected {}, found {}",
756            context.into(),
757            format_type(expected),
758            format_type(actual)
759        );
760        if let Some(detail) = shape_mismatch_detail(expected, actual)
761            .or_else(|| nested_mismatch.as_ref().map(|note| note.message.clone()))
762        {
763            message.push_str(&format!(" ({detail})"));
764        }
765
766        let mut related = Vec::new();
767        if let Some((span, message)) = expected_origin {
768            related.push(RelatedDiagnostic { span, message });
769        }
770        if let Some(note) = nested_mismatch {
771            related.push(RelatedDiagnostic {
772                span,
773                message: format!("nested mismatch: {}", note.message),
774            });
775        }
776
777        self.diagnostics.push(TypeDiagnostic {
778            code,
779            message,
780            severity: DiagnosticSeverity::Error,
781            span: Some(span),
782            help: coercion_suggestion(expected, actual, value_span, self.source.as_deref()),
783            related,
784            fix: None,
785            details: Some(DiagnosticDetails::TypeMismatch {
786                expected: expected.clone(),
787                actual: actual.clone(),
788            }),
789            repair: default_repair(code),
790        });
791    }
792
793    pub(in crate::typechecker) fn error_at_with_fix(
794        &mut self,
795        code: Code,
796        message: String,
797        span: Span,
798        fix: Vec<FixEdit>,
799    ) {
800        self.diagnostics.push(TypeDiagnostic {
801            code,
802            message,
803            severity: DiagnosticSeverity::Error,
804            span: Some(span),
805            help: None,
806            related: Vec::new(),
807            fix: Some(fix),
808            details: None,
809            repair: default_repair(code),
810        });
811    }
812
813    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
814    /// exhaustive — a missing-case `match` is a hard error. Authors who
815    /// genuinely want partial coverage opt out with a wildcard `_` arm.
816    /// The missing-case list is structured so LSP code-actions can synthesize
817    /// "Add missing match arms" fixes without string-parsing the message.
818    pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
819        &mut self,
820        code: Code,
821        message: String,
822        span: Span,
823        missing: Vec<String>,
824    ) {
825        self.diagnostics.push(TypeDiagnostic {
826            code,
827            message,
828            severity: DiagnosticSeverity::Error,
829            span: Some(span),
830            help: None,
831            related: Vec::new(),
832            fix: None,
833            details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
834            repair: default_repair(code),
835        });
836    }
837
838    pub(in crate::typechecker) fn warning_at(&mut self, code: Code, message: String, span: Span) {
839        self.diagnostics.push(TypeDiagnostic {
840            code,
841            message,
842            severity: DiagnosticSeverity::Warning,
843            span: Some(span),
844            help: None,
845            related: Vec::new(),
846            fix: None,
847            details: None,
848            repair: default_repair(code),
849        });
850    }
851
852    pub(in crate::typechecker) fn call_arity_warning_at(
853        &mut self,
854        code: Code,
855        message: String,
856        span: Span,
857        callee: &str,
858        parameter_types: Vec<Option<TypeExpr>>,
859        required: usize,
860        actual: usize,
861    ) {
862        self.diagnostics.push(TypeDiagnostic {
863            code,
864            message,
865            severity: DiagnosticSeverity::Warning,
866            span: Some(span),
867            help: None,
868            related: Vec::new(),
869            fix: None,
870            details: Some(DiagnosticDetails::CallArity {
871                callee: callee.to_string(),
872                parameter_types,
873                required,
874                actual,
875            }),
876            repair: default_repair(code),
877        });
878    }
879
880    #[allow(dead_code)]
881    pub(in crate::typechecker) fn warning_at_with_help(
882        &mut self,
883        code: Code,
884        message: String,
885        span: Span,
886        help: String,
887    ) {
888        self.diagnostics.push(TypeDiagnostic {
889            code,
890            message,
891            severity: DiagnosticSeverity::Warning,
892            span: Some(span),
893            help: Some(help),
894            related: Vec::new(),
895            fix: None,
896            details: None,
897            repair: default_repair(code),
898        });
899    }
900
901    pub(in crate::typechecker) fn lint_warning_at_with_fix(
902        &mut self,
903        code: Code,
904        rule: &'static str,
905        message: String,
906        span: Span,
907        help: String,
908        fix: Vec<FixEdit>,
909    ) {
910        self.diagnostics.push(TypeDiagnostic {
911            code,
912            message,
913            severity: DiagnosticSeverity::Warning,
914            span: Some(span),
915            help: Some(help),
916            related: Vec::new(),
917            fix: Some(fix),
918            details: Some(DiagnosticDetails::LintRule { rule }),
919            repair: default_repair(code),
920        });
921    }
922}
923
924/// Materialize the default [`Repair`] for a diagnostic code, or `None`
925/// if no static repair shape is registered. Cheap (one pointer
926/// dereference plus an allocation for the summary string); call sites
927/// pay nothing when the code has no repair template.
928pub(crate) fn default_repair(code: Code) -> Option<Repair> {
929    code.repair_template().map(Repair::from_template)
930}
931
932#[derive(Debug)]
933struct MismatchNote {
934    message: String,
935}
936
937fn first_nested_mismatch(
938    expected: &TypeExpr,
939    actual: &TypeExpr,
940    scope: &TypeScope,
941) -> Option<MismatchNote> {
942    let expected = resolve_type_for_diagnostic(expected, scope);
943    let actual = resolve_type_for_diagnostic(actual, scope);
944    match (&expected, &actual) {
945        (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
946            for expected_field in expected_fields {
947                if expected_field.optional {
948                    continue;
949                }
950                let Some(actual_field) = actual_fields
951                    .iter()
952                    .find(|actual_field| actual_field.name == expected_field.name)
953                else {
954                    return Some(MismatchNote {
955                        message: format!(
956                            "field `{}` is missing; expected {}",
957                            expected_field.name,
958                            format_type(&expected_field.type_expr)
959                        ),
960                    });
961                };
962                if !types_compatible_for_diagnostic(
963                    &expected_field.type_expr,
964                    &actual_field.type_expr,
965                    scope,
966                ) {
967                    return Some(MismatchNote {
968                        message: format!(
969                            "field `{}` expected {}, found {}",
970                            expected_field.name,
971                            format_type(&expected_field.type_expr),
972                            format_type(&actual_field.type_expr)
973                        ),
974                    });
975                }
976            }
977            None
978        }
979        (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
980            if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
981                || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
982            {
983                Some(MismatchNote {
984                    message: format!(
985                        "list element expected {}, found {}",
986                        format_type(expected_inner),
987                        format_type(actual_inner)
988                    ),
989                })
990            } else {
991                None
992            }
993        }
994        (
995            TypeExpr::DictType(expected_key, expected_value),
996            TypeExpr::DictType(actual_key, actual_value),
997        ) => {
998            if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
999                || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
1000            {
1001                Some(MismatchNote {
1002                    message: format!(
1003                        "dict key expected {}, found {}",
1004                        format_type(expected_key),
1005                        format_type(actual_key)
1006                    ),
1007                })
1008            } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
1009                || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
1010            {
1011                Some(MismatchNote {
1012                    message: format!(
1013                        "dict value expected {}, found {}",
1014                        format_type(expected_value),
1015                        format_type(actual_value)
1016                    ),
1017                })
1018            } else {
1019                None
1020            }
1021        }
1022        (
1023            TypeExpr::Applied {
1024                name: expected_name,
1025                args: expected_args,
1026            },
1027            TypeExpr::Applied {
1028                name: actual_name,
1029                args: actual_args,
1030            },
1031        ) if expected_name == actual_name => expected_args
1032            .iter()
1033            .zip(actual_args.iter())
1034            .enumerate()
1035            .find_map(|(idx, (expected_arg, actual_arg))| {
1036                if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
1037                    && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
1038                {
1039                    None
1040                } else {
1041                    Some(MismatchNote {
1042                        message: format!(
1043                            "{} type argument {} expected {}, found {}",
1044                            expected_name,
1045                            idx + 1,
1046                            format_type(expected_arg),
1047                            format_type(actual_arg)
1048                        ),
1049                    })
1050                }
1051            }),
1052        (
1053            TypeExpr::FnType {
1054                params: expected_params,
1055                return_type: expected_return,
1056            },
1057            TypeExpr::FnType {
1058                params: actual_params,
1059                return_type: actual_return,
1060            },
1061        ) => {
1062            for (idx, (expected_param, actual_param)) in
1063                expected_params.iter().zip(actual_params.iter()).enumerate()
1064            {
1065                if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
1066                    return Some(MismatchNote {
1067                        message: format!(
1068                            "function parameter {} expected {}, found {}",
1069                            idx + 1,
1070                            format_type(expected_param),
1071                            format_type(actual_param)
1072                        ),
1073                    });
1074                }
1075            }
1076            if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
1077                Some(MismatchNote {
1078                    message: format!(
1079                        "function return expected {}, found {}",
1080                        format_type(expected_return),
1081                        format_type(actual_return)
1082                    ),
1083                })
1084            } else {
1085                None
1086            }
1087        }
1088        _ => None,
1089    }
1090}
1091
1092fn types_compatible_for_diagnostic(
1093    expected: &TypeExpr,
1094    actual: &TypeExpr,
1095    scope: &TypeScope,
1096) -> bool {
1097    TypeChecker::new().types_compatible(expected, actual, scope)
1098}
1099
1100fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
1101    TypeChecker::new().resolve_alias(ty, scope)
1102}
1103
1104fn coercion_suggestion(
1105    expected: &TypeExpr,
1106    actual: &TypeExpr,
1107    value_span: Option<Span>,
1108    source: Option<&str>,
1109) -> Option<String> {
1110    let expr = value_span
1111        .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
1112        .map(str::trim)
1113        .filter(|expr| !expr.is_empty());
1114    if is_nilable(actual) {
1115        return Some("handle `nil` first or provide a default with `??`".to_string());
1116    }
1117    let expected_ty = expected;
1118    let expected = simple_type_name(expected)?;
1119    let actual_name = simple_type_name(actual)?;
1120    let with_expr = |template: &str| {
1121        expr.map(|expr| template.replace("{}", expr))
1122            .unwrap_or_else(|| template.replace("{}", "value"))
1123    };
1124
1125    match (expected, actual_name) {
1126        ("string", "int" | "float" | "bool" | "nil" | "duration") => {
1127            Some(format!("did you mean `{}`?", with_expr("to_string({})")))
1128        }
1129        ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
1130        ("float", "string" | "int") => {
1131            Some(format!("did you mean `{}`?", with_expr("to_float({})")))
1132        }
1133        (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
1134        _ if actual_is_result_of(expected_ty, actual) => Some(format!(
1135            "did you mean `{}` or `{}`?",
1136            with_expr("{}?"),
1137            with_expr("unwrap_or({}, default)")
1138        )),
1139        _ => None,
1140    }
1141}
1142
1143fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
1144    match ty {
1145        TypeExpr::Named(name) => Some(name.as_str()),
1146        TypeExpr::LitString(_) => Some("string"),
1147        TypeExpr::LitInt(_) => Some("int"),
1148        _ => None,
1149    }
1150}
1151
1152fn is_nilable(ty: &TypeExpr) -> bool {
1153    match ty {
1154        TypeExpr::Union(members) if members.len() == 2 => members
1155            .iter()
1156            .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
1157        _ => false,
1158    }
1159}
1160
1161fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
1162    matches!(
1163        actual,
1164        TypeExpr::Applied { name, args }
1165            if name == "Result" && args.first().is_some_and(|ok| ok == expected)
1166    )
1167}
1168
1169/// The names of the gradual *top* types — values whose static type is
1170/// deliberately unknown (`any`/`unknown`) or a wildcard (`_`). A gradual type
1171/// is assignment- and operator-compatible with everything; the real check is
1172/// deferred to runtime. Centralized so every site that special-cases "we don't
1173/// statically know this type" agrees on the same set. Note this is the
1174/// non-`nil` gradual set: callers that also want to treat `nil` leniently must
1175/// check for it separately.
1176pub(in crate::typechecker) fn is_gradual_type_name(name: &str) -> bool {
1177    matches!(name, "any" | "unknown" | "_")
1178}
1179
1180impl Default for TypeChecker {
1181    fn default() -> Self {
1182        Self::new()
1183    }
1184}
1185
1186#[cfg(test)]
1187mod tests;