Skip to main content

gdscript_hir/
infer.rs

1//! Gradual type inference (Playbook §3.3–§3.6 + §5): a single forward, bottom-up,
2//! bidirectional walk over a lowered [`Body`]. No unification variables — types flow forward
3//! from annotations, literals, and the engine API (rust-analyzer's *structure*, Pyright's
4//! gradual *semantics*).
5//!
6//! The walk memoizes every expression's [`Ty`] in [`InferenceResult::expr_ty`] (the source of
7//! hover + inlay), does flow-scoped `is`/`as` narrowing over the lexical guarded sub-tree, and
8//! raises the §5 type diagnostics. The load-bearing invariant: a `Variant`/`Unknown`/`Error`
9//! receiver is *uninformative* — it never fires `UNSAFE_*`, never cascades — so cross-file code
10//! (which lands on `Unknown` via the seam) produces zero false diagnostics.
11
12use gdscript_api::{EngineApi, MemberRef, TyRef};
13use gdscript_base::{Diagnostic, DiagnosticSource, FileId, Severity, TextRange};
14use gdscript_db::Db;
15use gdscript_scene::{SceneModel, SceneNode};
16use gdscript_syntax::GdNode;
17use rustc_hash::{FxHashMap, FxHashSet};
18use smol_str::SmolStr;
19
20use std::sync::Arc;
21
22use crate::body::{self, BinOp, Body, Expr, ExprId, Literal, ParamBinding, Stmt, UnOp};
23use crate::cst::{self, AstPtr};
24use crate::flow::{self, FlowAnalysis, NarrowedTy, Place};
25use crate::item_tree::{ItemTree, Member, item_tree};
26use crate::resolve::{self, ClassItem, ClassScope, GlobalDef};
27use crate::ty::{self, Assign, EnumRef, ScriptRefId, Ty};
28use crate::warnings::{RawWarning, WarningCode};
29
30// ---- diagnostic codes + message templates (Playbook §5, engine-matching) -----------------
31
32/// `:=` / inferred binding from a statically-`Variant` value.
33pub const INFERENCE_ON_VARIANT: &str = "INFERENCE_ON_VARIANT";
34/// Incompatible hard types (our umbrella for the engine's `push_error`).
35pub const TYPE_MISMATCH: &str = "TYPE_MISMATCH";
36/// `float` stored into an `int` slot.
37pub const NARROWING_CONVERSION: &str = "NARROWING_CONVERSION";
38/// `int / int`.
39pub const INTEGER_DIVISION: &str = "INTEGER_DIVISION";
40/// A property missing on a statically-known base.
41pub const UNSAFE_PROPERTY_ACCESS: &str = "UNSAFE_PROPERTY_ACCESS";
42/// A method missing on a statically-known base.
43pub const UNSAFE_METHOD_ACCESS: &str = "UNSAFE_METHOD_ACCESS";
44/// An argument whose static type needs an unsafe implicit cast (`Variant` / a downcast) into the
45/// resolved parameter type — Godot's per-argument value-prop warning.
46pub const UNSAFE_CALL_ARGUMENT: &str = "UNSAFE_CALL_ARGUMENT";
47/// A `$Path`/`%Unique`/`get_node("…")` whose literal path is genuinely absent in the owning scene
48/// (only raised when the script attaches to exactly one scene — never on an `..`/absolute path or a
49/// path that descends into an instanced sub-scene we don't see).
50pub const INVALID_NODE_PATH: &str = "INVALID_NODE_PATH";
51/// A declared `class_name` that shadows another global identifier — a duplicate user `class_name`,
52/// an engine/native class, a builtin/utility, a global enum/const, or a `*`-autoload singleton.
53/// Godot's `gdscript_analyzer.cpp` raises this (as an error) so the global namespace stays unique.
54pub const SHADOWED_GLOBAL_IDENTIFIER: &str = "SHADOWED_GLOBAL_IDENTIFIER";
55/// A genuine `extends` cycle: a file's base chain transitively returns to itself (`A extends B`,
56/// `B extends A`). Illegal in Godot (`gdscript_analyzer.cpp` raises it). Only the `extends`
57/// inheritance chain cycles — a `preload`/`load` cycle is legal at runtime and is NOT reported.
58pub const CYCLIC_INHERITANCE: &str = "CYCLIC_INHERITANCE";
59
60/// What kind of binding a [`Binding`] describes.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum BindingKind {
63    /// A local `var` / `const`.
64    Var,
65    /// A function / lambda parameter.
66    Param,
67    /// A `for` loop variable.
68    ForVar,
69    /// A `var x` capture in a `match` pattern (typed `Variant`; arm-scoped).
70    MatchBind,
71}
72
73/// A typed local binding — the unit hover + inlay hints read for `var`/param/`for` names.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Binding {
76    /// The binding name (for unused-binding analysis, find-references).
77    pub name: SmolStr,
78    /// The name token's range.
79    pub name_range: TextRange,
80    /// The binding's resolved type. For an untyped `var x = e` this is the gradual `Variant`;
81    /// the precise initializer type (for an "add type annotation" action) is [`Binding::init`].
82    pub ty: Ty,
83    /// The initializer expression, when the binding has one (a `var`/`const` with `= e`).
84    pub init: Option<ExprId>,
85    /// Whether the source carried an explicit `: T` annotation.
86    pub annotated: bool,
87    /// Whether the source used `:=` (inferred-but-hard).
88    pub inferred_colon_eq: bool,
89    /// Whether this is a `const` (vs a `var`) — distinguishes `UNUSED_LOCAL_CONSTANT` from
90    /// `UNUSED_VARIABLE`.
91    pub is_const: bool,
92    /// What kind of binding this is.
93    pub kind: BindingKind,
94}
95
96/// The result of inferring one body.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct InferenceResult {
99    /// Every expression's inferred type (feeds hover + inlay).
100    pub expr_ty: FxHashMap<ExprId, Ty>,
101    /// The local bindings introduced by the body (params, `var`/`const`, `for` vars).
102    pub bindings: Vec<Binding>,
103    /// The §5 type diagnostics raised directly (the ungated analyzer-native codes:
104    /// `TYPE_MISMATCH`, `INVALID_NODE_PATH` — these have no Godot warning-setting key).
105    pub diagnostics: Vec<Diagnostic>,
106    /// The gateable Godot warnings, recorded severity-free. Resolved into final diagnostics by
107    /// [`crate::warnings::gate`] downstream of the cached `analyze_file` query (Workstream 1).
108    pub raw_warnings: Vec<RawWarning>,
109}
110
111impl InferenceResult {
112    /// The inferred type of an expression, if it was visited.
113    #[must_use]
114    pub fn type_of(&self, id: ExprId) -> Option<&Ty> {
115        self.expr_ty.get(&id)
116    }
117
118    /// The binding whose name token contains `offset`, if any.
119    #[must_use]
120    pub fn binding_at(&self, offset: u32) -> Option<&Binding> {
121        self.bindings
122            .iter()
123            .find(|b| b.name_range.start <= offset && offset < b.name_range.end)
124    }
125}
126
127/// Infer a lowered `body` (its `tail` initializer expression and/or its statement block).
128/// `return_ty` is the function's declared return type (`Variant` if none / for an
129/// initializer body).
130#[must_use]
131pub fn infer(
132    db: &dyn Db,
133    api: &EngineApi,
134    root: &GdNode,
135    class: &ClassScope,
136    body: &Body,
137    return_ty: Ty,
138    is_func_body: bool,
139) -> InferenceResult {
140    let self_ty = class.self_ty.clone();
141    let mut cx = Cx {
142        db,
143        api,
144        root,
145        body,
146        class,
147        self_ty,
148        return_ty,
149        expr_ty: FxHashMap::default(),
150        bindings: Vec::new(),
151        diagnostics: Vec::new(),
152        raw_warnings: Vec::new(),
153        locals: FxHashMap::default(),
154        used_locals: FxHashSet::default(),
155        narrowing: FxHashMap::default(),
156        flow: flow::analyze(body),
157        is_func_body,
158    };
159    // Parameters bind first (their defaults can reference earlier params).
160    let params = body.params.clone();
161    for p in &params {
162        let ty = cx.param_ty(p);
163        cx.bindings.push(Binding {
164            name: p.name.clone(),
165            name_range: p.name_range,
166            ty: ty.clone(),
167            init: None,
168            annotated: p.type_ref.is_some(),
169            inferred_colon_eq: false,
170            is_const: false,
171            kind: BindingKind::Param,
172        });
173        cx.locals.insert(p.name.clone(), ty);
174    }
175    if let Some(tail) = body.tail {
176        cx.infer_expr(tail, &Expectation::None);
177    }
178    let block = body.block.clone();
179    cx.infer_block(&block);
180
181    // UNUSED_* — a declared local/param/const never read. Only for a *function* body: a class-field
182    // initializer body would otherwise false-flag every field (the member is read in other methods,
183    // not in its own initializer). `_`-prefixed names + loop/match captures are excluded.
184    if is_func_body {
185        let unused: Vec<(TextRange, WarningCode, String)> = cx
186            .bindings
187            .iter()
188            .filter_map(|b| {
189                if b.name.starts_with('_') || cx.used_locals.contains(&b.name) {
190                    return None;
191                }
192                let (code, what) = match b.kind {
193                    BindingKind::Param => (WarningCode::UnusedParameter, "parameter"),
194                    BindingKind::Var if b.is_const => {
195                        (WarningCode::UnusedLocalConstant, "local constant")
196                    }
197                    BindingKind::Var => (WarningCode::UnusedVariable, "local variable"),
198                    BindingKind::ForVar | BindingKind::MatchBind => return None,
199                };
200                Some((
201                    b.name_range,
202                    code,
203                    format!("The {what} \"{}\" is declared but never used.", b.name),
204                ))
205            })
206            .collect();
207        for (range, code, msg) in unused {
208            cx.warn(range, code, msg);
209        }
210    }
211
212    // UNREACHABLE_CODE — statements after a return/break/continue / exhaustive branch (Workstream 2).
213    let unreachable = cx.flow.unreachable_ranges(body);
214    for range in unreachable {
215        cx.warn(
216            range,
217            WarningCode::UnreachableCode,
218            "Unreachable code (statement after a return, break, continue, or an exhaustive match)."
219                .to_owned(),
220        );
221    }
222
223    InferenceResult {
224        expr_ty: cx.expr_ty,
225        bindings: cx.bindings,
226        diagnostics: cx.diagnostics,
227        raw_warnings: cx.raw_warnings,
228    }
229}
230
231/// Convenience: recover a function node from its [`AstPtr`], lower its body, resolve its
232/// declared return type, and infer it.
233#[must_use]
234pub fn infer_func(
235    db: &dyn Db,
236    api: &EngineApi,
237    root: &GdNode,
238    class: &ClassScope,
239    ptr: AstPtr,
240) -> InferenceResult {
241    let Some(node) = ptr.to_node(root) else {
242        return InferenceResult::default();
243    };
244    let body = body::body_of_func(&node);
245    // The return-type annotation is the FuncDecl's direct `TypeRef` child (params' type refs
246    // are nested inside the ParamList, so they are not direct children).
247    let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
248        .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
249    infer(db, api, root, class, &body, return_ty, true)
250}
251
252/// One inferred unit of a file: a function body or a class field's initializer, with its
253/// lowered [`Body`] and [`InferenceResult`] (kept so position-based features — hover, inlay,
254/// member completion — can map a cursor back through the source map).
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct Unit {
257    /// The source range this unit covers (the function decl or the field decl).
258    pub range: TextRange,
259    /// The lowered body.
260    pub body: Body,
261    /// The inference result.
262    pub result: InferenceResult,
263}
264
265/// The full single-file inference: the item tree, every inferred unit, and the merged §5
266/// diagnostics. The whole-file entry point the IDE layer consumes.
267#[derive(Debug, Clone, PartialEq, Eq, Default)]
268pub struct FileInference {
269    /// The lowered item tree.
270    pub tree: Arc<ItemTree>,
271    /// The inferred function/field units.
272    pub units: Vec<Unit>,
273    /// The ungated analyzer-native diagnostics, merged across units (`TYPE_MISMATCH`,
274    /// `INVALID_NODE_PATH`) plus the file-level `SHADOWED_GLOBAL_IDENTIFIER` / `CYCLIC_INHERITANCE`.
275    pub diagnostics: Vec<Diagnostic>,
276    /// The severity-free gateable Godot warnings, merged across units. The IDE layer resolves
277    /// these via [`crate::warnings::gate`] against the project's settings (Workstream 1).
278    pub raw_warnings: Vec<RawWarning>,
279}
280
281impl FileInference {
282    /// The innermost unit whose range contains `offset`.
283    #[must_use]
284    pub fn unit_at(&self, offset: u32) -> Option<&Unit> {
285        self.units
286            .iter()
287            .filter(|u| u.range.start <= offset && offset < u.range.end)
288            .min_by_key(|u| u.range.end - u.range.start)
289    }
290}
291
292/// Infer an entire file: lower its item tree, then infer every function body and every
293/// class-field initializer against a shared [`ClassScope`]. The single entry point for the
294/// IDE features (Playbook §6 — a pure `(api, parsed file) -> result` function).
295#[must_use]
296#[allow(clippy::too_many_lines)] // the two-pass field-fixpoint + function walk reads best whole
297pub fn analyze_file(db: &dyn Db, api: &EngineApi, root: &GdNode, file_id: FileId) -> FileInference {
298    let tree = item_tree(root);
299    let mut units = Vec::new();
300    let mut diagnostics = Vec::new();
301    let mut raw_warnings: Vec<RawWarning> = Vec::new();
302
303    // EMPTY_FILE — a script with no members, no `class_name`, and no `extends` (Workstream 1).
304    if tree.members.is_empty() && tree.class_name.is_none() && tree.extends.is_none() {
305        raw_warnings.push(RawWarning {
306            range: TextRange::new(0, 0),
307            code: WarningCode::EmptyFile,
308            message: "Empty script file.".to_owned(),
309        });
310    }
311    let mut member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
312    // `self` is the script's OWN class (a self-`ScriptRef`), not just its engine base — so member
313    // access on an aliased `self` resolves the file's own members (see `ClassScope::self_ty`).
314    let self_ref = Ty::ScriptRef(ScriptRefId(file_id.0));
315    // The file's own `res://` path, for anchoring relative `preload`/`extends` to its directory.
316    let res_path = db.file_text(file_id).and_then(|ft| ft.res_path(db));
317
318    // A declared `class_name` that collides with another global identifier (W2). Mirrors Godot's
319    // `gdscript_analyzer.cpp` uniqueness check over the global namespace, projected through the
320    // cross-file firewall (`class_name_collisions`) and the offset-free global resolvers — so it
321    // fires only when genuinely shadowing, never on the seam. Emitted once, at the decl's NAME.
322    if let Some(name) = tree.class_name.clone() {
323        let collides = collisions_contains(db, &name)
324            || resolve::resolve_global(api, &name).is_some()
325            || is_autoload_singleton(db, &name);
326        if collides && let Some(range) = class_name_decl_range(root) {
327            diagnostics.push(Diagnostic {
328                range,
329                severity: Severity::Warning,
330                code: SHADOWED_GLOBAL_IDENTIFIER.to_owned(),
331                message: format!(
332                    "The global class \"{name}\" hides a built-in/native/global/autoload."
333                ),
334                source: DiagnosticSource::Type,
335                fixes: Vec::new(),
336            });
337        }
338    }
339
340    // A genuine `extends` cycle (D7): walk THIS file's base chain by `FileId`; if it returns to the
341    // start, the inheritance is cyclic (illegal in Godot). Reported once, at the file's own `extends`
342    // decl range. Only `extends` cycles are walked here (member lookup is the only thing that loops);
343    // `preload`/`load` cycles are legal at runtime and never reach this resolver. We start by stepping
344    // ONTO the user base — if the very first base is the start file (`extends "res://self.gd"`, or two
345    // files A↔B), the revisit-of-start check fires; a deep but ACYCLIC chain bottoms out at an engine
346    // `Object`/`Unknown` and never revisits, so it does not false-fire.
347    if extends_chain_is_cyclic(db, file_id)
348        && let Some(range) = extends_decl_range(root)
349    {
350        diagnostics.push(Diagnostic {
351            range,
352            severity: Severity::Warning,
353            code: CYCLIC_INHERITANCE.to_owned(),
354            message: "Cyclic class hierarchy: this class's `extends` chain returns to itself."
355                .to_owned(),
356            source: DiagnosticSource::Type,
357            fixes: Vec::new(),
358        });
359    }
360
361    // Pass 1 — class fields. Inferring each `var`/`const` seeds `member_types` so the function
362    // pass sees the *inferred* field type (`var n := 0` → `int`), not just the annotation.
363    //
364    // A field initializer may reference an *earlier* field (`var a := 1` then `var b := a + 1`),
365    // so a single shallow round sees the referent as `Variant`/seam. We run a BOUNDED fixpoint:
366    // each round re-infers every field against the prior round's `member_types`, until the map
367    // stops changing or we hit the round cap. Cheap (fields are few, types settle in a round or
368    // two) and deterministic. Only the final round's units/diagnostics are kept — earlier rounds
369    // are throwaway probes feeding the seed.
370    {
371        // Bound the iteration: a linear `a -> b -> c -> …` chain settles in O(n) rounds, but a
372        // small constant is enough in practice (the corpus settles in ≤2) and guarantees
373        // termination even if a type oscillated.
374        const MAX_ROUNDS: usize = 4;
375        let mut final_units: Vec<Unit> = Vec::new();
376        let mut final_diagnostics: Vec<Diagnostic> = Vec::new();
377        let mut final_raw_warnings: Vec<RawWarning> = Vec::new();
378        for _ in 0..MAX_ROUNDS {
379            let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
380            class.self_ty = self_ref.clone();
381            class.member_types.clone_from(&member_types);
382            let mut next_member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
383            final_units = Vec::new();
384            final_diagnostics = Vec::new();
385            final_raw_warnings = Vec::new();
386            for m in &tree.members {
387                let (ptr, range) = match m {
388                    Member::Var(v) => (v.ptr, v.range),
389                    Member::Const(c) => (c.ptr, c.range),
390                    _ => continue,
391                };
392                if let Some(unit) = unit_from_decl(db, api, root, &class, ptr, range) {
393                    if let (Some(name), Some(b)) = (m.name(), unit.result.bindings.first()) {
394                        next_member_types.insert(SmolStr::new(name), b.ty.clone());
395                    }
396                    final_diagnostics.extend(unit.result.diagnostics.iter().cloned());
397                    final_raw_warnings.extend(unit.result.raw_warnings.iter().cloned());
398                    final_units.push(unit);
399                }
400            }
401            if next_member_types == member_types {
402                break;
403            }
404            member_types = next_member_types;
405        }
406        diagnostics.extend(final_diagnostics);
407        raw_warnings.extend(final_raw_warnings);
408        units.extend(final_units);
409    }
410
411    // Pass 2 — functions, against a scope carrying the seeded field types.
412    {
413        let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
414        class.member_types = member_types;
415        class.self_ty = self_ref.clone();
416        for m in &tree.members {
417            let Member::Func(f) = m else { continue };
418            let Some(node) = f.ptr.to_node(root) else {
419                continue;
420            };
421            let body = body::body_of_func(&node);
422            let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
423                .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
424            let result = infer(db, api, root, &class, &body, return_ty, true);
425            diagnostics.extend(result.diagnostics.iter().cloned());
426            raw_warnings.extend(result.raw_warnings.iter().cloned());
427            units.push(Unit {
428                range: f.range,
429                body,
430                result,
431            });
432        }
433    }
434
435    FileInference {
436        tree,
437        units,
438        diagnostics,
439        raw_warnings,
440    }
441}
442
443/// Whether `name` is declared as a `class_name` by more than one file in the project (W2). Reads
444/// the cross-file `class_name_collisions` firewall; `false` (no warning) when no source root is set
445/// — single-file analysis cannot observe a duplicate.
446fn collisions_contains(db: &dyn Db, name: &SmolStr) -> bool {
447    db.source_root()
448        .is_some_and(|root| crate::queries::class_name_collisions(db, root).contains(name))
449}
450
451/// Whether `name` is a `*`-flagged autoload singleton (a bare global). `false` when no
452/// `project.godot` is loaded — the seam, no warning.
453fn is_autoload_singleton(db: &dyn Db, name: &str) -> bool {
454    db.project_config().is_some_and(|config| {
455        crate::queries::autoload_registry(db, config)
456            .resolve_path(name)
457            .is_some()
458    })
459}
460
461/// The NAME range of the file's `class_name` declaration, trimmed to the bare identifier (the
462/// `Name` CST node absorbs leading inter-token trivia). `None` if the file declares no `class_name`
463/// or the decl has no name token. Mirrors `item_tree::trimmed_name_range` / navigation's
464/// `class_decl_target` (which lives in the IDE crate, hence this local CST scan).
465fn class_name_decl_range(root: &GdNode) -> Option<TextRange> {
466    use gdscript_syntax::SyntaxKind;
467    let decl = gdscript_syntax::ast::descendants(root)
468        .into_iter()
469        .find(|n| n.kind() == SyntaxKind::ClassNameDecl)?;
470    let name_node = decl.children().find(|c| c.kind() == SyntaxKind::Name)?;
471    let r = cst::text_range_of(name_node);
472    let text = name_node.text().to_string();
473    let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
474    let len = u32::try_from(text.trim().len()).unwrap_or(0);
475    Some(TextRange::new(r.start + lead, r.start + lead + len))
476}
477
478/// The byte range of the file's top-level `extends` declaration — the anchor for `CYCLIC_INHERITANCE`.
479/// Two surface forms: a standalone `extends Target` (an [`ExtendsClause`] child of the `SourceFile`),
480/// or the inline `class_name Name extends Target` (the `extends` keyword + target inside the
481/// [`ClassNameDecl`]). Scans only the `SourceFile`'s DIRECT children, so an inner class's `extends`
482/// (nested under `Class`/`ClassBody`) is never mistaken for the file's own. `None` if the file has no
483/// top-level `extends`.
484fn extends_decl_range(root: &GdNode) -> Option<TextRange> {
485    use gdscript_syntax::SyntaxKind;
486    for child in root.children() {
487        match child.kind() {
488            // Standalone `extends Target` — the whole clause is the anchor.
489            SyntaxKind::ExtendsClause => return Some(cst::text_range_of(child)),
490            // Inline `class_name Name extends Target` — anchor the `extends` keyword onward.
491            SyntaxKind::ClassNameDecl => {
492                if let Some(kw) = child.children().find(|c| c.kind() == SyntaxKind::ExtendsKw) {
493                    let start = cst::text_range_of(kw).start;
494                    let end = cst::text_range_of(child).end;
495                    return Some(TextRange::new(start, end));
496                }
497            }
498            _ => {}
499        }
500    }
501    None
502}
503
504/// Whether the file's `extends` inheritance chain transitively returns to itself (a genuine cycle).
505/// Walks base-by-base by `FileId` from `start`, stepping only across user `ScriptRef` bases (an
506/// engine `Object`/`Unknown` base ends the chain). A `FileId` revisit means a cycle. We stop as soon
507/// as we either revisit a file (cycle) or hit a non-script base (acyclic) — a deep but acyclic chain
508/// terminates without a revisit and is NOT flagged. Depth is also hard-capped as belt-and-suspenders
509/// (the visited set already guarantees termination).
510fn extends_chain_is_cyclic(db: &dyn Db, start: FileId) -> bool {
511    use std::collections::HashSet;
512    let mut visited: HashSet<FileId> = HashSet::new();
513    visited.insert(start);
514    let mut current = start;
515    for _ in 0..=64 {
516        let Some(file) = db.file_text(current) else {
517            return false;
518        };
519        let base = crate::queries::script_class(db, file).base().clone();
520        let Ty::ScriptRef(next) = base else {
521            return false; // engine `Object` / `Unknown` base — chain ends, no cycle.
522        };
523        let next_id = FileId(next.0);
524        if !visited.insert(next_id) {
525            // Revisiting an already-seen file closes a cycle. We report the cycle for every file ON
526            // it (each file's own `extends` is genuinely cyclic), so no need to special-case `start`.
527            return true;
528        }
529        current = next_id;
530    }
531    false
532}
533
534/// Infer a class field declaration as a single local-var statement (full annotation checks).
535fn unit_from_decl(
536    db: &dyn Db,
537    api: &EngineApi,
538    root: &GdNode,
539    class: &ClassScope,
540    ptr: AstPtr,
541    range: TextRange,
542) -> Option<Unit> {
543    let node = ptr.to_node(root)?;
544    let body = body::body_of_decl_stmt(&node);
545    let result = infer(db, api, root, class, &body, Ty::Variant, false);
546    Some(Unit {
547        range,
548        body,
549        result,
550    })
551}
552
553/// What type is expected of an expression (bidirectional checking).
554enum Expectation {
555    /// No expectation — pure synthesis.
556    None,
557    /// The expression is checked against this declared type.
558    Has(Ty),
559}
560
561struct Cx<'a> {
562    db: &'a dyn Db,
563    api: &'a EngineApi,
564    root: &'a GdNode,
565    body: &'a Body,
566    class: &'a ClassScope<'a>,
567    self_ty: Ty,
568    return_ty: Ty,
569    expr_ty: FxHashMap<ExprId, Ty>,
570    bindings: Vec<Binding>,
571    diagnostics: Vec<Diagnostic>,
572    /// Severity-free gateable warnings (Workstream 1), resolved by `gate()` downstream.
573    raw_warnings: Vec<RawWarning>,
574    /// Function-scoped local bindings (GDScript locals are function-, not block-, scoped).
575    locals: FxHashMap<SmolStr, Ty>,
576    /// The names of locals/params that were *read* during the walk — drives the `UNUSED_*` family
577    /// (a declared binding whose name never appears here is unused). Conservative: a write-only use
578    /// also records the name (no false positives, only the occasional missed warning).
579    used_locals: FxHashSet<SmolStr>,
580    /// The active narrowing env for the current statement, keyed by a dotted access path. Rebuilt
581    /// per statement from [`Cx::flow`] (Workstream 2) — not mutated ad-hoc anymore.
582    narrowing: FxHashMap<String, Ty>,
583    /// The precomputed per-body control-flow narrowing facts (Workstream 2). The checker consults
584    /// `facts_before(stmt)` to build [`Cx::narrowing`]; it survives `else`/early-return/`and`-`or`.
585    flow: FlowAnalysis,
586    /// Whether this is a real function body (vs a class-field initializer body). Gates the
587    /// body-only checks (`UNUSED_*`, `SHADOWED_VARIABLE`) so a field initializer doesn't, e.g.,
588    /// "shadow itself" against its own member entry.
589    is_func_body: bool,
590}
591
592impl Cx<'_> {
593    // ---- small type constructors ----
594
595    fn builtin(&self, name: &str) -> Ty {
596        self.api
597            .builtin_by_name(name)
598            .map_or(Ty::Variant, Ty::Builtin)
599    }
600    fn int_ty(&self) -> Ty {
601        self.builtin("int")
602    }
603    fn float_ty(&self) -> Ty {
604        self.builtin("float")
605    }
606    fn bool_ty(&self) -> Ty {
607        self.builtin("bool")
608    }
609    fn is_int(&self, ty: &Ty) -> bool {
610        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "int")
611    }
612    fn is_float(&self, ty: &Ty) -> bool {
613        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "float")
614    }
615    fn is_numeric(&self, ty: &Ty) -> bool {
616        self.is_int(ty) || self.is_float(ty)
617    }
618
619    // ---- diagnostics ----
620
621    fn emit(&mut self, range: TextRange, severity: Severity, code: &str, message: String) {
622        self.diagnostics.push(Diagnostic {
623            range,
624            severity,
625            code: code.to_owned(),
626            message,
627            source: DiagnosticSource::Type,
628            fixes: Vec::new(),
629        });
630    }
631
632    /// Record a gateable Godot warning, severity-free. The resolved severity (and whether it fires
633    /// at all) is decided later by [`crate::warnings::gate`], keyed on the project's warning
634    /// settings — so a settings edit never re-runs inference (Workstream 1, the salsa firewall).
635    fn warn(&mut self, range: TextRange, code: WarningCode, message: String) {
636        self.raw_warnings.push(RawWarning {
637            range,
638            code,
639            message,
640        });
641    }
642
643    fn range_of(&self, id: ExprId) -> TextRange {
644        self.body.source_map.expr_range(id)
645    }
646
647    /// Run `is_assignable(from, to)` and raise the matching diagnostic. Safe to call
648    /// unconditionally: `to` being `Variant`/`Unknown` yields `Ok`/no diagnostic.
649    fn check_assign(&mut self, from: &Ty, to: &Ty, range: TextRange) {
650        match ty::is_assignable(self.api, from, to) {
651            Assign::Narrowing => self.warn(
652                range,
653                WarningCode::NarrowingConversion,
654                "Narrowing conversion (float is converted to int and loses precision).".to_owned(),
655            ),
656            Assign::No => {
657                let to_label = to.label(self.api).unwrap_or_else(|| "?".to_owned());
658                let from_label = from.label(self.api).unwrap_or_else(|| "?".to_owned());
659                self.emit(
660                    range,
661                    Severity::Error,
662                    TYPE_MISMATCH,
663                    format!(
664                        "Cannot assign a value of type \"{from_label}\" to a target of type \"{to_label}\"."
665                    ),
666                );
667            }
668            // `int` assigned to an enum slot without an explicit cast (the previously-dead arm).
669            Assign::IntAsEnum => self.warn(
670                range,
671                WarningCode::IntAsEnumWithoutCast,
672                "Integer used when an enum value is expected. Cast the value to the enum type."
673                    .to_owned(),
674            ),
675            Assign::Ok | Assign::OkUnsafe => {}
676        }
677    }
678
679    /// Flag a statement whose expression has no effect: a bare value (`STANDALONE_EXPRESSION`) or a
680    /// ternary used as a statement (`STANDALONE_TERNARY`). A call / await / assignment / `preload`
681    /// has an effect and is never flagged.
682    fn check_standalone(&mut self, e: ExprId) {
683        if self.expr_has_side_effect(e) {
684            return;
685        }
686        match self.body.expr(e) {
687            Expr::Ternary { .. } => self.warn(
688                self.range_of(e),
689                WarningCode::StandaloneTernary,
690                "Standalone ternary conditional: the return value is discarded.".to_owned(),
691            ),
692            // Not value-like statements / forms with subtle effects — never flag.
693            Expr::Missing | Expr::Lambda { .. } | Expr::GetNode { .. } | Expr::Preload { .. } => {}
694            _ => self.warn(
695                self.range_of(e),
696                WarningCode::StandaloneExpression,
697                "Standalone expression (the line has no effect).".to_owned(),
698            ),
699        }
700    }
701
702    /// Whether evaluating an expression may have a side effect — a call, an `await`, a `preload`,
703    /// or an assignment anywhere in the subtree. Used to suppress `STANDALONE_*` on effectful lines.
704    fn expr_has_side_effect(&self, e: ExprId) -> bool {
705        match self.body.expr(e) {
706            Expr::Call { .. }
707            | Expr::Await(_)
708            | Expr::Preload { .. }
709            | Expr::Bin {
710                op: BinOp::Assign, ..
711            } => true,
712            Expr::Bin { lhs, rhs, .. }
713            | Expr::In { lhs, rhs, .. }
714            | Expr::Index {
715                base: lhs,
716                index: rhs,
717            } => self.expr_has_side_effect(*lhs) || self.expr_has_side_effect(*rhs),
718            Expr::Unary { operand, .. }
719            | Expr::Paren(operand)
720            | Expr::Cast { operand, .. }
721            | Expr::Is { operand, .. } => self.expr_has_side_effect(*operand),
722            Expr::Field { receiver, .. } => self.expr_has_side_effect(*receiver),
723            Expr::Ternary {
724                cond,
725                then_branch,
726                else_branch,
727            } => {
728                self.expr_has_side_effect(*cond)
729                    || self.expr_has_side_effect(*then_branch)
730                    || self.expr_has_side_effect(*else_branch)
731            }
732            Expr::Array(items) => items.iter().any(|&i| self.expr_has_side_effect(i)),
733            Expr::Dict(entries) => entries.iter().any(|(k, v)| {
734                self.expr_has_side_effect(*k) || v.is_some_and(|e| self.expr_has_side_effect(e))
735            }),
736            _ => false,
737        }
738    }
739
740    // ---- statements ----
741
742    fn infer_block(&mut self, block: &[body::StmtId]) {
743        for &stmt in block {
744            self.infer_stmt(stmt);
745        }
746    }
747
748    fn infer_stmt(&mut self, id: body::StmtId) {
749        // Install the narrowing in force *before* this statement (Workstream 2). Recomputed per
750        // statement from the precomputed flow facts — replaces the old ad-hoc `in_branch` frames.
751        self.narrowing = self.facts_to_narrowing(id);
752        match self.body.stmt(id).clone() {
753            Stmt::Expr(e) => {
754                self.infer_expr(e, &Expectation::None);
755                self.check_standalone(e);
756            }
757            Stmt::Var(v) => self.infer_local_var(&v),
758            Stmt::Return(e) => {
759                if let Some(e) = e {
760                    let expected = if self.return_ty.is_uninformative() {
761                        Expectation::None
762                    } else {
763                        Expectation::Has(self.return_ty.clone())
764                    };
765                    let t = self.infer_expr(e, &expected);
766                    if let Expectation::Has(ret) = expected {
767                        self.check_assign(&t, &ret, self.range_of(e));
768                    }
769                }
770            }
771            Stmt::If {
772                cond,
773                then_branch,
774                elifs,
775                else_branch,
776            } => {
777                // The branch narrowing now lives in the flow facts, so each sub-statement installs
778                // its own via `infer_stmt`. Restore the if-level facts before each guard (a block
779                // walk overwrites `self.narrowing`).
780                let at_if = self.narrowing.clone();
781                self.infer_expr(cond, &Expectation::None);
782                self.infer_block(&then_branch);
783                for (econd, eblock) in elifs {
784                    self.narrowing.clone_from(&at_if);
785                    self.infer_expr(econd, &Expectation::None);
786                    self.infer_block(&eblock);
787                }
788                if let Some(eb) = else_branch {
789                    self.infer_block(&eb);
790                }
791            }
792            Stmt::While { cond, body } => {
793                self.infer_expr(cond, &Expectation::None);
794                self.infer_block(&body);
795            }
796            Stmt::For(f) => {
797                let iter_ty = self.infer_expr(f.iter, &Expectation::None);
798                let var_ty = f.var_type.as_ref().map_or_else(
799                    || self.loop_var_ty(&iter_ty),
800                    |ptr| self.resolve_ptr_ty(*ptr),
801                );
802                self.bindings.push(Binding {
803                    name: f.var.clone(),
804                    name_range: f.var_range,
805                    ty: var_ty.clone(),
806                    init: None,
807                    annotated: f.var_type.is_some(),
808                    inferred_colon_eq: false,
809                    is_const: false,
810                    kind: BindingKind::ForVar,
811                });
812                self.locals.insert(f.var.clone(), var_ty);
813                self.infer_block(&f.body);
814            }
815            Stmt::Match { scrutinee, arms } => {
816                let at_match = self.narrowing.clone();
817                self.infer_expr(scrutinee, &Expectation::None);
818                for arm in arms {
819                    // Restore the match-level facts before each arm's guard (a prior arm's body
820                    // walk overwrote `self.narrowing`).
821                    self.narrowing.clone_from(&at_match);
822                    for b in &arm.binds {
823                        // Record the capture as a binding so navigation (find-refs / rename) sees
824                        // it as a local that shadows a same-named member; the type is the Phase-2
825                        // `Variant`.
826                        self.bindings.push(Binding {
827                            name: b.name.clone(),
828                            name_range: b.range,
829                            ty: Ty::Variant,
830                            init: None,
831                            annotated: false,
832                            inferred_colon_eq: false,
833                            is_const: false,
834                            kind: BindingKind::MatchBind,
835                        });
836                        self.locals.insert(b.name.clone(), Ty::Variant);
837                    }
838                    if let Some(g) = arm.guard {
839                        self.infer_expr(g, &Expectation::None);
840                    }
841                    self.infer_block(&arm.body);
842                }
843            }
844            Stmt::Break | Stmt::Continue | Stmt::Pass => {}
845            Stmt::Assert(cond) => {
846                if let Some(cond) = cond {
847                    self.infer_expr(cond, &Expectation::None);
848                }
849            }
850        }
851    }
852
853    fn infer_local_var(&mut self, v: &body::LocalVar) {
854        let annotated = v.type_ref.map(|p| self.resolve_ptr_ty(p));
855        let init_ty = v.init.map(|e| {
856            let expected = annotated
857                .as_ref()
858                .map_or(Expectation::None, |t| Expectation::Has(t.clone()));
859            self.infer_expr(e, &expected)
860        });
861        let range = v.init.map_or(v.name_range, |e| self.range_of(e));
862
863        let binding_ty = match (&annotated, &init_ty) {
864            // `var x: T = e` — hard slot; check the initializer against it.
865            (Some(t), Some(init)) => {
866                self.check_assign(init, t, range);
867                t.clone()
868            }
869            // `var x: T` (no init).
870            (Some(t), None) => t.clone(),
871            // `var x := e` — inferred (hard); guard the Variant / null cases.
872            (None, Some(init)) if v.is_inferred => {
873                if init.is_variant() {
874                    self.warn(
875                        range,
876                        WarningCode::InferenceOnVariant,
877                        inference_on_variant_msg(if v.is_const { "constant" } else { "variable" }),
878                    );
879                    Ty::Variant
880                } else {
881                    // `Unknown` (the seam) stays `Unknown` with no warning.
882                    init.clone()
883                }
884            }
885            // `var x = e` — untyped, soft → Variant. `const X = e` keeps the inferred type.
886            (None, Some(init)) => {
887                if v.is_const {
888                    init.clone()
889                } else {
890                    Ty::Variant
891                }
892            }
893            (None, None) => Ty::Variant,
894        };
895        // SHADOWED_VARIABLE — a local `var`/`const` whose name shadows a parameter or an own class
896        // member (a redeclared *local* is a Godot error, not handled here). Sound: only fires on a
897        // genuine outer-scope shadow. The binding isn't pushed yet, so the `Param` scan can't see it.
898        // Gated to a real function body — a class-field initializer's own `var n` is not a shadow.
899        let shadows_param = self
900            .bindings
901            .iter()
902            .any(|b| b.kind == BindingKind::Param && b.name == v.name);
903        // Only a *value* member (var/const/signal, or an anon-enum constant) — not a method or a
904        // type name, where the "shadow" framing is weaker — counts, to stay conservative.
905        let shadows_member = match self.class.lookup(&v.name) {
906            Some(ClassItem::EnumVariant) => true,
907            Some(item) => matches!(
908                self.class.member(item),
909                Some(Member::Var(_) | Member::Const(_) | Member::Signal(_))
910            ),
911            None => false,
912        };
913        if self.is_func_body && (shadows_param || shadows_member) {
914            let what = if v.is_const { "constant" } else { "variable" };
915            let outer = if shadows_param {
916                "parameter"
917            } else {
918                "class member"
919            };
920            self.warn(
921                v.name_range,
922                WarningCode::ShadowedVariable,
923                format!(
924                    "The local {what} \"{}\" shadows a {outer} of the same name.",
925                    v.name
926                ),
927            );
928        }
929        self.bindings.push(Binding {
930            name: v.name.clone(),
931            name_range: v.name_range,
932            ty: binding_ty.clone(),
933            init: v.init,
934            annotated: v.type_ref.is_some(),
935            inferred_colon_eq: v.is_inferred,
936            is_const: v.is_const,
937            kind: BindingKind::Var,
938        });
939        // A (re-)declaration's narrowing invalidation is handled by the flow analysis (Workstream 2).
940        self.locals.insert(v.name.clone(), binding_ty);
941    }
942
943    // ---- expressions ----
944
945    fn infer_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
946        let ty = self.synth_expr(id, expected);
947        self.expr_ty.insert(id, ty.clone());
948        ty
949    }
950
951    #[allow(clippy::too_many_lines)]
952    fn synth_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
953        match self.body.expr(id).clone() {
954            Expr::Missing => Ty::Error,
955            Expr::Literal(lit) => self.literal_ty(lit),
956            Expr::Name(name) => self.resolve_name(id, &name),
957            Expr::SelfExpr => self.self_ty.clone(),
958            Expr::Super => self.class.base.clone(),
959            Expr::Paren(inner) => self.infer_expr(inner, expected),
960            Expr::Bin { op, lhs, rhs } => self.infer_bin(id, op, lhs, rhs),
961            Expr::Unary { op, operand } => {
962                let t = self.infer_expr(operand, &Expectation::None);
963                match op {
964                    UnOp::Not => self.bool_ty(),
965                    UnOp::BitNot => self.int_ty(),
966                    UnOp::Neg | UnOp::Pos => {
967                        if t.is_uninformative() || self.is_numeric(&t) {
968                            t
969                        } else {
970                            Ty::Variant
971                        }
972                    }
973                }
974            }
975            Expr::Ternary {
976                cond,
977                then_branch,
978                else_branch,
979            } => {
980                self.infer_expr(cond, &Expectation::None);
981                let a = self.infer_expr(then_branch, expected);
982                let b = self.infer_expr(else_branch, expected);
983                // A `null` branch does not poison the other: `x if c else null` is nullable-`x`.
984                if self.is_null(else_branch) {
985                    a
986                } else if self.is_null(then_branch) {
987                    b
988                } else {
989                    let r = self.join(&a, &b);
990                    // Both arms informative but with no common type (the join widened to Variant) —
991                    // the ternary's two values are mutually incompatible.
992                    if r.is_variant() && !a.is_uninformative() && !b.is_uninformative() {
993                        self.warn(
994                            self.range_of(id),
995                            WarningCode::IncompatibleTernary,
996                            "The values of the ternary conditional are not mutually compatible."
997                                .to_owned(),
998                        );
999                    }
1000                    r
1001                }
1002            }
1003            Expr::Call { callee, args } => self.infer_call(callee, &args),
1004            Expr::Field {
1005                receiver,
1006                name,
1007                name_range,
1008            } => {
1009                self.infer_field(receiver, &name, name_range, /*as_method=*/ false)
1010            }
1011            Expr::Index { base, index } => {
1012                let base_ty = self.infer_expr(base, &Expectation::None);
1013                self.infer_expr(index, &Expectation::None);
1014                self.index_ty(&base_ty)
1015            }
1016            Expr::Is { operand, .. } => {
1017                self.infer_expr(operand, &Expectation::None);
1018                self.bool_ty()
1019            }
1020            Expr::Cast { operand, ty } => {
1021                self.infer_expr(operand, &Expectation::None);
1022                ty.map_or(Ty::Variant, |p| self.resolve_ptr_ty(p))
1023            }
1024            Expr::In { lhs, rhs, .. } => {
1025                self.infer_expr(lhs, &Expectation::None);
1026                self.infer_expr(rhs, &Expectation::None);
1027                self.bool_ty()
1028            }
1029            Expr::Await(operand) => {
1030                let operand_ty = self.infer_expr(operand, &Expectation::None);
1031                // `await coroutine()` yields the call's value, so await is **identity** on the operand
1032                // type (`await f()` for `func f() -> int` is `int`) — recovered here. `await signal`
1033                // instead yields the signal's emitted payload, which needs the Phase-3+ signal-signature
1034                // table; until then it's the seam (never `Variant`, so `var x := await sig` never warns).
1035                if matches!(operand_ty, Ty::Signal(_)) {
1036                    Ty::Unknown
1037                } else {
1038                    operand_ty
1039                }
1040            }
1041            Expr::Array(elems) => {
1042                // Checking mode: an expected `Array[T]` is pushed down onto the literal (so
1043                // `var a: Array[String] = []` / `[...]` is accepted). Otherwise the engine does
1044                // not infer a literal's element type past `Variant`.
1045                let pushed = match expected {
1046                    Expectation::Has(Ty::Array(e)) => Some((**e).clone()),
1047                    _ => None,
1048                };
1049                let elem_exp = pushed.clone().map_or(Expectation::None, Expectation::Has);
1050                for e in elems {
1051                    self.infer_expr(e, &elem_exp);
1052                }
1053                pushed.map_or_else(Ty::array_of_variant, |e| Ty::Array(Box::new(e)))
1054            }
1055            Expr::Dict(entries) => {
1056                let pushed = match expected {
1057                    Expectation::Has(Ty::Dict(k, v)) => Some(((**k).clone(), (**v).clone())),
1058                    _ => None,
1059                };
1060                let (kx, vx) = pushed
1061                    .clone()
1062                    .map_or((Expectation::None, Expectation::None), |(k, v)| {
1063                        (Expectation::Has(k), Expectation::Has(v))
1064                    });
1065                for (k, v) in entries {
1066                    self.infer_expr(k, &kx);
1067                    if let Some(v) = v {
1068                        self.infer_expr(v, &vx);
1069                    }
1070                }
1071                pushed.map_or_else(Ty::dict_of_variant, |(k, v)| {
1072                    Ty::Dict(Box::new(k), Box::new(v))
1073                })
1074            }
1075            Expr::Lambda { params, body } => {
1076                self.infer_lambda(&params, &body);
1077                Ty::Callable
1078            }
1079            Expr::Preload { arg, path } => {
1080                if let Some(arg) = arg {
1081                    self.infer_expr(arg, &Expectation::None);
1082                }
1083                // A constant string-literal path resolves to the declaring file's `ScriptRef`
1084                // (M3 — a SCRIPT meta-type in Godot; `X.new()`/`X.member` then resolve via the
1085                // usual `ScriptRef` walk). A non-constant argument (`preload(var)`) — which Godot
1086                // itself rejects — stays the seam, never a false diagnostic.
1087                match path {
1088                    // Anchor a relative `preload("sibling.gd")` to the importing file's directory
1089                    // before resolving (Godot anchors relative resource paths); absolute paths pass
1090                    // through, and a relative path with no anchor stays the seam.
1091                    Some(p) => {
1092                        match resolve::anchor_res_path(self.self_res_path().as_deref(), &p) {
1093                            Some(abs) => resolve::resolve_external(
1094                                self.db,
1095                                &resolve::ExternalRef::Preload(abs),
1096                            ),
1097                            None => Ty::Unknown,
1098                        }
1099                    }
1100                    None => Ty::Unknown,
1101                }
1102            }
1103            // `$Path`/`%Unique` — resolve the literal path against the owning scene to the node's
1104            // concrete type (Phase-4 M1); a computed/unresolvable path stays `Object(Node)`.
1105            Expr::GetNode { path, unique } => self.resolve_node_path(id, path.as_deref(), unique),
1106        }
1107    }
1108
1109    /// Whether `id` is the `null` literal.
1110    fn is_null(&self, id: ExprId) -> bool {
1111        matches!(self.body.expr(id), Expr::Literal(Literal::Null))
1112    }
1113
1114    fn literal_ty(&self, lit: Literal) -> Ty {
1115        match lit {
1116            Literal::Int => self.int_ty(),
1117            Literal::Float | Literal::MathConst => self.float_ty(),
1118            Literal::Bool => self.bool_ty(),
1119            Literal::Str => self.builtin("String"),
1120            Literal::StringName => self.builtin("StringName"),
1121            Literal::NodePath => self.builtin("NodePath"),
1122            // `null` is compatible everywhere; typing it `Variant` avoids false mismatches.
1123            Literal::Null => Ty::Variant,
1124        }
1125    }
1126
1127    fn node_ty(&self) -> Ty {
1128        self.api
1129            .class_by_name("Node")
1130            .map_or(Ty::Unknown, Ty::Object)
1131    }
1132
1133    // ---- scene-aware node-path typing (Phase-4 M1) ----
1134
1135    /// Resolve a `$Path`/`%Unique`/`get_node("…")` literal node path against the owning scene to the
1136    /// node's concrete type. A computed (`None`) path, no owning scene, an `..`/absolute escape, or a
1137    /// path that descends into an instanced sub-scene all degrade to `Object(Node)` — never a false
1138    /// positive. A *genuinely* absent in-scene node raises `INVALID_NODE_PATH` (M2), but only when
1139    /// the script attaches to exactly one scene (an ambiguous multi-scene attachment stays silent).
1140    fn resolve_node_path(&mut self, id: ExprId, path: Option<&str>, unique: bool) -> Ty {
1141        use gdscript_scene::NodePathResolution as R;
1142        let fallback = self.node_ty();
1143        let Some(path) = path else {
1144            return fallback; // computed `get_node(var)` — stays `Node`
1145        };
1146        let Some(ctx) = self.owning_scene() else {
1147            return fallback; // no scene attaches this script (dynamic UI / single-file)
1148        };
1149        let resolution = if unique {
1150            ctx.model.classify_unique(path)
1151        } else {
1152            ctx.model.classify_path_from(ctx.attach, path)
1153        };
1154        match resolution {
1155            R::Resolved(idx) => ctx
1156                .model
1157                .node(idx)
1158                .and_then(|n| self.scene_node_ty(&ctx.model, n, 0))
1159                .unwrap_or(fallback),
1160            R::Missing if !ctx.ambiguous => {
1161                let what = if unique { "unique name" } else { "node path" };
1162                let sigil = if unique { "%" } else { "$" };
1163                self.emit(
1164                    self.range_of(id),
1165                    Severity::Warning,
1166                    INVALID_NODE_PATH,
1167                    format!("no {what} `{sigil}{path}` in the owning scene"),
1168                );
1169                fallback
1170            }
1171            // The path descends into an instanced sub-scene (`$Enemy/Sprite`): resolve the tail in
1172            // the sub-scene's own tree (`Sprite` typed by `enemy.tscn`). Any failure → `Node`.
1173            R::IntoInstance => {
1174                let walked = if unique {
1175                    ctx.model.resolve_unique_into_instance(path)
1176                } else {
1177                    ctx.model.resolve_into_instance(ctx.attach, path)
1178                };
1179                walked
1180                    .and_then(|(inst, tail)| {
1181                        let inst_node = ctx.model.node(inst)?;
1182                        self.resolve_into_instance_ty(&ctx.model, inst_node, &tail, 0)
1183                    })
1184                    .unwrap_or(fallback)
1185            }
1186            // ambiguous miss / escape (`..`/absolute) → `Node`, never a false warning
1187            _ => fallback,
1188        }
1189    }
1190
1191    /// The owning-scene context for the current file (scene + attach node + multi-scene ambiguity).
1192    /// Recovered from `self_ty`, which `analyze_file` sets to the file's own `ScriptRef` (so no extra
1193    /// `FileId` threading).
1194    fn owning_scene(&self) -> Option<crate::queries::SceneContext> {
1195        let Ty::ScriptRef(sref) = &self.self_ty else {
1196            return None;
1197        };
1198        let ft = self.db.file_text(FileId(sref.0))?;
1199        crate::queries::scene_context(self.db, ft)
1200    }
1201
1202    /// The importing file's own `res://` path (from `self_ty`), for anchoring relative
1203    /// `preload`/`extends` paths to its directory. `None` when the file has no resource path.
1204    fn self_res_path(&self) -> Option<SmolStr> {
1205        let Ty::ScriptRef(sref) = &self.self_ty else {
1206            return None;
1207        };
1208        self.db.file_text(FileId(sref.0))?.res_path(self.db)
1209    }
1210
1211    /// The concrete `Ty` of a scene node, by precedence: an attached script's own class (most
1212    /// specific) wins; else the declared `type=` (native class or `class_name`); else — an instanced
1213    /// node (`instance=`, no own `type=`/script) — the **instanced sub-scene's root** type (M3,
1214    /// recursive). `None` for a node we can't sharpen (the caller degrades to `Node`).
1215    fn scene_node_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
1216        if let Some(script_ty) = self.node_script_ref(scene, node) {
1217            return Some(script_ty);
1218        }
1219        if let Some(decl) = node.decl_type.as_ref() {
1220            let ty = resolve::resolve_type_name(self.db, self.api, decl);
1221            if !ty.is_uninformative() {
1222                return Some(ty);
1223            }
1224        }
1225        self.instance_root_ty(scene, node, depth)
1226    }
1227
1228    /// An instanced node (`instance=ExtResource(id)`) takes the type of the instanced sub-scene's
1229    /// ROOT node — resolved recursively, so the root's own script / `type=` / nested instance all
1230    /// flow through (so `$Enemy` types as `enemy.tscn`'s root class, not bare `Node`). Depth-bounded
1231    /// against an instancing cycle (scene A instances B instances A).
1232    fn instance_root_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
1233        if depth >= 16 {
1234            return None;
1235        }
1236        let (sub, sub_root) = self.instance_subscene(scene, node)?;
1237        let root_node = sub.node(sub_root)?;
1238        self.scene_node_ty(&sub, root_node, depth + 1)
1239    }
1240
1241    /// The instanced sub-scene's model + its root index, for an instance node (`instance=ExtResource`
1242    /// → `res://` path → `FileId` → `scene_model`). The shared resolution step for both
1243    /// [`instance_root_ty`](Self::instance_root_ty) (the node's own type) and
1244    /// [`resolve_into_instance_ty`](Self::resolve_into_instance_ty) (paths that go *into* it).
1245    fn instance_subscene(
1246        &self,
1247        scene: &SceneModel,
1248        node: &SceneNode,
1249    ) -> Option<(Arc<SceneModel>, gdscript_scene::NodeIdx)> {
1250        let inst = node.instance.as_ref()?;
1251        let path = scene.ext_resources.get(inst)?.path.as_ref()?;
1252        let root = self.db.source_root()?;
1253        let file = crate::queries::res_path_registry(self.db, root)
1254            .get(path.as_str())
1255            .copied()?;
1256        let ft = self.db.file_text(file)?;
1257        let sub = crate::queries::scene_model(self.db, ft);
1258        let sub_root = sub.root?;
1259        Some((sub, sub_root))
1260    }
1261
1262    /// Type a node path that descends INTO an instanced sub-scene: `instance_node` is the boundary
1263    /// (an `instance=` node) and `tail` is the remaining path. Resolve `tail` from the sub-scene's
1264    /// root, recursing through further instance boundaries inside it. Depth-bounded against an
1265    /// instancing cycle. `None` (→ `Node`, no false warning) if the tail genuinely can't be typed.
1266    fn resolve_into_instance_ty(
1267        &self,
1268        scene: &SceneModel,
1269        instance_node: &SceneNode,
1270        tail: &str,
1271        depth: u32,
1272    ) -> Option<Ty> {
1273        if depth >= 16 {
1274            return None;
1275        }
1276        let (sub, sub_root) = self.instance_subscene(scene, instance_node)?;
1277        if let Some(idx) = sub.resolve_path_from(sub_root, tail) {
1278            let n = sub.node(idx)?;
1279            return self.scene_node_ty(&sub, n, depth + 1);
1280        }
1281        // The tail crosses a further instance boundary *inside* the sub-scene — keep descending.
1282        let (inner, inner_tail) = sub.resolve_into_instance(sub_root, tail)?;
1283        let inner_node = sub.node(inner)?;
1284        self.resolve_into_instance_ty(&sub, inner_node, &inner_tail, depth + 1)
1285    }
1286
1287    /// The `ScriptRef` of a node's attached `.gd` script (`script = ExtResource(id)` → its `res://`
1288    /// path → `FileId`), or `None` if it has no resolvable external script.
1289    fn node_script_ref(&self, scene: &SceneModel, node: &SceneNode) -> Option<Ty> {
1290        let path = scene
1291            .ext_resources
1292            .get(node.script.as_ref()?)?
1293            .path
1294            .as_ref()?;
1295        let root = self.db.source_root()?;
1296        let file = crate::queries::res_path_registry(self.db, root)
1297            .get(path.as_str())
1298            .copied()?;
1299        Some(Ty::ScriptRef(ScriptRefId(file.0)))
1300    }
1301
1302    fn infer_bin(&mut self, id: ExprId, op: BinOp, lhs: ExprId, rhs: ExprId) -> Ty {
1303        if op == BinOp::Assign {
1304            return self.infer_assign(lhs, rhs);
1305        }
1306        // Short-circuit narrowing (Workstream 2): the RHS of `a and b` is typed under `a`'s
1307        // then-facts; `a or b`'s RHS under `a`'s else-facts. Restore the env afterward.
1308        if matches!(op, BinOp::And | BinOp::Or) {
1309            self.infer_expr(lhs, &Expectation::None);
1310            let saved = self.narrowing.clone();
1311            self.apply_condition_facts(lhs, op == BinOp::And);
1312            self.infer_expr(rhs, &Expectation::None);
1313            self.narrowing = saved;
1314            return self.bool_ty();
1315        }
1316        let lt = self.infer_expr(lhs, &Expectation::None);
1317        let rt = self.infer_expr(rhs, &Expectation::None);
1318        if op.is_boolean() {
1319            return self.bool_ty();
1320        }
1321        // `int / int` discards the fractional part.
1322        if op == BinOp::Div && self.is_int(&lt) && self.is_int(&rt) {
1323            self.warn(
1324                self.range_of(id),
1325                WarningCode::IntegerDivision,
1326                "Integer division. Decimal part will be discarded.".to_owned(),
1327            );
1328            return self.int_ty();
1329        }
1330        self.bin_result(op, &lt, &rt)
1331    }
1332
1333    fn infer_assign(&mut self, lhs: ExprId, rhs: ExprId) -> Ty {
1334        let slot = self.infer_expr(lhs, &Expectation::None);
1335        let expected = if slot.is_uninformative() {
1336            Expectation::None
1337        } else {
1338            Expectation::Has(slot.clone())
1339        };
1340        let value = self.infer_expr(rhs, &expected);
1341        if !slot.is_uninformative() {
1342            self.check_assign(&value, &slot, self.range_of(rhs));
1343        }
1344        // Assignment *invalidates* the place's narrowing (handled by the flow analysis, Workstream
1345        // 2); re-narrowing from the assigned value's type is a post-1.0 precision item.
1346        slot
1347    }
1348
1349    /// Resolve a binary operator's result type via the builtin operator table, with a numeric
1350    /// fallback. Comparison/logical operators are handled by the caller.
1351    fn bin_result(&self, op: BinOp, lt: &Ty, rt: &Ty) -> Ty {
1352        if let (Ty::Builtin(b), Some(sym)) = (lt, op_symbol(op)) {
1353            for o in self.api.builtin_operators(*b) {
1354                if o.op == sym
1355                    && let Some(right) = &o.right
1356                    && self.tyref_matches(right, rt)
1357                {
1358                    return ty::resolve_tyref(self.api, &o.result);
1359                }
1360            }
1361        }
1362        if self.is_numeric(lt) && self.is_numeric(rt) {
1363            return if self.is_float(lt) || self.is_float(rt) {
1364                self.float_ty()
1365            } else {
1366                self.int_ty()
1367            };
1368        }
1369        // A seam operand keeps the result on the seam (`a + unknown` is `Unknown`, not the
1370        // gradual `Variant`, so `var x := a + unknown` never warns).
1371        if lt.is_unknown() || rt.is_unknown() || lt.is_error() || rt.is_error() {
1372            return Ty::Unknown;
1373        }
1374        Ty::Variant
1375    }
1376
1377    fn tyref_matches(&self, tyref: &TyRef, ty: &Ty) -> bool {
1378        let resolved = ty::resolve_tyref(self.api, tyref);
1379        resolved.is_variant() || &resolved == ty
1380    }
1381
1382    fn infer_call(&mut self, callee: ExprId, args: &[ExprId]) -> Ty {
1383        // Argument expressions are always inferred (their own diagnostics + hover).
1384        for &a in args {
1385            self.infer_expr(a, &Expectation::None);
1386        }
1387        let ret = match self.body.expr(callee).clone() {
1388            Expr::Field {
1389                receiver,
1390                name,
1391                name_range,
1392            } => {
1393                self.infer_field(receiver, &name, name_range, /*as_method=*/ true)
1394            }
1395            Expr::Name(name) => {
1396                let ret = self.resolve_call_name(&name);
1397                self.expr_ty.insert(callee, Ty::Callable);
1398                ret
1399            }
1400            // Calling an arbitrary expression — a `Callable` value or an immediately-invoked
1401            // lambda (`(func(): …).call()`): the callee's return type isn't tracked, so the
1402            // result is the seam (not `Variant`), and `var x := f()()` never warns.
1403            _ => {
1404                self.infer_expr(callee, &Expectation::None);
1405                Ty::Unknown
1406            }
1407        };
1408        // UNSAFE_CALL_ARGUMENT (Phase-2 §5): args + receiver are now inferred (in `expr_ty`), so
1409        // check each argument against the statically-resolved callee's parameter types.
1410        self.check_call_args(callee, args);
1411        ret
1412    }
1413
1414    /// Raise `UNSAFE_CALL_ARGUMENT` for each argument whose static type needs an unsafe implicit
1415    /// cast (`Variant` / a downcast) into the resolved parameter type — Godot's per-argument
1416    /// value-prop warning. Only fires when the callee resolves to a concrete signature here; an
1417    /// uninformative argument (the cross-file seam) is `Assign::Ok` and correctly silent, and an
1418    /// untyped parameter accepts anything.
1419    fn check_call_args(&mut self, callee: ExprId, args: &[ExprId]) {
1420        let Some(params) = self.call_param_tys(callee) else {
1421            return;
1422        };
1423        for (i, &arg) in args.iter().enumerate() {
1424            let Some(param_ty) = params.get(i) else {
1425                break; // a vararg tail or an arity mismatch — not an argument-type concern
1426            };
1427            if param_ty.is_uninformative() || param_ty.is_variant() {
1428                continue; // an untyped parameter accepts anything safely
1429            }
1430            // A missing arg type defaults to the seam (never warns), not `Variant` (would warn).
1431            let arg_ty = self.expr_ty.get(&arg).cloned().unwrap_or(Ty::Unknown);
1432            if ty::is_assignable(self.api, &arg_ty, param_ty) == Assign::OkUnsafe {
1433                let pl = param_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
1434                let al = arg_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
1435                self.warn(
1436                    self.range_of(arg),
1437                    WarningCode::UnsafeCallArgument,
1438                    format!(
1439                        "The argument {} requires a value of type \"{pl}\" but is passed \"{al}\", which is unsafe.",
1440                        i + 1
1441                    ),
1442                );
1443            }
1444        }
1445    }
1446
1447    /// Parameter types of a statically-resolved callee, for [`Self::check_call_args`]. `None` when
1448    /// the callee isn't concretely resolvable here (a cross-file script method — params aren't
1449    /// modeled —, a builtin/utility, a `Callable` value): those raise no argument warning.
1450    fn call_param_tys(&self, callee: ExprId) -> Option<Vec<Ty>> {
1451        match self.body.expr(callee) {
1452            Expr::Name(name) => self.name_call_param_tys(name),
1453            Expr::Field { receiver, name, .. } => match self.expr_ty.get(receiver)? {
1454                Ty::Object(class) => match self.api.lookup_member(*class, name)? {
1455                    MemberRef::Method(sig) => Some(
1456                        sig.params
1457                            .iter()
1458                            .map(|p| ty::resolve_tyref(self.api, &p.ty))
1459                            .collect(),
1460                    ),
1461                    _ => None,
1462                },
1463                // ScriptRef / builtin / seam receivers: params not uniformly modeled — skip.
1464                _ => None,
1465            },
1466            _ => None,
1467        }
1468    }
1469
1470    /// Parameter types for a bare-name call (`foo(...)` / an inherited `method(...)`): an own `func`
1471    /// first, then the `self` engine base's method. Utilities/builtins are skipped (looser, often
1472    /// variadic typing — out of the conservative MVP slice).
1473    fn name_call_param_tys(&self, name: &str) -> Option<Vec<Ty>> {
1474        if let Some(item) = self.class.lookup(name)
1475            && let Some(Member::Func(f)) = self.class.member(item)
1476        {
1477            return Some(
1478                f.params
1479                    .iter()
1480                    .map(|p| {
1481                        p.type_ref.as_deref().map_or(Ty::Variant, |t| {
1482                            resolve::resolve_type_name(self.db, self.api, t)
1483                        })
1484                    })
1485                    .collect(),
1486            );
1487        }
1488        if let Ty::Object(base) = self.class.base
1489            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
1490        {
1491            return Some(
1492                sig.params
1493                    .iter()
1494                    .map(|p| ty::resolve_tyref(self.api, &p.ty))
1495                    .collect(),
1496            );
1497        }
1498        None
1499    }
1500
1501    /// Resolve a bare-name call (`foo(...)`): own method → utility/builtin fn → constructor.
1502    fn resolve_call_name(&self, name: &str) -> Ty {
1503        if let Some(item) = self.class.lookup(name)
1504            && let Some(Member::Func(f)) = self.class.member(item)
1505        {
1506            return self.func_return_ty(f.return_type.as_deref());
1507        }
1508        // A bare call inside the class is `self.name(...)` — resolve against the inherited base.
1509        if let Ty::Object(base) = self.class.base
1510            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
1511        {
1512            return ty::resolve_tyref(self.api, &sig.return_ty);
1513        }
1514        if let Some(u) = self.api.utility(name) {
1515            return ty::resolve_tyref(self.api, &u.return_ty);
1516        }
1517        if let Some(f) = self.api.gdscript_builtin(name) {
1518            return resolve::layer_to_ty(self.api, f.ret);
1519        }
1520        // A builtin / class name used as a constructor: `Vector2(...)` / `Array(...)`.
1521        // Normalize via `resolve_tyref` so `Array`/`Dictionary`/`Callable`/`Signal` land on
1522        // their dedicated `Ty` variants rather than `Builtin(...)`.
1523        if let Some(b) = self.api.builtin_by_name(name) {
1524            return ty::resolve_tyref(self.api, &TyRef::Builtin(b));
1525        }
1526        // Otherwise unresolved — most likely a cross-file global / autoload / a method on a
1527        // `class_name` base we can't see. Treat as the seam so `var x := foo()` never warns.
1528        Ty::Unknown
1529    }
1530
1531    fn func_return_ty(&self, annotation: Option<&str>) -> Ty {
1532        annotation.map_or(Ty::Variant, |t| {
1533            resolve::resolve_type_name(self.db, self.api, t)
1534        })
1535    }
1536
1537    /// Member access `receiver.name`. When `as_method`, resolve a method (and use its return
1538    /// type); otherwise resolve a property/const/etc. Raises `UNSAFE_*` only on a statically
1539    /// **known** receiver.
1540    fn infer_field(
1541        &mut self,
1542        receiver: ExprId,
1543        name: &str,
1544        name_range: TextRange,
1545        as_method: bool,
1546    ) -> Ty {
1547        let is_self = matches!(self.body.expr(receiver), Expr::SelfExpr);
1548        let recv_ty = self.infer_expr(receiver, &Expectation::None);
1549
1550        // `self.member` consults this file's own members first (Playbook §3.2).
1551        if is_self && let Some(item) = self.class.lookup(name) {
1552            return self.own_member_ty(item, as_method);
1553        }
1554
1555        match &recv_ty {
1556            // Uninformative receivers are unchecked and **propagate the seam**: a member of an
1557            // `Unknown` (cross-file) value is itself `Unknown` (never warns), a member of a
1558            // `Variant` is `Variant`, of an `Error` is `Error`. Collapsing `Unknown` to
1559            // `Variant` here would wrongly fire `INFERENCE_ON_VARIANT` on `var x := other.field`.
1560            t if t.is_uninformative() => recv_ty.clone(),
1561            Ty::Object(class) => {
1562                if name == "new" {
1563                    // `Class.new(...)` always constructs an instance of the class (some classes,
1564                    // e.g. GDScript, also carry a modeled `new` member — the constructor wins).
1565                    recv_ty.clone()
1566                } else if let Some(m) = self.api.lookup_member(*class, name) {
1567                    self.member_ref_ty(&m, as_method)
1568                } else if let Some(t) = self.class_enum_value(*class, name) {
1569                    // A statically-accessed enum value (`Control.PRESET_FULL_RECT`).
1570                    t
1571                } else {
1572                    // Self with an Object base already checked own members above.
1573                    self.emit_unsafe(name, &recv_ty, name_range, as_method);
1574                    Ty::Variant
1575                }
1576            }
1577            Ty::Builtin(_) | Ty::Array(_) | Ty::Dict(..) | Ty::Callable | Ty::Signal(_) => {
1578                self.builtin_member_ty(&recv_ty, name, name_range, as_method)
1579            }
1580            // Enum value access (`MyEnum.VALUE`) is an `int`.
1581            Ty::Enum(_) => self.int_ty(),
1582            // A cross-file script reference: resolve the member against its (own) member table.
1583            Ty::ScriptRef(sref) => self.script_member_ty(*sref, name, as_method),
1584            _ => Ty::Variant,
1585        }
1586    }
1587
1588    /// A member of a cross-file script (`ScriptRef`): looked up in the script's own member table
1589    /// (M1). A member we don't model — e.g. one inherited from a base we don't resolve until M2 —
1590    /// yields the seam (`Unknown`), **never** an `UNSAFE_*` warning. `Class.new(...)` constructs
1591    /// an instance of the class.
1592    fn script_member_ty(&self, sref: ScriptRefId, name: &str, as_method: bool) -> Ty {
1593        if name == "new" {
1594            return Ty::ScriptRef(sref);
1595        }
1596        self.script_member_walk(sref, name, as_method, 0)
1597            .unwrap_or(Ty::Unknown)
1598    }
1599
1600    /// Walk a script class's `extends` chain for `name`: own members first, then a user base
1601    /// (another `ScriptRef`), then an engine base (the API table). Depth-bounded so a cyclic
1602    /// `extends` cannot loop. `None` = not found anywhere in the chain (the seam).
1603    fn script_member_walk(
1604        &self,
1605        sref: ScriptRefId,
1606        name: &str,
1607        as_method: bool,
1608        depth: u32,
1609    ) -> Option<Ty> {
1610        if depth > 32 {
1611            return None;
1612        }
1613        let file = self.db.file_text(FileId(sref.0))?;
1614        let sc = crate::queries::script_class(self.db, file);
1615        if let Some(m) = sc.member(name) {
1616            return Some(match m {
1617                crate::queries::MemberSig::Method(ret) => {
1618                    if as_method {
1619                        ret.clone()
1620                    } else {
1621                        Ty::Callable
1622                    }
1623                }
1624                crate::queries::MemberSig::Field(t) => t.clone(),
1625                crate::queries::MemberSig::Signal => Ty::Signal(None),
1626            });
1627        }
1628        // Not an own member — continue up the inheritance chain.
1629        match sc.base() {
1630            Ty::ScriptRef(base) => self.script_member_walk(*base, name, as_method, depth + 1),
1631            Ty::Object(class) => self
1632                .api
1633                .lookup_member(*class, name)
1634                .map(|m| self.member_ref_ty(&m, as_method)),
1635            _ => None,
1636        }
1637    }
1638
1639    /// Whether a value of type `sub` is statically a subtype of `sup` — composing user `ScriptRef`
1640    /// `extends` chains with the engine class table (M4, for `is`/`as` widen-only narrowing). A
1641    /// `ScriptRef` IS-A its native base (so `script_value is Node` holds), but Godot's asymmetry is
1642    /// honored: a native/script value is **not** a subtype of an *unrelated* user script.
1643    fn is_subtype(&self, sub: &Ty, sup: &Ty) -> bool {
1644        match (sub, sup) {
1645            (Ty::Object(a), Ty::Object(b)) => self.api.is_subclass(*a, *b),
1646            (Ty::ScriptRef(a), Ty::ScriptRef(b)) => self.script_is_subtype(*a, *b, 0),
1647            (Ty::ScriptRef(a), Ty::Object(b)) => self.script_extends_engine(*a, *b, 0),
1648            _ => false,
1649        }
1650    }
1651
1652    /// Whether script `sub` is `sup` or transitively extends it — walk the `extends` base chain by
1653    /// script identity (depth-bounded, like [`script_member_walk`](Self::script_member_walk)).
1654    fn script_is_subtype(&self, sub: ScriptRefId, sup: ScriptRefId, depth: u32) -> bool {
1655        if depth > 32 {
1656            return false;
1657        }
1658        if sub == sup {
1659            return true;
1660        }
1661        let Some(file) = self.db.file_text(FileId(sub.0)) else {
1662            return false;
1663        };
1664        match crate::queries::script_class(self.db, file).base() {
1665            Ty::ScriptRef(base) => self.script_is_subtype(*base, sup, depth + 1),
1666            _ => false,
1667        }
1668    }
1669
1670    /// Whether script `sub`'s `extends` chain reaches engine class `sup_native` at its native base.
1671    fn script_extends_engine(
1672        &self,
1673        sub: ScriptRefId,
1674        sup_native: gdscript_api::ClassId,
1675        depth: u32,
1676    ) -> bool {
1677        if depth > 32 {
1678            return false;
1679        }
1680        let Some(file) = self.db.file_text(FileId(sub.0)) else {
1681            return false;
1682        };
1683        match crate::queries::script_class(self.db, file).base() {
1684            Ty::ScriptRef(base) => self.script_extends_engine(*base, sup_native, depth + 1),
1685            Ty::Object(native) => self.api.is_subclass(*native, sup_native),
1686            _ => false,
1687        }
1688    }
1689
1690    fn emit_unsafe(&mut self, name: &str, recv: &Ty, range: TextRange, as_method: bool) {
1691        let recv_label = recv.label(self.api).unwrap_or_else(|| "?".to_owned());
1692        let (code, message) = if as_method {
1693            (
1694                WarningCode::UnsafeMethodAccess,
1695                format!(
1696                    "The method \"{name}()\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
1697                ),
1698            )
1699        } else {
1700            (
1701                WarningCode::UnsafePropertyAccess,
1702                format!(
1703                    "The property \"{name}\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
1704                ),
1705            )
1706        };
1707        self.warn(range, code, message);
1708    }
1709
1710    fn member_ref_ty(&self, m: &MemberRef, as_method: bool) -> Ty {
1711        match m {
1712            MemberRef::Method(sig) => {
1713                if as_method {
1714                    ty::resolve_tyref(self.api, &sig.return_ty)
1715                } else {
1716                    Ty::Callable
1717                }
1718            }
1719            MemberRef::Property(p) => p.enum_of.as_ref().map_or_else(
1720                || ty::resolve_tyref(self.api, &p.ty),
1721                |q| {
1722                    Ty::Enum(EnumRef {
1723                        qualified: SmolStr::new(q),
1724                        bitfield: false,
1725                    })
1726                },
1727            ),
1728            MemberRef::Const(c) => ty::resolve_tyref(self.api, &c.ty),
1729            MemberRef::Signal(_) => Ty::Signal(None),
1730            MemberRef::Enum(_) => Ty::Variant,
1731        }
1732    }
1733
1734    fn builtin_member_ty(
1735        &mut self,
1736        recv: &Ty,
1737        name: &str,
1738        range: TextRange,
1739        as_method: bool,
1740    ) -> Ty {
1741        let Some(bid) = self.builtin_id_of(recv) else {
1742            return Ty::Variant;
1743        };
1744        if as_method {
1745            return if let Some(sig) = self.api.builtin_method(bid, name) {
1746                ty::resolve_tyref(self.api, &sig.return_ty)
1747            } else {
1748                self.emit_unsafe(name, recv, range, true);
1749                Ty::Variant
1750            };
1751        }
1752        if let Some(member) = self.api.builtin_member(bid, name) {
1753            return ty::resolve_tyref(self.api, &member.ty);
1754        }
1755        // Static constants (`Vector2.ZERO`, `Color.WHITE`) and enum values (`Variant.Type.*`).
1756        let data = self.api.builtin(bid);
1757        if let Some(c) = data.constants.iter().find(|c| c.name == name) {
1758            return ty::resolve_tyref(self.api, &c.ty);
1759        }
1760        if data
1761            .enums
1762            .iter()
1763            .any(|e| e.values.iter().any(|v| v.name == name))
1764        {
1765            return self.int_ty();
1766        }
1767        if self.api.builtin_method(bid, name).is_some() {
1768            return Ty::Callable;
1769        }
1770        self.emit_unsafe(name, recv, range, false);
1771        Ty::Variant
1772    }
1773
1774    /// The type of a class enum **value** accessed statically (`Control.PRESET_FULL_RECT`):
1775    /// the engine exposes enum values as class members, so search every (inherited) enum's
1776    /// values. Returns the value's **declaring enum type** (`Ty::Enum`) — mirroring how a
1777    /// `Class.Enum` *annotation* resolves (`resolve::resolve_named`), so an enum member assigned
1778    /// to a slot of that same enum is `Assign::Ok`, not a false `INT_AS_ENUM_WITHOUT_CAST`. (An
1779    /// enum value is still freely assignable to `int` — see `ty::is_assignable`.)
1780    fn class_enum_value(&self, class: gdscript_api::ClassId, name: &str) -> Option<Ty> {
1781        let mut cur = Some(class);
1782        while let Some(cid) = cur {
1783            let c = self.api.class(cid);
1784            if let Some(e) = c
1785                .enums
1786                .iter()
1787                .find(|e| e.values.iter().any(|v| v.name == name))
1788            {
1789                return Some(Ty::Enum(EnumRef {
1790                    qualified: SmolStr::new(format!("{}.{}", c.name, e.name)),
1791                    bitfield: e.is_bitfield,
1792                }));
1793            }
1794            cur = c.base;
1795        }
1796        None
1797    }
1798
1799    /// The builtin id backing a builtin / `Array` / `Dictionary` receiver.
1800    fn builtin_id_of(&self, ty: &Ty) -> Option<gdscript_api::BuiltinId> {
1801        match ty {
1802            Ty::Builtin(b) => Some(*b),
1803            Ty::Array(_) => self.api.builtin_by_name("Array"),
1804            Ty::Dict(..) => self.api.builtin_by_name("Dictionary"),
1805            Ty::Callable => self.api.builtin_by_name("Callable"),
1806            Ty::Signal(_) => self.api.builtin_by_name("Signal"),
1807            _ => None,
1808        }
1809    }
1810
1811    /// The element type of an indexing expression (Playbook §2 switch).
1812    fn index_ty(&self, base: &Ty) -> Ty {
1813        match base {
1814            Ty::Array(elem) => (**elem).clone(),
1815            Ty::Builtin(b) => self
1816                .api
1817                .builtin(*b)
1818                .indexing_return
1819                .as_ref()
1820                .map_or(Ty::Variant, |r| ty::resolve_tyref(self.api, r)),
1821            // Indexing through the seam stays on the seam (never warns).
1822            Ty::Unknown => Ty::Unknown,
1823            Ty::Error => Ty::Error,
1824            _ => Ty::Variant,
1825        }
1826    }
1827
1828    /// The loop variable's type for `for v in iter:` (Playbook §2 switch).
1829    fn loop_var_ty(&self, iter: &Ty) -> Ty {
1830        match iter {
1831            Ty::Array(elem) => (**elem).clone(),
1832            Ty::Builtin(b) => {
1833                let data = self.api.builtin(*b);
1834                if data.name == "int" {
1835                    // `for i in 5` / `for i in range(...)` → int.
1836                    self.int_ty()
1837                } else if let Some(r) = &data.indexing_return {
1838                    // `for c in "abc"` → String; `for s in packed_string_array` → String; …
1839                    ty::resolve_tyref(self.api, r)
1840                } else {
1841                    Ty::Variant
1842                }
1843            }
1844            // Iterating a seam value keeps the loop var on the seam (never warns).
1845            Ty::Unknown => Ty::Unknown,
1846            Ty::Error => Ty::Error,
1847            _ => Ty::Variant,
1848        }
1849    }
1850
1851    fn infer_lambda(&mut self, params: &[ParamBinding], body: &[body::StmtId]) {
1852        // Lambda params shadow within the body; restore the outer locals afterward. A `return`
1853        // inside the lambda is the *lambda's* return, not the enclosing function's — so disable
1854        // return checking (set the expected return to `Variant`) while walking the body.
1855        let saved_locals = self.locals.clone();
1856        let saved_ret = std::mem::replace(&mut self.return_ty, Ty::Variant);
1857        for p in params {
1858            let ty = self.param_ty(p);
1859            self.bindings.push(Binding {
1860                name: p.name.clone(),
1861                name_range: p.name_range,
1862                ty: ty.clone(),
1863                init: None,
1864                annotated: p.type_ref.is_some(),
1865                inferred_colon_eq: false,
1866                is_const: false,
1867                kind: BindingKind::Param,
1868            });
1869            self.locals.insert(p.name.clone(), ty);
1870        }
1871        self.infer_block(body);
1872        self.return_ty = saved_ret;
1873        self.locals = saved_locals;
1874    }
1875
1876    fn param_ty(&mut self, p: &ParamBinding) -> Ty {
1877        if let Some(ptr) = p.type_ref {
1878            return self.resolve_ptr_ty(ptr);
1879        }
1880        // An unannotated param infers from its default, else `Variant`.
1881        p.default
1882            .map_or(Ty::Variant, |e| self.infer_expr(e, &Expectation::None))
1883    }
1884
1885    // ---- name resolution (local → class member → inherited → global) ----
1886
1887    fn resolve_name(&mut self, id: ExprId, name: &str) -> Ty {
1888        // Record a read of a local/param for the `UNUSED_*` analysis (before the narrowing check,
1889        // so a narrowed read still counts as used).
1890        if self.locals.contains_key(name) {
1891            self.used_locals.insert(SmolStr::new(name));
1892        }
1893        // Flow narrowing wins over the binding's declared type.
1894        if let Some(key) = self.narrow_key(id)
1895            && let Some(t) = self.narrowing.get(&key)
1896        {
1897            return t.clone();
1898        }
1899        if let Some(t) = self.locals.get(name) {
1900            return t.clone();
1901        }
1902        if let Some(item) = self.class.lookup(name) {
1903            return self.own_member_ty(item, false);
1904        }
1905        // Inherited members: an engine `Object` base via the API table, or a user `ScriptRef`
1906        // base via the script member walk (M2 — so a class extending another class_name sees its
1907        // inherited members).
1908        match self.class.base.clone() {
1909            Ty::Object(base) => {
1910                if let Some(m) = self.api.lookup_member(base, name) {
1911                    return self.member_ref_ty(&m, false);
1912                }
1913            }
1914            Ty::ScriptRef(base) => {
1915                if let Some(t) = self.script_member_walk(base, name, false, 0) {
1916                    return t;
1917                }
1918            }
1919            _ => {}
1920        }
1921        if let Some(g) = resolve::resolve_global(self.api, name) {
1922            return global_ty(&g);
1923        }
1924        // A project-global `class_name` used as a value — the class itself, for static access
1925        // (`V.fc()`) or as a constructor (`Player.new()`). Resolves to a `ScriptRef` via the
1926        // registry. Precedence (Godot `reduce_identifier`): `class_name` global ≫ autoload
1927        // singleton. So try `class_name` first, then a `*`-autoload, then the seam.
1928        let by_class = resolve::resolve_external(
1929            self.db,
1930            &resolve::ExternalRef::ClassName(SmolStr::new(name)),
1931        );
1932        if !by_class.is_unknown() {
1933            return by_class;
1934        }
1935        resolve::resolve_external(self.db, &resolve::ExternalRef::Autoload(SmolStr::new(name)))
1936    }
1937
1938    fn own_member_ty(&self, item: ClassItem, as_method: bool) -> Ty {
1939        match item {
1940            ClassItem::EnumVariant => self.int_ty(),
1941            ClassItem::Member(_) => match self.class.member(item) {
1942                Some(Member::Var(v)) => self.field_ty(&v.name, v.ptr),
1943                Some(Member::Const(c)) => self.field_ty(&c.name, c.ptr),
1944                Some(Member::Func(f)) => {
1945                    if as_method {
1946                        self.func_return_ty(f.return_type.as_deref())
1947                    } else {
1948                        Ty::Callable
1949                    }
1950                }
1951                Some(Member::Signal(_)) => Ty::Signal(None),
1952                Some(Member::Class(_)) => Ty::Unknown,
1953                Some(Member::Enum(_)) | None => Ty::Variant,
1954            },
1955        }
1956    }
1957
1958    /// The type of an own field (`var`/`const`): the type seeded by the field pre-pass (which
1959    /// captures the inferred type of `var n := 0`), falling back to the written annotation.
1960    fn field_ty(&self, name: &str, ptr: AstPtr) -> Ty {
1961        if let Some(t) = self.class.member_types.get(name) {
1962            return t.clone();
1963        }
1964        self.resolve_decl_annotation(ptr)
1965    }
1966
1967    /// Resolve a declaration's annotation (recovering its `TypeRef` node), else `Variant`.
1968    fn resolve_decl_annotation(&self, ptr: AstPtr) -> Ty {
1969        let Some(node) = ptr.to_node(self.root) else {
1970            return Ty::Variant;
1971        };
1972        cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
1973            .map_or(Ty::Variant, |t| {
1974                resolve::resolve_type_ref(self.db, self.api, &t)
1975            })
1976    }
1977
1978    // ---- narrowing ----
1979
1980    /// Build the narrowing env for a statement from the precomputed flow facts (Workstream 2).
1981    ///
1982    /// Only `Is` facts contribute a type (`NotNull`/`Not` are recorded by the flow pass but not yet
1983    /// consumed for typing — the 1.0 cut). The **widen-only + `is_uninformative`** soundness gate is
1984    /// preserved verbatim from the old `apply_narrowing`: `is`-narrowing is a deliberate divergence
1985    /// from upstream Godot (whose `is` does not flow-narrow), kept widen-only so it never produces a
1986    /// type Godot would reject — narrow only when the tested type is a downcast of the place's
1987    /// declared type, or the declared type is uninformative; never un-narrow a known subtype
1988    /// (`d: Derived; if d is Base` keeps `Derived`), never narrow to a type we couldn't resolve.
1989    fn facts_to_narrowing(&self, id: body::StmtId) -> FxHashMap<String, Ty> {
1990        let mut out = FxHashMap::default();
1991        if let Some(facts) = self.flow.facts_before(id) {
1992            for (place, nt) in facts.iter() {
1993                if let Some((key, ty)) = self.narrowing_entry(place, nt) {
1994                    out.insert(key, ty);
1995                }
1996            }
1997        }
1998        out
1999    }
2000
2001    /// Resolve one flow fact into a `(dotted-key, narrowed-type)` narrowing entry, applying the
2002    /// widen-only + `is_uninformative` soundness gate. `None` if the fact doesn't narrow a type
2003    /// (a `NotNull`/`Not`, an unresolvable/uninformative type, or an un-narrowing of a known subtype).
2004    fn narrowing_entry(&self, place: &Place, nt: &NarrowedTy) -> Option<(String, Ty)> {
2005        let NarrowedTy::Is(ptr) = nt else {
2006            return None;
2007        };
2008        let narrowed = self.resolve_ptr_ty(*ptr);
2009        if narrowed.is_uninformative() {
2010            return None;
2011        }
2012        // Gate against a local/param's declared type; for `self`-members / field chains the
2013        // `is_uninformative` check above is the soundness floor.
2014        if let Place::Local(n) = place
2015            && let Some(cur) = self.locals.get(n)
2016            && !cur.is_uninformative()
2017            && !self.is_subtype(&narrowed, cur)
2018        {
2019            return None;
2020        }
2021        Some((place.dotted_key(), narrowed))
2022    }
2023
2024    /// Apply a condition's short-circuit narrowing to the active env, for typing the RHS of an
2025    /// `and`/`or` (Workstream 2): `if x is T and x.method():` narrows `x` for `x.method()`.
2026    fn apply_condition_facts(&mut self, cond: ExprId, truthy: bool) {
2027        for (place, nt) in flow::condition_facts(self.body, cond, truthy) {
2028            if let Some((key, ty)) = self.narrowing_entry(&place, &nt) {
2029                self.narrowing.insert(key, ty);
2030            }
2031        }
2032    }
2033
2034    /// A dotted access-path key for narrowing (`x`, `self.field`, `a.b.c`), or `None` for a
2035    /// non-path expression.
2036    fn narrow_key(&self, id: ExprId) -> Option<String> {
2037        match self.body.expr(id) {
2038            Expr::Name(n) => Some(n.to_string()),
2039            Expr::SelfExpr => Some("self".to_owned()),
2040            Expr::Paren(inner) => self.narrow_key(*inner),
2041            Expr::Field { receiver, name, .. } => {
2042                Some(format!("{}.{name}", self.narrow_key(*receiver)?))
2043            }
2044            _ => None,
2045        }
2046    }
2047
2048    fn resolve_ptr_ty(&self, ptr: AstPtr) -> Ty {
2049        ptr.to_node(self.root).map_or(Ty::Variant, |n| {
2050            resolve::resolve_type_ref(self.db, self.api, &n)
2051        })
2052    }
2053
2054    // ---- helpers ----
2055
2056    /// The join (least upper bound) of two branch types — conservative: equal types collapse,
2057    /// a subtype widens to its supertype, else `Variant`.
2058    ///
2059    /// The three uninformative markers do NOT collapse to `Variant` — that would defeat the
2060    /// seam. They propagate by priority: `Error` (already diagnosed) → `Unknown` (the cross-file
2061    /// seam — must never warn or cascade) → `Variant` (the gradual top). So
2062    /// `x if c else <unknown>` stays `Unknown`, and `var y := (x if c else unknown)` does not
2063    /// fire a false `INFERENCE_ON_VARIANT`.
2064    fn join(&self, a: &Ty, b: &Ty) -> Ty {
2065        if a == b {
2066            return a.clone();
2067        }
2068        if a.is_error() || b.is_error() {
2069            return Ty::Error;
2070        }
2071        if a.is_unknown() || b.is_unknown() {
2072            return Ty::Unknown;
2073        }
2074        if a.is_variant() || b.is_variant() {
2075            return Ty::Variant;
2076        }
2077        if ty::is_assignable(self.api, a, b) == Assign::Ok {
2078            return b.clone();
2079        }
2080        if ty::is_assignable(self.api, b, a) == Assign::Ok {
2081            return a.clone();
2082        }
2083        Ty::Variant
2084    }
2085}
2086
2087/// Map a resolved global definition to the type of a bare reference to it.
2088fn global_ty(g: &GlobalDef) -> Ty {
2089    match g {
2090        GlobalDef::Const(t) => t.clone(),
2091        GlobalDef::Singleton(c) | GlobalDef::ClassType(c) => Ty::Object(*c),
2092        GlobalDef::BuiltinType(b) => Ty::Builtin(*b),
2093        // A bare function referenced as a value is a `Callable`; an enum namespace is opaque.
2094        GlobalDef::Builtin | GlobalDef::Utility => Ty::Callable,
2095        GlobalDef::GlobalEnum => Ty::Variant,
2096    }
2097}
2098
2099fn inference_on_variant_msg(kind: &str) -> String {
2100    format!(
2101        "The {kind} type is being inferred from a Variant value, so it will be typed as Variant."
2102    )
2103}
2104
2105/// The `extension_api.json` operator spelling for a binary operator.
2106fn op_symbol(op: BinOp) -> Option<&'static str> {
2107    Some(match op {
2108        BinOp::Add => "+",
2109        BinOp::Sub => "-",
2110        BinOp::Mul => "*",
2111        BinOp::Div => "/",
2112        BinOp::Mod => "%",
2113        BinOp::Pow => "**",
2114        BinOp::BitAnd => "&",
2115        BinOp::BitOr => "|",
2116        BinOp::BitXor => "^",
2117        BinOp::Shl => "<<",
2118        BinOp::Shr => ">>",
2119        _ => return None,
2120    })
2121}
2122
2123#[cfg(test)]
2124mod tests {
2125    use super::*;
2126    use crate::item_tree::item_tree;
2127    use gdscript_syntax::{SyntaxKind, parse};
2128
2129    struct Harness {
2130        result: InferenceResult,
2131        body: Body,
2132    }
2133
2134    /// Infer the (first) function in `src` against a fresh class scope.
2135    fn infer_first_func(src: &str) -> Harness {
2136        let api = gdscript_api::bundled();
2137        let db = gdscript_db::RootDatabase::default();
2138        let root = parse(src).syntax_node();
2139        let tree = item_tree(&root);
2140        let class = ClassScope::new(&db, api, &tree, None);
2141        let func = gdscript_syntax::ast::descendants(&root)
2142            .into_iter()
2143            .find(|n| n.kind() == SyntaxKind::FuncDecl)
2144            .expect("a function");
2145        let body = body::body_of_func(&func);
2146        let return_ty = cst::first_child(&func, |k| k == SyntaxKind::TypeRef)
2147            .map_or(Ty::Variant, |t| resolve::resolve_type_ref(&db, api, &t));
2148        let result = infer(&db, api, &root, &class, &body, return_ty, true);
2149        Harness { result, body }
2150    }
2151
2152    /// Every code inference produced — the ungated `diagnostics` plus the severity-free
2153    /// `raw_warnings` (the gateable Godot codes, post-W1-M0). Infer-level tests assert what the
2154    /// checker *records*; the gate-level resolution is tested in `crate::warnings`.
2155    fn codes(h: &Harness) -> Vec<&str> {
2156        h.result
2157            .diagnostics
2158            .iter()
2159            .map(|d| d.code.as_str())
2160            .chain(h.result.raw_warnings.iter().map(|w| w.code.as_str()))
2161            .collect()
2162    }
2163
2164    /// Run the whole-file pass (Pass 1 field fixpoint + Pass 2 functions) and collect every
2165    /// diagnostic code (ungated diagnostics + raw gateable warnings). Drives `analyze_file`
2166    /// directly so the bounded member fixpoint runs.
2167    fn file_codes(src: &str) -> Vec<String> {
2168        let api = gdscript_api::bundled();
2169        let db = gdscript_db::RootDatabase::default();
2170        let root = parse(src).syntax_node();
2171        let fi = analyze_file(&db, api, &root, FileId(0));
2172        fi.diagnostics
2173            .iter()
2174            .map(|d| d.code.clone())
2175            .chain(fi.raw_warnings.iter().map(|w| w.code.as_str().to_owned()))
2176            .collect()
2177    }
2178
2179    #[test]
2180    fn integer_division_warns() {
2181        let h = infer_first_func("func f():\n\tvar x = 5 / 2\n");
2182        assert!(codes(&h).contains(&INTEGER_DIVISION));
2183    }
2184
2185    #[test]
2186    fn float_div_does_not_warn() {
2187        let h = infer_first_func("func f():\n\tvar x = 5.0 / 2\n");
2188        assert!(!codes(&h).contains(&INTEGER_DIVISION));
2189    }
2190
2191    #[test]
2192    fn type_mismatch_on_hard_annotation() {
2193        let h = infer_first_func("func f():\n\tvar s: String = 5\n");
2194        assert!(codes(&h).contains(&TYPE_MISMATCH));
2195    }
2196
2197    #[test]
2198    fn narrowing_conversion_float_to_int() {
2199        let h = infer_first_func("func f():\n\tvar n: int = 1.5\n");
2200        assert!(codes(&h).contains(&NARROWING_CONVERSION));
2201    }
2202
2203    #[test]
2204    fn int_to_float_is_silent() {
2205        let h = infer_first_func("func f():\n\tvar x: float = 3\n\treturn x\n");
2206        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
2207    }
2208
2209    #[test]
2210    fn local_shadowing_a_param_warns_shadowed_variable() {
2211        let h = infer_first_func("func f(x):\n\tvar x = 1\n\treturn x\n");
2212        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
2213    }
2214
2215    #[test]
2216    fn local_shadowing_a_class_member_warns_shadowed_variable() {
2217        // The class scope (built from the whole file) sees the member `health`; the local shadows it.
2218        let h =
2219            infer_first_func("var health = 100\nfunc f():\n\tvar health = 1\n\treturn health\n");
2220        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
2221    }
2222
2223    #[test]
2224    fn non_shadowing_local_does_not_warn_shadowed_variable() {
2225        let h = infer_first_func("func f(x):\n\tvar y = 1\n\treturn x + y\n");
2226        assert!(!codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
2227    }
2228
2229    #[test]
2230    fn enum_member_into_its_own_enum_slot_is_not_int_as_enum() {
2231        // `var m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE` is valid GDScript with no
2232        // cast — the enum member must type as its enum (not bare `int`), so `check_assign` sees
2233        // `Enum → Enum` (Ok). A regression here would false-warn on extremely common engine code.
2234        let h = infer_first_func(
2235            "func f():\n\tvar m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE\n\treturn m\n",
2236        );
2237        assert!(
2238            !codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
2239            "{:?}",
2240            codes(&h)
2241        );
2242    }
2243
2244    #[test]
2245    fn bare_int_into_enum_slot_still_warns() {
2246        // The fix must not over-suppress: a genuine uncast `int` into an enum slot still warns.
2247        let h = infer_first_func("func f():\n\tvar m: Tween.TweenProcessMode = 0\n\treturn m\n");
2248        assert!(
2249            codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
2250            "{:?}",
2251            codes(&h)
2252        );
2253    }
2254
2255    #[test]
2256    fn member_access_resolves_engine_property() {
2257        // In a Node script, bare `get_node(...)` resolves via the inherited base to Object(Node);
2258        // `get_parent()` is a real Node method → no UNSAFE.
2259        let h = infer_first_func(
2260            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.get_parent()\n",
2261        );
2262        assert!(
2263            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
2264            "{:?}",
2265            h.result.diagnostics
2266        );
2267    }
2268
2269    #[test]
2270    fn unsafe_method_on_known_type() {
2271        let h = infer_first_func(
2272            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.totally_bogus_method()\n",
2273        );
2274        assert!(
2275            codes(&h).contains(&UNSAFE_METHOD_ACCESS),
2276            "{:?}",
2277            h.result.diagnostics
2278        );
2279    }
2280
2281    #[test]
2282    fn is_narrowing_suppresses_unsafe() {
2283        // Without narrowing, `x.free()` on an untyped param would be unchecked anyway; with
2284        // `is Node` it is checked against Node and `free` IS a Node method → no UNSAFE.
2285        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.queue_free()\n");
2286        assert!(
2287            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
2288            "{:?}",
2289            h.result.diagnostics
2290        );
2291    }
2292
2293    #[test]
2294    fn is_narrowing_flags_real_missing_member() {
2295        // After `is Node`, x is Node; `.bogus()` is genuinely missing → UNSAFE.
2296        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.bogus_method()\n");
2297        assert!(codes(&h).contains(&UNSAFE_METHOD_ACCESS));
2298    }
2299
2300    #[test]
2301    fn early_return_is_guard_narrows_past_the_guard() {
2302        // `if not (x is Node): return` — the only non-returning path proves x is Node, so after the
2303        // guard a real Node method is safe and a missing one warns (Workstream 2, beats the engine).
2304        let safe =
2305            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.get_parent()\n");
2306        assert!(
2307            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
2308            "real Node method must not warn after the guard: {:?}",
2309            codes(&safe)
2310        );
2311        let bogus =
2312            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.bogus_method()\n");
2313        assert!(
2314            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
2315            "missing method must warn after the guard: {:?}",
2316            codes(&bogus)
2317        );
2318    }
2319
2320    #[test]
2321    fn and_short_circuit_narrows_the_rhs() {
2322        // `x is Node and x.<m>()` types the RHS under x: Node — a real method is safe, a missing
2323        // one warns. The engine does not narrow here (Workstream 2, beats the engine).
2324        let safe = infer_first_func("func f(x):\n\tif x is Node and x.get_parent():\n\t\tpass\n");
2325        assert!(
2326            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
2327            "real Node method in the and-rhs must not warn: {:?}",
2328            codes(&safe)
2329        );
2330        let bogus =
2331            infer_first_func("func f(x):\n\tif x is Node and x.bogus_method():\n\t\tpass\n");
2332        assert!(
2333            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
2334            "missing method in the and-rhs must warn: {:?}",
2335            codes(&bogus)
2336        );
2337    }
2338
2339    // ---- Workstream 1 M1: self-contained checks ----
2340
2341    #[test]
2342    fn empty_file_warns() {
2343        assert!(file_codes("").iter().any(|c| c == "EMPTY_FILE"));
2344        assert!(
2345            file_codes("# just a comment\n")
2346                .iter()
2347                .any(|c| c == "EMPTY_FILE")
2348        );
2349        assert!(
2350            file_codes("extends Node\n")
2351                .iter()
2352                .all(|c| c != "EMPTY_FILE")
2353        );
2354    }
2355
2356    #[test]
2357    fn unused_variable_and_parameter() {
2358        let h = infer_first_func("func f(unused_p):\n\tvar unused_v = 1\n");
2359        assert!(codes(&h).contains(&"UNUSED_PARAMETER"), "{:?}", codes(&h));
2360        assert!(codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
2361        // A used binding does not warn; a `_`-prefixed one is intentionally ignored.
2362        let used = infer_first_func("func f(p):\n\tvar v = p\n\treturn v\n");
2363        assert!(codes(&used).iter().all(|c| !c.starts_with("UNUSED")));
2364        let underscored = infer_first_func("func f(_ignored):\n\tpass\n");
2365        assert!(!codes(&underscored).contains(&"UNUSED_PARAMETER"));
2366    }
2367
2368    #[test]
2369    fn standalone_expression_and_ternary() {
2370        let expr = infer_first_func("func f(a, b):\n\ta + b\n");
2371        assert!(
2372            codes(&expr).contains(&"STANDALONE_EXPRESSION"),
2373            "{:?}",
2374            codes(&expr)
2375        );
2376        let tern = infer_first_func("func f(c):\n\t1 if c else 2\n");
2377        assert!(
2378            codes(&tern).contains(&"STANDALONE_TERNARY"),
2379            "{:?}",
2380            codes(&tern)
2381        );
2382        // A call statement has an effect — never flagged.
2383        let call = infer_first_func("func f(n):\n\tn.queue_free()\n");
2384        assert!(codes(&call).iter().all(|c| !c.starts_with("STANDALONE")));
2385    }
2386
2387    #[test]
2388    fn unreachable_code_after_return() {
2389        let h = infer_first_func("func f():\n\treturn\n\tprint(\"dead\")\n");
2390        assert!(codes(&h).contains(&"UNREACHABLE_CODE"), "{:?}", codes(&h));
2391    }
2392
2393    #[test]
2394    fn incompatible_ternary_warns() {
2395        // `"s" if c else 1` — String vs int, no common type.
2396        let h = infer_first_func("func f(c):\n\tvar x = \"s\" if c else 1\n\treturn x\n");
2397        assert!(
2398            codes(&h).contains(&"INCOMPATIBLE_TERNARY"),
2399            "{:?}",
2400            codes(&h)
2401        );
2402    }
2403
2404    #[test]
2405    fn variant_receiver_never_unsafe() {
2406        // Untyped param → Variant receiver → unchecked, no diagnostic.
2407        let h = infer_first_func("func f(x):\n\tx.anything_at_all()\n");
2408        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
2409    }
2410
2411    #[test]
2412    fn unsafe_call_argument_on_variant_into_typed_param() {
2413        // Passing an untyped (Variant) value to a typed own-method parameter needs an unsafe cast.
2414        let h = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n: Node2D):\n\tpass\n");
2415        assert!(
2416            codes(&h).contains(&UNSAFE_CALL_ARGUMENT),
2417            "{:?}",
2418            h.result.diagnostics
2419        );
2420    }
2421
2422    #[test]
2423    fn unsafe_call_argument_silent_on_safe_and_untyped() {
2424        // A subtype arg (upcast) is safe; an untyped parameter accepts anything — neither warns.
2425        let upcast =
2426            infer_first_func("func f(n: Node2D):\n\ttake(n)\nfunc take(n: Node):\n\tpass\n");
2427        assert!(
2428            !codes(&upcast).contains(&UNSAFE_CALL_ARGUMENT),
2429            "upcast is safe: {:?}",
2430            upcast.result.diagnostics
2431        );
2432        let untyped = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n):\n\tpass\n");
2433        assert!(
2434            !codes(&untyped).contains(&UNSAFE_CALL_ARGUMENT),
2435            "untyped param accepts anything: {:?}",
2436            untyped.result.diagnostics
2437        );
2438    }
2439
2440    #[test]
2441    fn inference_on_variant() {
2442        // `:=` from an untyped (Variant) param.
2443        let h = infer_first_func("func f(x):\n\tvar y := x\n");
2444        assert!(codes(&h).contains(&INFERENCE_ON_VARIANT));
2445    }
2446
2447    #[test]
2448    fn field_inferred_from_earlier_field_is_typed() {
2449        // W2-MEMBER-FIXPOINT: `b`'s initializer references the earlier field `a`. A single shallow
2450        // field pass would see `a` as `Variant` (seam) and fire INFERENCE_ON_VARIANT on `:= a`; the
2451        // bounded fixpoint seeds `a: int` so `a + 1` is `int` and `:=` is precise — no warning.
2452        let codes = file_codes("var a := 1\nvar b := a + 1\n");
2453        assert!(
2454            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
2455            "field `b` from earlier field `a` should type as int, not Variant: {codes:?}"
2456        );
2457    }
2458
2459    #[test]
2460    fn field_forward_reference_is_seamed_not_warned() {
2461        // A field referencing a *later* field still resolves through the fixpoint (both rounds
2462        // see each other's seeded type), and at worst lands on the conservative seam — never a
2463        // false INFERENCE_ON_VARIANT. (`b` precedes `a` lexically here.)
2464        let codes = file_codes("var b := a\nvar a := 1\n");
2465        assert!(
2466            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
2467            "forward field reference must not false-warn: {codes:?}"
2468        );
2469    }
2470
2471    #[test]
2472    fn standalone_inferred_field_unchanged() {
2473        // No-regression: a self-contained inferred field still types from its literal, no warning.
2474        let codes = file_codes("var n := 0\n");
2475        assert!(
2476            codes.is_empty(),
2477            "a literal-initialised field should produce no diagnostics: {codes:?}"
2478        );
2479    }
2480
2481    #[test]
2482    fn lambda_var_is_callable_not_variant() {
2483        let h = infer_first_func("func f():\n\tvar cb := func():\n\t\tpass\n");
2484        assert!(
2485            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2486            "{:?}",
2487            h.result.diagnostics
2488        );
2489    }
2490
2491    #[test]
2492    fn multiline_lambda_then_paren_line_no_false_warning() {
2493        // A multi-line lambda bound to a var, followed by a statement that begins with `(`.
2494        // The parser now ends the lambda at its dedent (the `(` line is its own statement), so
2495        // there is no spurious call-on-lambda and no false `INFERENCE_ON_VARIANT`.
2496        let src = "func f(state, i, loop):\n\tvar cb := func():\n\t\tif i >= state.size():\n\t\t\treturn\n\t(loop as SceneTree).process_frame.connect(cb, CONNECT_ONE_SHOT)\n";
2497        let h = infer_first_func(src);
2498        assert!(
2499            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2500            "{:?}",
2501            h.result.diagnostics
2502        );
2503    }
2504
2505    #[test]
2506    fn calling_a_callable_value_is_seam_not_variant() {
2507        // Invoking an arbitrary expression (here a parenthesized `Callable` value) reaches the
2508        // seam arm of `infer_call`: the return type isn't tracked, so the result is Unknown,
2509        // not `Variant`, and the inferred-on-Variant warning never fires.
2510        let src = "func f(cb: Callable):\n\tvar x := (cb)()\n\treturn x\n";
2511        let h = infer_first_func(src);
2512        assert!(
2513            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2514            "{:?}",
2515            h.result.diagnostics
2516        );
2517    }
2518
2519    #[test]
2520    fn ternary_with_seam_branch_does_not_collapse_to_variant() {
2521        // A ternary whose else-branch is the seam (`await` is untracked → Unknown) must `join`
2522        // to Unknown, NOT Variant — otherwise `var x := …` fires a false INFERENCE_ON_VARIANT.
2523        // (Regression: `join` used to absorb any uninformative branch to Variant.)
2524        let src =
2525            "func f(c: bool):\n\tvar x := 5 if c else await get_tree().process_frame\n\treturn x\n";
2526        let h = infer_first_func(src);
2527        assert!(
2528            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2529            "seam branch should keep the ternary on the seam: {:?}",
2530            h.result.diagnostics
2531        );
2532    }
2533
2534    #[test]
2535    fn await_a_coroutine_call_recovers_its_return_type() {
2536        // `await f()` yields the call's value, so await is identity on a non-signal operand:
2537        // `await make()` for `func make() -> int` types `x` as int (was the seam before).
2538        let src = "func g() -> int:\n\tvar x := await make()\n\treturn x\nfunc make() -> int:\n\treturn 5\n";
2539        let h = infer_first_func(src);
2540        assert!(
2541            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2542            "no false variant warning: {:?}",
2543            h.result.diagnostics
2544        );
2545        let api = gdscript_api::bundled();
2546        let x = &h.result.bindings[0];
2547        assert!(
2548            matches!(&x.ty, Ty::Builtin(b) if api.builtin(*b).name == "int"),
2549            "await make() should recover int, got {:?}",
2550            x.ty
2551        );
2552    }
2553
2554    #[test]
2555    fn await_a_signal_stays_the_seam() {
2556        // `await sig` yields the signal's payload (needs the Phase-3+ sig table) — must stay the seam,
2557        // never the Signal type itself, and never a false INFERENCE_ON_VARIANT.
2558        let src = "func f():\n\tvar x := await get_tree().process_frame\n\treturn x\n";
2559        let h = infer_first_func(src);
2560        assert!(
2561            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2562            "awaiting a signal must not warn: {:?}",
2563            h.result.diagnostics
2564        );
2565        assert!(
2566            matches!(&h.result.bindings[0].ty, Ty::Unknown),
2567            "awaiting a signal stays the seam, got {:?}",
2568            h.result.bindings[0].ty
2569        );
2570    }
2571
2572    #[test]
2573    fn for_var_over_packed_string_array_is_string() {
2574        // `for s in "a,b".split(",")` iterates a PackedStringArray → String, so `s.to_int()`
2575        // resolves and `var x := s` does not warn.
2576        let h = infer_first_func("func f():\n\tfor s in \"a,b\".split(\",\"):\n\t\tvar x := s\n");
2577        assert!(
2578            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2579            "{:?}",
2580            h.result.diagnostics
2581        );
2582    }
2583
2584    #[test]
2585    fn class_new_is_object_not_variant() {
2586        let h = infer_first_func("func f():\n\tvar s := GDScript.new()\n");
2587        assert!(
2588            !codes(&h).contains(&INFERENCE_ON_VARIANT),
2589            "{:?}",
2590            h.result.diagnostics
2591        );
2592    }
2593
2594    #[test]
2595    fn unknown_seam_never_warns() {
2596        // `preload(...)` is Unknown; `:=` from it does NOT warn, and member access is unchecked.
2597        let h = infer_first_func("func f():\n\tvar s := preload(\"res://x.gd\")\n\ts.whatever()\n");
2598        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
2599    }
2600
2601    #[test]
2602    fn expr_types_are_memoized_for_hover() {
2603        let h = infer_first_func("func f():\n\tvar n := 42\n");
2604        // The `42` literal expr should be typed int.
2605        let has_int = h
2606            .result
2607            .expr_ty
2608            .values()
2609            .any(|t| matches!(t, Ty::Builtin(_)));
2610        assert!(has_int);
2611        // sanity: the body lowered at least one expr
2612        assert!(!h.body.exprs.is_empty());
2613    }
2614}