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/// Every assignment-LHS `ExprId` in a body (`x = …` / `x += …`, all lowered to `BinOp::Assign`) —
131/// a *write* site, excluded from the `UNASSIGNED_VARIABLE` read-before-assign check.
132fn collect_assign_lhs(body: &Body) -> FxHashSet<ExprId> {
133    body.exprs
134        .iter()
135        .filter_map(|e| match e {
136            Expr::Bin {
137                op: BinOp::Assign,
138                lhs,
139                ..
140            } => Some(*lhs),
141            _ => None,
142        })
143        .collect()
144}
145
146#[must_use]
147#[allow(
148    clippy::too_many_lines,
149    reason = "the per-body inference orchestration reads best whole"
150)]
151pub fn infer(
152    db: &dyn Db,
153    api: &EngineApi,
154    root: &GdNode,
155    class: &ClassScope,
156    body: &Body,
157    return_ty: Ty,
158    is_func_body: bool,
159) -> InferenceResult {
160    let self_ty = class.self_ty.clone();
161    let mut cx = Cx {
162        db,
163        api,
164        root,
165        body,
166        class,
167        self_ty,
168        return_ty,
169        expr_ty: FxHashMap::default(),
170        bindings: Vec::new(),
171        diagnostics: Vec::new(),
172        raw_warnings: Vec::new(),
173        locals: FxHashMap::default(),
174        used_locals: FxHashSet::default(),
175        narrowing: FxHashMap::default(),
176        flow: flow::analyze(body),
177        is_func_body,
178        assigned: flow::analyze_assigned(
179            body,
180            &body
181                .params
182                .iter()
183                .map(|p| p.name.clone())
184                .collect::<Vec<_>>(),
185        ),
186        cur_stmt: None,
187        needs_assignment: FxHashSet::default(),
188        assign_lhs: collect_assign_lhs(body),
189    };
190    // Parameters bind first (their defaults can reference earlier params).
191    let params = body.params.clone();
192    for p in &params {
193        let ty = cx.param_ty(p);
194        cx.bindings.push(Binding {
195            name: p.name.clone(),
196            name_range: p.name_range,
197            ty: ty.clone(),
198            init: None,
199            annotated: p.type_ref.is_some(),
200            inferred_colon_eq: false,
201            is_const: false,
202            kind: BindingKind::Param,
203        });
204        cx.locals.insert(p.name.clone(), ty);
205    }
206    if let Some(tail) = body.tail {
207        cx.infer_expr(tail, &Expectation::None);
208    }
209    let block = body.block.clone();
210    cx.infer_block(&block);
211
212    // UNUSED_* — a declared local/param/const never read. Only for a *function* body: a class-field
213    // initializer body would otherwise false-flag every field (the member is read in other methods,
214    // not in its own initializer). `_`-prefixed names + loop/match captures are excluded.
215    if is_func_body {
216        let unused: Vec<(TextRange, WarningCode, String)> = cx
217            .bindings
218            .iter()
219            .filter_map(|b| {
220                if b.name.starts_with('_') || cx.used_locals.contains(&b.name) {
221                    return None;
222                }
223                let (code, what) = match b.kind {
224                    BindingKind::Param => (WarningCode::UnusedParameter, "parameter"),
225                    BindingKind::Var if b.is_const => {
226                        (WarningCode::UnusedLocalConstant, "local constant")
227                    }
228                    BindingKind::Var => (WarningCode::UnusedVariable, "local variable"),
229                    BindingKind::ForVar | BindingKind::MatchBind => return None,
230                };
231                Some((
232                    b.name_range,
233                    code,
234                    format!("The {what} \"{}\" is declared but never used.", b.name),
235                ))
236            })
237            .collect();
238        for (range, code, msg) in unused {
239            cx.warn(range, code, msg);
240        }
241    }
242
243    // UNREACHABLE_CODE — statements after a return/break/continue / exhaustive branch (Workstream 2).
244    let unreachable = cx.flow.unreachable_ranges(body);
245    for range in unreachable {
246        cx.warn(
247            range,
248            WarningCode::UnreachableCode,
249            "Unreachable code (statement after a return, break, continue, or an exhaustive match)."
250                .to_owned(),
251        );
252    }
253
254    // UNREACHABLE_PATTERN — a `match` arm after an unconditional catch-all (Workstream 2).
255    let unreachable_patterns = cx.flow.unreachable_pattern_ranges().to_vec();
256    for range in unreachable_patterns {
257        cx.warn(
258            range,
259            WarningCode::UnreachablePattern,
260            "Unreachable pattern: an earlier arm's wildcard (`_`) or `var` binding always matches."
261                .to_owned(),
262        );
263    }
264
265    InferenceResult {
266        expr_ty: cx.expr_ty,
267        bindings: cx.bindings,
268        diagnostics: cx.diagnostics,
269        raw_warnings: cx.raw_warnings,
270    }
271}
272
273/// Convenience: recover a function node from its [`AstPtr`], lower its body, resolve its
274/// declared return type, and infer it.
275#[must_use]
276pub fn infer_func(
277    db: &dyn Db,
278    api: &EngineApi,
279    root: &GdNode,
280    class: &ClassScope,
281    ptr: AstPtr,
282) -> InferenceResult {
283    let Some(node) = ptr.to_node(root) else {
284        return InferenceResult::default();
285    };
286    let body = body::body_of_func(&node);
287    // The return-type annotation is the FuncDecl's direct `TypeRef` child (params' type refs
288    // are nested inside the ParamList, so they are not direct children).
289    let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
290        .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
291    infer(db, api, root, class, &body, return_ty, true)
292}
293
294/// One inferred unit of a file: a function body or a class field's initializer, with its
295/// lowered [`Body`] and [`InferenceResult`] (kept so position-based features — hover, inlay,
296/// member completion — can map a cursor back through the source map).
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct Unit {
299    /// The source range this unit covers (the function decl or the field decl).
300    pub range: TextRange,
301    /// The lowered body.
302    pub body: Body,
303    /// The inference result.
304    pub result: InferenceResult,
305}
306
307/// The full single-file inference: the item tree, every inferred unit, and the merged §5
308/// diagnostics. The whole-file entry point the IDE layer consumes.
309#[derive(Debug, Clone, PartialEq, Eq, Default)]
310pub struct FileInference {
311    /// The lowered item tree.
312    pub tree: Arc<ItemTree>,
313    /// The inferred function/field units.
314    pub units: Vec<Unit>,
315    /// The ungated analyzer-native diagnostics, merged across units (`TYPE_MISMATCH`,
316    /// `INVALID_NODE_PATH`) plus the file-level `SHADOWED_GLOBAL_IDENTIFIER` / `CYCLIC_INHERITANCE`.
317    pub diagnostics: Vec<Diagnostic>,
318    /// The severity-free gateable Godot warnings, merged across units. The IDE layer resolves
319    /// these via [`crate::warnings::gate`] against the project's settings (Workstream 1).
320    pub raw_warnings: Vec<RawWarning>,
321}
322
323impl FileInference {
324    /// The innermost unit whose range contains `offset`.
325    #[must_use]
326    pub fn unit_at(&self, offset: u32) -> Option<&Unit> {
327        self.units
328            .iter()
329            .filter(|u| u.range.start <= offset && offset < u.range.end)
330            .min_by_key(|u| u.range.end - u.range.start)
331    }
332}
333
334/// Infer an entire file: lower its item tree, then infer every function body and every
335/// class-field initializer against a shared [`ClassScope`]. The single entry point for the
336/// IDE features (Playbook §6 — a pure `(api, parsed file) -> result` function).
337#[must_use]
338#[allow(clippy::too_many_lines)] // the two-pass field-fixpoint + function walk reads best whole
339pub fn analyze_file(db: &dyn Db, api: &EngineApi, root: &GdNode, file_id: FileId) -> FileInference {
340    let tree = item_tree(root);
341    let mut units = Vec::new();
342    let mut diagnostics = Vec::new();
343    let mut raw_warnings: Vec<RawWarning> = Vec::new();
344
345    // EMPTY_FILE — a script with no members, no `class_name`, and no `extends` (Workstream 1).
346    if tree.members.is_empty() && tree.class_name.is_none() && tree.extends.is_none() {
347        raw_warnings.push(RawWarning {
348            range: TextRange::new(0, 0),
349            code: WarningCode::EmptyFile,
350            message: "Empty script file.".to_owned(),
351        });
352    }
353    let mut member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
354    // `self` is the script's OWN class (a self-`ScriptRef`), not just its engine base — so member
355    // access on an aliased `self` resolves the file's own members (see `ClassScope::self_ty`).
356    let self_ref = Ty::ScriptRef(ScriptRefId(file_id.0));
357    // The file's own `res://` path, for anchoring relative `preload`/`extends` to its directory.
358    let res_path = db.file_text(file_id).and_then(|ft| ft.res_path(db));
359
360    // A declared `class_name` that collides with another global identifier (W2). Mirrors Godot's
361    // `gdscript_analyzer.cpp` uniqueness check over the global namespace, projected through the
362    // cross-file firewall (`class_name_collisions`) and the offset-free global resolvers — so it
363    // fires only when genuinely shadowing, never on the seam. Emitted once, at the decl's NAME.
364    if let Some(name) = tree.class_name.clone() {
365        let collides = collisions_contains(db, &name)
366            || resolve::resolve_global(api, &name).is_some()
367            || is_autoload_singleton(db, &name);
368        if collides && let Some(range) = class_name_decl_range(root) {
369            diagnostics.push(Diagnostic {
370                range,
371                severity: Severity::Warning,
372                code: SHADOWED_GLOBAL_IDENTIFIER.to_owned(),
373                message: format!(
374                    "The global class \"{name}\" hides a built-in/native/global/autoload."
375                ),
376                source: DiagnosticSource::Type,
377                fixes: Vec::new(),
378            });
379        }
380    }
381
382    // A genuine `extends` cycle (D7): walk THIS file's base chain by `FileId`; if it returns to the
383    // start, the inheritance is cyclic (illegal in Godot). Reported once, at the file's own `extends`
384    // decl range. Only `extends` cycles are walked here (member lookup is the only thing that loops);
385    // `preload`/`load` cycles are legal at runtime and never reach this resolver. We start by stepping
386    // ONTO the user base — if the very first base is the start file (`extends "res://self.gd"`, or two
387    // files A↔B), the revisit-of-start check fires; a deep but ACYCLIC chain bottoms out at an engine
388    // `Object`/`Unknown` and never revisits, so it does not false-fire.
389    if extends_chain_is_cyclic(db, file_id)
390        && let Some(range) = extends_decl_range(root)
391    {
392        diagnostics.push(Diagnostic {
393            range,
394            severity: Severity::Warning,
395            code: CYCLIC_INHERITANCE.to_owned(),
396            message: "Cyclic class hierarchy: this class's `extends` chain returns to itself."
397                .to_owned(),
398            source: DiagnosticSource::Type,
399            fixes: Vec::new(),
400        });
401    }
402
403    // File-level (member) warnings that need the whole item-tree, not a single body.
404    raw_warnings.extend(member_level_warnings(
405        db,
406        api,
407        root,
408        &tree,
409        res_path.as_deref(),
410    ));
411
412    // Pass 1 — class fields. Inferring each `var`/`const` seeds `member_types` so the function
413    // pass sees the *inferred* field type (`var n := 0` → `int`), not just the annotation.
414    //
415    // A field initializer may reference an *earlier* field (`var a := 1` then `var b := a + 1`),
416    // so a single shallow round sees the referent as `Variant`/seam. We run a BOUNDED fixpoint:
417    // each round re-infers every field against the prior round's `member_types`, until the map
418    // stops changing or we hit the round cap. Cheap (fields are few, types settle in a round or
419    // two) and deterministic. Only the final round's units/diagnostics are kept — earlier rounds
420    // are throwaway probes feeding the seed.
421    {
422        // Bound the iteration: a linear `a -> b -> c -> …` chain settles in O(n) rounds, but a
423        // small constant is enough in practice (the corpus settles in ≤2) and guarantees
424        // termination even if a type oscillated.
425        const MAX_ROUNDS: usize = 4;
426        let mut final_units: Vec<Unit> = Vec::new();
427        let mut final_diagnostics: Vec<Diagnostic> = Vec::new();
428        let mut final_raw_warnings: Vec<RawWarning> = Vec::new();
429        for _ in 0..MAX_ROUNDS {
430            let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
431            class.self_ty = self_ref.clone();
432            class.member_types.clone_from(&member_types);
433            let mut next_member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
434            final_units = Vec::new();
435            final_diagnostics = Vec::new();
436            final_raw_warnings = Vec::new();
437            for m in &tree.members {
438                let (ptr, range) = match m {
439                    Member::Var(v) => (v.ptr, v.range),
440                    Member::Const(c) => (c.ptr, c.range),
441                    _ => continue,
442                };
443                if let Some(unit) = unit_from_decl(db, api, root, &class, ptr, range) {
444                    if let (Some(name), Some(b)) = (m.name(), unit.result.bindings.first()) {
445                        next_member_types.insert(SmolStr::new(name), b.ty.clone());
446                    }
447                    final_diagnostics.extend(unit.result.diagnostics.iter().cloned());
448                    final_raw_warnings.extend(unit.result.raw_warnings.iter().cloned());
449                    final_units.push(unit);
450                }
451            }
452            if next_member_types == member_types {
453                break;
454            }
455            member_types = next_member_types;
456        }
457        diagnostics.extend(final_diagnostics);
458        raw_warnings.extend(final_raw_warnings);
459        units.extend(final_units);
460    }
461
462    // Pass 2 — functions, against a scope carrying the seeded field types.
463    {
464        let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
465        class.member_types = member_types;
466        class.self_ty = self_ref.clone();
467        for m in &tree.members {
468            let Member::Func(f) = m else { continue };
469            let Some(node) = f.ptr.to_node(root) else {
470                continue;
471            };
472            let body = body::body_of_func(&node);
473            let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
474                .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
475            let result = infer(db, api, root, &class, &body, return_ty, true);
476            diagnostics.extend(result.diagnostics.iter().cloned());
477            raw_warnings.extend(result.raw_warnings.iter().cloned());
478            units.push(Unit {
479                range: f.range,
480                body,
481                result,
482            });
483        }
484    }
485
486    FileInference {
487        tree,
488        units,
489        diagnostics,
490        raw_warnings,
491    }
492}
493
494/// File-level (member) warnings that need the whole item-tree, not a single body (W1):
495/// `ENUM_VARIABLE_WITHOUT_DEFAULT` on an enum-typed field with no initializer, and `UNUSED_SIGNAL`
496/// on a signal never referenced anywhere in the file. Both are sound + conservative.
497fn member_level_warnings(
498    db: &dyn Db,
499    api: &EngineApi,
500    root: &GdNode,
501    tree: &ItemTree,
502    res_path: Option<&str>,
503) -> Vec<RawWarning> {
504    let mut out = Vec::new();
505    let has_signal = tree.members.iter().any(|m| matches!(m, Member::Signal(_)));
506    // Only pay for the whole-file name scan when there is a signal to judge.
507    let uses = has_signal.then(|| NameUses::collect(root));
508    // The resolved ENGINE base, for NATIVE_METHOD_OVERRIDE (an unresolved/user base ⇒ no check).
509    let engine_base = match resolve::resolve_base(db, api, tree, res_path) {
510        Ty::Object(c) => Some(c),
511        _ => None,
512    };
513    for m in &tree.members {
514        match m {
515            // An enum-typed field with no initializer (the local case is in `infer_local_var`).
516            Member::Var(v) if !v.has_init => {
517                if let Some(tref) = &v.type_ref
518                    && matches!(resolve::resolve_type_name(db, api, tref), Ty::Enum(_))
519                {
520                    out.push(RawWarning {
521                        range: v.name_range,
522                        code: WarningCode::EnumVariableWithoutDefault,
523                        message: format!(
524                            "The enum variable \"{}\" has no default value (it defaults to 0, which may not be a valid enum value).",
525                            v.name
526                        ),
527                    });
528                }
529            }
530            // A signal never referenced in this file (emit/connect/string). Same-file only, like
531            // Godot — a signal connected purely from a scene/other file is invisible (the known
532            // limitation); the conservative scan only warns when the name appears nowhere else.
533            Member::Signal(s) => {
534                if let Some(uses) = &uses
535                    && !uses.is_referenced(&s.name)
536                {
537                    out.push(RawWarning {
538                        range: s.name_range,
539                        code: WarningCode::UnusedSignal,
540                        message: format!(
541                            "The signal \"{}\" is never emitted or connected in this file.",
542                            s.name
543                        ),
544                    });
545                }
546            }
547            // NATIVE_METHOD_OVERRIDE (ERROR-default) — an override of an engine VIRTUAL whose
548            // signature is clearly incompatible. Conservative to the extreme (a false positive is a
549            // loud error): warn ONLY on a *definite type clash* at an overlapping typed parameter —
550            // both the override's annotation and the virtual's param resolve to known engine types
551            // that are mutually NON-assignable. Arity, defaults/vararg, and variance subtleties are
552            // deliberately left to under-warn (see `TECH_DEBT.md`).
553            Member::Func(f) => {
554                if let Some(base) = engine_base
555                    && let Some(MemberRef::Method(vsig)) = api.lookup_member(base, &f.name)
556                    && vsig.is_virtual
557                {
558                    for (p, vp) in f.params.iter().zip(vsig.params.iter()) {
559                        let Some(ann) = &p.type_ref else { continue };
560                        let pty = resolve::resolve_type_name(db, api, ann);
561                        let vty = ty::resolve_tyref(api, &vp.ty);
562                        if types_definitely_clash(api, &pty, &vty) {
563                            out.push(RawWarning {
564                                range: f.name_range,
565                                code: WarningCode::NativeMethodOverride,
566                                message: format!(
567                                    "The override of the native virtual method \"{}\" has an incompatible type for parameter \"{}\".",
568                                    f.name, p.name
569                                ),
570                            });
571                            break; // one warning per overriding function
572                        }
573                    }
574                }
575            }
576            _ => {}
577        }
578    }
579    out
580}
581
582/// Whether two **known** engine types are mutually non-assignable — a *definite* clash. An
583/// uninformative type (`Variant`/`Unknown`) never clashes (gradual), and any assignable relation in
584/// either direction (subtype, widening, enum/int, …) is treated as "related" (not a clash), so the
585/// conservative `NATIVE_METHOD_OVERRIDE` only fires on genuinely unrelated types.
586fn types_definitely_clash(api: &EngineApi, a: &Ty, b: &Ty) -> bool {
587    if a.is_uninformative() || b.is_uninformative() {
588        return false;
589    }
590    // Enums are int-backed and their qualified name resolves differently on the annotation side
591    // (`resolve_type_name` → `Class.Enum`) than on the engine-model side (`resolve_tyref`), so an
592    // enum "clash" is unreliable — never clash on an enum. (Fixes a false NATIVE_METHOD_OVERRIDE on
593    // a valid dotted-enum-typed override param, e.g. `p_mode: MultiplayerPeer.TransferMode`.)
594    if matches!(a, Ty::Enum(_)) || matches!(b, Ty::Enum(_)) {
595        return false;
596    }
597    matches!(ty::is_assignable(api, a, b), Assign::No)
598        && matches!(ty::is_assignable(api, b, a), Assign::No)
599}
600
601/// Identifier-occurrence counts + string-literal contents across a file's CST — the file-wide
602/// "is this name referenced anywhere?" check (drives `UNUSED_SIGNAL`).
603struct NameUses {
604    ident_counts: FxHashMap<SmolStr, u32>,
605    strings: FxHashSet<SmolStr>,
606}
607
608impl NameUses {
609    fn collect(root: &GdNode) -> Self {
610        let mut ident_counts: FxHashMap<SmolStr, u32> = FxHashMap::default();
611        let mut strings: FxHashSet<SmolStr> = FxHashSet::default();
612        for node in gdscript_syntax::ast::descendants(root) {
613            for el in node.children_with_tokens() {
614                let Some(tok) = el.into_token() else { continue };
615                match tok.kind() {
616                    gdscript_syntax::SyntaxKind::Ident => {
617                        *ident_counts.entry(SmolStr::new(tok.text())).or_insert(0) += 1;
618                    }
619                    gdscript_syntax::SyntaxKind::String => {
620                        strings.insert(SmolStr::new(tok.text().trim_matches(['"', '\''])));
621                    }
622                    _ => {}
623                }
624            }
625        }
626        Self {
627            ident_counts,
628            strings,
629        }
630    }
631
632    /// Whether `name` is referenced beyond its single declaration — a 2nd identifier occurrence, or
633    /// any string literal naming it (covering `emit_signal("name")` / `connect("name", …)`).
634    fn is_referenced(&self, name: &str) -> bool {
635        self.ident_counts.get(name).copied().unwrap_or(0) > 1 || self.strings.contains(name)
636    }
637}
638
639/// Whether `name` is declared as a `class_name` by more than one file in the project (W2). Reads
640/// the cross-file `class_name_collisions` firewall; `false` (no warning) when no source root is set
641/// — single-file analysis cannot observe a duplicate.
642fn collisions_contains(db: &dyn Db, name: &SmolStr) -> bool {
643    db.source_root()
644        .is_some_and(|root| crate::queries::class_name_collisions(db, root).contains(name))
645}
646
647/// Whether `name` is a `*`-flagged autoload singleton (a bare global). `false` when no
648/// `project.godot` is loaded — the seam, no warning.
649fn is_autoload_singleton(db: &dyn Db, name: &str) -> bool {
650    db.project_config().is_some_and(|config| {
651        crate::queries::autoload_registry(db, config)
652            .resolve_path(name)
653            .is_some()
654    })
655}
656
657/// The NAME range of the file's `class_name` declaration, trimmed to the bare identifier (the
658/// `Name` CST node absorbs leading inter-token trivia). `None` if the file declares no `class_name`
659/// or the decl has no name token. Mirrors `item_tree::trimmed_name_range` / navigation's
660/// `class_decl_target` (which lives in the IDE crate, hence this local CST scan).
661fn class_name_decl_range(root: &GdNode) -> Option<TextRange> {
662    use gdscript_syntax::SyntaxKind;
663    let decl = gdscript_syntax::ast::descendants(root)
664        .into_iter()
665        .find(|n| n.kind() == SyntaxKind::ClassNameDecl)?;
666    let name_node = decl.children().find(|c| c.kind() == SyntaxKind::Name)?;
667    let r = cst::text_range_of(name_node);
668    let text = name_node.text().to_string();
669    let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
670    let len = u32::try_from(text.trim().len()).unwrap_or(0);
671    Some(TextRange::new(r.start + lead, r.start + lead + len))
672}
673
674/// The byte range of the file's top-level `extends` declaration — the anchor for `CYCLIC_INHERITANCE`.
675/// Two surface forms: a standalone `extends Target` (an [`ExtendsClause`] child of the `SourceFile`),
676/// or the inline `class_name Name extends Target` (the `extends` keyword + target inside the
677/// [`ClassNameDecl`]). Scans only the `SourceFile`'s DIRECT children, so an inner class's `extends`
678/// (nested under `Class`/`ClassBody`) is never mistaken for the file's own. `None` if the file has no
679/// top-level `extends`.
680fn extends_decl_range(root: &GdNode) -> Option<TextRange> {
681    use gdscript_syntax::SyntaxKind;
682    for child in root.children() {
683        match child.kind() {
684            // Standalone `extends Target` — the whole clause is the anchor.
685            SyntaxKind::ExtendsClause => return Some(cst::text_range_of(child)),
686            // Inline `class_name Name extends Target` — anchor the `extends` keyword onward.
687            SyntaxKind::ClassNameDecl => {
688                if let Some(kw) = child.children().find(|c| c.kind() == SyntaxKind::ExtendsKw) {
689                    let start = cst::text_range_of(kw).start;
690                    let end = cst::text_range_of(child).end;
691                    return Some(TextRange::new(start, end));
692                }
693            }
694            _ => {}
695        }
696    }
697    None
698}
699
700/// Whether the file's `extends` inheritance chain transitively returns to itself (a genuine cycle).
701/// Walks base-by-base by `FileId` from `start`, stepping only across user `ScriptRef` bases (an
702/// engine `Object`/`Unknown` base ends the chain). A `FileId` revisit means a cycle. We stop as soon
703/// as we either revisit a file (cycle) or hit a non-script base (acyclic) — a deep but acyclic chain
704/// terminates without a revisit and is NOT flagged. Depth is also hard-capped as belt-and-suspenders
705/// (the visited set already guarantees termination).
706fn extends_chain_is_cyclic(db: &dyn Db, start: FileId) -> bool {
707    use std::collections::HashSet;
708    let mut visited: HashSet<FileId> = HashSet::new();
709    visited.insert(start);
710    let mut current = start;
711    for _ in 0..=64 {
712        let Some(file) = db.file_text(current) else {
713            return false;
714        };
715        let base = crate::queries::script_class(db, file).base().clone();
716        let Ty::ScriptRef(next) = base else {
717            return false; // engine `Object` / `Unknown` base — chain ends, no cycle.
718        };
719        let next_id = FileId(next.0);
720        if !visited.insert(next_id) {
721            // Revisiting an already-seen file closes a cycle. We report the cycle for every file ON
722            // it (each file's own `extends` is genuinely cyclic), so no need to special-case `start`.
723            return true;
724        }
725        current = next_id;
726    }
727    false
728}
729
730/// Infer a class field declaration as a single local-var statement (full annotation checks).
731fn unit_from_decl(
732    db: &dyn Db,
733    api: &EngineApi,
734    root: &GdNode,
735    class: &ClassScope,
736    ptr: AstPtr,
737    range: TextRange,
738) -> Option<Unit> {
739    let node = ptr.to_node(root)?;
740    let body = body::body_of_decl_stmt(&node);
741    let result = infer(db, api, root, class, &body, Ty::Variant, false);
742    Some(Unit {
743        range,
744        body,
745        result,
746    })
747}
748
749/// What type is expected of an expression (bidirectional checking).
750enum Expectation {
751    /// No expectation — pure synthesis.
752    None,
753    /// The expression is checked against this declared type.
754    Has(Ty),
755}
756
757struct Cx<'a> {
758    db: &'a dyn Db,
759    api: &'a EngineApi,
760    root: &'a GdNode,
761    body: &'a Body,
762    class: &'a ClassScope<'a>,
763    self_ty: Ty,
764    return_ty: Ty,
765    expr_ty: FxHashMap<ExprId, Ty>,
766    bindings: Vec<Binding>,
767    diagnostics: Vec<Diagnostic>,
768    /// Severity-free gateable warnings (Workstream 1), resolved by `gate()` downstream.
769    raw_warnings: Vec<RawWarning>,
770    /// Function-scoped local bindings (GDScript locals are function-, not block-, scoped).
771    locals: FxHashMap<SmolStr, Ty>,
772    /// The names of locals/params that were *read* during the walk — drives the `UNUSED_*` family
773    /// (a declared binding whose name never appears here is unused). Conservative: a write-only use
774    /// also records the name (no false positives, only the occasional missed warning).
775    used_locals: FxHashSet<SmolStr>,
776    /// The active narrowing env for the current statement, keyed by a dotted access path. Rebuilt
777    /// per statement from [`Cx::flow`] (Workstream 2) — not mutated ad-hoc anymore.
778    narrowing: FxHashMap<String, Ty>,
779    /// The precomputed per-body control-flow narrowing facts (Workstream 2). The checker consults
780    /// `facts_before(stmt)` to build [`Cx::narrowing`]; it survives `else`/early-return/`and`-`or`.
781    flow: FlowAnalysis,
782    /// Whether this is a real function body (vs a class-field initializer body). Gates the
783    /// body-only checks (`UNUSED_*`, `SHADOWED_VARIABLE`) so a field initializer doesn't, e.g.,
784    /// "shadow itself" against its own member entry.
785    is_func_body: bool,
786    /// Definite-assignment facts (Workstream 2) — the locals assigned before each statement, for
787    /// `UNASSIGNED_VARIABLE`. Consulted at a read via [`Cx::cur_stmt`].
788    assigned: flow::AssignedAnalysis,
789    /// The statement currently being inferred (set in `infer_stmt`), so a read can look up
790    /// [`Cx::assigned`].
791    cur_stmt: Option<body::StmtId>,
792    /// Typed locals declared **without** an initializer — the only locals `UNASSIGNED_VARIABLE`
793    /// considers (an untyped/`:=`/initialized local is never read-before-assign). Grows as the walk
794    /// passes each declaration.
795    needs_assignment: FxHashSet<SmolStr>,
796    /// Names that are the direct LHS of an assignment (`x = …`/`x += …`) — a *write*, not a read, so
797    /// excluded from the `UNASSIGNED_VARIABLE` check even though inference resolves the LHS.
798    assign_lhs: FxHashSet<ExprId>,
799}
800
801impl Cx<'_> {
802    // ---- small type constructors ----
803
804    fn builtin(&self, name: &str) -> Ty {
805        self.api
806            .builtin_by_name(name)
807            .map_or(Ty::Variant, Ty::Builtin)
808    }
809    fn int_ty(&self) -> Ty {
810        self.builtin("int")
811    }
812    fn float_ty(&self) -> Ty {
813        self.builtin("float")
814    }
815    fn bool_ty(&self) -> Ty {
816        self.builtin("bool")
817    }
818    fn is_int(&self, ty: &Ty) -> bool {
819        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "int")
820    }
821    fn is_float(&self, ty: &Ty) -> bool {
822        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "float")
823    }
824    fn is_numeric(&self, ty: &Ty) -> bool {
825        self.is_int(ty) || self.is_float(ty)
826    }
827
828    // ---- diagnostics ----
829
830    fn emit(&mut self, range: TextRange, severity: Severity, code: &str, message: String) {
831        self.diagnostics.push(Diagnostic {
832            range,
833            severity,
834            code: code.to_owned(),
835            message,
836            source: DiagnosticSource::Type,
837            fixes: Vec::new(),
838        });
839    }
840
841    /// Record a gateable Godot warning, severity-free. The resolved severity (and whether it fires
842    /// at all) is decided later by [`crate::warnings::gate`], keyed on the project's warning
843    /// settings — so a settings edit never re-runs inference (Workstream 1, the salsa firewall).
844    fn warn(&mut self, range: TextRange, code: WarningCode, message: String) {
845        self.raw_warnings.push(RawWarning {
846            range,
847            code,
848            message,
849        });
850    }
851
852    fn range_of(&self, id: ExprId) -> TextRange {
853        self.body.source_map.expr_range(id)
854    }
855
856    /// Run `is_assignable(from, to)` and raise the matching diagnostic. Safe to call
857    /// unconditionally: `to` being `Variant`/`Unknown` yields `Ok`/no diagnostic.
858    fn check_assign(&mut self, from: &Ty, to: &Ty, range: TextRange) {
859        match ty::is_assignable(self.api, from, to) {
860            Assign::Narrowing => self.warn(
861                range,
862                WarningCode::NarrowingConversion,
863                "Narrowing conversion (float is converted to int and loses precision).".to_owned(),
864            ),
865            Assign::No => {
866                let to_label = to.label(self.api).unwrap_or_else(|| "?".to_owned());
867                let from_label = from.label(self.api).unwrap_or_else(|| "?".to_owned());
868                self.emit(
869                    range,
870                    Severity::Error,
871                    TYPE_MISMATCH,
872                    format!(
873                        "Cannot assign a value of type \"{from_label}\" to a target of type \"{to_label}\"."
874                    ),
875                );
876            }
877            // `int` assigned to an enum slot without an explicit cast (the previously-dead arm).
878            Assign::IntAsEnum => self.warn(
879                range,
880                WarningCode::IntAsEnumWithoutCast,
881                "Integer used when an enum value is expected. Cast the value to the enum type."
882                    .to_owned(),
883            ),
884            Assign::Ok | Assign::OkUnsafe => {}
885        }
886    }
887
888    /// Flag a statement whose expression has no effect: a bare value (`STANDALONE_EXPRESSION`) or a
889    /// ternary used as a statement (`STANDALONE_TERNARY`). A call / await / assignment / `preload`
890    /// has an effect and is never flagged.
891    fn check_standalone(&mut self, e: ExprId) {
892        if self.expr_has_side_effect(e) {
893            return;
894        }
895        match self.body.expr(e) {
896            Expr::Ternary { .. } => self.warn(
897                self.range_of(e),
898                WarningCode::StandaloneTernary,
899                "Standalone ternary conditional: the return value is discarded.".to_owned(),
900            ),
901            // Not value-like statements / forms with subtle effects — never flag.
902            Expr::Missing | Expr::Lambda { .. } | Expr::GetNode { .. } | Expr::Preload { .. } => {}
903            _ => self.warn(
904                self.range_of(e),
905                WarningCode::StandaloneExpression,
906                "Standalone expression (the line has no effect).".to_owned(),
907            ),
908        }
909    }
910
911    /// Whether evaluating an expression may have a side effect — a call, an `await`, a `preload`,
912    /// or an assignment anywhere in the subtree. Used to suppress `STANDALONE_*` on effectful lines.
913    fn expr_has_side_effect(&self, e: ExprId) -> bool {
914        match self.body.expr(e) {
915            Expr::Call { .. }
916            | Expr::Await(_)
917            | Expr::Preload { .. }
918            | Expr::Bin {
919                op: BinOp::Assign, ..
920            } => true,
921            Expr::Bin { lhs, rhs, .. }
922            | Expr::In { lhs, rhs, .. }
923            | Expr::Index {
924                base: lhs,
925                index: rhs,
926            } => self.expr_has_side_effect(*lhs) || self.expr_has_side_effect(*rhs),
927            Expr::Unary { operand, .. }
928            | Expr::Paren(operand)
929            | Expr::Cast { operand, .. }
930            | Expr::Is { operand, .. } => self.expr_has_side_effect(*operand),
931            Expr::Field { receiver, .. } => self.expr_has_side_effect(*receiver),
932            Expr::Ternary {
933                cond,
934                then_branch,
935                else_branch,
936            } => {
937                self.expr_has_side_effect(*cond)
938                    || self.expr_has_side_effect(*then_branch)
939                    || self.expr_has_side_effect(*else_branch)
940            }
941            Expr::Array(items) => items.iter().any(|&i| self.expr_has_side_effect(i)),
942            Expr::Dict(entries) => entries.iter().any(|(k, v)| {
943                self.expr_has_side_effect(*k) || v.is_some_and(|e| self.expr_has_side_effect(e))
944            }),
945            _ => false,
946        }
947    }
948
949    // ---- statements ----
950
951    fn infer_block(&mut self, block: &[body::StmtId]) {
952        for &stmt in block {
953            self.infer_stmt(stmt);
954        }
955    }
956
957    fn infer_stmt(&mut self, id: body::StmtId) {
958        // Install the narrowing in force *before* this statement (Workstream 2). Recomputed per
959        // statement from the precomputed flow facts — replaces the old ad-hoc `in_branch` frames.
960        self.narrowing = self.facts_to_narrowing(id);
961        self.cur_stmt = Some(id); // for the read-before-assign (UNASSIGNED_VARIABLE) check
962        match self.body.stmt(id).clone() {
963            Stmt::Expr(e) => {
964                self.infer_expr(e, &Expectation::None);
965                self.check_standalone(e);
966            }
967            Stmt::Var(v) => self.infer_local_var(&v),
968            Stmt::Return(e) => {
969                if let Some(e) = e {
970                    let expected = if self.return_ty.is_uninformative() {
971                        Expectation::None
972                    } else {
973                        Expectation::Has(self.return_ty.clone())
974                    };
975                    let t = self.infer_expr(e, &expected);
976                    if let Expectation::Has(ret) = expected {
977                        self.check_assign(&t, &ret, self.range_of(e));
978                    }
979                }
980            }
981            Stmt::If {
982                cond,
983                then_branch,
984                elifs,
985                else_branch,
986            } => {
987                // The branch narrowing now lives in the flow facts, so each sub-statement installs
988                // its own via `infer_stmt`. Restore the if-level facts before each guard (a block
989                // walk overwrites `self.narrowing`).
990                let at_if = self.narrowing.clone();
991                self.infer_expr(cond, &Expectation::None);
992                self.infer_block(&then_branch);
993                for (econd, eblock) in elifs {
994                    self.narrowing.clone_from(&at_if);
995                    self.infer_expr(econd, &Expectation::None);
996                    self.infer_block(&eblock);
997                }
998                if let Some(eb) = else_branch {
999                    self.infer_block(&eb);
1000                }
1001            }
1002            Stmt::While { cond, body } => {
1003                self.infer_expr(cond, &Expectation::None);
1004                self.infer_block(&body);
1005            }
1006            Stmt::For(f) => {
1007                let iter_ty = self.infer_expr(f.iter, &Expectation::None);
1008                let var_ty = f.var_type.as_ref().map_or_else(
1009                    || self.loop_var_ty(&iter_ty),
1010                    |ptr| self.resolve_ptr_ty(*ptr),
1011                );
1012                self.bindings.push(Binding {
1013                    name: f.var.clone(),
1014                    name_range: f.var_range,
1015                    ty: var_ty.clone(),
1016                    init: None,
1017                    annotated: f.var_type.is_some(),
1018                    inferred_colon_eq: false,
1019                    is_const: false,
1020                    kind: BindingKind::ForVar,
1021                });
1022                self.locals.insert(f.var.clone(), var_ty);
1023                self.infer_block(&f.body);
1024            }
1025            Stmt::Match { scrutinee, arms } => {
1026                let at_match = self.narrowing.clone();
1027                self.infer_expr(scrutinee, &Expectation::None);
1028                for arm in arms {
1029                    // Restore the match-level facts before each arm's guard (a prior arm's body
1030                    // walk overwrote `self.narrowing`).
1031                    self.narrowing.clone_from(&at_match);
1032                    for b in &arm.binds {
1033                        // Record the capture as a binding so navigation (find-refs / rename) sees
1034                        // it as a local that shadows a same-named member; the type is the Phase-2
1035                        // `Variant`.
1036                        self.bindings.push(Binding {
1037                            name: b.name.clone(),
1038                            name_range: b.range,
1039                            ty: Ty::Variant,
1040                            init: None,
1041                            annotated: false,
1042                            inferred_colon_eq: false,
1043                            is_const: false,
1044                            kind: BindingKind::MatchBind,
1045                        });
1046                        self.locals.insert(b.name.clone(), Ty::Variant);
1047                    }
1048                    if let Some(g) = arm.guard {
1049                        self.infer_expr(g, &Expectation::None);
1050                    }
1051                    self.infer_block(&arm.body);
1052                }
1053            }
1054            Stmt::Break | Stmt::Continue | Stmt::Pass => {}
1055            Stmt::Assert(cond) => {
1056                if let Some(cond) = cond {
1057                    self.infer_expr(cond, &Expectation::None);
1058                }
1059            }
1060        }
1061    }
1062
1063    fn infer_local_var(&mut self, v: &body::LocalVar) {
1064        let annotated = v.type_ref.map(|p| self.resolve_ptr_ty(p));
1065        let init_ty = v.init.map(|e| {
1066            let expected = annotated
1067                .as_ref()
1068                .map_or(Expectation::None, |t| Expectation::Has(t.clone()));
1069            self.infer_expr(e, &expected)
1070        });
1071        let range = v.init.map_or(v.name_range, |e| self.range_of(e));
1072
1073        let binding_ty = match (&annotated, &init_ty) {
1074            // `var x: T = e` — hard slot; check the initializer against it.
1075            (Some(t), Some(init)) => {
1076                self.check_assign(init, t, range);
1077                t.clone()
1078            }
1079            // `var x: T` (no init).
1080            (Some(t), None) => t.clone(),
1081            // `var x := e` — inferred (hard); guard the Variant / null cases.
1082            (None, Some(init)) if v.is_inferred => {
1083                if init.is_variant() {
1084                    self.warn(
1085                        range,
1086                        WarningCode::InferenceOnVariant,
1087                        inference_on_variant_msg(if v.is_const { "constant" } else { "variable" }),
1088                    );
1089                    Ty::Variant
1090                } else {
1091                    // `Unknown` (the seam) stays `Unknown` with no warning.
1092                    init.clone()
1093                }
1094            }
1095            // `var x = e` — untyped, soft → Variant. `const X = e` keeps the inferred type.
1096            (None, Some(init)) => {
1097                if v.is_const {
1098                    init.clone()
1099                } else {
1100                    Ty::Variant
1101                }
1102            }
1103            (None, None) => Ty::Variant,
1104        };
1105        // SHADOWED_VARIABLE — a local `var`/`const` whose name shadows a parameter or an own class
1106        // member (a redeclared *local* is a Godot error, not handled here). Sound: only fires on a
1107        // genuine outer-scope shadow. The binding isn't pushed yet, so the `Param` scan can't see it.
1108        // Gated to a real function body — a class-field initializer's own `var n` is not a shadow.
1109        let shadows_param = self
1110            .bindings
1111            .iter()
1112            .any(|b| b.kind == BindingKind::Param && b.name == v.name);
1113        // Only a *value* member (var/const/signal, or an anon-enum constant) — not a method or a
1114        // type name, where the "shadow" framing is weaker — counts, to stay conservative.
1115        let shadows_member = match self.class.lookup(&v.name) {
1116            Some(ClassItem::EnumVariant) => true,
1117            Some(item) => matches!(
1118                self.class.member(item),
1119                Some(Member::Var(_) | Member::Const(_) | Member::Signal(_))
1120            ),
1121            None => false,
1122        };
1123        if self.is_func_body {
1124            let what = if v.is_const { "constant" } else { "variable" };
1125            if shadows_param || shadows_member {
1126                let outer = if shadows_param {
1127                    "parameter"
1128                } else {
1129                    "class member"
1130                };
1131                self.warn(
1132                    v.name_range,
1133                    WarningCode::ShadowedVariable,
1134                    format!(
1135                        "The local {what} \"{}\" shadows a {outer} of the same name.",
1136                        v.name
1137                    ),
1138                );
1139            } else if self.engine_base_has_value_member(&v.name) {
1140                // An own-member shadow already won above; only a *base*-member shadow reaches here.
1141                self.warn(
1142                    v.name_range,
1143                    WarningCode::ShadowedVariableBaseClass,
1144                    format!(
1145                        "The local {what} \"{}\" shadows a member of a base class.",
1146                        v.name
1147                    ),
1148                );
1149            }
1150            // ENUM_VARIABLE_WITHOUT_DEFAULT — a local typed as an enum with no initializer (the
1151            // implicit `0` may not name a valid enum value). Only an explicit `Ty::Enum` annotation.
1152            if v.init.is_none() && matches!(annotated.as_ref(), Some(Ty::Enum(_))) {
1153                self.warn(
1154                    v.name_range,
1155                    WarningCode::EnumVariableWithoutDefault,
1156                    format!(
1157                        "The enum variable \"{}\" has no default value (it defaults to 0, which may not be a valid enum value).",
1158                        v.name
1159                    ),
1160                );
1161            }
1162            // A typed local declared WITHOUT an initializer is the only `UNASSIGNED_VARIABLE`
1163            // candidate (an untyped / `:=` / initialized local is never read-before-assign).
1164            if v.type_ref.is_some() && v.init.is_none() {
1165                self.needs_assignment.insert(v.name.clone());
1166            }
1167        }
1168        self.bindings.push(Binding {
1169            name: v.name.clone(),
1170            name_range: v.name_range,
1171            ty: binding_ty.clone(),
1172            init: v.init,
1173            annotated: v.type_ref.is_some(),
1174            inferred_colon_eq: v.is_inferred,
1175            is_const: v.is_const,
1176            kind: BindingKind::Var,
1177        });
1178        // A (re-)declaration's narrowing invalidation is handled by the flow analysis (Workstream 2).
1179        self.locals.insert(v.name.clone(), binding_ty);
1180    }
1181
1182    // ---- expressions ----
1183
1184    fn infer_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
1185        let ty = self.synth_expr(id, expected);
1186        self.expr_ty.insert(id, ty.clone());
1187        ty
1188    }
1189
1190    #[allow(clippy::too_many_lines)]
1191    fn synth_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
1192        match self.body.expr(id).clone() {
1193            Expr::Missing => Ty::Error,
1194            Expr::Literal(lit) => self.literal_ty(lit),
1195            Expr::Name(name) => self.resolve_name(id, &name),
1196            Expr::SelfExpr => self.self_ty.clone(),
1197            Expr::Super => self.class.base.clone(),
1198            Expr::Paren(inner) => self.infer_expr(inner, expected),
1199            Expr::Bin { op, lhs, rhs } => self.infer_bin(id, op, lhs, rhs),
1200            Expr::Unary { op, operand } => {
1201                let t = self.infer_expr(operand, &Expectation::None);
1202                match op {
1203                    UnOp::Not => self.bool_ty(),
1204                    UnOp::BitNot => self.int_ty(),
1205                    UnOp::Neg | UnOp::Pos => {
1206                        if t.is_uninformative() || self.is_numeric(&t) {
1207                            t
1208                        } else {
1209                            Ty::Variant
1210                        }
1211                    }
1212                }
1213            }
1214            Expr::Ternary {
1215                cond,
1216                then_branch,
1217                else_branch,
1218            } => {
1219                self.infer_expr(cond, &Expectation::None);
1220                let a = self.infer_expr(then_branch, expected);
1221                let b = self.infer_expr(else_branch, expected);
1222                // A `null` branch does not poison the other: `x if c else null` is nullable-`x`.
1223                if self.is_null(else_branch) {
1224                    a
1225                } else if self.is_null(then_branch) {
1226                    b
1227                } else {
1228                    let r = self.join(&a, &b);
1229                    // Both arms informative but with no common type (the join widened to Variant) —
1230                    // the ternary's two values are mutually incompatible.
1231                    if r.is_variant() && !a.is_uninformative() && !b.is_uninformative() {
1232                        self.warn(
1233                            self.range_of(id),
1234                            WarningCode::IncompatibleTernary,
1235                            "The values of the ternary conditional are not mutually compatible."
1236                                .to_owned(),
1237                        );
1238                    }
1239                    r
1240                }
1241            }
1242            Expr::Call { callee, args } => self.infer_call(callee, &args),
1243            Expr::Field {
1244                receiver,
1245                name,
1246                name_range,
1247            } => {
1248                self.infer_field(receiver, &name, name_range, /*as_method=*/ false)
1249            }
1250            Expr::Index { base, index } => {
1251                let base_ty = self.infer_expr(base, &Expectation::None);
1252                self.infer_expr(index, &Expectation::None);
1253                self.index_ty(&base_ty)
1254            }
1255            Expr::Is { operand, .. } => {
1256                self.infer_expr(operand, &Expectation::None);
1257                self.bool_ty()
1258            }
1259            Expr::Cast { operand, ty } => {
1260                self.infer_expr(operand, &Expectation::None);
1261                ty.map_or(Ty::Variant, |p| self.resolve_ptr_ty(p))
1262            }
1263            Expr::In { lhs, rhs, .. } => {
1264                self.infer_expr(lhs, &Expectation::None);
1265                self.infer_expr(rhs, &Expectation::None);
1266                self.bool_ty()
1267            }
1268            Expr::Await(operand) => {
1269                let operand_ty = self.infer_expr(operand, &Expectation::None);
1270                // `await coroutine()` yields the call's value, so await is **identity** on the operand
1271                // type (`await f()` for `func f() -> int` is `int`) — recovered here. `await signal`
1272                // instead yields the signal's emitted payload, which needs the Phase-3+ signal-signature
1273                // table; until then it's the seam (never `Variant`, so `var x := await sig` never warns).
1274                if matches!(operand_ty, Ty::Signal(_)) {
1275                    Ty::Unknown
1276                } else {
1277                    operand_ty
1278                }
1279            }
1280            Expr::Array(elems) => {
1281                // Checking mode: an expected `Array[T]` is pushed down onto the literal (so
1282                // `var a: Array[String] = []` / `[...]` is accepted). Otherwise the engine does
1283                // not infer a literal's element type past `Variant`.
1284                let pushed = match expected {
1285                    Expectation::Has(Ty::Array(e)) => Some((**e).clone()),
1286                    _ => None,
1287                };
1288                let elem_exp = pushed.clone().map_or(Expectation::None, Expectation::Has);
1289                for e in elems {
1290                    self.infer_expr(e, &elem_exp);
1291                }
1292                pushed.map_or_else(Ty::array_of_variant, |e| Ty::Array(Box::new(e)))
1293            }
1294            Expr::Dict(entries) => {
1295                let pushed = match expected {
1296                    Expectation::Has(Ty::Dict(k, v)) => Some(((**k).clone(), (**v).clone())),
1297                    _ => None,
1298                };
1299                let (kx, vx) = pushed
1300                    .clone()
1301                    .map_or((Expectation::None, Expectation::None), |(k, v)| {
1302                        (Expectation::Has(k), Expectation::Has(v))
1303                    });
1304                for (k, v) in entries {
1305                    self.infer_expr(k, &kx);
1306                    if let Some(v) = v {
1307                        self.infer_expr(v, &vx);
1308                    }
1309                }
1310                pushed.map_or_else(Ty::dict_of_variant, |(k, v)| {
1311                    Ty::Dict(Box::new(k), Box::new(v))
1312                })
1313            }
1314            Expr::Lambda { params, body } => {
1315                self.infer_lambda(&params, &body);
1316                Ty::Callable
1317            }
1318            Expr::Preload { arg, path } => {
1319                if let Some(arg) = arg {
1320                    self.infer_expr(arg, &Expectation::None);
1321                }
1322                // A constant string-literal path resolves to the declaring file's `ScriptRef`
1323                // (M3 — a SCRIPT meta-type in Godot; `X.new()`/`X.member` then resolve via the
1324                // usual `ScriptRef` walk). A non-constant argument (`preload(var)`) — which Godot
1325                // itself rejects — stays the seam, never a false diagnostic.
1326                match path {
1327                    // Anchor a relative `preload("sibling.gd")` to the importing file's directory
1328                    // before resolving (Godot anchors relative resource paths); absolute paths pass
1329                    // through, and a relative path with no anchor stays the seam.
1330                    Some(p) => {
1331                        match resolve::anchor_res_path(self.self_res_path().as_deref(), &p) {
1332                            Some(abs) => resolve::resolve_external(
1333                                self.db,
1334                                &resolve::ExternalRef::Preload(abs),
1335                            ),
1336                            None => Ty::Unknown,
1337                        }
1338                    }
1339                    None => Ty::Unknown,
1340                }
1341            }
1342            // `$Path`/`%Unique` — resolve the literal path against the owning scene to the node's
1343            // concrete type (Phase-4 M1); a computed/unresolvable path stays `Object(Node)`.
1344            Expr::GetNode { path, unique } => self.resolve_node_path(id, path.as_deref(), unique),
1345        }
1346    }
1347
1348    /// Whether `id` is the `null` literal.
1349    fn is_null(&self, id: ExprId) -> bool {
1350        matches!(self.body.expr(id), Expr::Literal(Literal::Null))
1351    }
1352
1353    fn literal_ty(&self, lit: Literal) -> Ty {
1354        match lit {
1355            Literal::Int => self.int_ty(),
1356            Literal::Float | Literal::MathConst => self.float_ty(),
1357            Literal::Bool => self.bool_ty(),
1358            Literal::Str => self.builtin("String"),
1359            Literal::StringName => self.builtin("StringName"),
1360            Literal::NodePath => self.builtin("NodePath"),
1361            // `null` is compatible everywhere; typing it `Variant` avoids false mismatches.
1362            Literal::Null => Ty::Variant,
1363        }
1364    }
1365
1366    fn node_ty(&self) -> Ty {
1367        self.api
1368            .class_by_name("Node")
1369            .map_or(Ty::Unknown, Ty::Object)
1370    }
1371
1372    // ---- scene-aware node-path typing (Phase-4 M1) ----
1373
1374    /// Resolve a `$Path`/`%Unique`/`get_node("…")` literal node path against the owning scene to the
1375    /// node's concrete type. A computed (`None`) path, no owning scene, an `..`/absolute escape, or a
1376    /// path that descends into an instanced sub-scene all degrade to `Object(Node)` — never a false
1377    /// positive. A *genuinely* absent in-scene node raises `INVALID_NODE_PATH` (M2), but only when
1378    /// the script attaches to exactly one scene (an ambiguous multi-scene attachment stays silent).
1379    fn resolve_node_path(&mut self, id: ExprId, path: Option<&str>, unique: bool) -> Ty {
1380        use gdscript_scene::NodePathResolution as R;
1381        let fallback = self.node_ty();
1382        let Some(path) = path else {
1383            return fallback; // computed `get_node(var)` — stays `Node`
1384        };
1385        let Some(ctx) = self.owning_scene() else {
1386            return fallback; // no scene attaches this script (dynamic UI / single-file)
1387        };
1388        let resolution = if unique {
1389            ctx.model.classify_unique(path)
1390        } else {
1391            ctx.model.classify_path_from(ctx.attach, path)
1392        };
1393        match resolution {
1394            R::Resolved(idx) => ctx
1395                .model
1396                .node(idx)
1397                .and_then(|n| self.scene_node_ty(&ctx.model, n, 0))
1398                .unwrap_or(fallback),
1399            R::Missing if !ctx.ambiguous => {
1400                let what = if unique { "unique name" } else { "node path" };
1401                let sigil = if unique { "%" } else { "$" };
1402                self.emit(
1403                    self.range_of(id),
1404                    Severity::Warning,
1405                    INVALID_NODE_PATH,
1406                    format!("no {what} `{sigil}{path}` in the owning scene"),
1407                );
1408                fallback
1409            }
1410            // The path descends into an instanced sub-scene (`$Enemy/Sprite`): resolve the tail in
1411            // the sub-scene's own tree (`Sprite` typed by `enemy.tscn`). Any failure → `Node`.
1412            R::IntoInstance => {
1413                let walked = if unique {
1414                    ctx.model.resolve_unique_into_instance(path)
1415                } else {
1416                    ctx.model.resolve_into_instance(ctx.attach, path)
1417                };
1418                walked
1419                    .and_then(|(inst, tail)| {
1420                        let inst_node = ctx.model.node(inst)?;
1421                        self.resolve_into_instance_ty(&ctx.model, inst_node, &tail, 0)
1422                    })
1423                    .unwrap_or(fallback)
1424            }
1425            // ambiguous miss / escape (`..`/absolute) → `Node`, never a false warning
1426            _ => fallback,
1427        }
1428    }
1429
1430    /// The owning-scene context for the current file (scene + attach node + multi-scene ambiguity).
1431    /// Recovered from `self_ty`, which `analyze_file` sets to the file's own `ScriptRef` (so no extra
1432    /// `FileId` threading).
1433    fn owning_scene(&self) -> Option<crate::queries::SceneContext> {
1434        let Ty::ScriptRef(sref) = &self.self_ty else {
1435            return None;
1436        };
1437        let ft = self.db.file_text(FileId(sref.0))?;
1438        crate::queries::scene_context(self.db, ft)
1439    }
1440
1441    /// The importing file's own `res://` path (from `self_ty`), for anchoring relative
1442    /// `preload`/`extends` paths to its directory. `None` when the file has no resource path.
1443    fn self_res_path(&self) -> Option<SmolStr> {
1444        let Ty::ScriptRef(sref) = &self.self_ty else {
1445            return None;
1446        };
1447        self.db.file_text(FileId(sref.0))?.res_path(self.db)
1448    }
1449
1450    /// The concrete `Ty` of a scene node, by precedence: an attached script's own class (most
1451    /// specific) wins; else the declared `type=` (native class or `class_name`); else — an instanced
1452    /// node (`instance=`, no own `type=`/script) — the **instanced sub-scene's root** type (M3,
1453    /// recursive). `None` for a node we can't sharpen (the caller degrades to `Node`).
1454    fn scene_node_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
1455        if let Some(script_ty) = self.node_script_ref(scene, node) {
1456            return Some(script_ty);
1457        }
1458        if let Some(decl) = node.decl_type.as_ref() {
1459            let ty = resolve::resolve_type_name(self.db, self.api, decl);
1460            if !ty.is_uninformative() {
1461                return Some(ty);
1462            }
1463        }
1464        self.instance_root_ty(scene, node, depth)
1465    }
1466
1467    /// An instanced node (`instance=ExtResource(id)`) takes the type of the instanced sub-scene's
1468    /// ROOT node — resolved recursively, so the root's own script / `type=` / nested instance all
1469    /// flow through (so `$Enemy` types as `enemy.tscn`'s root class, not bare `Node`). Depth-bounded
1470    /// against an instancing cycle (scene A instances B instances A).
1471    fn instance_root_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
1472        if depth >= 16 {
1473            return None;
1474        }
1475        let (sub, sub_root) = self.instance_subscene(scene, node)?;
1476        let root_node = sub.node(sub_root)?;
1477        self.scene_node_ty(&sub, root_node, depth + 1)
1478    }
1479
1480    /// The instanced sub-scene's model + its root index, for an instance node (`instance=ExtResource`
1481    /// → `res://` path → `FileId` → `scene_model`). The shared resolution step for both
1482    /// [`instance_root_ty`](Self::instance_root_ty) (the node's own type) and
1483    /// [`resolve_into_instance_ty`](Self::resolve_into_instance_ty) (paths that go *into* it).
1484    fn instance_subscene(
1485        &self,
1486        scene: &SceneModel,
1487        node: &SceneNode,
1488    ) -> Option<(Arc<SceneModel>, gdscript_scene::NodeIdx)> {
1489        let inst = node.instance.as_ref()?;
1490        let path = scene.ext_resources.get(inst)?.path.as_ref()?;
1491        let root = self.db.source_root()?;
1492        let file = crate::queries::res_path_registry(self.db, root)
1493            .get(path.as_str())
1494            .copied()?;
1495        let ft = self.db.file_text(file)?;
1496        let sub = crate::queries::scene_model(self.db, ft);
1497        let sub_root = sub.root?;
1498        Some((sub, sub_root))
1499    }
1500
1501    /// Type a node path that descends INTO an instanced sub-scene: `instance_node` is the boundary
1502    /// (an `instance=` node) and `tail` is the remaining path. Resolve `tail` from the sub-scene's
1503    /// root, recursing through further instance boundaries inside it. Depth-bounded against an
1504    /// instancing cycle. `None` (→ `Node`, no false warning) if the tail genuinely can't be typed.
1505    fn resolve_into_instance_ty(
1506        &self,
1507        scene: &SceneModel,
1508        instance_node: &SceneNode,
1509        tail: &str,
1510        depth: u32,
1511    ) -> Option<Ty> {
1512        if depth >= 16 {
1513            return None;
1514        }
1515        let (sub, sub_root) = self.instance_subscene(scene, instance_node)?;
1516        if let Some(idx) = sub.resolve_path_from(sub_root, tail) {
1517            let n = sub.node(idx)?;
1518            return self.scene_node_ty(&sub, n, depth + 1);
1519        }
1520        // The tail crosses a further instance boundary *inside* the sub-scene — keep descending.
1521        let (inner, inner_tail) = sub.resolve_into_instance(sub_root, tail)?;
1522        let inner_node = sub.node(inner)?;
1523        self.resolve_into_instance_ty(&sub, inner_node, &inner_tail, depth + 1)
1524    }
1525
1526    /// The `ScriptRef` of a node's attached `.gd` script (`script = ExtResource(id)` → its `res://`
1527    /// path → `FileId`), or `None` if it has no resolvable external script.
1528    fn node_script_ref(&self, scene: &SceneModel, node: &SceneNode) -> Option<Ty> {
1529        let path = scene
1530            .ext_resources
1531            .get(node.script.as_ref()?)?
1532            .path
1533            .as_ref()?;
1534        let root = self.db.source_root()?;
1535        let file = crate::queries::res_path_registry(self.db, root)
1536            .get(path.as_str())
1537            .copied()?;
1538        Some(Ty::ScriptRef(ScriptRefId(file.0)))
1539    }
1540
1541    fn infer_bin(&mut self, id: ExprId, op: BinOp, lhs: ExprId, rhs: ExprId) -> Ty {
1542        if op == BinOp::Assign {
1543            return self.infer_assign(lhs, rhs);
1544        }
1545        // Short-circuit narrowing (Workstream 2): the RHS of `a and b` is typed under `a`'s
1546        // then-facts; `a or b`'s RHS under `a`'s else-facts. Restore the env afterward.
1547        if matches!(op, BinOp::And | BinOp::Or) {
1548            self.infer_expr(lhs, &Expectation::None);
1549            let saved = self.narrowing.clone();
1550            self.apply_condition_facts(lhs, op == BinOp::And);
1551            self.infer_expr(rhs, &Expectation::None);
1552            self.narrowing = saved;
1553            return self.bool_ty();
1554        }
1555        let lt = self.infer_expr(lhs, &Expectation::None);
1556        let rt = self.infer_expr(rhs, &Expectation::None);
1557        if op.is_boolean() {
1558            return self.bool_ty();
1559        }
1560        // `int / int` discards the fractional part.
1561        if op == BinOp::Div && self.is_int(&lt) && self.is_int(&rt) {
1562            self.warn(
1563                self.range_of(id),
1564                WarningCode::IntegerDivision,
1565                "Integer division. Decimal part will be discarded.".to_owned(),
1566            );
1567            return self.int_ty();
1568        }
1569        self.bin_result(op, &lt, &rt)
1570    }
1571
1572    fn infer_assign(&mut self, lhs: ExprId, rhs: ExprId) -> Ty {
1573        let slot = self.infer_expr(lhs, &Expectation::None);
1574        let expected = if slot.is_uninformative() {
1575            Expectation::None
1576        } else {
1577            Expectation::Has(slot.clone())
1578        };
1579        let value = self.infer_expr(rhs, &expected);
1580        if !slot.is_uninformative() {
1581            self.check_assign(&value, &slot, self.range_of(rhs));
1582        }
1583        // Assignment *invalidates* the place's narrowing (handled by the flow analysis, Workstream
1584        // 2); re-narrowing from the assigned value's type is a post-1.0 precision item.
1585        slot
1586    }
1587
1588    /// Resolve a binary operator's result type via the builtin operator table, with a numeric
1589    /// fallback. Comparison/logical operators are handled by the caller.
1590    fn bin_result(&self, op: BinOp, lt: &Ty, rt: &Ty) -> Ty {
1591        if let (Ty::Builtin(b), Some(sym)) = (lt, op_symbol(op)) {
1592            for o in self.api.builtin_operators(*b) {
1593                if o.op == sym
1594                    && let Some(right) = &o.right
1595                    && self.tyref_matches(right, rt)
1596                {
1597                    return ty::resolve_tyref(self.api, &o.result);
1598                }
1599            }
1600        }
1601        if self.is_numeric(lt) && self.is_numeric(rt) {
1602            return if self.is_float(lt) || self.is_float(rt) {
1603                self.float_ty()
1604            } else {
1605                self.int_ty()
1606            };
1607        }
1608        // A seam operand keeps the result on the seam (`a + unknown` is `Unknown`, not the
1609        // gradual `Variant`, so `var x := a + unknown` never warns).
1610        if lt.is_unknown() || rt.is_unknown() || lt.is_error() || rt.is_error() {
1611            return Ty::Unknown;
1612        }
1613        Ty::Variant
1614    }
1615
1616    fn tyref_matches(&self, tyref: &TyRef, ty: &Ty) -> bool {
1617        let resolved = ty::resolve_tyref(self.api, tyref);
1618        resolved.is_variant() || &resolved == ty
1619    }
1620
1621    fn infer_call(&mut self, callee: ExprId, args: &[ExprId]) -> Ty {
1622        // Argument expressions are always inferred (their own diagnostics + hover).
1623        for &a in args {
1624            self.infer_expr(a, &Expectation::None);
1625        }
1626        let ret = match self.body.expr(callee).clone() {
1627            Expr::Field {
1628                receiver,
1629                name,
1630                name_range,
1631            } => {
1632                self.infer_field(receiver, &name, name_range, /*as_method=*/ true)
1633            }
1634            Expr::Name(name) => {
1635                let ret = self.resolve_call_name(&name);
1636                self.expr_ty.insert(callee, Ty::Callable);
1637                ret
1638            }
1639            // Calling an arbitrary expression — a `Callable` value or an immediately-invoked
1640            // lambda (`(func(): …).call()`): the callee's return type isn't tracked, so the
1641            // result is the seam (not `Variant`), and `var x := f()()` never warns.
1642            _ => {
1643                self.infer_expr(callee, &Expectation::None);
1644                Ty::Unknown
1645            }
1646        };
1647        // UNSAFE_CALL_ARGUMENT (Phase-2 §5): args + receiver are now inferred (in `expr_ty`), so
1648        // check each argument against the statically-resolved callee's parameter types.
1649        self.check_call_args(callee, args);
1650        ret
1651    }
1652
1653    /// Raise `UNSAFE_CALL_ARGUMENT` for each argument whose static type needs an unsafe implicit
1654    /// cast (`Variant` / a downcast) into the resolved parameter type — Godot's per-argument
1655    /// value-prop warning. Only fires when the callee resolves to a concrete signature here; an
1656    /// uninformative argument (the cross-file seam) is `Assign::Ok` and correctly silent, and an
1657    /// untyped parameter accepts anything.
1658    fn check_call_args(&mut self, callee: ExprId, args: &[ExprId]) {
1659        let Some(params) = self.call_param_tys(callee) else {
1660            return;
1661        };
1662        for (i, &arg) in args.iter().enumerate() {
1663            let Some(param_ty) = params.get(i) else {
1664                break; // a vararg tail or an arity mismatch — not an argument-type concern
1665            };
1666            if param_ty.is_uninformative() || param_ty.is_variant() {
1667                continue; // an untyped parameter accepts anything safely
1668            }
1669            // A missing arg type defaults to the seam (never warns), not `Variant` (would warn).
1670            let arg_ty = self.expr_ty.get(&arg).cloned().unwrap_or(Ty::Unknown);
1671            if ty::is_assignable(self.api, &arg_ty, param_ty) == Assign::OkUnsafe {
1672                let pl = param_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
1673                let al = arg_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
1674                self.warn(
1675                    self.range_of(arg),
1676                    WarningCode::UnsafeCallArgument,
1677                    format!(
1678                        "The argument {} requires a value of type \"{pl}\" but is passed \"{al}\", which is unsafe.",
1679                        i + 1
1680                    ),
1681                );
1682            }
1683        }
1684    }
1685
1686    /// Parameter types of a statically-resolved callee, for [`Self::check_call_args`]. `None` when
1687    /// the callee isn't concretely resolvable here (a cross-file script method — params aren't
1688    /// modeled —, a builtin/utility, a `Callable` value): those raise no argument warning.
1689    fn call_param_tys(&self, callee: ExprId) -> Option<Vec<Ty>> {
1690        match self.body.expr(callee) {
1691            Expr::Name(name) => self.name_call_param_tys(name),
1692            Expr::Field { receiver, name, .. } => match self.expr_ty.get(receiver)? {
1693                Ty::Object(class) => match self.api.lookup_member(*class, name)? {
1694                    MemberRef::Method(sig) => Some(
1695                        sig.params
1696                            .iter()
1697                            .map(|p| ty::resolve_tyref(self.api, &p.ty))
1698                            .collect(),
1699                    ),
1700                    _ => None,
1701                },
1702                // ScriptRef / builtin / seam receivers: params not uniformly modeled — skip.
1703                _ => None,
1704            },
1705            _ => None,
1706        }
1707    }
1708
1709    /// Parameter types for a bare-name call (`foo(...)` / an inherited `method(...)`): an own `func`
1710    /// first, then the `self` engine base's method. Utilities/builtins are skipped (looser, often
1711    /// variadic typing — out of the conservative MVP slice).
1712    fn name_call_param_tys(&self, name: &str) -> Option<Vec<Ty>> {
1713        if let Some(item) = self.class.lookup(name)
1714            && let Some(Member::Func(f)) = self.class.member(item)
1715        {
1716            return Some(
1717                f.params
1718                    .iter()
1719                    .map(|p| {
1720                        p.type_ref.as_deref().map_or(Ty::Variant, |t| {
1721                            resolve::resolve_type_name(self.db, self.api, t)
1722                        })
1723                    })
1724                    .collect(),
1725            );
1726        }
1727        if let Ty::Object(base) = self.class.base
1728            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
1729        {
1730            return Some(
1731                sig.params
1732                    .iter()
1733                    .map(|p| ty::resolve_tyref(self.api, &p.ty))
1734                    .collect(),
1735            );
1736        }
1737        None
1738    }
1739
1740    /// Resolve a bare-name call (`foo(...)`): own method → utility/builtin fn → constructor.
1741    fn resolve_call_name(&self, name: &str) -> Ty {
1742        if let Some(item) = self.class.lookup(name)
1743            && let Some(Member::Func(f)) = self.class.member(item)
1744        {
1745            return self.func_return_ty(f.return_type.as_deref());
1746        }
1747        // A bare call inside the class is `self.name(...)` — resolve against the inherited base.
1748        if let Ty::Object(base) = self.class.base
1749            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
1750        {
1751            return ty::resolve_tyref(self.api, &sig.return_ty);
1752        }
1753        if let Some(u) = self.api.utility(name) {
1754            return ty::resolve_tyref(self.api, &u.return_ty);
1755        }
1756        if let Some(f) = self.api.gdscript_builtin(name) {
1757            return resolve::layer_to_ty(self.api, f.ret);
1758        }
1759        // A builtin / class name used as a constructor: `Vector2(...)` / `Array(...)`.
1760        // Normalize via `resolve_tyref` so `Array`/`Dictionary`/`Callable`/`Signal` land on
1761        // their dedicated `Ty` variants rather than `Builtin(...)`.
1762        if let Some(b) = self.api.builtin_by_name(name) {
1763            return ty::resolve_tyref(self.api, &TyRef::Builtin(b));
1764        }
1765        // Otherwise unresolved — most likely a cross-file global / autoload / a method on a
1766        // `class_name` base we can't see. Treat as the seam so `var x := foo()` never warns.
1767        Ty::Unknown
1768    }
1769
1770    fn func_return_ty(&self, annotation: Option<&str>) -> Ty {
1771        annotation.map_or(Ty::Variant, |t| {
1772            resolve::resolve_type_name(self.db, self.api, t)
1773        })
1774    }
1775
1776    /// Member access `receiver.name`. When `as_method`, resolve a method (and use its return
1777    /// type); otherwise resolve a property/const/etc. Raises `UNSAFE_*` only on a statically
1778    /// **known** receiver.
1779    fn infer_field(
1780        &mut self,
1781        receiver: ExprId,
1782        name: &str,
1783        name_range: TextRange,
1784        as_method: bool,
1785    ) -> Ty {
1786        let is_self = matches!(self.body.expr(receiver), Expr::SelfExpr);
1787        let recv_ty = self.infer_expr(receiver, &Expectation::None);
1788
1789        // `self.member` consults this file's own members first (Playbook §3.2).
1790        if is_self && let Some(item) = self.class.lookup(name) {
1791            return self.own_member_ty(item, as_method);
1792        }
1793
1794        match &recv_ty {
1795            // Uninformative receivers are unchecked and **propagate the seam**: a member of an
1796            // `Unknown` (cross-file) value is itself `Unknown` (never warns), a member of a
1797            // `Variant` is `Variant`, of an `Error` is `Error`. Collapsing `Unknown` to
1798            // `Variant` here would wrongly fire `INFERENCE_ON_VARIANT` on `var x := other.field`.
1799            t if t.is_uninformative() => recv_ty.clone(),
1800            Ty::Object(class) => {
1801                if name == "new" {
1802                    // `Class.new(...)` always constructs an instance of the class (some classes,
1803                    // e.g. GDScript, also carry a modeled `new` member — the constructor wins).
1804                    recv_ty.clone()
1805                } else if let Some(m) = self.api.lookup_member(*class, name) {
1806                    self.check_member_kind_misuse(&m, as_method, name, name_range);
1807                    self.check_static_on_instance(receiver, &m, as_method, name_range);
1808                    self.member_ref_ty(&m, as_method)
1809                } else if let Some(t) = self.class_enum_value(*class, name) {
1810                    // A statically-accessed enum value (`Control.PRESET_FULL_RECT`).
1811                    t
1812                } else {
1813                    // Self with an Object base already checked own members above.
1814                    self.emit_unsafe(name, &recv_ty, name_range, as_method);
1815                    Ty::Variant
1816                }
1817            }
1818            Ty::Builtin(_) | Ty::Array(_) | Ty::Dict(..) | Ty::Callable | Ty::Signal(_) => {
1819                self.builtin_member_ty(&recv_ty, name, name_range, as_method)
1820            }
1821            // Enum value access (`MyEnum.VALUE`) is an `int`.
1822            Ty::Enum(_) => self.int_ty(),
1823            // A cross-file script reference: resolve the member against its (own) member table.
1824            Ty::ScriptRef(sref) => self.script_member_ty(*sref, name, as_method),
1825            _ => Ty::Variant,
1826        }
1827    }
1828
1829    /// A member of a cross-file script (`ScriptRef`): looked up in the script's own member table
1830    /// (M1). A member we don't model — e.g. one inherited from a base we don't resolve until M2 —
1831    /// yields the seam (`Unknown`), **never** an `UNSAFE_*` warning. `Class.new(...)` constructs
1832    /// an instance of the class.
1833    fn script_member_ty(&self, sref: ScriptRefId, name: &str, as_method: bool) -> Ty {
1834        if name == "new" {
1835            return Ty::ScriptRef(sref);
1836        }
1837        self.script_member_walk(sref, name, as_method, 0)
1838            .unwrap_or(Ty::Unknown)
1839    }
1840
1841    /// Walk a script class's `extends` chain for `name`: own members first, then a user base
1842    /// (another `ScriptRef`), then an engine base (the API table). Depth-bounded so a cyclic
1843    /// `extends` cannot loop. `None` = not found anywhere in the chain (the seam).
1844    fn script_member_walk(
1845        &self,
1846        sref: ScriptRefId,
1847        name: &str,
1848        as_method: bool,
1849        depth: u32,
1850    ) -> Option<Ty> {
1851        if depth > 32 {
1852            return None;
1853        }
1854        let file = self.db.file_text(FileId(sref.0))?;
1855        let sc = crate::queries::script_class(self.db, file);
1856        if let Some(m) = sc.member(name) {
1857            return Some(match m {
1858                crate::queries::MemberSig::Method(ret) => {
1859                    if as_method {
1860                        ret.clone()
1861                    } else {
1862                        Ty::Callable
1863                    }
1864                }
1865                crate::queries::MemberSig::Field(t) => t.clone(),
1866                crate::queries::MemberSig::Signal => Ty::Signal(None),
1867            });
1868        }
1869        // Not an own member — continue up the inheritance chain.
1870        match sc.base() {
1871            Ty::ScriptRef(base) => self.script_member_walk(*base, name, as_method, depth + 1),
1872            Ty::Object(class) => self
1873                .api
1874                .lookup_member(*class, name)
1875                .map(|m| self.member_ref_ty(&m, as_method)),
1876            _ => None,
1877        }
1878    }
1879
1880    /// Whether a value of type `sub` is statically a subtype of `sup` — composing user `ScriptRef`
1881    /// `extends` chains with the engine class table (M4, for `is`/`as` widen-only narrowing). A
1882    /// `ScriptRef` IS-A its native base (so `script_value is Node` holds), but Godot's asymmetry is
1883    /// honored: a native/script value is **not** a subtype of an *unrelated* user script.
1884    fn is_subtype(&self, sub: &Ty, sup: &Ty) -> bool {
1885        match (sub, sup) {
1886            (Ty::Object(a), Ty::Object(b)) => self.api.is_subclass(*a, *b),
1887            (Ty::ScriptRef(a), Ty::ScriptRef(b)) => self.script_is_subtype(*a, *b, 0),
1888            (Ty::ScriptRef(a), Ty::Object(b)) => self.script_extends_engine(*a, *b, 0),
1889            _ => false,
1890        }
1891    }
1892
1893    /// Whether script `sub` is `sup` or transitively extends it — walk the `extends` base chain by
1894    /// script identity (depth-bounded, like [`script_member_walk`](Self::script_member_walk)).
1895    fn script_is_subtype(&self, sub: ScriptRefId, sup: ScriptRefId, depth: u32) -> bool {
1896        if depth > 32 {
1897            return false;
1898        }
1899        if sub == sup {
1900            return true;
1901        }
1902        let Some(file) = self.db.file_text(FileId(sub.0)) else {
1903            return false;
1904        };
1905        match crate::queries::script_class(self.db, file).base() {
1906            Ty::ScriptRef(base) => self.script_is_subtype(*base, sup, depth + 1),
1907            _ => false,
1908        }
1909    }
1910
1911    /// Whether script `sub`'s `extends` chain reaches engine class `sup_native` at its native base.
1912    fn script_extends_engine(
1913        &self,
1914        sub: ScriptRefId,
1915        sup_native: gdscript_api::ClassId,
1916        depth: u32,
1917    ) -> bool {
1918        if depth > 32 {
1919            return false;
1920        }
1921        let Some(file) = self.db.file_text(FileId(sub.0)) else {
1922            return false;
1923        };
1924        match crate::queries::script_class(self.db, file).base() {
1925            Ty::ScriptRef(base) => self.script_extends_engine(*base, sup_native, depth + 1),
1926            Ty::Object(native) => self.api.is_subclass(*native, sup_native),
1927            _ => false,
1928        }
1929    }
1930
1931    fn emit_unsafe(&mut self, name: &str, recv: &Ty, range: TextRange, as_method: bool) {
1932        let recv_label = recv.label(self.api).unwrap_or_else(|| "?".to_owned());
1933        let (code, message) = if as_method {
1934            (
1935                WarningCode::UnsafeMethodAccess,
1936                format!(
1937                    "The method \"{name}()\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
1938                ),
1939            )
1940        } else {
1941            (
1942                WarningCode::UnsafePropertyAccess,
1943                format!(
1944                    "The property \"{name}\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
1945                ),
1946            )
1947        };
1948        self.warn(range, code, message);
1949    }
1950
1951    /// Whether the class's RESOLVED **engine** base declares a *value* member (var/const/signal)
1952    /// named `name` — the sound floor for `SHADOWED_VARIABLE_BASE_CLASS`. Only the engine base is
1953    /// consulted: an unresolved base (the cross-file seam) returns `false` (no warning), and the
1954    /// cross-file *user*-base `MemberSig` is lossy (no kind detail) so user-base shadowing stays
1955    /// deferred (see `TECH_DEBT.md`). Methods are excluded (matches the own-member shadow rule).
1956    fn engine_base_has_value_member(&self, name: &str) -> bool {
1957        let Ty::Object(base) = &self.class.base else {
1958            return false;
1959        };
1960        matches!(
1961            self.api.lookup_member(*base, name),
1962            Some(MemberRef::Property(_) | MemberRef::Const(_) | MemberRef::Signal(_))
1963        )
1964    }
1965
1966    /// Flag a deprecated member-kind misuse on a statically-resolved engine member:
1967    /// `PROPERTY_USED_AS_FUNCTION` / `CONSTANT_USED_AS_FUNCTION` when a property/const is *called*.
1968    /// Guarded against a Callable/Signal/uninformative-typed member (those can legitimately be
1969    /// invoked). `FUNCTION_USED_AS_PROPERTY` is intentionally NOT emitted — a bare `obj.method` is an
1970    /// idiomatic `Callable` reference (every signal `.connect`), indistinguishable from a misuse
1971    /// without call-context, so it would false-positive everywhere (see `TECH_DEBT.md`).
1972    fn check_member_kind_misuse(
1973        &mut self,
1974        m: &MemberRef,
1975        as_method: bool,
1976        name: &str,
1977        range: TextRange,
1978    ) {
1979        if !as_method {
1980            return;
1981        }
1982        let (code, kind, ty) = match m {
1983            MemberRef::Property(p) => (
1984                WarningCode::PropertyUsedAsFunction,
1985                "property",
1986                ty::resolve_tyref(self.api, &p.ty),
1987            ),
1988            MemberRef::Const(c) => (
1989                WarningCode::ConstantUsedAsFunction,
1990                "constant",
1991                ty::resolve_tyref(self.api, &c.ty),
1992            ),
1993            _ => return,
1994        };
1995        // A Callable/Signal-typed (or uninformative) member can be invoked — never flag it.
1996        if ty.is_uninformative() || matches!(ty, Ty::Callable | Ty::Signal(_)) {
1997            return;
1998        }
1999        self.warn(
2000            range,
2001            code,
2002            format!("The {kind} \"{name}\" is being called as if it were a function."),
2003        );
2004    }
2005
2006    /// Flag `STATIC_CALLED_ON_INSTANCE`: an engine static method called through an instance value
2007    /// rather than the type. Conservative + sound — fires only when the receiver is a **typed local
2008    /// instance** (a `Name` bound in `locals`), never a bare class name (`Class.static()` is
2009    /// correct) nor an expression we can't classify. Under-warns by design; zero false positives.
2010    fn check_static_on_instance(
2011        &mut self,
2012        receiver: ExprId,
2013        m: &MemberRef,
2014        as_method: bool,
2015        range: TextRange,
2016    ) {
2017        if !as_method {
2018            return;
2019        }
2020        let MemberRef::Method(sig) = m else {
2021            return;
2022        };
2023        if !sig.is_static {
2024            return;
2025        }
2026        let Expr::Name(rname) = self.body.expr(receiver) else {
2027            return;
2028        };
2029        if !self.locals.contains_key(rname) {
2030            return;
2031        }
2032        // A local that ALIASES a type/var (`var t := JSON; t.stringify()`) is not an instance —
2033        // calling a static method through it is valid (`t` holds the type, not an object). A bare
2034        // `Name` initializer marks such an alias; only a constructor/call init (or a param/field
2035        // with no init) is a true instance. Skipping the alias case fixes a false positive.
2036        if let Some(b) = self.bindings.iter().rev().find(|b| &b.name == rname)
2037            && let Some(init) = b.init
2038            && matches!(self.body.expr(init), Expr::Name(_))
2039        {
2040            return;
2041        }
2042        self.warn(
2043            range,
2044            WarningCode::StaticCalledOnInstance,
2045            "A static method is being called on an instance; call it on the type instead."
2046                .to_owned(),
2047        );
2048    }
2049
2050    fn member_ref_ty(&self, m: &MemberRef, as_method: bool) -> Ty {
2051        match m {
2052            MemberRef::Method(sig) => {
2053                if as_method {
2054                    ty::resolve_tyref(self.api, &sig.return_ty)
2055                } else {
2056                    Ty::Callable
2057                }
2058            }
2059            MemberRef::Property(p) => p.enum_of.as_ref().map_or_else(
2060                || ty::resolve_tyref(self.api, &p.ty),
2061                |q| {
2062                    Ty::Enum(EnumRef {
2063                        qualified: SmolStr::new(q),
2064                        bitfield: false,
2065                    })
2066                },
2067            ),
2068            MemberRef::Const(c) => ty::resolve_tyref(self.api, &c.ty),
2069            MemberRef::Signal(_) => Ty::Signal(None),
2070            MemberRef::Enum(_) => Ty::Variant,
2071        }
2072    }
2073
2074    fn builtin_member_ty(
2075        &mut self,
2076        recv: &Ty,
2077        name: &str,
2078        range: TextRange,
2079        as_method: bool,
2080    ) -> Ty {
2081        let Some(bid) = self.builtin_id_of(recv) else {
2082            return Ty::Variant;
2083        };
2084        if as_method {
2085            return if let Some(sig) = self.api.builtin_method(bid, name) {
2086                ty::resolve_tyref(self.api, &sig.return_ty)
2087            } else {
2088                self.emit_unsafe(name, recv, range, true);
2089                Ty::Variant
2090            };
2091        }
2092        if let Some(member) = self.api.builtin_member(bid, name) {
2093            return ty::resolve_tyref(self.api, &member.ty);
2094        }
2095        // Static constants (`Vector2.ZERO`, `Color.WHITE`) and enum values (`Variant.Type.*`).
2096        let data = self.api.builtin(bid);
2097        if let Some(c) = data.constants.iter().find(|c| c.name == name) {
2098            return ty::resolve_tyref(self.api, &c.ty);
2099        }
2100        if data
2101            .enums
2102            .iter()
2103            .any(|e| e.values.iter().any(|v| v.name == name))
2104        {
2105            return self.int_ty();
2106        }
2107        if self.api.builtin_method(bid, name).is_some() {
2108            return Ty::Callable;
2109        }
2110        self.emit_unsafe(name, recv, range, false);
2111        Ty::Variant
2112    }
2113
2114    /// The type of a class enum **value** accessed statically (`Control.PRESET_FULL_RECT`):
2115    /// the engine exposes enum values as class members, so search every (inherited) enum's
2116    /// values. Returns the value's **declaring enum type** (`Ty::Enum`) — mirroring how a
2117    /// `Class.Enum` *annotation* resolves (`resolve::resolve_named`), so an enum member assigned
2118    /// to a slot of that same enum is `Assign::Ok`, not a false `INT_AS_ENUM_WITHOUT_CAST`. (An
2119    /// enum value is still freely assignable to `int` — see `ty::is_assignable`.)
2120    fn class_enum_value(&self, class: gdscript_api::ClassId, name: &str) -> Option<Ty> {
2121        let mut cur = Some(class);
2122        while let Some(cid) = cur {
2123            let c = self.api.class(cid);
2124            if let Some(e) = c
2125                .enums
2126                .iter()
2127                .find(|e| e.values.iter().any(|v| v.name == name))
2128            {
2129                return Some(Ty::Enum(EnumRef {
2130                    qualified: SmolStr::new(format!("{}.{}", c.name, e.name)),
2131                    bitfield: e.is_bitfield,
2132                }));
2133            }
2134            cur = c.base;
2135        }
2136        None
2137    }
2138
2139    /// The builtin id backing a builtin / `Array` / `Dictionary` receiver.
2140    fn builtin_id_of(&self, ty: &Ty) -> Option<gdscript_api::BuiltinId> {
2141        match ty {
2142            Ty::Builtin(b) => Some(*b),
2143            Ty::Array(_) => self.api.builtin_by_name("Array"),
2144            Ty::Dict(..) => self.api.builtin_by_name("Dictionary"),
2145            Ty::Callable => self.api.builtin_by_name("Callable"),
2146            Ty::Signal(_) => self.api.builtin_by_name("Signal"),
2147            _ => None,
2148        }
2149    }
2150
2151    /// The element type of an indexing expression (Playbook §2 switch).
2152    fn index_ty(&self, base: &Ty) -> Ty {
2153        match base {
2154            Ty::Array(elem) => (**elem).clone(),
2155            Ty::Builtin(b) => self
2156                .api
2157                .builtin(*b)
2158                .indexing_return
2159                .as_ref()
2160                .map_or(Ty::Variant, |r| ty::resolve_tyref(self.api, r)),
2161            // Indexing through the seam stays on the seam (never warns).
2162            Ty::Unknown => Ty::Unknown,
2163            Ty::Error => Ty::Error,
2164            _ => Ty::Variant,
2165        }
2166    }
2167
2168    /// The loop variable's type for `for v in iter:` (Playbook §2 switch).
2169    fn loop_var_ty(&self, iter: &Ty) -> Ty {
2170        match iter {
2171            Ty::Array(elem) => (**elem).clone(),
2172            Ty::Builtin(b) => {
2173                let data = self.api.builtin(*b);
2174                if data.name == "int" {
2175                    // `for i in 5` / `for i in range(...)` → int.
2176                    self.int_ty()
2177                } else if let Some(r) = &data.indexing_return {
2178                    // `for c in "abc"` → String; `for s in packed_string_array` → String; …
2179                    ty::resolve_tyref(self.api, r)
2180                } else {
2181                    Ty::Variant
2182                }
2183            }
2184            // Iterating a seam value keeps the loop var on the seam (never warns).
2185            Ty::Unknown => Ty::Unknown,
2186            Ty::Error => Ty::Error,
2187            _ => Ty::Variant,
2188        }
2189    }
2190
2191    fn infer_lambda(&mut self, params: &[ParamBinding], body: &[body::StmtId]) {
2192        // Lambda params shadow within the body; restore the outer locals afterward. A `return`
2193        // inside the lambda is the *lambda's* return, not the enclosing function's — so disable
2194        // return checking (set the expected return to `Variant`) while walking the body.
2195        let saved_locals = self.locals.clone();
2196        let saved_ret = std::mem::replace(&mut self.return_ty, Ty::Variant);
2197        for p in params {
2198            let ty = self.param_ty(p);
2199            self.bindings.push(Binding {
2200                name: p.name.clone(),
2201                name_range: p.name_range,
2202                ty: ty.clone(),
2203                init: None,
2204                annotated: p.type_ref.is_some(),
2205                inferred_colon_eq: false,
2206                is_const: false,
2207                kind: BindingKind::Param,
2208            });
2209            self.locals.insert(p.name.clone(), ty);
2210        }
2211        self.infer_block(body);
2212        self.return_ty = saved_ret;
2213        self.locals = saved_locals;
2214    }
2215
2216    fn param_ty(&mut self, p: &ParamBinding) -> Ty {
2217        if let Some(ptr) = p.type_ref {
2218            return self.resolve_ptr_ty(ptr);
2219        }
2220        // An unannotated param infers from its default, else `Variant`.
2221        p.default
2222            .map_or(Ty::Variant, |e| self.infer_expr(e, &Expectation::None))
2223    }
2224
2225    // ---- name resolution (local → class member → inherited → global) ----
2226
2227    fn resolve_name(&mut self, id: ExprId, name: &str) -> Ty {
2228        // Record a read of a local/param for the `UNUSED_*` analysis (before the narrowing check,
2229        // so a narrowed read still counts as used).
2230        if self.locals.contains_key(name) {
2231            self.used_locals.insert(SmolStr::new(name));
2232        }
2233        // UNASSIGNED_VARIABLE (Workstream 2) — a *read* of a typed-no-init local that is not
2234        // definitely assigned on every path reaching here. Excludes the LHS of an assignment (a
2235        // write) and reads inside a lambda body (which `assigned_before` leaves `None`, unchecked).
2236        if self.is_func_body
2237            && self.needs_assignment.contains(name)
2238            && !self.assign_lhs.contains(&id)
2239            && let Some(cur) = self.cur_stmt
2240            && self
2241                .assigned
2242                .assigned_before(cur)
2243                .is_some_and(|a| !a.contains(name))
2244        {
2245            self.warn(
2246                self.range_of(id),
2247                WarningCode::UnassignedVariable,
2248                format!("The variable \"{name}\" may be used before it is assigned a value."),
2249            );
2250        }
2251        // Flow narrowing wins over the binding's declared type.
2252        if let Some(key) = self.narrow_key(id)
2253            && let Some(t) = self.narrowing.get(&key)
2254        {
2255            return t.clone();
2256        }
2257        if let Some(t) = self.locals.get(name) {
2258            return t.clone();
2259        }
2260        if let Some(item) = self.class.lookup(name) {
2261            return self.own_member_ty(item, false);
2262        }
2263        // Inherited members: an engine `Object` base via the API table, or a user `ScriptRef`
2264        // base via the script member walk (M2 — so a class extending another class_name sees its
2265        // inherited members).
2266        match self.class.base.clone() {
2267            Ty::Object(base) => {
2268                if let Some(m) = self.api.lookup_member(base, name) {
2269                    return self.member_ref_ty(&m, false);
2270                }
2271            }
2272            Ty::ScriptRef(base) => {
2273                if let Some(t) = self.script_member_walk(base, name, false, 0) {
2274                    return t;
2275                }
2276            }
2277            _ => {}
2278        }
2279        if let Some(g) = resolve::resolve_global(self.api, name) {
2280            return global_ty(&g);
2281        }
2282        // A project-global `class_name` used as a value — the class itself, for static access
2283        // (`V.fc()`) or as a constructor (`Player.new()`). Resolves to a `ScriptRef` via the
2284        // registry. Precedence (Godot `reduce_identifier`): `class_name` global ≫ autoload
2285        // singleton. So try `class_name` first, then a `*`-autoload, then the seam.
2286        let by_class = resolve::resolve_external(
2287            self.db,
2288            &resolve::ExternalRef::ClassName(SmolStr::new(name)),
2289        );
2290        if !by_class.is_unknown() {
2291            return by_class;
2292        }
2293        resolve::resolve_external(self.db, &resolve::ExternalRef::Autoload(SmolStr::new(name)))
2294    }
2295
2296    fn own_member_ty(&self, item: ClassItem, as_method: bool) -> Ty {
2297        match item {
2298            ClassItem::EnumVariant => self.int_ty(),
2299            ClassItem::Member(_) => match self.class.member(item) {
2300                Some(Member::Var(v)) => self.field_ty(&v.name, v.ptr),
2301                Some(Member::Const(c)) => self.field_ty(&c.name, c.ptr),
2302                Some(Member::Func(f)) => {
2303                    if as_method {
2304                        self.func_return_ty(f.return_type.as_deref())
2305                    } else {
2306                        Ty::Callable
2307                    }
2308                }
2309                Some(Member::Signal(_)) => Ty::Signal(None),
2310                Some(Member::Class(_)) => Ty::Unknown,
2311                Some(Member::Enum(_)) | None => Ty::Variant,
2312            },
2313        }
2314    }
2315
2316    /// The type of an own field (`var`/`const`): the type seeded by the field pre-pass (which
2317    /// captures the inferred type of `var n := 0`), falling back to the written annotation.
2318    fn field_ty(&self, name: &str, ptr: AstPtr) -> Ty {
2319        if let Some(t) = self.class.member_types.get(name) {
2320            return t.clone();
2321        }
2322        self.resolve_decl_annotation(ptr)
2323    }
2324
2325    /// Resolve a declaration's annotation (recovering its `TypeRef` node), else `Variant`.
2326    fn resolve_decl_annotation(&self, ptr: AstPtr) -> Ty {
2327        let Some(node) = ptr.to_node(self.root) else {
2328            return Ty::Variant;
2329        };
2330        cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
2331            .map_or(Ty::Variant, |t| {
2332                resolve::resolve_type_ref(self.db, self.api, &t)
2333            })
2334    }
2335
2336    // ---- narrowing ----
2337
2338    /// Build the narrowing env for a statement from the precomputed flow facts (Workstream 2).
2339    ///
2340    /// Only `Is` facts contribute a type (`NotNull`/`Not` are recorded by the flow pass but not yet
2341    /// consumed for typing — the 1.0 cut). The **widen-only + `is_uninformative`** soundness gate is
2342    /// preserved verbatim from the old `apply_narrowing`: `is`-narrowing is a deliberate divergence
2343    /// from upstream Godot (whose `is` does not flow-narrow), kept widen-only so it never produces a
2344    /// type Godot would reject — narrow only when the tested type is a downcast of the place's
2345    /// declared type, or the declared type is uninformative; never un-narrow a known subtype
2346    /// (`d: Derived; if d is Base` keeps `Derived`), never narrow to a type we couldn't resolve.
2347    fn facts_to_narrowing(&self, id: body::StmtId) -> FxHashMap<String, Ty> {
2348        let mut out = FxHashMap::default();
2349        if let Some(facts) = self.flow.facts_before(id) {
2350            for (place, nt) in facts.iter() {
2351                if let Some((key, ty)) = self.narrowing_entry(place, nt) {
2352                    out.insert(key, ty);
2353                }
2354            }
2355        }
2356        out
2357    }
2358
2359    /// Resolve one flow fact into a `(dotted-key, narrowed-type)` narrowing entry, applying the
2360    /// widen-only + `is_uninformative` soundness gate. `None` if the fact doesn't narrow a type
2361    /// (a `NotNull`/`Not`, an unresolvable/uninformative type, or an un-narrowing of a known subtype).
2362    fn narrowing_entry(&self, place: &Place, nt: &NarrowedTy) -> Option<(String, Ty)> {
2363        let NarrowedTy::Is(ptr) = nt else {
2364            return None;
2365        };
2366        let narrowed = self.resolve_ptr_ty(*ptr);
2367        if narrowed.is_uninformative() {
2368            return None;
2369        }
2370        // Gate against a local/param's declared type; for `self`-members / field chains the
2371        // `is_uninformative` check above is the soundness floor.
2372        if let Place::Local(n) = place
2373            && let Some(cur) = self.locals.get(n)
2374            && !cur.is_uninformative()
2375            && !self.is_subtype(&narrowed, cur)
2376        {
2377            return None;
2378        }
2379        Some((place.dotted_key(), narrowed))
2380    }
2381
2382    /// Apply a condition's short-circuit narrowing to the active env, for typing the RHS of an
2383    /// `and`/`or` (Workstream 2): `if x is T and x.method():` narrows `x` for `x.method()`.
2384    fn apply_condition_facts(&mut self, cond: ExprId, truthy: bool) {
2385        for (place, nt) in flow::condition_facts(self.body, cond, truthy) {
2386            if let Some((key, ty)) = self.narrowing_entry(&place, &nt) {
2387                self.narrowing.insert(key, ty);
2388            }
2389        }
2390    }
2391
2392    /// A dotted access-path key for narrowing (`x`, `self.field`, `a.b.c`), or `None` for a
2393    /// non-path expression.
2394    fn narrow_key(&self, id: ExprId) -> Option<String> {
2395        match self.body.expr(id) {
2396            Expr::Name(n) => Some(n.to_string()),
2397            Expr::SelfExpr => Some("self".to_owned()),
2398            Expr::Paren(inner) => self.narrow_key(*inner),
2399            Expr::Field { receiver, name, .. } => {
2400                Some(format!("{}.{name}", self.narrow_key(*receiver)?))
2401            }
2402            _ => None,
2403        }
2404    }
2405
2406    fn resolve_ptr_ty(&self, ptr: AstPtr) -> Ty {
2407        ptr.to_node(self.root).map_or(Ty::Variant, |n| {
2408            resolve::resolve_type_ref(self.db, self.api, &n)
2409        })
2410    }
2411
2412    // ---- helpers ----
2413
2414    /// The join (least upper bound) of two branch types — conservative: equal types collapse,
2415    /// a subtype widens to its supertype, else `Variant`.
2416    ///
2417    /// The three uninformative markers do NOT collapse to `Variant` — that would defeat the
2418    /// seam. They propagate by priority: `Error` (already diagnosed) → `Unknown` (the cross-file
2419    /// seam — must never warn or cascade) → `Variant` (the gradual top). So
2420    /// `x if c else <unknown>` stays `Unknown`, and `var y := (x if c else unknown)` does not
2421    /// fire a false `INFERENCE_ON_VARIANT`.
2422    fn join(&self, a: &Ty, b: &Ty) -> Ty {
2423        if a == b {
2424            return a.clone();
2425        }
2426        if a.is_error() || b.is_error() {
2427            return Ty::Error;
2428        }
2429        if a.is_unknown() || b.is_unknown() {
2430            return Ty::Unknown;
2431        }
2432        if a.is_variant() || b.is_variant() {
2433            return Ty::Variant;
2434        }
2435        if ty::is_assignable(self.api, a, b) == Assign::Ok {
2436            return b.clone();
2437        }
2438        if ty::is_assignable(self.api, b, a) == Assign::Ok {
2439            return a.clone();
2440        }
2441        Ty::Variant
2442    }
2443}
2444
2445/// Map a resolved global definition to the type of a bare reference to it.
2446fn global_ty(g: &GlobalDef) -> Ty {
2447    match g {
2448        GlobalDef::Const(t) => t.clone(),
2449        GlobalDef::Singleton(c) | GlobalDef::ClassType(c) => Ty::Object(*c),
2450        GlobalDef::BuiltinType(b) => Ty::Builtin(*b),
2451        // A bare function referenced as a value is a `Callable`; an enum namespace is opaque.
2452        GlobalDef::Builtin | GlobalDef::Utility => Ty::Callable,
2453        GlobalDef::GlobalEnum => Ty::Variant,
2454    }
2455}
2456
2457fn inference_on_variant_msg(kind: &str) -> String {
2458    format!(
2459        "The {kind} type is being inferred from a Variant value, so it will be typed as Variant."
2460    )
2461}
2462
2463/// The `extension_api.json` operator spelling for a binary operator.
2464fn op_symbol(op: BinOp) -> Option<&'static str> {
2465    Some(match op {
2466        BinOp::Add => "+",
2467        BinOp::Sub => "-",
2468        BinOp::Mul => "*",
2469        BinOp::Div => "/",
2470        BinOp::Mod => "%",
2471        BinOp::Pow => "**",
2472        BinOp::BitAnd => "&",
2473        BinOp::BitOr => "|",
2474        BinOp::BitXor => "^",
2475        BinOp::Shl => "<<",
2476        BinOp::Shr => ">>",
2477        _ => return None,
2478    })
2479}
2480
2481#[cfg(test)]
2482mod tests {
2483    use super::*;
2484    use crate::item_tree::item_tree;
2485    use gdscript_syntax::{SyntaxKind, parse};
2486
2487    struct Harness {
2488        result: InferenceResult,
2489        body: Body,
2490    }
2491
2492    /// Infer the (first) function in `src` against a fresh class scope.
2493    fn infer_first_func(src: &str) -> Harness {
2494        let api = gdscript_api::bundled();
2495        let db = gdscript_db::RootDatabase::default();
2496        let root = parse(src).syntax_node();
2497        let tree = item_tree(&root);
2498        let class = ClassScope::new(&db, api, &tree, None);
2499        let func = gdscript_syntax::ast::descendants(&root)
2500            .into_iter()
2501            .find(|n| n.kind() == SyntaxKind::FuncDecl)
2502            .expect("a function");
2503        let body = body::body_of_func(&func);
2504        let return_ty = cst::first_child(&func, |k| k == SyntaxKind::TypeRef)
2505            .map_or(Ty::Variant, |t| resolve::resolve_type_ref(&db, api, &t));
2506        let result = infer(&db, api, &root, &class, &body, return_ty, true);
2507        Harness { result, body }
2508    }
2509
2510    /// Every code inference produced — the ungated `diagnostics` plus the severity-free
2511    /// `raw_warnings` (the gateable Godot codes, post-W1-M0). Infer-level tests assert what the
2512    /// checker *records*; the gate-level resolution is tested in `crate::warnings`.
2513    fn codes(h: &Harness) -> Vec<&str> {
2514        h.result
2515            .diagnostics
2516            .iter()
2517            .map(|d| d.code.as_str())
2518            .chain(h.result.raw_warnings.iter().map(|w| w.code.as_str()))
2519            .collect()
2520    }
2521
2522    /// Run the whole-file pass (Pass 1 field fixpoint + Pass 2 functions) and collect every
2523    /// diagnostic code (ungated diagnostics + raw gateable warnings). Drives `analyze_file`
2524    /// directly so the bounded member fixpoint runs.
2525    fn file_codes(src: &str) -> Vec<String> {
2526        let api = gdscript_api::bundled();
2527        let db = gdscript_db::RootDatabase::default();
2528        let root = parse(src).syntax_node();
2529        let fi = analyze_file(&db, api, &root, FileId(0));
2530        fi.diagnostics
2531            .iter()
2532            .map(|d| d.code.clone())
2533            .chain(fi.raw_warnings.iter().map(|w| w.code.as_str().to_owned()))
2534            .collect()
2535    }
2536
2537    #[test]
2538    fn integer_division_warns() {
2539        let h = infer_first_func("func f():\n\tvar x = 5 / 2\n");
2540        assert!(codes(&h).contains(&INTEGER_DIVISION));
2541    }
2542
2543    #[test]
2544    fn float_div_does_not_warn() {
2545        let h = infer_first_func("func f():\n\tvar x = 5.0 / 2\n");
2546        assert!(!codes(&h).contains(&INTEGER_DIVISION));
2547    }
2548
2549    #[test]
2550    fn type_mismatch_on_hard_annotation() {
2551        let h = infer_first_func("func f():\n\tvar s: String = 5\n");
2552        assert!(codes(&h).contains(&TYPE_MISMATCH));
2553    }
2554
2555    #[test]
2556    fn narrowing_conversion_float_to_int() {
2557        let h = infer_first_func("func f():\n\tvar n: int = 1.5\n");
2558        assert!(codes(&h).contains(&NARROWING_CONVERSION));
2559    }
2560
2561    #[test]
2562    fn int_to_float_is_silent() {
2563        let h = infer_first_func("func f():\n\tvar x: float = 3\n\treturn x\n");
2564        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
2565    }
2566
2567    #[test]
2568    fn local_shadowing_a_param_warns_shadowed_variable() {
2569        let h = infer_first_func("func f(x):\n\tvar x = 1\n\treturn x\n");
2570        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
2571    }
2572
2573    #[test]
2574    fn local_shadowing_a_class_member_warns_shadowed_variable() {
2575        // The class scope (built from the whole file) sees the member `health`; the local shadows it.
2576        let h =
2577            infer_first_func("var health = 100\nfunc f():\n\tvar health = 1\n\treturn health\n");
2578        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
2579    }
2580
2581    #[test]
2582    fn non_shadowing_local_does_not_warn_shadowed_variable() {
2583        let h = infer_first_func("func f(x):\n\tvar y = 1\n\treturn x + y\n");
2584        assert!(!codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
2585    }
2586
2587    #[test]
2588    fn local_shadowing_a_base_member_warns_base_class() {
2589        // `position` is a Node2D property; a local of that name shadows the base member.
2590        let h =
2591            infer_first_func("extends Node2D\nfunc f():\n\tvar position = 1\n\treturn position\n");
2592        assert!(
2593            codes(&h).contains(&"SHADOWED_VARIABLE_BASE_CLASS"),
2594            "{:?}",
2595            codes(&h)
2596        );
2597    }
2598
2599    #[test]
2600    fn shadowing_an_unresolved_base_is_silent() {
2601        // No false positive when the base can't be resolved (the cross-file seam).
2602        let h = infer_first_func(
2603            "extends SomeUnknownThirdPartyClass\nfunc f():\n\tvar position = 1\n\treturn position\n",
2604        );
2605        assert!(
2606            !codes(&h).contains(&"SHADOWED_VARIABLE_BASE_CLASS"),
2607            "{:?}",
2608            codes(&h)
2609        );
2610    }
2611
2612    #[test]
2613    fn typed_local_read_before_assignment_warns() {
2614        let h = infer_first_func("func f() -> int:\n\tvar x: int\n\treturn x\n");
2615        assert!(
2616            codes(&h).contains(&"UNASSIGNED_VARIABLE"),
2617            "{:?}",
2618            codes(&h)
2619        );
2620    }
2621
2622    #[test]
2623    fn typed_local_assigned_then_read_does_not_warn() {
2624        let h = infer_first_func("func f() -> int:\n\tvar x: int\n\tx = 5\n\treturn x\n");
2625        assert!(
2626            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
2627            "{:?}",
2628            codes(&h)
2629        );
2630    }
2631
2632    #[test]
2633    fn typed_local_with_initializer_is_not_unassigned() {
2634        let h = infer_first_func("func f() -> int:\n\tvar x: int = 0\n\treturn x\n");
2635        assert!(
2636            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
2637            "{:?}",
2638            codes(&h)
2639        );
2640    }
2641
2642    #[test]
2643    fn untyped_local_is_not_unassigned_checked() {
2644        // An untyped `var x` is not an UNASSIGNED_VARIABLE candidate (no declared slot type).
2645        let h = infer_first_func("func f():\n\tvar x\n\tvar y = x\n\treturn y\n");
2646        assert!(
2647            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
2648            "{:?}",
2649            codes(&h)
2650        );
2651    }
2652
2653    #[test]
2654    fn typed_local_assigned_in_all_branches_then_read_does_not_warn() {
2655        // Both branches assign before the merge ⇒ definitely assigned ⇒ no warning (the join).
2656        let h = infer_first_func(
2657            "func f(c) -> int:\n\tvar x: int\n\tif c:\n\t\tx = 1\n\telse:\n\t\tx = 2\n\treturn x\n",
2658        );
2659        assert!(
2660            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
2661            "{:?}",
2662            codes(&h)
2663        );
2664    }
2665
2666    #[test]
2667    fn typed_local_assigned_in_one_branch_then_read_warns() {
2668        // Assigned only in the `then` branch ⇒ may be unassigned at the read ⇒ warns (matches Godot).
2669        let h =
2670            infer_first_func("func f(c) -> int:\n\tvar x: int\n\tif c:\n\t\tx = 1\n\treturn x\n");
2671        assert!(
2672            codes(&h).contains(&"UNASSIGNED_VARIABLE"),
2673            "{:?}",
2674            codes(&h)
2675        );
2676    }
2677
2678    #[test]
2679    fn arm_after_wildcard_is_unreachable_pattern() {
2680        let h =
2681            infer_first_func("func f(x):\n\tmatch x:\n\t\t_:\n\t\t\tpass\n\t\t1:\n\t\t\tpass\n");
2682        assert!(
2683            codes(&h).contains(&"UNREACHABLE_PATTERN"),
2684            "{:?}",
2685            codes(&h)
2686        );
2687    }
2688
2689    #[test]
2690    fn arm_after_var_bind_is_unreachable_pattern() {
2691        let h = infer_first_func(
2692            "func f(x):\n\tmatch x:\n\t\tvar y:\n\t\t\treturn y\n\t\t1:\n\t\t\tpass\n",
2693        );
2694        assert!(
2695            codes(&h).contains(&"UNREACHABLE_PATTERN"),
2696            "{:?}",
2697            codes(&h)
2698        );
2699    }
2700
2701    #[test]
2702    fn arm_before_wildcard_is_not_unreachable() {
2703        let h =
2704            infer_first_func("func f(x):\n\tmatch x:\n\t\t1:\n\t\t\tpass\n\t\t_:\n\t\t\tpass\n");
2705        assert!(
2706            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
2707            "{:?}",
2708            codes(&h)
2709        );
2710    }
2711
2712    #[test]
2713    fn guarded_wildcard_is_not_a_catch_all() {
2714        // `_ when c:` is conditional — a following arm is NOT unreachable.
2715        let h = infer_first_func(
2716            "func f(x, c):\n\tmatch x:\n\t\t_ when c:\n\t\t\tpass\n\t\t1:\n\t\t\tpass\n",
2717        );
2718        assert!(
2719            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
2720            "{:?}",
2721            codes(&h)
2722        );
2723    }
2724
2725    #[test]
2726    fn multi_pattern_with_wildcard_is_conservatively_not_catch_all() {
2727        // `1, _:` IS a catch-all in Godot, but we conservatively under-warn (no false positive).
2728        let h =
2729            infer_first_func("func f(x):\n\tmatch x:\n\t\t1, _:\n\t\t\tpass\n\t\t2:\n\t\t\tpass\n");
2730        assert!(
2731            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
2732            "{:?}",
2733            codes(&h)
2734        );
2735    }
2736
2737    #[test]
2738    fn enum_local_without_default_warns() {
2739        let h = infer_first_func("func f():\n\tvar m: Tween.TweenProcessMode\n");
2740        assert!(
2741            codes(&h).contains(&"ENUM_VARIABLE_WITHOUT_DEFAULT"),
2742            "{:?}",
2743            codes(&h)
2744        );
2745    }
2746
2747    #[test]
2748    fn enum_member_without_default_warns() {
2749        let codes = file_codes("var err: Error\nfunc f():\n\tpass\n");
2750        assert!(
2751            codes.iter().any(|c| c == "ENUM_VARIABLE_WITHOUT_DEFAULT"),
2752            "{codes:?}"
2753        );
2754    }
2755
2756    #[test]
2757    fn native_virtual_override_with_clashing_param_type_warns() {
2758        // `_input(event: InputEvent)` is a Node virtual; `event: int` is an incompatible override.
2759        let codes = file_codes("extends Node\nfunc _input(event: int):\n\tpass\n");
2760        assert!(
2761            codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
2762            "{codes:?}"
2763        );
2764    }
2765
2766    #[test]
2767    fn native_virtual_override_with_correct_param_type_does_not_warn() {
2768        let codes = file_codes("extends Node\nfunc _input(event: InputEvent):\n\tpass\n");
2769        assert!(
2770            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
2771            "{codes:?}"
2772        );
2773    }
2774
2775    #[test]
2776    fn native_virtual_override_with_untyped_param_does_not_warn() {
2777        let codes = file_codes("extends Node\nfunc _input(event):\n\tpass\n");
2778        assert!(
2779            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
2780            "{codes:?}"
2781        );
2782    }
2783
2784    #[test]
2785    fn a_non_virtual_method_is_not_a_native_override() {
2786        let codes = file_codes("extends Node\nfunc my_helper(x: int):\n\treturn x\n");
2787        assert!(
2788            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
2789            "{codes:?}"
2790        );
2791    }
2792
2793    #[test]
2794    fn dotted_enum_override_param_does_not_false_warn() {
2795        // A valid override whose param is a dotted engine enum must NOT clash (enums are int-backed
2796        // and resolve to different qualified names on the annotation vs model side). Bug-hunt repro.
2797        let codes = file_codes(
2798            "extends MultiplayerPeerExtension\nfunc _set_transfer_mode(p_mode: MultiplayerPeer.TransferMode):\n\tpass\n",
2799        );
2800        assert!(
2801            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
2802            "{codes:?}"
2803        );
2804    }
2805
2806    #[test]
2807    fn unused_signal_warns() {
2808        let codes = file_codes("signal my_event\nfunc f():\n\tpass\n");
2809        assert!(codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
2810    }
2811
2812    #[test]
2813    fn emitted_signal_is_not_unused() {
2814        let codes = file_codes("signal my_event\nfunc f():\n\tmy_event.emit()\n");
2815        assert!(!codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
2816    }
2817
2818    #[test]
2819    fn signal_connected_by_string_is_not_unused() {
2820        let codes = file_codes("signal my_event\nfunc f():\n\tconnect(\"my_event\", Callable())\n");
2821        assert!(!codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
2822    }
2823
2824    #[test]
2825    fn enum_local_with_default_does_not_warn() {
2826        let h = infer_first_func(
2827            "func f():\n\tvar m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE\n\treturn m\n",
2828        );
2829        assert!(
2830            !codes(&h).contains(&"ENUM_VARIABLE_WITHOUT_DEFAULT"),
2831            "{:?}",
2832            codes(&h)
2833        );
2834    }
2835
2836    #[test]
2837    fn static_method_on_instance_warns() {
2838        // `JSON.stringify` is static; calling it through a JSON *instance* warns.
2839        let h =
2840            infer_first_func("func f():\n\tvar j := JSON.new()\n\tj.stringify({})\n\treturn j\n");
2841        assert!(
2842            codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
2843            "{:?}",
2844            codes(&h)
2845        );
2846    }
2847
2848    #[test]
2849    fn static_method_on_the_type_does_not_warn() {
2850        // `JSON.stringify(...)` (on the type) is the correct form — never flagged.
2851        let h = infer_first_func("func f():\n\tJSON.stringify({})\n");
2852        assert!(
2853            !codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
2854            "{:?}",
2855            codes(&h)
2856        );
2857    }
2858
2859    #[test]
2860    fn static_method_through_a_type_aliased_local_does_not_warn() {
2861        // `var t := JSON` aliases the TYPE; `t.stringify()` is valid, not static-on-instance.
2862        let h = infer_first_func("func f():\n\tvar t := JSON\n\tt.stringify({})\n");
2863        assert!(
2864            !codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
2865            "{:?}",
2866            codes(&h)
2867        );
2868    }
2869
2870    #[test]
2871    fn property_called_as_function_warns() {
2872        // `n.name` is a Node property; calling it is PROPERTY_USED_AS_FUNCTION.
2873        let h = infer_first_func("func f(n: Node):\n\tn.name()\n");
2874        assert!(
2875            codes(&h).contains(&"PROPERTY_USED_AS_FUNCTION"),
2876            "{:?}",
2877            codes(&h)
2878        );
2879    }
2880
2881    #[test]
2882    fn constant_called_as_function_warns() {
2883        // `NOTIFICATION_READY` is a Node constant; calling it is CONSTANT_USED_AS_FUNCTION.
2884        let h = infer_first_func("func f(n: Node):\n\tn.NOTIFICATION_READY()\n");
2885        assert!(
2886            codes(&h).contains(&"CONSTANT_USED_AS_FUNCTION"),
2887            "{:?}",
2888            codes(&h)
2889        );
2890    }
2891
2892    #[test]
2893    fn calling_a_real_method_is_not_a_kind_misuse() {
2894        let h = infer_first_func("func f(n: Node):\n\tn.get_parent()\n");
2895        assert!(
2896            codes(&h).iter().all(|c| !c.ends_with("_USED_AS_FUNCTION")),
2897            "{:?}",
2898            codes(&h)
2899        );
2900    }
2901
2902    #[test]
2903    fn reading_a_property_as_a_value_is_not_a_kind_misuse() {
2904        let h = infer_first_func("func f(n: Node):\n\tvar s = n.name\n\treturn s\n");
2905        assert!(
2906            codes(&h).iter().all(|c| !c.ends_with("_USED_AS_FUNCTION")),
2907            "{:?}",
2908            codes(&h)
2909        );
2910    }
2911
2912    #[test]
2913    fn enum_member_into_its_own_enum_slot_is_not_int_as_enum() {
2914        // `var m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE` is valid GDScript with no
2915        // cast — the enum member must type as its enum (not bare `int`), so `check_assign` sees
2916        // `Enum → Enum` (Ok). A regression here would false-warn on extremely common engine code.
2917        let h = infer_first_func(
2918            "func f():\n\tvar m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE\n\treturn m\n",
2919        );
2920        assert!(
2921            !codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
2922            "{:?}",
2923            codes(&h)
2924        );
2925    }
2926
2927    #[test]
2928    fn bare_int_into_enum_slot_still_warns() {
2929        // The fix must not over-suppress: a genuine uncast `int` into an enum slot still warns.
2930        let h = infer_first_func("func f():\n\tvar m: Tween.TweenProcessMode = 0\n\treturn m\n");
2931        assert!(
2932            codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
2933            "{:?}",
2934            codes(&h)
2935        );
2936    }
2937
2938    #[test]
2939    fn member_access_resolves_engine_property() {
2940        // In a Node script, bare `get_node(...)` resolves via the inherited base to Object(Node);
2941        // `get_parent()` is a real Node method → no UNSAFE.
2942        let h = infer_first_func(
2943            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.get_parent()\n",
2944        );
2945        assert!(
2946            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
2947            "{:?}",
2948            h.result.diagnostics
2949        );
2950    }
2951
2952    #[test]
2953    fn unsafe_method_on_known_type() {
2954        let h = infer_first_func(
2955            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.totally_bogus_method()\n",
2956        );
2957        assert!(
2958            codes(&h).contains(&UNSAFE_METHOD_ACCESS),
2959            "{:?}",
2960            h.result.diagnostics
2961        );
2962    }
2963
2964    #[test]
2965    fn is_narrowing_suppresses_unsafe() {
2966        // Without narrowing, `x.free()` on an untyped param would be unchecked anyway; with
2967        // `is Node` it is checked against Node and `free` IS a Node method → no UNSAFE.
2968        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.queue_free()\n");
2969        assert!(
2970            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
2971            "{:?}",
2972            h.result.diagnostics
2973        );
2974    }
2975
2976    #[test]
2977    fn is_narrowing_flags_real_missing_member() {
2978        // After `is Node`, x is Node; `.bogus()` is genuinely missing → UNSAFE.
2979        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.bogus_method()\n");
2980        assert!(codes(&h).contains(&UNSAFE_METHOD_ACCESS));
2981    }
2982
2983    #[test]
2984    fn early_return_is_guard_narrows_past_the_guard() {
2985        // `if not (x is Node): return` — the only non-returning path proves x is Node, so after the
2986        // guard a real Node method is safe and a missing one warns (Workstream 2, beats the engine).
2987        let safe =
2988            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.get_parent()\n");
2989        assert!(
2990            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
2991            "real Node method must not warn after the guard: {:?}",
2992            codes(&safe)
2993        );
2994        let bogus =
2995            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.bogus_method()\n");
2996        assert!(
2997            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
2998            "missing method must warn after the guard: {:?}",
2999            codes(&bogus)
3000        );
3001    }
3002
3003    #[test]
3004    fn and_short_circuit_narrows_the_rhs() {
3005        // `x is Node and x.<m>()` types the RHS under x: Node — a real method is safe, a missing
3006        // one warns. The engine does not narrow here (Workstream 2, beats the engine).
3007        let safe = infer_first_func("func f(x):\n\tif x is Node and x.get_parent():\n\t\tpass\n");
3008        assert!(
3009            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
3010            "real Node method in the and-rhs must not warn: {:?}",
3011            codes(&safe)
3012        );
3013        let bogus =
3014            infer_first_func("func f(x):\n\tif x is Node and x.bogus_method():\n\t\tpass\n");
3015        assert!(
3016            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
3017            "missing method in the and-rhs must warn: {:?}",
3018            codes(&bogus)
3019        );
3020    }
3021
3022    // ---- Workstream 1 M1: self-contained checks ----
3023
3024    #[test]
3025    fn empty_file_warns() {
3026        assert!(file_codes("").iter().any(|c| c == "EMPTY_FILE"));
3027        assert!(
3028            file_codes("# just a comment\n")
3029                .iter()
3030                .any(|c| c == "EMPTY_FILE")
3031        );
3032        assert!(
3033            file_codes("extends Node\n")
3034                .iter()
3035                .all(|c| c != "EMPTY_FILE")
3036        );
3037    }
3038
3039    #[test]
3040    fn unused_variable_and_parameter() {
3041        let h = infer_first_func("func f(unused_p):\n\tvar unused_v = 1\n");
3042        assert!(codes(&h).contains(&"UNUSED_PARAMETER"), "{:?}", codes(&h));
3043        assert!(codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
3044        // A used binding does not warn; a `_`-prefixed one is intentionally ignored.
3045        let used = infer_first_func("func f(p):\n\tvar v = p\n\treturn v\n");
3046        assert!(codes(&used).iter().all(|c| !c.starts_with("UNUSED")));
3047        let underscored = infer_first_func("func f(_ignored):\n\tpass\n");
3048        assert!(!codes(&underscored).contains(&"UNUSED_PARAMETER"));
3049    }
3050
3051    #[test]
3052    fn standalone_expression_and_ternary() {
3053        let expr = infer_first_func("func f(a, b):\n\ta + b\n");
3054        assert!(
3055            codes(&expr).contains(&"STANDALONE_EXPRESSION"),
3056            "{:?}",
3057            codes(&expr)
3058        );
3059        let tern = infer_first_func("func f(c):\n\t1 if c else 2\n");
3060        assert!(
3061            codes(&tern).contains(&"STANDALONE_TERNARY"),
3062            "{:?}",
3063            codes(&tern)
3064        );
3065        // A call statement has an effect — never flagged.
3066        let call = infer_first_func("func f(n):\n\tn.queue_free()\n");
3067        assert!(codes(&call).iter().all(|c| !c.starts_with("STANDALONE")));
3068    }
3069
3070    #[test]
3071    fn unreachable_code_after_return() {
3072        let h = infer_first_func("func f():\n\treturn\n\tprint(\"dead\")\n");
3073        assert!(codes(&h).contains(&"UNREACHABLE_CODE"), "{:?}", codes(&h));
3074    }
3075
3076    #[test]
3077    fn incompatible_ternary_warns() {
3078        // `"s" if c else 1` — String vs int, no common type.
3079        let h = infer_first_func("func f(c):\n\tvar x = \"s\" if c else 1\n\treturn x\n");
3080        assert!(
3081            codes(&h).contains(&"INCOMPATIBLE_TERNARY"),
3082            "{:?}",
3083            codes(&h)
3084        );
3085    }
3086
3087    #[test]
3088    fn variant_receiver_never_unsafe() {
3089        // Untyped param → Variant receiver → unchecked, no diagnostic.
3090        let h = infer_first_func("func f(x):\n\tx.anything_at_all()\n");
3091        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
3092    }
3093
3094    #[test]
3095    fn unsafe_call_argument_on_variant_into_typed_param() {
3096        // Passing an untyped (Variant) value to a typed own-method parameter needs an unsafe cast.
3097        let h = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n: Node2D):\n\tpass\n");
3098        assert!(
3099            codes(&h).contains(&UNSAFE_CALL_ARGUMENT),
3100            "{:?}",
3101            h.result.diagnostics
3102        );
3103    }
3104
3105    #[test]
3106    fn unsafe_call_argument_silent_on_safe_and_untyped() {
3107        // A subtype arg (upcast) is safe; an untyped parameter accepts anything — neither warns.
3108        let upcast =
3109            infer_first_func("func f(n: Node2D):\n\ttake(n)\nfunc take(n: Node):\n\tpass\n");
3110        assert!(
3111            !codes(&upcast).contains(&UNSAFE_CALL_ARGUMENT),
3112            "upcast is safe: {:?}",
3113            upcast.result.diagnostics
3114        );
3115        let untyped = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n):\n\tpass\n");
3116        assert!(
3117            !codes(&untyped).contains(&UNSAFE_CALL_ARGUMENT),
3118            "untyped param accepts anything: {:?}",
3119            untyped.result.diagnostics
3120        );
3121    }
3122
3123    #[test]
3124    fn inference_on_variant() {
3125        // `:=` from an untyped (Variant) param.
3126        let h = infer_first_func("func f(x):\n\tvar y := x\n");
3127        assert!(codes(&h).contains(&INFERENCE_ON_VARIANT));
3128    }
3129
3130    #[test]
3131    fn field_inferred_from_earlier_field_is_typed() {
3132        // W2-MEMBER-FIXPOINT: `b`'s initializer references the earlier field `a`. A single shallow
3133        // field pass would see `a` as `Variant` (seam) and fire INFERENCE_ON_VARIANT on `:= a`; the
3134        // bounded fixpoint seeds `a: int` so `a + 1` is `int` and `:=` is precise — no warning.
3135        let codes = file_codes("var a := 1\nvar b := a + 1\n");
3136        assert!(
3137            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
3138            "field `b` from earlier field `a` should type as int, not Variant: {codes:?}"
3139        );
3140    }
3141
3142    #[test]
3143    fn field_forward_reference_is_seamed_not_warned() {
3144        // A field referencing a *later* field still resolves through the fixpoint (both rounds
3145        // see each other's seeded type), and at worst lands on the conservative seam — never a
3146        // false INFERENCE_ON_VARIANT. (`b` precedes `a` lexically here.)
3147        let codes = file_codes("var b := a\nvar a := 1\n");
3148        assert!(
3149            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
3150            "forward field reference must not false-warn: {codes:?}"
3151        );
3152    }
3153
3154    #[test]
3155    fn standalone_inferred_field_unchanged() {
3156        // No-regression: a self-contained inferred field still types from its literal, no warning.
3157        let codes = file_codes("var n := 0\n");
3158        assert!(
3159            codes.is_empty(),
3160            "a literal-initialised field should produce no diagnostics: {codes:?}"
3161        );
3162    }
3163
3164    #[test]
3165    fn lambda_var_is_callable_not_variant() {
3166        let h = infer_first_func("func f():\n\tvar cb := func():\n\t\tpass\n");
3167        assert!(
3168            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3169            "{:?}",
3170            h.result.diagnostics
3171        );
3172    }
3173
3174    #[test]
3175    fn multiline_lambda_then_paren_line_no_false_warning() {
3176        // A multi-line lambda bound to a var, followed by a statement that begins with `(`.
3177        // The parser now ends the lambda at its dedent (the `(` line is its own statement), so
3178        // there is no spurious call-on-lambda and no false `INFERENCE_ON_VARIANT`.
3179        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";
3180        let h = infer_first_func(src);
3181        assert!(
3182            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3183            "{:?}",
3184            h.result.diagnostics
3185        );
3186    }
3187
3188    #[test]
3189    fn calling_a_callable_value_is_seam_not_variant() {
3190        // Invoking an arbitrary expression (here a parenthesized `Callable` value) reaches the
3191        // seam arm of `infer_call`: the return type isn't tracked, so the result is Unknown,
3192        // not `Variant`, and the inferred-on-Variant warning never fires.
3193        let src = "func f(cb: Callable):\n\tvar x := (cb)()\n\treturn x\n";
3194        let h = infer_first_func(src);
3195        assert!(
3196            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3197            "{:?}",
3198            h.result.diagnostics
3199        );
3200    }
3201
3202    #[test]
3203    fn ternary_with_seam_branch_does_not_collapse_to_variant() {
3204        // A ternary whose else-branch is the seam (`await` is untracked → Unknown) must `join`
3205        // to Unknown, NOT Variant — otherwise `var x := …` fires a false INFERENCE_ON_VARIANT.
3206        // (Regression: `join` used to absorb any uninformative branch to Variant.)
3207        let src =
3208            "func f(c: bool):\n\tvar x := 5 if c else await get_tree().process_frame\n\treturn x\n";
3209        let h = infer_first_func(src);
3210        assert!(
3211            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3212            "seam branch should keep the ternary on the seam: {:?}",
3213            h.result.diagnostics
3214        );
3215    }
3216
3217    #[test]
3218    fn await_a_coroutine_call_recovers_its_return_type() {
3219        // `await f()` yields the call's value, so await is identity on a non-signal operand:
3220        // `await make()` for `func make() -> int` types `x` as int (was the seam before).
3221        let src = "func g() -> int:\n\tvar x := await make()\n\treturn x\nfunc make() -> int:\n\treturn 5\n";
3222        let h = infer_first_func(src);
3223        assert!(
3224            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3225            "no false variant warning: {:?}",
3226            h.result.diagnostics
3227        );
3228        let api = gdscript_api::bundled();
3229        let x = &h.result.bindings[0];
3230        assert!(
3231            matches!(&x.ty, Ty::Builtin(b) if api.builtin(*b).name == "int"),
3232            "await make() should recover int, got {:?}",
3233            x.ty
3234        );
3235    }
3236
3237    #[test]
3238    fn await_a_signal_stays_the_seam() {
3239        // `await sig` yields the signal's payload (needs the Phase-3+ sig table) — must stay the seam,
3240        // never the Signal type itself, and never a false INFERENCE_ON_VARIANT.
3241        let src = "func f():\n\tvar x := await get_tree().process_frame\n\treturn x\n";
3242        let h = infer_first_func(src);
3243        assert!(
3244            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3245            "awaiting a signal must not warn: {:?}",
3246            h.result.diagnostics
3247        );
3248        assert!(
3249            matches!(&h.result.bindings[0].ty, Ty::Unknown),
3250            "awaiting a signal stays the seam, got {:?}",
3251            h.result.bindings[0].ty
3252        );
3253    }
3254
3255    #[test]
3256    fn for_var_over_packed_string_array_is_string() {
3257        // `for s in "a,b".split(",")` iterates a PackedStringArray → String, so `s.to_int()`
3258        // resolves and `var x := s` does not warn.
3259        let h = infer_first_func("func f():\n\tfor s in \"a,b\".split(\",\"):\n\t\tvar x := s\n");
3260        assert!(
3261            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3262            "{:?}",
3263            h.result.diagnostics
3264        );
3265    }
3266
3267    #[test]
3268    fn class_new_is_object_not_variant() {
3269        let h = infer_first_func("func f():\n\tvar s := GDScript.new()\n");
3270        assert!(
3271            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3272            "{:?}",
3273            h.result.diagnostics
3274        );
3275    }
3276
3277    #[test]
3278    fn unknown_seam_never_warns() {
3279        // `preload(...)` is Unknown; `:=` from it does NOT warn, and member access is unchecked.
3280        let h = infer_first_func("func f():\n\tvar s := preload(\"res://x.gd\")\n\ts.whatever()\n");
3281        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
3282    }
3283
3284    #[test]
3285    fn expr_types_are_memoized_for_hover() {
3286        let h = infer_first_func("func f():\n\tvar n := 42\n");
3287        // The `42` literal expr should be typed int.
3288        let has_int = h
3289            .result
3290            .expr_ty
3291            .values()
3292            .any(|t| matches!(t, Ty::Builtin(_)));
3293        assert!(has_int);
3294        // sanity: the body lowered at least one expr
3295        assert!(!h.body.exprs.is_empty());
3296    }
3297}