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