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::{InnerClassItem, ItemTree, Member, has_annotation, 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/// Infer one function/initializer body against a class scope: walks the lowered [`body::Body`],
147/// resolving each expression's [`Ty`] (engine + cross-file members, scene-node paths, flow
148/// narrowing) and recording the bindings, diagnostics, and severity-free gateable warnings.
149/// Returns the [`InferenceResult`] the IDE features and the warning gate read.
150#[must_use]
151#[allow(
152    clippy::too_many_lines,
153    reason = "the per-body inference orchestration reads best whole"
154)]
155pub fn infer(
156    db: &dyn Db,
157    api: &EngineApi,
158    root: &GdNode,
159    class: &ClassScope,
160    body: &Body,
161    return_ty: Ty,
162    is_func_body: bool,
163) -> InferenceResult {
164    let self_ty = class.self_ty.clone();
165    let mut cx = Cx {
166        db,
167        api,
168        root,
169        body,
170        class,
171        self_ty,
172        return_ty,
173        expr_ty: FxHashMap::default(),
174        bindings: Vec::new(),
175        diagnostics: Vec::new(),
176        raw_warnings: Vec::new(),
177        locals: FxHashMap::default(),
178        used_locals: FxHashSet::default(),
179        narrowing: FxHashMap::default(),
180        flow: flow::analyze(body),
181        is_func_body,
182        assigned: flow::analyze_assigned(
183            body,
184            &body
185                .params
186                .iter()
187                .map(|p| p.name.clone())
188                .collect::<Vec<_>>(),
189        ),
190        cur_stmt: None,
191        needs_assignment: FxHashSet::default(),
192        assign_lhs: collect_assign_lhs(body),
193    };
194    // Parameters bind first (their defaults can reference earlier params).
195    let params = body.params.clone();
196    for p in &params {
197        let ty = cx.param_ty(p);
198        cx.bindings.push(Binding {
199            name: p.name.clone(),
200            name_range: p.name_range,
201            ty: ty.clone(),
202            init: None,
203            annotated: p.type_ref.is_some(),
204            inferred_colon_eq: false,
205            is_const: false,
206            kind: BindingKind::Param,
207        });
208        cx.locals.insert(p.name.clone(), ty);
209    }
210    if let Some(tail) = body.tail {
211        cx.infer_expr(tail, &Expectation::None);
212    }
213    let block = body.block.clone();
214    cx.infer_block(&block);
215
216    // UNUSED_* — a declared local/param/const never read. Only for a *function* body: a class-field
217    // initializer body would otherwise false-flag every field (the member is read in other methods,
218    // not in its own initializer). `_`-prefixed names + loop/match captures are excluded.
219    if is_func_body {
220        let unused: Vec<(TextRange, WarningCode, String)> = cx
221            .bindings
222            .iter()
223            .filter_map(|b| {
224                if b.name.starts_with('_') || cx.used_locals.contains(&b.name) {
225                    return None;
226                }
227                let (code, what) = match b.kind {
228                    BindingKind::Param => (WarningCode::UnusedParameter, "parameter"),
229                    BindingKind::Var if b.is_const => {
230                        (WarningCode::UnusedLocalConstant, "local constant")
231                    }
232                    BindingKind::Var => (WarningCode::UnusedVariable, "local variable"),
233                    BindingKind::ForVar | BindingKind::MatchBind => return None,
234                };
235                Some((
236                    b.name_range,
237                    code,
238                    format!("The {what} \"{}\" is declared but never used.", b.name),
239                ))
240            })
241            .collect();
242        for (range, code, msg) in unused {
243            cx.warn(range, code, msg);
244        }
245    }
246
247    // SHADOWED_GLOBAL_IDENTIFIER — a parameter / local / `for` / pattern-bind whose name collides
248    // with a project/engine global (built-in type/function, native class, engine singleton, project
249    // `class_name`, or autoload). Godot's `is_shadowing` fires for every local-scope binding. A local
250    // `var`/`const` that *also* shadows a param/member emits THIS instead of `SHADOWED_VARIABLE` (the
251    // global check wins in `gdscript_analyzer.cpp`; `infer_local_var` suppresses the variable-shadow
252    // when a global one applies, so the two never double-fire on one declaration).
253    if is_func_body {
254        let global_shadows: Vec<(TextRange, String)> = cx
255            .bindings
256            .iter()
257            .filter_map(|b| {
258                let kind = shadowed_global_kind(db, api, &b.name)?;
259                let what = match b.kind {
260                    BindingKind::Param => "parameter",
261                    BindingKind::Var if b.is_const => "constant",
262                    BindingKind::Var => "variable",
263                    BindingKind::ForVar => "for loop variable",
264                    BindingKind::MatchBind => "pattern bind",
265                };
266                Some((
267                    b.name_range,
268                    format!("The {what} \"{}\" has the same name as a {kind}.", b.name),
269                ))
270            })
271            .collect();
272        for (range, msg) in global_shadows {
273            cx.warn(range, WarningCode::ShadowedGlobalIdentifier, msg);
274        }
275    }
276
277    // UNTYPED_DECLARATION / INFERRED_DECLARATION — the opt-in declaration-strictness codes (default
278    // IGNORE; promoted to WARN under a strict / standalone run). Driven directly by the binding flags:
279    // a `var` declared with `:=` is INFERRED_DECLARATION; a `var` / parameter with neither a `: T`
280    // annotation nor `:=` is UNTYPED_DECLARATION. `const` (its value type is always statically known),
281    // `for` vars, and pattern binds (fixed by the iterable / scrutinee, not user-typeable) are excluded
282    // — they can't carry the static type Godot's strict check expects.
283    if is_func_body {
284        let decl_strictness: Vec<(TextRange, WarningCode, String)> = cx
285            .bindings
286            .iter()
287            .filter_map(|b| match b.kind {
288                BindingKind::Param if !b.annotated => Some((
289                    b.name_range,
290                    WarningCode::UntypedDeclaration,
291                    format!("The parameter \"{}\" has no static type.", b.name),
292                )),
293                BindingKind::Var if !b.is_const && b.inferred_colon_eq => Some((
294                    b.name_range,
295                    WarningCode::InferredDeclaration,
296                    format!(
297                        "The variable \"{}\" uses inferred typing (`:=`); consider declaring its type explicitly.",
298                        b.name
299                    ),
300                )),
301                BindingKind::Var if !b.is_const && !b.annotated => Some((
302                    b.name_range,
303                    WarningCode::UntypedDeclaration,
304                    format!("The variable \"{}\" has no static type.", b.name),
305                )),
306                _ => None,
307            })
308            .collect();
309        for (range, code, msg) in decl_strictness {
310            cx.warn(range, code, msg);
311        }
312    }
313
314    // CONFUSABLE_IDENTIFIER — a parameter / local binding whose name mixes scripts in a spoofable
315    // way (the same UTS #39 check used for member names; ASCII names fast-path out).
316    let confusable_bindings: Vec<TextRange> = cx
317        .bindings
318        .iter()
319        .filter(|b| is_confusable_identifier(&b.name))
320        .map(|b| b.name_range)
321        .collect();
322    for range in confusable_bindings {
323        cx.warn(
324            range,
325            WarningCode::ConfusableIdentifier,
326            "This identifier uses confusable characters (mixed scripts).".to_owned(),
327        );
328    }
329
330    // UNREACHABLE_CODE — statements after a return/break/continue / exhaustive branch (Workstream 2).
331    let unreachable = cx.flow.unreachable_ranges(body);
332    for range in unreachable {
333        cx.warn(
334            range,
335            WarningCode::UnreachableCode,
336            "Unreachable code (statement after a return, break, continue, or an exhaustive match)."
337                .to_owned(),
338        );
339    }
340
341    // UNREACHABLE_PATTERN — a `match` arm after an unconditional catch-all (Workstream 2).
342    let unreachable_patterns = cx.flow.unreachable_pattern_ranges().to_vec();
343    for range in unreachable_patterns {
344        cx.warn(
345            range,
346            WarningCode::UnreachablePattern,
347            "Unreachable pattern: an earlier arm's wildcard (`_`) or `var` binding always matches."
348                .to_owned(),
349        );
350    }
351
352    InferenceResult {
353        expr_ty: cx.expr_ty,
354        bindings: cx.bindings,
355        diagnostics: cx.diagnostics,
356        raw_warnings: cx.raw_warnings,
357    }
358}
359
360/// Convenience: recover a function node from its [`AstPtr`], lower its body, resolve its
361/// declared return type, and infer it.
362#[must_use]
363pub fn infer_func(
364    db: &dyn Db,
365    api: &EngineApi,
366    root: &GdNode,
367    class: &ClassScope,
368    ptr: AstPtr,
369) -> InferenceResult {
370    let Some(node) = ptr.to_node(root) else {
371        return InferenceResult::default();
372    };
373    let body = body::body_of_func(&node);
374    // The return-type annotation is the FuncDecl's direct `TypeRef` child (params' type refs
375    // are nested inside the ParamList, so they are not direct children).
376    let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
377        .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
378    infer(db, api, root, class, &body, return_ty, true)
379}
380
381/// One inferred unit of a file: a function body or a class field's initializer, with its
382/// lowered [`Body`] and [`InferenceResult`] (kept so position-based features — hover, inlay,
383/// member completion — can map a cursor back through the source map).
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct Unit {
386    /// The source range this unit covers (the function decl or the field decl).
387    pub range: TextRange,
388    /// The lowered body.
389    pub body: Body,
390    /// The inference result.
391    pub result: InferenceResult,
392}
393
394/// The full single-file inference: the item tree, every inferred unit, and the merged §5
395/// diagnostics. The whole-file entry point the IDE layer consumes.
396#[derive(Debug, Clone, PartialEq, Eq, Default)]
397pub struct FileInference {
398    /// The lowered item tree.
399    pub tree: Arc<ItemTree>,
400    /// The inferred function/field units.
401    pub units: Vec<Unit>,
402    /// The ungated analyzer-native diagnostics, merged across units (`TYPE_MISMATCH`,
403    /// `INVALID_NODE_PATH`) plus the file-level `SHADOWED_GLOBAL_IDENTIFIER` / `CYCLIC_INHERITANCE`.
404    pub diagnostics: Vec<Diagnostic>,
405    /// The severity-free gateable Godot warnings, merged across units. The IDE layer resolves
406    /// these via [`crate::warnings::gate`] against the project's settings (Workstream 1).
407    pub raw_warnings: Vec<RawWarning>,
408}
409
410impl FileInference {
411    /// The innermost unit whose range contains `offset`.
412    #[must_use]
413    pub fn unit_at(&self, offset: u32) -> Option<&Unit> {
414        self.units
415            .iter()
416            .filter(|u| u.range.start <= offset && offset < u.range.end)
417            .min_by_key(|u| u.range.end - u.range.start)
418    }
419}
420
421/// Infer an entire file: lower its item tree, then infer every function body and every
422/// class-field initializer against a shared [`ClassScope`]. The single entry point for the
423/// IDE features (Playbook §6 — a pure `(api, parsed file) -> result` function).
424#[must_use]
425#[allow(clippy::too_many_lines)] // the two-pass field-fixpoint + function walk reads best whole
426pub fn analyze_file(db: &dyn Db, api: &EngineApi, root: &GdNode, file_id: FileId) -> FileInference {
427    let tree = item_tree(root);
428    let mut units = Vec::new();
429    let mut diagnostics = Vec::new();
430    let mut raw_warnings: Vec<RawWarning> = Vec::new();
431
432    // EMPTY_FILE — a script with no members, no `class_name`, and no `extends` (Workstream 1).
433    if tree.members.is_empty() && tree.class_name.is_none() && tree.extends.is_none() {
434        raw_warnings.push(RawWarning {
435            range: TextRange::new(0, 0),
436            code: WarningCode::EmptyFile,
437            message: "Empty script file.".to_owned(),
438        });
439    }
440    let mut member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
441    // `self` is the script's OWN class (a self-`ScriptRef`), not just its engine base — so member
442    // access on an aliased `self` resolves the file's own members (see `ClassScope::self_ty`).
443    let self_ref = Ty::ScriptRef(ScriptRefId(file_id.0));
444    // The file's own `res://` path, for anchoring relative `preload`/`extends` to its directory.
445    let res_path = db.file_text(file_id).and_then(|ft| ft.res_path(db));
446
447    // A declared `class_name` that collides with another global identifier (W2). Mirrors Godot's
448    // `gdscript_analyzer.cpp` uniqueness check over the global namespace, projected through the
449    // cross-file firewall (`class_name_collisions`) and the offset-free global resolvers — so it
450    // fires only when genuinely shadowing, never on the seam. Emitted once, at the decl's NAME.
451    if let Some(name) = tree.class_name.clone() {
452        let collides = collisions_contains(db, &name)
453            || resolve::resolve_global(api, &name).is_some()
454            || is_autoload_singleton(db, &name);
455        if collides && let Some(range) = class_name_decl_range(root) {
456            diagnostics.push(Diagnostic {
457                range,
458                severity: Severity::Warning,
459                code: SHADOWED_GLOBAL_IDENTIFIER.to_owned(),
460                message: format!(
461                    "The global class \"{name}\" hides a built-in/native/global/autoload."
462                ),
463                source: DiagnosticSource::Type,
464                fixes: Vec::new(),
465            });
466        }
467        // CONFUSABLE_IDENTIFIER on the `class_name` itself (gated, unlike the hides-global check).
468        if is_confusable_identifier(&name)
469            && let Some(range) = class_name_decl_range(root)
470        {
471            raw_warnings.push(RawWarning {
472                range,
473                code: WarningCode::ConfusableIdentifier,
474                message: format!(
475                    "The identifier \"{name}\" uses confusable characters (mixed scripts)."
476                ),
477            });
478        }
479    }
480
481    // A genuine `extends` cycle (D7): walk THIS file's base chain by `FileId`; if it returns to the
482    // start, the inheritance is cyclic (illegal in Godot). Reported once, at the file's own `extends`
483    // decl range. Only `extends` cycles are walked here (member lookup is the only thing that loops);
484    // `preload`/`load` cycles are legal at runtime and never reach this resolver. We start by stepping
485    // ONTO the user base — if the very first base is the start file (`extends "res://self.gd"`, or two
486    // files A↔B), the revisit-of-start check fires; a deep but ACYCLIC chain bottoms out at an engine
487    // `Object`/`Unknown` and never revisits, so it does not false-fire.
488    if extends_chain_is_cyclic(db, file_id)
489        && let Some(range) = extends_decl_range(root)
490    {
491        diagnostics.push(Diagnostic {
492            range,
493            severity: Severity::Warning,
494            code: CYCLIC_INHERITANCE.to_owned(),
495            message: "Cyclic class hierarchy: this class's `extends` chain returns to itself."
496                .to_owned(),
497            source: DiagnosticSource::Type,
498            fixes: Vec::new(),
499        });
500    }
501
502    // File-level (member) warnings that need the whole item-tree, not a single body.
503    raw_warnings.extend(member_level_warnings(
504        db,
505        api,
506        root,
507        &tree,
508        res_path.as_deref(),
509    ));
510
511    // Pass 1 — class fields. Inferring each `var`/`const` seeds `member_types` so the function
512    // pass sees the *inferred* field type (`var n := 0` → `int`), not just the annotation.
513    //
514    // A field initializer may reference an *earlier* field (`var a := 1` then `var b := a + 1`),
515    // so a single shallow round sees the referent as `Variant`/seam. We run a BOUNDED fixpoint:
516    // each round re-infers every field against the prior round's `member_types`, until the map
517    // stops changing or we hit the round cap. Cheap (fields are few, types settle in a round or
518    // two) and deterministic. Only the final round's units/diagnostics are kept — earlier rounds
519    // are throwaway probes feeding the seed.
520    {
521        // Bound the iteration: a linear `a -> b -> c -> …` chain settles in O(n) rounds, but a
522        // small constant is enough in practice (the corpus settles in ≤2) and guarantees
523        // termination even if a type oscillated.
524        const MAX_ROUNDS: usize = 4;
525        let mut final_units: Vec<Unit> = Vec::new();
526        let mut final_diagnostics: Vec<Diagnostic> = Vec::new();
527        let mut final_raw_warnings: Vec<RawWarning> = Vec::new();
528        for _ in 0..MAX_ROUNDS {
529            let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
530            class.self_ty = self_ref.clone();
531            class.member_types.clone_from(&member_types);
532            let mut next_member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
533            final_units = Vec::new();
534            final_diagnostics = Vec::new();
535            final_raw_warnings = Vec::new();
536            for m in &tree.members {
537                let (ptr, range) = match m {
538                    Member::Var(v) => (v.ptr, v.range),
539                    Member::Const(c) => (c.ptr, c.range),
540                    _ => continue,
541                };
542                if let Some(unit) = unit_from_decl(db, api, root, &class, ptr, range) {
543                    if let (Some(name), Some(b)) = (m.name(), unit.result.bindings.first()) {
544                        next_member_types.insert(SmolStr::new(name), b.ty.clone());
545                    }
546                    final_diagnostics.extend(unit.result.diagnostics.iter().cloned());
547                    final_raw_warnings.extend(unit.result.raw_warnings.iter().cloned());
548                    final_units.push(unit);
549                }
550            }
551            if next_member_types == member_types {
552                break;
553            }
554            member_types = next_member_types;
555        }
556        diagnostics.extend(final_diagnostics);
557        raw_warnings.extend(final_raw_warnings);
558        units.extend(final_units);
559    }
560
561    // Pass 2 — functions, against a scope carrying the seeded field types.
562    {
563        let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
564        class.member_types = member_types;
565        class.self_ty = self_ref.clone();
566        for m in &tree.members {
567            let Member::Func(f) = m else { continue };
568            let Some(node) = f.ptr.to_node(root) else {
569                continue;
570            };
571            let body = body::body_of_func(&node);
572            let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
573                .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
574            let result = infer(db, api, root, &class, &body, return_ty, true);
575            diagnostics.extend(result.diagnostics.iter().cloned());
576            raw_warnings.extend(result.raw_warnings.iter().cloned());
577            units.push(Unit {
578                range: f.range,
579                body,
580                result,
581            });
582        }
583    }
584
585    // Pass 2b — inner-class method bodies. The top-level pass skips `Member::Class`, so inner `class
586    // Name:` methods were never inferred (no units / diagnostics / resolvable refs). Analyze them with
587    // `self` typed as the inner class, so `self.member` / bare member refs resolve against the inner
588    // item-tree + its `extends` chain; anything unresolved stays the seam (no false positive).
589    infer_inner_class_bodies(
590        db,
591        api,
592        root,
593        &tree,
594        file_id,
595        "",
596        res_path.as_deref(),
597        &mut units,
598        &mut diagnostics,
599        &mut raw_warnings,
600        0,
601    );
602
603    FileInference {
604        tree,
605        units,
606        diagnostics,
607        raw_warnings,
608    }
609}
610
611/// Infer the method bodies of every inner `class Name:` in `tree` (recursively), with `self` typed as
612/// the inner class. `path_prefix` is the dotted path to `tree`'s class (`""` at the top level), used
613/// to build each inner class's [`crate::ty::InnerClassRef`] path. Depth-bounded against pathological
614/// nesting. Inner-class *field* fixpoint pre-pass is intentionally skipped (an inner field types by
615/// annotation only — lossy, like the cross-file path).
616#[allow(
617    clippy::too_many_arguments,
618    reason = "threads the same analyze_file accumulators a free helper can't capture from a closure"
619)]
620fn infer_inner_class_bodies(
621    db: &dyn Db,
622    api: &EngineApi,
623    root: &GdNode,
624    tree: &ItemTree,
625    file_id: FileId,
626    path_prefix: &str,
627    res_path: Option<&str>,
628    units: &mut Vec<Unit>,
629    diagnostics: &mut Vec<Diagnostic>,
630    raw_warnings: &mut Vec<RawWarning>,
631    depth: u32,
632) {
633    if depth > 16 {
634        return;
635    }
636    for m in &tree.members {
637        let Member::Class(c) = m else { continue };
638        let inner_path = if path_prefix.is_empty() {
639            c.name.to_string()
640        } else {
641            format!("{path_prefix}.{}", c.name)
642        };
643        let mut class = ClassScope::new(db, api, &c.tree, res_path);
644        class.self_ty = Ty::InnerClass(crate::ty::InnerClassRef {
645            file: file_id.0,
646            path: SmolStr::new(&inner_path),
647        });
648        for im in &c.tree.members {
649            let Member::Func(f) = im else { continue };
650            let Some(node) = f.ptr.to_node(root) else {
651                continue;
652            };
653            let body = body::body_of_func(&node);
654            let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
655                .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
656            let result = infer(db, api, root, &class, &body, return_ty, true);
657            diagnostics.extend(result.diagnostics.iter().cloned());
658            raw_warnings.extend(result.raw_warnings.iter().cloned());
659            units.push(Unit {
660                range: f.range,
661                body,
662                result,
663            });
664        }
665        infer_inner_class_bodies(
666            db,
667            api,
668            root,
669            &c.tree,
670            file_id,
671            &inner_path,
672            res_path,
673            units,
674            diagnostics,
675            raw_warnings,
676            depth + 1,
677        );
678    }
679}
680
681/// Class-level annotation checks (W1), unblocked by first-class item-tree annotations:
682/// `REDUNDANT_STATIC_UNLOAD` (`@static_unload` with no `static var`) and `MISSING_TOOL` (a non-`@tool`
683/// class extending a `@tool` user-script base).
684fn class_annotation_warnings(
685    db: &dyn Db,
686    api: &EngineApi,
687    root: &GdNode,
688    tree: &ItemTree,
689    res_path: Option<&str>,
690) -> Vec<RawWarning> {
691    let mut out = Vec::new();
692    // REDUNDANT_STATIC_UNLOAD — `@static_unload` on a class that declares no `static var`.
693    if let Some(unload) = tree.annotations.iter().find(|a| a.name == "static_unload")
694        && !tree
695            .members
696            .iter()
697            .any(|m| matches!(m, Member::Var(v) if v.is_static))
698    {
699        out.push(RawWarning {
700            range: unload.range,
701            code: WarningCode::RedundantStaticUnload,
702            message: "`@static_unload` is redundant on a class with no static variables."
703                .to_owned(),
704        });
705    }
706    // MISSING_TOOL — this class is not `@tool` but its (user-script) base is, so it will NOT run in
707    // the editor. Only the resolvable user-script base is checked (an engine base is never `@tool`).
708    if !has_annotation(&tree.annotations, "tool")
709        && user_base_is_tool(db, api, tree, res_path)
710        && let Some(range) = extends_decl_range(root)
711    {
712        out.push(RawWarning {
713            range,
714            code: WarningCode::MissingTool,
715            message: "This class extends a `@tool` script but is not itself `@tool` (it will not run in the editor)."
716                .to_owned(),
717        });
718    }
719    out
720}
721
722/// File-level (member) warnings that need the whole item-tree, not a single body (W1):
723/// `ENUM_VARIABLE_WITHOUT_DEFAULT`, `UNUSED_SIGNAL`, `UNUSED_PRIVATE_CLASS_VARIABLE`,
724/// `SHADOWED_GLOBAL_IDENTIFIER`, `CONFUSABLE_IDENTIFIER`, the annotation-lifecycle checks, and
725/// `NATIVE_METHOD_OVERRIDE` — each independent + conservative.
726#[allow(
727    clippy::too_many_lines,
728    reason = "a flat sequence of independent per-member warning checks; reads best as one walk"
729)]
730fn member_level_warnings(
731    db: &dyn Db,
732    api: &EngineApi,
733    root: &GdNode,
734    tree: &ItemTree,
735    res_path: Option<&str>,
736) -> Vec<RawWarning> {
737    let mut out = Vec::new();
738    let has_signal = tree.members.iter().any(|m| matches!(m, Member::Signal(_)));
739    // A `_`-prefixed, non-exported member var is an UNUSED_PRIVATE_CLASS_VARIABLE candidate.
740    let has_private_var = tree
741        .members
742        .iter()
743        .any(|m| matches!(m, Member::Var(v) if v.name.starts_with('_') && !v.is_exported));
744    // Only pay for the whole-file name scan when there is a signal or a private var to judge.
745    let uses = (has_signal || has_private_var).then(|| NameUses::collect(root));
746    // The resolved ENGINE base, for NATIVE_METHOD_OVERRIDE (an unresolved/user base ⇒ no check).
747    let engine_base = match resolve::resolve_base(db, api, tree, res_path) {
748        Ty::Object(c) => Some(c),
749        _ => None,
750    };
751
752    out.extend(class_annotation_warnings(db, api, root, tree, res_path));
753
754    for m in &tree.members {
755        // UNUSED_PRIVATE_CLASS_VARIABLE — a `_`-prefixed, non-exported member var never referenced
756        // anywhere in the file (same-file scan, like UNUSED_SIGNAL). Exported vars are set externally
757        // (inspector / scene), so they are excluded to keep the contract no-false-positive.
758        if let Member::Var(v) = m
759            && v.name.starts_with('_')
760            && !v.is_exported
761            && let Some(uses) = &uses
762            && !uses.is_referenced(&v.name)
763        {
764            out.push(RawWarning {
765                range: v.name_range,
766                code: WarningCode::UnusedPrivateClassVariable,
767                message: format!(
768                    "The class variable \"{}\" is never used in this file.",
769                    v.name
770                ),
771            });
772        }
773        // ONREADY_WITH_EXPORT — `@onready` and `@export` on the same member (Godot raises this).
774        if let Member::Var(v) = m
775            && has_annotation(&v.annotations, "onready")
776            && v.is_exported
777        {
778            out.push(RawWarning {
779                range: v.name_range,
780                code: WarningCode::OnreadyWithExport,
781                message: format!(
782                    "The member \"{}\" has both `@onready` and `@export`; they conflict.",
783                    v.name
784                ),
785            });
786        }
787        // SHADOWED_GLOBAL_IDENTIFIER — a value member (`var`/`const`/`signal`) whose name collides
788        // with a project/engine global. Godot's `is_shadowing` fires for member declarations too.
789        if let Some((name, range, what)) = member_value_decl(m)
790            && let Some(kind) = shadowed_global_kind(db, api, name)
791        {
792            out.push(RawWarning {
793                range,
794                code: WarningCode::ShadowedGlobalIdentifier,
795                message: format!("The {what} \"{name}\" has the same name as a {kind}."),
796            });
797        }
798        // CONFUSABLE_IDENTIFIER — any member name that mixes scripts in a spoofable way.
799        if let Some((name, range)) = member_decl_name(m)
800            && is_confusable_identifier(name)
801        {
802            out.push(RawWarning {
803                range,
804                code: WarningCode::ConfusableIdentifier,
805                message: format!(
806                    "The identifier \"{name}\" uses confusable characters (mixed scripts)."
807                ),
808            });
809        }
810        match m {
811            // An enum-typed field with no initializer (the local case is in `infer_local_var`).
812            Member::Var(v) if !v.has_init => {
813                if let Some(tref) = &v.type_ref
814                    && matches!(resolve::resolve_type_name(db, api, tref), Ty::Enum(_))
815                {
816                    out.push(RawWarning {
817                        range: v.name_range,
818                        code: WarningCode::EnumVariableWithoutDefault,
819                        message: format!(
820                            "The enum variable \"{}\" has no default value (it defaults to 0, which may not be a valid enum value).",
821                            v.name
822                        ),
823                    });
824                }
825            }
826            // A signal never referenced in this file (emit/connect/string). Same-file only, like
827            // Godot — a signal connected purely from a scene/other file is invisible (the known
828            // limitation); the conservative scan only warns when the name appears nowhere else.
829            Member::Signal(s) => {
830                if let Some(uses) = &uses
831                    && !uses.is_referenced(&s.name)
832                {
833                    out.push(RawWarning {
834                        range: s.name_range,
835                        code: WarningCode::UnusedSignal,
836                        message: format!(
837                            "The signal \"{}\" is never emitted or connected in this file.",
838                            s.name
839                        ),
840                    });
841                }
842            }
843            // NATIVE_METHOD_OVERRIDE (ERROR-default) — an override of an engine VIRTUAL whose
844            // signature is clearly incompatible. Conservative to the extreme (a false positive is a
845            // loud error): warn ONLY on a *definite type clash* at an overlapping typed parameter —
846            // both the override's annotation and the virtual's param resolve to known engine types
847            // that are mutually NON-assignable. Arity, defaults/vararg, and variance subtleties are
848            // deliberately left to under-warn (see `TECH_DEBT.md`).
849            Member::Func(f) => {
850                if let Some(base) = engine_base
851                    && let Some(MemberRef::Method(vsig)) = api.lookup_member(base, &f.name)
852                    && vsig.is_virtual
853                {
854                    for (p, vp) in f.params.iter().zip(vsig.params.iter()) {
855                        let Some(ann) = &p.type_ref else { continue };
856                        let pty = resolve::resolve_type_name(db, api, ann);
857                        let vty = ty::resolve_tyref(api, &vp.ty);
858                        if types_definitely_clash(api, &pty, &vty) {
859                            out.push(RawWarning {
860                                range: f.name_range,
861                                code: WarningCode::NativeMethodOverride,
862                                message: format!(
863                                    "The override of the native virtual method \"{}\" has an incompatible type for parameter \"{}\".",
864                                    f.name, p.name
865                                ),
866                            });
867                            break; // one warning per overriding function
868                        }
869                    }
870                }
871            }
872            _ => {}
873        }
874    }
875    out
876}
877
878/// Whether two **known** engine types are mutually non-assignable — a *definite* clash. An
879/// uninformative type (`Variant`/`Unknown`) never clashes (gradual), and any assignable relation in
880/// either direction (subtype, widening, enum/int, …) is treated as "related" (not a clash), so the
881/// conservative `NATIVE_METHOD_OVERRIDE` only fires on genuinely unrelated types.
882fn types_definitely_clash(api: &EngineApi, a: &Ty, b: &Ty) -> bool {
883    if a.is_uninformative() || b.is_uninformative() {
884        return false;
885    }
886    // Enums are int-backed and their qualified name resolves differently on the annotation side
887    // (`resolve_type_name` → `Class.Enum`) than on the engine-model side (`resolve_tyref`), so an
888    // enum "clash" is unreliable — never clash on an enum. (Fixes a false NATIVE_METHOD_OVERRIDE on
889    // a valid dotted-enum-typed override param, e.g. `p_mode: MultiplayerPeer.TransferMode`.)
890    if matches!(a, Ty::Enum(_)) || matches!(b, Ty::Enum(_)) {
891        return false;
892    }
893    matches!(ty::is_assignable(api, a, b), Assign::No)
894        && matches!(ty::is_assignable(api, b, a), Assign::No)
895}
896
897/// Identifier-occurrence counts + string-literal contents across a file's CST — the file-wide
898/// "is this name referenced anywhere?" check (drives `UNUSED_SIGNAL`).
899struct NameUses {
900    ident_counts: FxHashMap<SmolStr, u32>,
901    strings: FxHashSet<SmolStr>,
902}
903
904impl NameUses {
905    fn collect(root: &GdNode) -> Self {
906        let mut ident_counts: FxHashMap<SmolStr, u32> = FxHashMap::default();
907        let mut strings: FxHashSet<SmolStr> = FxHashSet::default();
908        for node in gdscript_syntax::ast::descendants(root) {
909            for el in node.children_with_tokens() {
910                let Some(tok) = el.into_token() else { continue };
911                match tok.kind() {
912                    gdscript_syntax::SyntaxKind::Ident => {
913                        *ident_counts.entry(SmolStr::new(tok.text())).or_insert(0) += 1;
914                    }
915                    gdscript_syntax::SyntaxKind::String => {
916                        strings.insert(SmolStr::new(tok.text().trim_matches(['"', '\''])));
917                    }
918                    _ => {}
919                }
920            }
921        }
922        Self {
923            ident_counts,
924            strings,
925        }
926    }
927
928    /// Whether `name` is referenced beyond its single declaration — a 2nd identifier occurrence, or
929    /// any string literal naming it (covering `emit_signal("name")` / `connect("name", …)`).
930    fn is_referenced(&self, name: &str) -> bool {
931        self.ident_counts.get(name).copied().unwrap_or(0) > 1 || self.strings.contains(name)
932    }
933}
934
935/// Whether `name` is declared as a `class_name` by more than one file in the project (W2). Reads
936/// the cross-file `class_name_collisions` firewall; `false` (no warning) when no source root is set
937/// — single-file analysis cannot observe a duplicate.
938fn collisions_contains(db: &dyn Db, name: &SmolStr) -> bool {
939    db.source_root()
940        .is_some_and(|root| crate::queries::class_name_collisions(db, root).contains(name))
941}
942
943/// Whether `name` is a `*`-flagged autoload singleton (a bare global). `false` when no
944/// `project.godot` is loaded — the seam, no warning.
945fn is_autoload_singleton(db: &dyn Db, name: &str) -> bool {
946    db.project_config().is_some_and(|config| {
947        crate::queries::autoload_registry(db, config)
948            .resolve_path(name)
949            .is_some()
950    })
951}
952
953/// Whether the file's resolved **user-script** base carries the `@tool` annotation (for
954/// `MISSING_TOOL`). An engine base or an unresolved base is never `@tool`. Firewall-safe: reads the
955/// base's `item_tree` (signature-level — a base *body* edit leaves the annotation set unchanged).
956fn user_base_is_tool(
957    db: &dyn Db,
958    api: &EngineApi,
959    tree: &ItemTree,
960    res_path: Option<&str>,
961) -> bool {
962    let Ty::ScriptRef(sref) = resolve::resolve_base(db, api, tree, res_path) else {
963        return false;
964    };
965    let Some(ft) = db.file_text(FileId(sref.0)) else {
966        return false;
967    };
968    has_annotation(&crate::queries::item_tree(db, ft).annotations, "tool")
969}
970
971/// Whether `name` is registered as a global `class_name` by some file in the project. `false` when
972/// no source root is set (single-file analysis can't observe the registry). Reads the firewalled
973/// [`crate::queries::global_registry`].
974fn is_registered_global_class(db: &dyn Db, name: &str) -> bool {
975    db.source_root().is_some_and(|root| {
976        crate::queries::global_registry(db, root)
977            .resolve(name)
978            .is_some()
979    })
980}
981
982/// Whether a declared identifier `name` shadows a project/engine **global**, returning Godot's
983/// category label for the `SHADOWED_GLOBAL_IDENTIFIER` message, else `None`. Mirrors
984/// `gdscript_analyzer.cpp`'s `is_shadowing` global checks: a built-in function, a built-in (Variant)
985/// type, a native class, an engine singleton, a project `class_name` global, or a `*`-autoload
986/// singleton. **Conservative:** bare global pseudo-constants (`PI`/`TAU`) and global enum namespaces
987/// (`Error`/`Key`) are deliberately excluded — they are rare as user identifiers (and the tokenizer
988/// treats the math constants as literals), so this only ever *under*-warns vs. Godot, never a false
989/// positive. With no source root / `project.godot`, the cross-file/autoload arms are silent (seam).
990fn shadowed_global_kind(db: &dyn Db, api: &EngineApi, name: &str) -> Option<&'static str> {
991    match resolve::resolve_global(api, name) {
992        Some(GlobalDef::Builtin | GlobalDef::Utility) => return Some("built-in function"),
993        Some(GlobalDef::BuiltinType(_)) => return Some("built-in type"),
994        Some(GlobalDef::ClassType(_)) => return Some("native class"),
995        Some(GlobalDef::Singleton(_)) => return Some("engine singleton"),
996        // Bare pseudo-constants / global enums: intentionally not flagged (see doc above).
997        Some(GlobalDef::Const(_) | GlobalDef::GlobalEnum) | None => {}
998    }
999    if is_registered_global_class(db, name) {
1000        return Some("global class");
1001    }
1002    if is_autoload_singleton(db, name) {
1003        return Some("autoload");
1004    }
1005    None
1006}
1007
1008/// The `(name, name range, kind noun)` of a *value-declaring* member (`var`/`const`/`signal`) — the
1009/// members that `SHADOWED_GLOBAL_IDENTIFIER` checks at the class level. `None` for funcs / enums /
1010/// inner classes (where the "shadow" framing is weaker, matching Godot's `is_shadowing` callers).
1011fn member_value_decl(m: &Member) -> Option<(&SmolStr, TextRange, &'static str)> {
1012    match m {
1013        Member::Var(v) => Some((&v.name, v.name_range, "variable")),
1014        Member::Const(c) => Some((&c.name, c.name_range, "constant")),
1015        Member::Signal(s) => Some((&s.name, s.name_range, "signal")),
1016        _ => None,
1017    }
1018}
1019
1020/// The declared `(name, name range)` of any named member (`func`/`var`/`const`/`signal`/named
1021/// `enum`/inner `class`), for the `CONFUSABLE_IDENTIFIER` scan. An anonymous `enum { … }` has no
1022/// name → `None`.
1023fn member_decl_name(m: &Member) -> Option<(&SmolStr, TextRange)> {
1024    match m {
1025        Member::Func(f) => Some((&f.name, f.name_range)),
1026        Member::Var(v) => Some((&v.name, v.name_range)),
1027        Member::Const(c) => Some((&c.name, c.name_range)),
1028        Member::Signal(s) => Some((&s.name, s.name_range)),
1029        Member::Class(c) => Some((&c.name, c.name_range)),
1030        Member::Enum(e) => e.name.as_ref().map(|n| (n, e.name_range)),
1031    }
1032}
1033
1034/// Whether `name` is a `CONFUSABLE_IDENTIFIER` — a non-ASCII identifier that mixes scripts in a
1035/// spoofable way (UTS #39 restriction level ≥ `MinimallyRestrictive`, e.g. a Latin identifier with a
1036/// Cyrillic/Greek homoglyph like `pаypal`). Pure-ASCII (the overwhelming majority) and legitimate
1037/// single-script / CJK-plus-Latin identifiers are never flagged. Mirrors the intent of Godot's
1038/// `TextServer` confusable check with zero false positives on ordinary code.
1039fn is_confusable_identifier(name: &str) -> bool {
1040    use unicode_security::RestrictionLevel as RL;
1041    use unicode_security::RestrictionLevelDetection;
1042    if name.is_ascii() {
1043        return false; // the fast path for ~all real identifiers
1044    }
1045    name.detect_restriction_level() >= RL::MinimallyRestrictive
1046}
1047
1048/// The NAME range of the file's `class_name` declaration, trimmed to the bare identifier (the
1049/// `Name` CST node absorbs leading inter-token trivia). `None` if the file declares no `class_name`
1050/// or the decl has no name token. Mirrors `item_tree::trimmed_name_range` / navigation's
1051/// `class_decl_target` (which lives in the IDE crate, hence this local CST scan).
1052fn class_name_decl_range(root: &GdNode) -> Option<TextRange> {
1053    use gdscript_syntax::SyntaxKind;
1054    let decl = gdscript_syntax::ast::descendants(root)
1055        .into_iter()
1056        .find(|n| n.kind() == SyntaxKind::ClassNameDecl)?;
1057    let name_node = decl.children().find(|c| c.kind() == SyntaxKind::Name)?;
1058    let r = cst::text_range_of(name_node);
1059    let text = name_node.text().to_string();
1060    let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
1061    let len = u32::try_from(text.trim().len()).unwrap_or(0);
1062    Some(TextRange::new(r.start + lead, r.start + lead + len))
1063}
1064
1065/// The byte range of the file's top-level `extends` declaration — the anchor for `CYCLIC_INHERITANCE`.
1066/// Two surface forms: a standalone `extends Target` (an [`ExtendsClause`] child of the `SourceFile`),
1067/// or the inline `class_name Name extends Target` (the `extends` keyword + target inside the
1068/// [`ClassNameDecl`]). Scans only the `SourceFile`'s DIRECT children, so an inner class's `extends`
1069/// (nested under `Class`/`ClassBody`) is never mistaken for the file's own. `None` if the file has no
1070/// top-level `extends`.
1071fn extends_decl_range(root: &GdNode) -> Option<TextRange> {
1072    use gdscript_syntax::SyntaxKind;
1073    for child in root.children() {
1074        match child.kind() {
1075            // Standalone `extends Target` — the whole clause is the anchor.
1076            SyntaxKind::ExtendsClause => return Some(cst::text_range_of(child)),
1077            // Inline `class_name Name extends Target` — anchor the `extends` keyword onward.
1078            SyntaxKind::ClassNameDecl => {
1079                if let Some(kw) = child.children().find(|c| c.kind() == SyntaxKind::ExtendsKw) {
1080                    let start = cst::text_range_of(kw).start;
1081                    let end = cst::text_range_of(child).end;
1082                    return Some(TextRange::new(start, end));
1083                }
1084            }
1085            _ => {}
1086        }
1087    }
1088    None
1089}
1090
1091/// Whether the file's `extends` inheritance chain transitively returns to itself (a genuine cycle).
1092/// Walks base-by-base by `FileId` from `start`, stepping only across user `ScriptRef` bases (an
1093/// engine `Object`/`Unknown` base ends the chain). A `FileId` revisit means a cycle. We stop as soon
1094/// as we either revisit a file (cycle) or hit a non-script base (acyclic) — a deep but acyclic chain
1095/// terminates without a revisit and is NOT flagged. Depth is also hard-capped as belt-and-suspenders
1096/// (the visited set already guarantees termination).
1097fn extends_chain_is_cyclic(db: &dyn Db, start: FileId) -> bool {
1098    use std::collections::HashSet;
1099    let mut visited: HashSet<FileId> = HashSet::new();
1100    visited.insert(start);
1101    let mut current = start;
1102    for _ in 0..=64 {
1103        let Some(file) = db.file_text(current) else {
1104            return false;
1105        };
1106        let base = crate::queries::script_class(db, file).base().clone();
1107        let Ty::ScriptRef(next) = base else {
1108            return false; // engine `Object` / `Unknown` base — chain ends, no cycle.
1109        };
1110        let next_id = FileId(next.0);
1111        if !visited.insert(next_id) {
1112            // Revisiting an already-seen file closes a cycle. We report the cycle for every file ON
1113            // it (each file's own `extends` is genuinely cyclic), so no need to special-case `start`.
1114            return true;
1115        }
1116        current = next_id;
1117    }
1118    false
1119}
1120
1121/// Infer a class field declaration as a single local-var statement (full annotation checks).
1122fn unit_from_decl(
1123    db: &dyn Db,
1124    api: &EngineApi,
1125    root: &GdNode,
1126    class: &ClassScope,
1127    ptr: AstPtr,
1128    range: TextRange,
1129) -> Option<Unit> {
1130    let node = ptr.to_node(root)?;
1131    let body = body::body_of_decl_stmt(&node);
1132    let result = infer(db, api, root, class, &body, Ty::Variant, false);
1133    Some(Unit {
1134        range,
1135        body,
1136        result,
1137    })
1138}
1139
1140/// What type is expected of an expression (bidirectional checking).
1141enum Expectation {
1142    /// No expectation — pure synthesis.
1143    None,
1144    /// The expression is checked against this declared type.
1145    Has(Ty),
1146}
1147
1148/// Navigate a dotted inner-class path (`Inner` / `Outer.Inner`) from a file's top item-tree to the
1149/// target [`InnerClassItem`]. `None` if any segment isn't an inner class.
1150fn find_inner_class<'a>(tree: &'a ItemTree, path: &str) -> Option<&'a InnerClassItem> {
1151    let mut members: &'a [Member] = &tree.members;
1152    let mut found: Option<&'a InnerClassItem> = None;
1153    for seg in path.split('.') {
1154        found = members.iter().find_map(|m| match m {
1155            Member::Class(c) if c.name == seg => Some(c),
1156            _ => None,
1157        });
1158        members = &found?.tree.members;
1159    }
1160    found
1161}
1162
1163struct Cx<'a> {
1164    db: &'a dyn Db,
1165    api: &'a EngineApi,
1166    root: &'a GdNode,
1167    body: &'a Body,
1168    class: &'a ClassScope<'a>,
1169    self_ty: Ty,
1170    return_ty: Ty,
1171    expr_ty: FxHashMap<ExprId, Ty>,
1172    bindings: Vec<Binding>,
1173    diagnostics: Vec<Diagnostic>,
1174    /// Severity-free gateable warnings (Workstream 1), resolved by `gate()` downstream.
1175    raw_warnings: Vec<RawWarning>,
1176    /// Function-scoped local bindings (GDScript locals are function-, not block-, scoped).
1177    locals: FxHashMap<SmolStr, Ty>,
1178    /// The names of locals/params that were *read* during the walk — drives the `UNUSED_*` family (a
1179    /// declared binding whose name never appears here is unused). A bare assignment LHS (`x = …`) is a
1180    /// write, NOT a read, and is excluded (so an assigned-but-never-read local is correctly unused);
1181    /// a compound `x += …` still reads via its RHS, and a receiver / index target reads the base.
1182    used_locals: FxHashSet<SmolStr>,
1183    /// The active narrowing env for the current statement, keyed by a dotted access path. Rebuilt
1184    /// per statement from [`Cx::flow`] (Workstream 2) — not mutated ad-hoc anymore.
1185    narrowing: FxHashMap<String, Ty>,
1186    /// The precomputed per-body control-flow narrowing facts (Workstream 2). The checker consults
1187    /// `facts_before(stmt)` to build [`Cx::narrowing`]; it survives `else`/early-return/`and`-`or`.
1188    flow: FlowAnalysis,
1189    /// Whether this is a real function body (vs a class-field initializer body). Gates the
1190    /// body-only checks (`UNUSED_*`, `SHADOWED_VARIABLE`) so a field initializer doesn't, e.g.,
1191    /// "shadow itself" against its own member entry.
1192    is_func_body: bool,
1193    /// Definite-assignment facts (Workstream 2) — the locals assigned before each statement, for
1194    /// `UNASSIGNED_VARIABLE`. Consulted at a read via [`Cx::cur_stmt`].
1195    assigned: flow::AssignedAnalysis,
1196    /// The statement currently being inferred (set in `infer_stmt`), so a read can look up
1197    /// [`Cx::assigned`].
1198    cur_stmt: Option<body::StmtId>,
1199    /// Typed locals declared **without** an initializer — the only locals `UNASSIGNED_VARIABLE`
1200    /// considers (an untyped/`:=`/initialized local is never read-before-assign). Grows as the walk
1201    /// passes each declaration.
1202    needs_assignment: FxHashSet<SmolStr>,
1203    /// Names that are the direct LHS of an assignment (`x = …`/`x += …`) — a *write*, not a read, so
1204    /// excluded from the `UNASSIGNED_VARIABLE` check even though inference resolves the LHS.
1205    assign_lhs: FxHashSet<ExprId>,
1206}
1207
1208impl Cx<'_> {
1209    // ---- small type constructors ----
1210
1211    fn builtin(&self, name: &str) -> Ty {
1212        self.api
1213            .builtin_by_name(name)
1214            .map_or(Ty::Variant, Ty::Builtin)
1215    }
1216    fn int_ty(&self) -> Ty {
1217        self.builtin("int")
1218    }
1219    fn float_ty(&self) -> Ty {
1220        self.builtin("float")
1221    }
1222    fn bool_ty(&self) -> Ty {
1223        self.builtin("bool")
1224    }
1225    fn is_int(&self, ty: &Ty) -> bool {
1226        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "int")
1227    }
1228    fn is_float(&self, ty: &Ty) -> bool {
1229        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "float")
1230    }
1231    fn is_numeric(&self, ty: &Ty) -> bool {
1232        self.is_int(ty) || self.is_float(ty)
1233    }
1234
1235    // ---- diagnostics ----
1236
1237    fn emit(&mut self, range: TextRange, severity: Severity, code: &str, message: String) {
1238        self.diagnostics.push(Diagnostic {
1239            range,
1240            severity,
1241            code: code.to_owned(),
1242            message,
1243            source: DiagnosticSource::Type,
1244            fixes: Vec::new(),
1245        });
1246    }
1247
1248    /// Record a gateable Godot warning, severity-free. The resolved severity (and whether it fires
1249    /// at all) is decided later by [`crate::warnings::gate`], keyed on the project's warning
1250    /// settings — so a settings edit never re-runs inference (Workstream 1, the salsa firewall).
1251    fn warn(&mut self, range: TextRange, code: WarningCode, message: String) {
1252        self.raw_warnings.push(RawWarning {
1253            range,
1254            code,
1255            message,
1256        });
1257    }
1258
1259    fn range_of(&self, id: ExprId) -> TextRange {
1260        self.body.source_map.expr_range(id)
1261    }
1262
1263    /// Run `is_assignable(from, to)` and raise the matching diagnostic. Safe to call
1264    /// unconditionally: `to` being `Variant`/`Unknown` yields `Ok`/no diagnostic.
1265    fn check_assign(&mut self, from: &Ty, to: &Ty, range: TextRange) {
1266        match ty::is_assignable(self.api, from, to) {
1267            Assign::Narrowing => self.warn(
1268                range,
1269                WarningCode::NarrowingConversion,
1270                "Narrowing conversion (float is converted to int and loses precision).".to_owned(),
1271            ),
1272            Assign::No => {
1273                let to_label = to.label(self.api).unwrap_or_else(|| "?".to_owned());
1274                let from_label = from.label(self.api).unwrap_or_else(|| "?".to_owned());
1275                self.emit(
1276                    range,
1277                    Severity::Error,
1278                    TYPE_MISMATCH,
1279                    format!(
1280                        "Cannot assign a value of type \"{from_label}\" to a target of type \"{to_label}\"."
1281                    ),
1282                );
1283            }
1284            // `int` assigned to an enum slot without an explicit cast (the previously-dead arm).
1285            Assign::IntAsEnum => self.warn(
1286                range,
1287                WarningCode::IntAsEnumWithoutCast,
1288                "Integer used when an enum value is expected. Cast the value to the enum type."
1289                    .to_owned(),
1290            ),
1291            Assign::Ok | Assign::OkUnsafe => {}
1292        }
1293    }
1294
1295    /// Flag a statement whose expression has no effect: a bare value (`STANDALONE_EXPRESSION`) or a
1296    /// ternary used as a statement (`STANDALONE_TERNARY`). A call / await / assignment / `preload`
1297    /// has an effect and is never flagged.
1298    fn check_standalone(&mut self, e: ExprId) {
1299        if self.expr_has_side_effect(e) {
1300            return;
1301        }
1302        match self.body.expr(e) {
1303            Expr::Ternary { .. } => self.warn(
1304                self.range_of(e),
1305                WarningCode::StandaloneTernary,
1306                "Standalone ternary conditional: the return value is discarded.".to_owned(),
1307            ),
1308            // Not value-like statements / forms with subtle effects — never flag.
1309            Expr::Missing | Expr::Lambda { .. } | Expr::GetNode { .. } | Expr::Preload { .. } => {}
1310            _ => self.warn(
1311                self.range_of(e),
1312                WarningCode::StandaloneExpression,
1313                "Standalone expression (the line has no effect).".to_owned(),
1314            ),
1315        }
1316    }
1317
1318    /// Whether evaluating an expression may have a side effect — a call, an `await`, a `preload`,
1319    /// or an assignment anywhere in the subtree. Used to suppress `STANDALONE_*` on effectful lines.
1320    fn expr_has_side_effect(&self, e: ExprId) -> bool {
1321        match self.body.expr(e) {
1322            Expr::Call { .. }
1323            | Expr::Await(_)
1324            | Expr::Preload { .. }
1325            | Expr::Bin {
1326                op: BinOp::Assign, ..
1327            } => true,
1328            Expr::Bin { lhs, rhs, .. }
1329            | Expr::In { lhs, rhs, .. }
1330            | Expr::Index {
1331                base: lhs,
1332                index: rhs,
1333            } => self.expr_has_side_effect(*lhs) || self.expr_has_side_effect(*rhs),
1334            Expr::Unary { operand, .. }
1335            | Expr::Paren(operand)
1336            | Expr::Cast { operand, .. }
1337            | Expr::Is { operand, .. } => self.expr_has_side_effect(*operand),
1338            Expr::Field { receiver, .. } => self.expr_has_side_effect(*receiver),
1339            Expr::Ternary {
1340                cond,
1341                then_branch,
1342                else_branch,
1343            } => {
1344                self.expr_has_side_effect(*cond)
1345                    || self.expr_has_side_effect(*then_branch)
1346                    || self.expr_has_side_effect(*else_branch)
1347            }
1348            Expr::Array(items) => items.iter().any(|&i| self.expr_has_side_effect(i)),
1349            Expr::Dict(entries) => entries.iter().any(|(k, v)| {
1350                self.expr_has_side_effect(*k) || v.is_some_and(|e| self.expr_has_side_effect(e))
1351            }),
1352            _ => false,
1353        }
1354    }
1355
1356    // ---- statements ----
1357
1358    fn infer_block(&mut self, block: &[body::StmtId]) {
1359        for &stmt in block {
1360            self.infer_stmt(stmt);
1361        }
1362    }
1363
1364    fn infer_stmt(&mut self, id: body::StmtId) {
1365        // Install the narrowing in force *before* this statement (Workstream 2). Recomputed per
1366        // statement from the precomputed flow facts — replaces the old ad-hoc `in_branch` frames.
1367        self.narrowing = self.facts_to_narrowing(id);
1368        self.cur_stmt = Some(id); // for the read-before-assign (UNASSIGNED_VARIABLE) check
1369        match self.body.stmt(id).clone() {
1370            Stmt::Expr(e) => {
1371                self.infer_expr(e, &Expectation::None);
1372                self.check_standalone(e);
1373            }
1374            Stmt::Var(v) => self.infer_local_var(&v),
1375            Stmt::Return(e) => {
1376                if let Some(e) = e {
1377                    let expected = if self.return_ty.is_uninformative() {
1378                        Expectation::None
1379                    } else {
1380                        Expectation::Has(self.return_ty.clone())
1381                    };
1382                    let t = self.infer_expr(e, &expected);
1383                    if let Expectation::Has(ret) = expected {
1384                        self.check_assign(&t, &ret, self.range_of(e));
1385                    }
1386                }
1387            }
1388            Stmt::If {
1389                cond,
1390                then_branch,
1391                elifs,
1392                else_branch,
1393            } => {
1394                // The branch narrowing now lives in the flow facts, so each sub-statement installs
1395                // its own via `infer_stmt`. Restore the if-level facts before each guard (a block
1396                // walk overwrites `self.narrowing`).
1397                let at_if = self.narrowing.clone();
1398                self.infer_expr(cond, &Expectation::None);
1399                self.infer_block(&then_branch);
1400                for (econd, eblock) in elifs {
1401                    self.narrowing.clone_from(&at_if);
1402                    self.infer_expr(econd, &Expectation::None);
1403                    self.infer_block(&eblock);
1404                }
1405                if let Some(eb) = else_branch {
1406                    self.infer_block(&eb);
1407                }
1408            }
1409            Stmt::While { cond, body } => {
1410                self.infer_expr(cond, &Expectation::None);
1411                self.infer_block(&body);
1412            }
1413            Stmt::For(f) => {
1414                let iter_ty = self.infer_expr(f.iter, &Expectation::None);
1415                let var_ty = f.var_type.as_ref().map_or_else(
1416                    || self.loop_var_ty(&iter_ty),
1417                    |ptr| self.resolve_ptr_ty(*ptr),
1418                );
1419                self.bindings.push(Binding {
1420                    name: f.var.clone(),
1421                    name_range: f.var_range,
1422                    ty: var_ty.clone(),
1423                    init: None,
1424                    annotated: f.var_type.is_some(),
1425                    inferred_colon_eq: false,
1426                    is_const: false,
1427                    kind: BindingKind::ForVar,
1428                });
1429                self.locals.insert(f.var.clone(), var_ty);
1430                self.infer_block(&f.body);
1431            }
1432            Stmt::Match { scrutinee, arms } => {
1433                let at_match = self.narrowing.clone();
1434                self.infer_expr(scrutinee, &Expectation::None);
1435                for arm in arms {
1436                    // Restore the match-level facts before each arm's guard (a prior arm's body
1437                    // walk overwrote `self.narrowing`).
1438                    self.narrowing.clone_from(&at_match);
1439                    for b in &arm.binds {
1440                        // Record the capture as a binding so navigation (find-refs / rename) sees
1441                        // it as a local that shadows a same-named member; the type is the Phase-2
1442                        // `Variant`.
1443                        self.bindings.push(Binding {
1444                            name: b.name.clone(),
1445                            name_range: b.range,
1446                            ty: Ty::Variant,
1447                            init: None,
1448                            annotated: false,
1449                            inferred_colon_eq: false,
1450                            is_const: false,
1451                            kind: BindingKind::MatchBind,
1452                        });
1453                        self.locals.insert(b.name.clone(), Ty::Variant);
1454                    }
1455                    if let Some(g) = arm.guard {
1456                        self.infer_expr(g, &Expectation::None);
1457                    }
1458                    self.infer_block(&arm.body);
1459                }
1460            }
1461            Stmt::Break | Stmt::Continue | Stmt::Pass => {}
1462            Stmt::Assert(cond) => {
1463                if let Some(cond) = cond {
1464                    self.infer_expr(cond, &Expectation::None);
1465                    self.check_assert_constant(cond);
1466                }
1467            }
1468        }
1469    }
1470
1471    /// `ASSERT_ALWAYS_TRUE` / `ASSERT_ALWAYS_FALSE` — fire when the assert condition is a constant
1472    /// with a known boolean value (Godot `resolve_assert`: a constant condition is booleanized and
1473    /// warned). Sound subset via [`Cx::const_bool_of`]: a literal `true`/`false`, or `null` (false).
1474    fn check_assert_constant(&mut self, cond: ExprId) {
1475        let Some(always) = self.const_bool_of(cond) else {
1476            return;
1477        };
1478        let (code, msg) = if always {
1479            (
1480                WarningCode::AssertAlwaysTrue,
1481                "The assert condition is always true, so this assert has no effect.",
1482            )
1483        } else {
1484            (
1485                WarningCode::AssertAlwaysFalse,
1486                "The assert condition is always false, so this assert will always fail.",
1487            )
1488        };
1489        self.warn(self.range_of(cond), code, msg.to_owned());
1490    }
1491
1492    /// The constant boolean value of `expr`, when it is a literal whose booleanization is known — a
1493    /// bool literal, or `null` (false). `None` for any other / non-constant expression (the sound
1494    /// default: no false `ASSERT_ALWAYS_*`). Mirrors Godot's `reduced_value.booleanize()` restricted
1495    /// to the literal forms (named-constant / arithmetic folding is deliberately not attempted).
1496    fn const_bool_of(&self, expr: ExprId) -> Option<bool> {
1497        match self.body.expr(expr) {
1498            Expr::Literal(Literal::Bool(b)) => Some(*b),
1499            Expr::Literal(Literal::Null) => Some(false),
1500            _ => None,
1501        }
1502    }
1503
1504    fn infer_local_var(&mut self, v: &body::LocalVar) {
1505        let annotated = v.type_ref.map(|p| self.resolve_ptr_ty(p));
1506        let init_ty = v.init.map(|e| {
1507            let expected = annotated
1508                .as_ref()
1509                .map_or(Expectation::None, |t| Expectation::Has(t.clone()));
1510            self.infer_expr(e, &expected)
1511        });
1512        let range = v.init.map_or(v.name_range, |e| self.range_of(e));
1513
1514        let binding_ty = match (&annotated, &init_ty) {
1515            // `var x: T = e` — hard slot; check the initializer against it.
1516            (Some(t), Some(init)) => {
1517                self.check_assign(init, t, range);
1518                t.clone()
1519            }
1520            // `var x: T` (no init).
1521            (Some(t), None) => t.clone(),
1522            // `var x := e` — inferred (hard); guard the Variant / null cases.
1523            (None, Some(init)) if v.is_inferred => {
1524                if init.is_variant() {
1525                    self.warn(
1526                        range,
1527                        WarningCode::InferenceOnVariant,
1528                        inference_on_variant_msg(if v.is_const { "constant" } else { "variable" }),
1529                    );
1530                    Ty::Variant
1531                } else {
1532                    // `Unknown` (the seam) stays `Unknown` with no warning.
1533                    init.clone()
1534                }
1535            }
1536            // `var x = e` — untyped, soft → Variant. `const X = e` keeps the inferred type.
1537            (None, Some(init)) => {
1538                if v.is_const {
1539                    init.clone()
1540                } else {
1541                    Ty::Variant
1542                }
1543            }
1544            (None, None) => Ty::Variant,
1545        };
1546        // SHADOWED_VARIABLE — a local `var`/`const` whose name shadows a parameter or an own class
1547        // member (a redeclared *local* is a Godot error, not handled here). Sound: only fires on a
1548        // genuine outer-scope shadow. The binding isn't pushed yet, so the `Param` scan can't see it.
1549        // Gated to a real function body — a class-field initializer's own `var n` is not a shadow.
1550        let shadows_param = self
1551            .bindings
1552            .iter()
1553            .any(|b| b.kind == BindingKind::Param && b.name == v.name);
1554        // Only a *value* member (var/const/signal, or an anon-enum constant) — not a method or a
1555        // type name, where the "shadow" framing is weaker — counts, to stay conservative.
1556        let shadows_member = match self.class.lookup(&v.name) {
1557            Some(ClassItem::EnumVariant) => true,
1558            Some(item) => matches!(
1559                self.class.member(item),
1560                Some(Member::Var(_) | Member::Const(_) | Member::Signal(_))
1561            ),
1562            None => false,
1563        };
1564        if self.is_func_body {
1565            let what = if v.is_const { "constant" } else { "variable" };
1566            // A global-identifier shadow takes precedence over a variable/base-class shadow (Godot's
1567            // `is_shadowing` checks globals first and returns), so the variable-shadow is emitted only
1568            // when the name does NOT shadow a global — the global one is emitted by the binding
1569            // post-pass in `infer`, keeping a single warning per declaration.
1570            if shadowed_global_kind(self.db, self.api, &v.name).is_none() {
1571                if shadows_param || shadows_member {
1572                    let outer = if shadows_param {
1573                        "parameter"
1574                    } else {
1575                        "class member"
1576                    };
1577                    self.warn(
1578                        v.name_range,
1579                        WarningCode::ShadowedVariable,
1580                        format!(
1581                            "The local {what} \"{}\" shadows a {outer} of the same name.",
1582                            v.name
1583                        ),
1584                    );
1585                } else if self.engine_base_has_value_member(&v.name) {
1586                    // An own-member shadow already won above; only a *base*-member shadow reaches here.
1587                    self.warn(
1588                        v.name_range,
1589                        WarningCode::ShadowedVariableBaseClass,
1590                        format!(
1591                            "The local {what} \"{}\" shadows a member of a base class.",
1592                            v.name
1593                        ),
1594                    );
1595                }
1596            }
1597            // ENUM_VARIABLE_WITHOUT_DEFAULT — a local typed as an enum with no initializer (the
1598            // implicit `0` may not name a valid enum value). Only an explicit `Ty::Enum` annotation.
1599            if v.init.is_none() && matches!(annotated.as_ref(), Some(Ty::Enum(_))) {
1600                self.warn(
1601                    v.name_range,
1602                    WarningCode::EnumVariableWithoutDefault,
1603                    format!(
1604                        "The enum variable \"{}\" has no default value (it defaults to 0, which may not be a valid enum value).",
1605                        v.name
1606                    ),
1607                );
1608            }
1609            // A typed local declared WITHOUT an initializer is the only `UNASSIGNED_VARIABLE`
1610            // candidate (an untyped / `:=` / initialized local is never read-before-assign).
1611            if v.type_ref.is_some() && v.init.is_none() {
1612                self.needs_assignment.insert(v.name.clone());
1613            }
1614        }
1615        self.bindings.push(Binding {
1616            name: v.name.clone(),
1617            name_range: v.name_range,
1618            ty: binding_ty.clone(),
1619            init: v.init,
1620            annotated: v.type_ref.is_some(),
1621            inferred_colon_eq: v.is_inferred,
1622            is_const: v.is_const,
1623            kind: BindingKind::Var,
1624        });
1625        // A (re-)declaration's narrowing invalidation is handled by the flow analysis (Workstream 2).
1626        self.locals.insert(v.name.clone(), binding_ty);
1627    }
1628
1629    // ---- expressions ----
1630
1631    fn infer_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
1632        let ty = self.synth_expr(id, expected);
1633        self.expr_ty.insert(id, ty.clone());
1634        ty
1635    }
1636
1637    #[allow(clippy::too_many_lines)]
1638    fn synth_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
1639        match self.body.expr(id).clone() {
1640            Expr::Missing => Ty::Error,
1641            Expr::Literal(lit) => self.literal_ty(lit),
1642            Expr::Name(name) => self.resolve_name(id, &name),
1643            Expr::SelfExpr => self.self_ty.clone(),
1644            Expr::Super => self.class.base.clone(),
1645            Expr::Paren(inner) => self.infer_expr(inner, expected),
1646            Expr::Bin { op, lhs, rhs } => self.infer_bin(id, op, lhs, rhs),
1647            Expr::Unary { op, operand } => {
1648                let t = self.infer_expr(operand, &Expectation::None);
1649                match op {
1650                    UnOp::Not => self.bool_ty(),
1651                    UnOp::BitNot => self.int_ty(),
1652                    UnOp::Neg | UnOp::Pos => {
1653                        if t.is_uninformative() || self.is_numeric(&t) {
1654                            t
1655                        } else {
1656                            Ty::Variant
1657                        }
1658                    }
1659                }
1660            }
1661            Expr::Ternary {
1662                cond,
1663                then_branch,
1664                else_branch,
1665            } => {
1666                self.infer_expr(cond, &Expectation::None);
1667                let a = self.infer_expr(then_branch, expected);
1668                let b = self.infer_expr(else_branch, expected);
1669                // A `null` branch does not poison the other: `x if c else null` is nullable-`x`.
1670                if self.is_null(else_branch) {
1671                    a
1672                } else if self.is_null(then_branch) {
1673                    b
1674                } else {
1675                    let r = self.join(&a, &b);
1676                    // Both arms informative but with no common type (the join widened to Variant) —
1677                    // the ternary's two values are mutually incompatible.
1678                    if r.is_variant() && !a.is_uninformative() && !b.is_uninformative() {
1679                        self.warn(
1680                            self.range_of(id),
1681                            WarningCode::IncompatibleTernary,
1682                            "The values of the ternary conditional are not mutually compatible."
1683                                .to_owned(),
1684                        );
1685                    }
1686                    r
1687                }
1688            }
1689            Expr::Call { callee, args } => self.infer_call(callee, &args),
1690            Expr::Field {
1691                receiver,
1692                name,
1693                name_range,
1694            } => {
1695                self.infer_field(receiver, &name, name_range, /*as_method=*/ false)
1696            }
1697            Expr::Index { base, index } => {
1698                let base_ty = self.infer_expr(base, &Expectation::None);
1699                self.infer_expr(index, &Expectation::None);
1700                self.index_ty(&base_ty)
1701            }
1702            Expr::Is { operand, .. } => {
1703                self.infer_expr(operand, &Expectation::None);
1704                self.bool_ty()
1705            }
1706            Expr::Cast { operand, ty } => {
1707                self.infer_expr(operand, &Expectation::None);
1708                ty.map_or(Ty::Variant, |p| self.resolve_ptr_ty(p))
1709            }
1710            Expr::In { lhs, rhs, .. } => {
1711                self.infer_expr(lhs, &Expectation::None);
1712                self.infer_expr(rhs, &Expectation::None);
1713                self.bool_ty()
1714            }
1715            Expr::Await(operand) => {
1716                let operand_ty = self.infer_expr(operand, &Expectation::None);
1717                // `await coroutine()` yields the call's value, so await is **identity** on the operand
1718                // type (`await f()` for `func f() -> int` is `int`) — recovered here. `await signal`
1719                // instead yields the signal's emitted payload, which needs the Phase-3+ signal-signature
1720                // table; until then it's the seam (never `Variant`, so `var x := await sig` never warns).
1721                if matches!(operand_ty, Ty::Signal(_)) {
1722                    Ty::Unknown
1723                } else {
1724                    operand_ty
1725                }
1726            }
1727            Expr::Array(elems) => {
1728                // Checking mode: an expected `Array[T]` is pushed down onto the literal (so
1729                // `var a: Array[String] = []` / `[...]` is accepted). Otherwise the engine does
1730                // not infer a literal's element type past `Variant`.
1731                let pushed = match expected {
1732                    Expectation::Has(Ty::Array(e)) => Some((**e).clone()),
1733                    _ => None,
1734                };
1735                let elem_exp = pushed.clone().map_or(Expectation::None, Expectation::Has);
1736                for e in elems {
1737                    self.infer_expr(e, &elem_exp);
1738                }
1739                pushed.map_or_else(Ty::array_of_variant, |e| Ty::Array(Box::new(e)))
1740            }
1741            Expr::Dict(entries) => {
1742                let pushed = match expected {
1743                    Expectation::Has(Ty::Dict(k, v)) => Some(((**k).clone(), (**v).clone())),
1744                    _ => None,
1745                };
1746                let (kx, vx) = pushed
1747                    .clone()
1748                    .map_or((Expectation::None, Expectation::None), |(k, v)| {
1749                        (Expectation::Has(k), Expectation::Has(v))
1750                    });
1751                for (k, v) in entries {
1752                    self.infer_expr(k, &kx);
1753                    if let Some(v) = v {
1754                        self.infer_expr(v, &vx);
1755                    }
1756                }
1757                pushed.map_or_else(Ty::dict_of_variant, |(k, v)| {
1758                    Ty::Dict(Box::new(k), Box::new(v))
1759                })
1760            }
1761            Expr::Lambda { params, body } => {
1762                self.infer_lambda(&params, &body);
1763                Ty::Callable
1764            }
1765            Expr::Preload { arg, path } => {
1766                if let Some(arg) = arg {
1767                    self.infer_expr(arg, &Expectation::None);
1768                }
1769                // A constant string-literal path resolves to the declaring file's `ScriptRef`
1770                // (M3 — a SCRIPT meta-type in Godot; `X.new()`/`X.member` then resolve via the
1771                // usual `ScriptRef` walk). A non-constant argument (`preload(var)`) — which Godot
1772                // itself rejects — stays the seam, never a false diagnostic.
1773                match path {
1774                    // Anchor a relative `preload("sibling.gd")` to the importing file's directory
1775                    // before resolving (Godot anchors relative resource paths); absolute paths pass
1776                    // through, and a relative path with no anchor stays the seam.
1777                    Some(p) => {
1778                        match resolve::anchor_res_path(self.self_res_path().as_deref(), &p) {
1779                            Some(abs) => resolve::resolve_external(
1780                                self.db,
1781                                &resolve::ExternalRef::Preload(abs),
1782                            ),
1783                            None => Ty::Unknown,
1784                        }
1785                    }
1786                    None => Ty::Unknown,
1787                }
1788            }
1789            // `$Path`/`%Unique` — resolve the literal path against the owning scene to the node's
1790            // concrete type (Phase-4 M1); a computed/unresolvable path stays `Object(Node)`.
1791            Expr::GetNode { path, unique } => self.resolve_node_path(id, path.as_deref(), unique),
1792        }
1793    }
1794
1795    /// Whether `id` is the `null` literal.
1796    fn is_null(&self, id: ExprId) -> bool {
1797        matches!(self.body.expr(id), Expr::Literal(Literal::Null))
1798    }
1799
1800    fn literal_ty(&self, lit: Literal) -> Ty {
1801        match lit {
1802            Literal::Int => self.int_ty(),
1803            Literal::Float | Literal::MathConst => self.float_ty(),
1804            Literal::Bool(_) => self.bool_ty(),
1805            Literal::Str => self.builtin("String"),
1806            Literal::StringName => self.builtin("StringName"),
1807            Literal::NodePath => self.builtin("NodePath"),
1808            // `null` is compatible everywhere; typing it `Variant` avoids false mismatches.
1809            Literal::Null => Ty::Variant,
1810        }
1811    }
1812
1813    fn node_ty(&self) -> Ty {
1814        self.api
1815            .class_by_name("Node")
1816            .map_or(Ty::Unknown, Ty::Object)
1817    }
1818
1819    // ---- scene-aware node-path typing (Phase-4 M1) ----
1820
1821    /// Resolve a `$Path`/`%Unique`/`get_node("…")` literal node path against the owning scene to the
1822    /// node's concrete type. A computed (`None`) path, no owning scene, an `..`/absolute escape, or a
1823    /// path that descends into an instanced sub-scene all degrade to `Object(Node)` — never a false
1824    /// positive. A *genuinely* absent in-scene node raises `INVALID_NODE_PATH` (M2), but only when
1825    /// the script attaches to exactly one scene (an ambiguous multi-scene attachment stays silent).
1826    fn resolve_node_path(&mut self, id: ExprId, path: Option<&str>, unique: bool) -> Ty {
1827        use gdscript_scene::NodePathResolution as R;
1828        let fallback = self.node_ty();
1829        let Some(path) = path else {
1830            return fallback; // computed `get_node(var)` — stays `Node`
1831        };
1832        // An absolute `/root/<Autoload>` access resolves to the autoload's type — singleton OR
1833        // loaded-but-not-global (both live at `/root/Name`). Independent of any owning scene. A deeper
1834        // tail (`/root/Name/Child`) would need to walk the autoload's own scene; left as the seam.
1835        if !unique && let Some(ty) = self.resolve_root_autoload_path(path) {
1836            return ty;
1837        }
1838        let Some(ctx) = self.owning_scene() else {
1839            return fallback; // no scene attaches this script (dynamic UI / single-file)
1840        };
1841        // Multi-scene attachment (M2 §6.3): a `$Path` may resolve to a different node type in each
1842        // attaching scene, so type it as the COMMON BASE across all of them (never the first-scene
1843        // type, which could be wrong for another scene). If any scene can't resolve it identically,
1844        // degrade to `Node` — never a false positive and never a false `INVALID_NODE_PATH`.
1845        if ctx.ambiguous {
1846            return self.union_node_ty(path, unique).unwrap_or(fallback);
1847        }
1848        let resolution = if unique {
1849            ctx.model.classify_unique(path)
1850        } else {
1851            ctx.model.classify_path_from(ctx.attach, path)
1852        };
1853        match resolution {
1854            R::Resolved(idx) => ctx
1855                .model
1856                .node(idx)
1857                .and_then(|n| self.scene_node_ty(&ctx.model, n, 0))
1858                .unwrap_or(fallback),
1859            R::Missing => {
1860                let what = if unique { "unique name" } else { "node path" };
1861                let sigil = if unique { "%" } else { "$" };
1862                self.emit(
1863                    self.range_of(id),
1864                    Severity::Warning,
1865                    INVALID_NODE_PATH,
1866                    format!("no {what} `{sigil}{path}` in the owning scene"),
1867                );
1868                fallback
1869            }
1870            // The path descends into an instanced sub-scene (`$Enemy/Sprite`): resolve the tail in
1871            // the sub-scene's own tree (`Sprite` typed by `enemy.tscn`). Any failure → `Node`.
1872            R::IntoInstance => {
1873                let walked = if unique {
1874                    ctx.model.resolve_unique_into_instance(path)
1875                } else {
1876                    ctx.model.resolve_into_instance(ctx.attach, path)
1877                };
1878                walked
1879                    .and_then(|(inst, tail)| {
1880                        let inst_node = ctx.model.node(inst)?;
1881                        self.resolve_into_instance_ty(&ctx.model, inst_node, &tail, 0)
1882                    })
1883                    .unwrap_or(fallback)
1884            }
1885            // An `..`/absolute escape out of the slice → `Node`, never a false warning.
1886            R::Escaped => fallback,
1887        }
1888    }
1889
1890    /// Union-type a `$Path`/`%Unique` across **every** scene that attaches this script (the rare
1891    /// multi-scene case): resolve the path in each scene, then take the COMMON BASE of the per-scene
1892    /// node types. `None` (→ caller degrades to `Node`) if any scene fails to resolve the path the
1893    /// same way — keeping the no-false-positive contract on an ambiguous attachment.
1894    fn union_node_ty(&self, path: &str, unique: bool) -> Option<Ty> {
1895        use gdscript_scene::NodePathResolution as R;
1896        let res_path = self.self_res_path()?;
1897        let root = self.db.source_root()?;
1898        let attaches = crate::queries::script_scene_attachments(self.db, root)
1899            .get(res_path.as_str())
1900            .cloned()?;
1901        let mut acc: Option<Ty> = None;
1902        for (scene_file, attach) in &attaches {
1903            let ft = self.db.file_text(*scene_file)?;
1904            let model = crate::queries::scene_model(self.db, ft);
1905            let resolution = if unique {
1906                model.classify_unique(path)
1907            } else {
1908                model.classify_path_from(*attach, path)
1909            };
1910            let R::Resolved(idx) = resolution else {
1911                return None; // a miss / escape / into-instance in some scene → bail to `Node`
1912            };
1913            let ty = model
1914                .node(idx)
1915                .and_then(|n| self.scene_node_ty(&model, n, 0))?;
1916            acc = Some(match acc {
1917                None => ty,
1918                Some(prev) => self.common_base(&prev, &ty),
1919            });
1920        }
1921        acc
1922    }
1923
1924    /// The common base of two scene-node types — the lowest engine class both descend from (walk
1925    /// `a`'s ancestor chain, return the first that `b` is a subclass of). Identical types collapse to
1926    /// themselves; a `ScriptRef` or any mixed pair degrades to the `Node` floor (the engine base for
1927    /// every scene node), which is always a sound supertype.
1928    fn common_base(&self, a: &Ty, b: &Ty) -> Ty {
1929        if a == b {
1930            return a.clone();
1931        }
1932        if let (Ty::Object(ca), Ty::Object(cb)) = (a, b) {
1933            let mut cur = Some(*ca);
1934            while let Some(c) = cur {
1935                if self.api.is_subclass(*cb, c) {
1936                    return Ty::Object(c);
1937                }
1938                cur = self.api.class(c).base;
1939            }
1940        }
1941        self.node_ty()
1942    }
1943
1944    /// Resolve an absolute `/root/<Autoload>` node path to the autoload's type (singleton or
1945    /// loaded-but-not-global — both are children of the scene-tree root). `None` for any other path,
1946    /// including a deeper tail (`/root/Name/Child`, which would need the autoload's own scene) — those
1947    /// degrade to the `Node` seam with no false positive.
1948    fn resolve_root_autoload_path(&self, path: &str) -> Option<Ty> {
1949        let name = path.strip_prefix("/root/")?;
1950        // Only the autoload node itself (no trailing segment) for now.
1951        if name.is_empty() || name.contains('/') {
1952            return None;
1953        }
1954        let ty = resolve::resolve_autoload_any(self.db, name);
1955        (!ty.is_uninformative()).then_some(ty)
1956    }
1957
1958    /// The owning-scene context for the current file (scene + attach node + multi-scene ambiguity).
1959    /// Recovered from `self_ty`, which `analyze_file` sets to the file's own `ScriptRef` (so no extra
1960    /// `FileId` threading).
1961    fn owning_scene(&self) -> Option<crate::queries::SceneContext> {
1962        let Ty::ScriptRef(sref) = &self.self_ty else {
1963            return None;
1964        };
1965        let ft = self.db.file_text(FileId(sref.0))?;
1966        crate::queries::scene_context(self.db, ft)
1967    }
1968
1969    /// The importing file's own `res://` path (from `self_ty`), for anchoring relative
1970    /// `preload`/`extends` paths to its directory. `None` when the file has no resource path.
1971    fn self_res_path(&self) -> Option<SmolStr> {
1972        let Ty::ScriptRef(sref) = &self.self_ty else {
1973            return None;
1974        };
1975        self.db.file_text(FileId(sref.0))?.res_path(self.db)
1976    }
1977
1978    /// The concrete `Ty` of a scene node, by precedence: an attached script's own class (most
1979    /// specific) wins; else the declared `type=` (native class or `class_name`); else — an instanced
1980    /// node (`instance=`, no own `type=`/script) — the **instanced sub-scene's root** type (M3,
1981    /// recursive). `None` for a node we can't sharpen (the caller degrades to `Node`).
1982    fn scene_node_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
1983        if let Some(script_ty) = self.node_script_ref(scene, node) {
1984            return Some(script_ty);
1985        }
1986        if let Some(decl) = node.decl_type.as_ref() {
1987            let ty = resolve::resolve_type_name(self.db, self.api, decl);
1988            if !ty.is_uninformative() {
1989                return Some(ty);
1990            }
1991        }
1992        self.instance_root_ty(scene, node, depth)
1993            .or_else(|| self.override_child_ty(scene, node, depth))
1994    }
1995
1996    /// An **override child** *under* an instance: a node added/overridden in the outer scene beneath
1997    /// an `instance=` boundary (`[node name="Sprite" parent="Enemy"]` over an instanced `enemy.tscn`),
1998    /// carrying no own `type=`/`script`/`instance=` — so its real type lives in the instanced
1999    /// sub-scene. Walk up to the nearest instance-boundary ancestor, then type the node by its same
2000    /// path *inside* that sub-scene (so the outer override of `enemy.tscn`'s `Sprite` types as the
2001    /// sub-scene's `Sprite`, not bare `Node`). `None` if the node is not under an instance (the
2002    /// caller then floors to `Node`, unchanged). Depth-bounded against an instancing cycle.
2003    fn override_child_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
2004        if depth >= 16 {
2005            return None;
2006        }
2007        let mut segs_rev: Vec<String> = vec![node.name.to_string()];
2008        let mut parent_idx = node.parent_idx?;
2009        let mut guard = 0u32;
2010        loop {
2011            let parent = scene.node(parent_idx)?;
2012            if parent.instance.is_some() {
2013                segs_rev.reverse();
2014                let rel = segs_rev.join("/");
2015                return self.resolve_into_instance_ty(scene, parent, &rel, depth + 1);
2016            }
2017            segs_rev.push(parent.name.to_string());
2018            parent_idx = parent.parent_idx?;
2019            guard += 1;
2020            if guard > 4096 {
2021                return None;
2022            }
2023        }
2024    }
2025
2026    /// An instanced node (`instance=ExtResource(id)`) takes the type of the instanced sub-scene's
2027    /// ROOT node — resolved recursively, so the root's own script / `type=` / nested instance all
2028    /// flow through (so `$Enemy` types as `enemy.tscn`'s root class, not bare `Node`). Depth-bounded
2029    /// against an instancing cycle (scene A instances B instances A).
2030    fn instance_root_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
2031        if depth >= 16 {
2032            return None;
2033        }
2034        let (sub, sub_root) = self.instance_subscene(scene, node)?;
2035        let root_node = sub.node(sub_root)?;
2036        self.scene_node_ty(&sub, root_node, depth + 1)
2037    }
2038
2039    /// The instanced sub-scene's model + its root index, for an instance node (`instance=ExtResource`
2040    /// → `res://` path → `FileId` → `scene_model`). The shared resolution step for both
2041    /// [`instance_root_ty`](Self::instance_root_ty) (the node's own type) and
2042    /// [`resolve_into_instance_ty`](Self::resolve_into_instance_ty) (paths that go *into* it).
2043    fn instance_subscene(
2044        &self,
2045        scene: &SceneModel,
2046        node: &SceneNode,
2047    ) -> Option<(Arc<SceneModel>, gdscript_scene::NodeIdx)> {
2048        let inst = node.instance.as_ref()?;
2049        let path = scene.ext_resources.get(inst)?.path.as_ref()?;
2050        let root = self.db.source_root()?;
2051        let file = crate::queries::res_path_registry(self.db, root)
2052            .get(path.as_str())
2053            .copied()?;
2054        let ft = self.db.file_text(file)?;
2055        let sub = crate::queries::scene_model(self.db, ft);
2056        let sub_root = sub.root?;
2057        Some((sub, sub_root))
2058    }
2059
2060    /// Type a node path that descends INTO an instanced sub-scene: `instance_node` is the boundary
2061    /// (an `instance=` node) and `tail` is the remaining path. Resolve `tail` from the sub-scene's
2062    /// root, recursing through further instance boundaries inside it. Depth-bounded against an
2063    /// instancing cycle. `None` (→ `Node`, no false warning) if the tail genuinely can't be typed.
2064    fn resolve_into_instance_ty(
2065        &self,
2066        scene: &SceneModel,
2067        instance_node: &SceneNode,
2068        tail: &str,
2069        depth: u32,
2070    ) -> Option<Ty> {
2071        if depth >= 16 {
2072            return None;
2073        }
2074        let (sub, sub_root) = self.instance_subscene(scene, instance_node)?;
2075        if let Some(idx) = sub.resolve_path_from(sub_root, tail) {
2076            let n = sub.node(idx)?;
2077            return self.scene_node_ty(&sub, n, depth + 1);
2078        }
2079        // The tail crosses a further instance boundary *inside* the sub-scene — keep descending.
2080        let (inner, inner_tail) = sub.resolve_into_instance(sub_root, tail)?;
2081        let inner_node = sub.node(inner)?;
2082        self.resolve_into_instance_ty(&sub, inner_node, &inner_tail, depth + 1)
2083    }
2084
2085    /// The `ScriptRef` of a node's attached `.gd` script (`script = ExtResource(id)` → its `res://`
2086    /// path → `FileId`), or `None` if it has no resolvable external script.
2087    fn node_script_ref(&self, scene: &SceneModel, node: &SceneNode) -> Option<Ty> {
2088        let path = scene
2089            .ext_resources
2090            .get(node.script.as_ref()?)?
2091            .path
2092            .as_ref()?;
2093        let root = self.db.source_root()?;
2094        let file = crate::queries::res_path_registry(self.db, root)
2095            .get(path.as_str())
2096            .copied()?;
2097        Some(Ty::ScriptRef(ScriptRefId(file.0)))
2098    }
2099
2100    fn infer_bin(&mut self, id: ExprId, op: BinOp, lhs: ExprId, rhs: ExprId) -> Ty {
2101        if op == BinOp::Assign {
2102            return self.infer_assign(lhs, rhs);
2103        }
2104        // Short-circuit narrowing (Workstream 2): the RHS of `a and b` is typed under `a`'s
2105        // then-facts; `a or b`'s RHS under `a`'s else-facts. Restore the env afterward.
2106        if matches!(op, BinOp::And | BinOp::Or) {
2107            self.infer_expr(lhs, &Expectation::None);
2108            let saved = self.narrowing.clone();
2109            self.apply_condition_facts(lhs, op == BinOp::And);
2110            self.infer_expr(rhs, &Expectation::None);
2111            self.narrowing = saved;
2112            return self.bool_ty();
2113        }
2114        let lt = self.infer_expr(lhs, &Expectation::None);
2115        let rt = self.infer_expr(rhs, &Expectation::None);
2116        if op.is_boolean() {
2117            return self.bool_ty();
2118        }
2119        // `int / int` discards the fractional part.
2120        if op == BinOp::Div && self.is_int(&lt) && self.is_int(&rt) {
2121            self.warn(
2122                self.range_of(id),
2123                WarningCode::IntegerDivision,
2124                "Integer division. Decimal part will be discarded.".to_owned(),
2125            );
2126            return self.int_ty();
2127        }
2128        self.bin_result(op, &lt, &rt)
2129    }
2130
2131    fn infer_assign(&mut self, lhs: ExprId, rhs: ExprId) -> Ty {
2132        let slot = self.infer_expr(lhs, &Expectation::None);
2133        let expected = if slot.is_uninformative() {
2134            Expectation::None
2135        } else {
2136            Expectation::Has(slot.clone())
2137        };
2138        let value = self.infer_expr(rhs, &expected);
2139        if !slot.is_uninformative() {
2140            self.check_assign(&value, &slot, self.range_of(rhs));
2141        }
2142        // Assignment *invalidates* the place's narrowing (handled by the flow analysis, Workstream
2143        // 2); re-narrowing from the assigned value's type is a post-1.0 precision item.
2144        slot
2145    }
2146
2147    /// Resolve a binary operator's result type via the builtin operator table, with a numeric
2148    /// fallback. Comparison/logical operators are handled by the caller.
2149    fn bin_result(&self, op: BinOp, lt: &Ty, rt: &Ty) -> Ty {
2150        if let (Ty::Builtin(b), Some(sym)) = (lt, op_symbol(op)) {
2151            for o in self.api.builtin_operators(*b) {
2152                if o.op == sym
2153                    && let Some(right) = &o.right
2154                    && self.tyref_matches(right, rt)
2155                {
2156                    return ty::resolve_tyref(self.api, &o.result);
2157                }
2158            }
2159        }
2160        if self.is_numeric(lt) && self.is_numeric(rt) {
2161            return if self.is_float(lt) || self.is_float(rt) {
2162                self.float_ty()
2163            } else {
2164                self.int_ty()
2165            };
2166        }
2167        // A seam operand keeps the result on the seam (`a + unknown` is `Unknown`, not the
2168        // gradual `Variant`, so `var x := a + unknown` never warns).
2169        if lt.is_unknown() || rt.is_unknown() || lt.is_error() || rt.is_error() {
2170            return Ty::Unknown;
2171        }
2172        Ty::Variant
2173    }
2174
2175    fn tyref_matches(&self, tyref: &TyRef, ty: &Ty) -> bool {
2176        let resolved = ty::resolve_tyref(self.api, tyref);
2177        resolved.is_variant() || &resolved == ty
2178    }
2179
2180    fn infer_call(&mut self, callee: ExprId, args: &[ExprId]) -> Ty {
2181        // Argument expressions are always inferred (their own diagnostics + hover).
2182        for &a in args {
2183            self.infer_expr(a, &Expectation::None);
2184        }
2185        let ret = match self.body.expr(callee).clone() {
2186            Expr::Field {
2187                receiver,
2188                name,
2189                name_range,
2190            } => {
2191                self.infer_field(receiver, &name, name_range, /*as_method=*/ true)
2192            }
2193            Expr::Name(name) => {
2194                let ret = self.resolve_call_name(&name);
2195                self.expr_ty.insert(callee, Ty::Callable);
2196                ret
2197            }
2198            // Calling an arbitrary expression — a `Callable` value or an immediately-invoked
2199            // lambda (`(func(): …).call()`): the callee's return type isn't tracked, so the
2200            // result is the seam (not `Variant`), and `var x := f()()` never warns.
2201            _ => {
2202                self.infer_expr(callee, &Expectation::None);
2203                Ty::Unknown
2204            }
2205        };
2206        // UNSAFE_CALL_ARGUMENT (Phase-2 §5): args + receiver are now inferred (in `expr_ty`), so
2207        // check each argument against the statically-resolved callee's parameter types.
2208        self.check_call_args(callee, args);
2209        ret
2210    }
2211
2212    /// Raise `UNSAFE_CALL_ARGUMENT` for each argument whose static type needs an unsafe implicit
2213    /// cast (`Variant` / a downcast) into the resolved parameter type — Godot's per-argument
2214    /// value-prop warning. Only fires when the callee resolves to a concrete signature here; an
2215    /// uninformative argument (the cross-file seam) is `Assign::Ok` and correctly silent, and an
2216    /// untyped parameter accepts anything.
2217    fn check_call_args(&mut self, callee: ExprId, args: &[ExprId]) {
2218        let Some(params) = self.call_param_tys(callee) else {
2219            return;
2220        };
2221        for (i, &arg) in args.iter().enumerate() {
2222            let Some(param_ty) = params.get(i) else {
2223                break; // a vararg tail or an arity mismatch — not an argument-type concern
2224            };
2225            if param_ty.is_uninformative() || param_ty.is_variant() {
2226                continue; // an untyped parameter accepts anything safely
2227            }
2228            // A missing arg type defaults to the seam (never warns), not `Variant` (would warn).
2229            let arg_ty = self.expr_ty.get(&arg).cloned().unwrap_or(Ty::Unknown);
2230            if ty::is_assignable(self.api, &arg_ty, param_ty) == Assign::OkUnsafe {
2231                let pl = param_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
2232                let al = arg_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
2233                self.warn(
2234                    self.range_of(arg),
2235                    WarningCode::UnsafeCallArgument,
2236                    format!(
2237                        "The argument {} requires a value of type \"{pl}\" but is passed \"{al}\", which is unsafe.",
2238                        i + 1
2239                    ),
2240                );
2241            }
2242        }
2243    }
2244
2245    /// Parameter types of a statically-resolved callee, for [`Self::check_call_args`]. `None` when
2246    /// the callee isn't concretely resolvable here (a cross-file script method — params aren't
2247    /// modeled —, a builtin/utility, a `Callable` value): those raise no argument warning.
2248    fn call_param_tys(&self, callee: ExprId) -> Option<Vec<Ty>> {
2249        match self.body.expr(callee) {
2250            Expr::Name(name) => self.name_call_param_tys(name),
2251            Expr::Field { receiver, name, .. } => match self.expr_ty.get(receiver)? {
2252                Ty::Object(class) => match self.api.lookup_member(*class, name)? {
2253                    MemberRef::Method(sig) => Some(
2254                        sig.params
2255                            .iter()
2256                            .map(|p| ty::resolve_tyref(self.api, &p.ty))
2257                            .collect(),
2258                    ),
2259                    _ => None,
2260                },
2261                // ScriptRef / builtin / seam receivers: params not uniformly modeled — skip.
2262                _ => None,
2263            },
2264            _ => None,
2265        }
2266    }
2267
2268    /// Parameter types for a bare-name call (`foo(...)` / an inherited `method(...)`): an own `func`
2269    /// first, then the `self` engine base's method. Utilities/builtins are skipped (looser, often
2270    /// variadic typing — out of the conservative MVP slice).
2271    fn name_call_param_tys(&self, name: &str) -> Option<Vec<Ty>> {
2272        if let Some(item) = self.class.lookup(name)
2273            && let Some(Member::Func(f)) = self.class.member(item)
2274        {
2275            return Some(
2276                f.params
2277                    .iter()
2278                    .map(|p| {
2279                        p.type_ref.as_deref().map_or(Ty::Variant, |t| {
2280                            resolve::resolve_type_name(self.db, self.api, t)
2281                        })
2282                    })
2283                    .collect(),
2284            );
2285        }
2286        if let Ty::Object(base) = self.class.base
2287            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
2288        {
2289            return Some(
2290                sig.params
2291                    .iter()
2292                    .map(|p| ty::resolve_tyref(self.api, &p.ty))
2293                    .collect(),
2294            );
2295        }
2296        None
2297    }
2298
2299    /// Resolve a bare-name call (`foo(...)`): own method → utility/builtin fn → constructor.
2300    fn resolve_call_name(&self, name: &str) -> Ty {
2301        if let Some(item) = self.class.lookup(name)
2302            && let Some(Member::Func(f)) = self.class.member(item)
2303        {
2304            return self.func_return_ty(f.return_type.as_deref());
2305        }
2306        // A bare call inside the class is `self.name(...)` — resolve against the inherited base.
2307        if let Ty::Object(base) = self.class.base
2308            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
2309        {
2310            return ty::resolve_tyref(self.api, &sig.return_ty);
2311        }
2312        if let Some(u) = self.api.utility(name) {
2313            return ty::resolve_tyref(self.api, &u.return_ty);
2314        }
2315        if let Some(f) = self.api.gdscript_builtin(name) {
2316            return resolve::layer_to_ty(self.api, f.ret);
2317        }
2318        // A builtin / class name used as a constructor: `Vector2(...)` / `Array(...)`.
2319        // Normalize via `resolve_tyref` so `Array`/`Dictionary`/`Callable`/`Signal` land on
2320        // their dedicated `Ty` variants rather than `Builtin(...)`.
2321        if let Some(b) = self.api.builtin_by_name(name) {
2322            return ty::resolve_tyref(self.api, &TyRef::Builtin(b));
2323        }
2324        // Otherwise unresolved — most likely a cross-file global / autoload / a method on a
2325        // `class_name` base we can't see. Treat as the seam so `var x := foo()` never warns.
2326        Ty::Unknown
2327    }
2328
2329    fn func_return_ty(&self, annotation: Option<&str>) -> Ty {
2330        annotation.map_or(Ty::Variant, |t| {
2331            resolve::resolve_type_name(self.db, self.api, t)
2332        })
2333    }
2334
2335    /// Member access `receiver.name`. When `as_method`, resolve a method (and use its return
2336    /// type); otherwise resolve a property/const/etc. Raises `UNSAFE_*` only on a statically
2337    /// **known** receiver.
2338    fn infer_field(
2339        &mut self,
2340        receiver: ExprId,
2341        name: &str,
2342        name_range: TextRange,
2343        as_method: bool,
2344    ) -> Ty {
2345        let is_self = matches!(self.body.expr(receiver), Expr::SelfExpr);
2346        let recv_ty = self.infer_expr(receiver, &Expectation::None);
2347
2348        // `self.member` consults this file's own members first (Playbook §3.2).
2349        if is_self && let Some(item) = self.class.lookup(name) {
2350            return self.own_member_ty(item, as_method);
2351        }
2352
2353        match &recv_ty {
2354            // Uninformative receivers are unchecked and **propagate the seam**: a member of an
2355            // `Unknown` (cross-file) value is itself `Unknown` (never warns), a member of a
2356            // `Variant` is `Variant`, of an `Error` is `Error`. Collapsing `Unknown` to
2357            // `Variant` here would wrongly fire `INFERENCE_ON_VARIANT` on `var x := other.field`.
2358            t if t.is_uninformative() => recv_ty.clone(),
2359            Ty::Object(class) => {
2360                if name == "new" {
2361                    // `Class.new(...)` always constructs an instance of the class (some classes,
2362                    // e.g. GDScript, also carry a modeled `new` member — the constructor wins).
2363                    recv_ty.clone()
2364                } else if let Some(m) = self.api.lookup_member(*class, name) {
2365                    self.check_member_kind_misuse(&m, as_method, name, name_range);
2366                    self.check_static_on_instance(receiver, &m, as_method, name_range);
2367                    self.member_ref_ty(&m, as_method)
2368                } else if let Some(t) = self.class_enum_value(*class, name) {
2369                    // A statically-accessed enum value (`Control.PRESET_FULL_RECT`).
2370                    t
2371                } else {
2372                    // Self with an Object base already checked own members above.
2373                    self.emit_unsafe(name, &recv_ty, name_range, as_method);
2374                    Ty::Variant
2375                }
2376            }
2377            Ty::Builtin(_) | Ty::Array(_) | Ty::Dict(..) | Ty::Callable | Ty::Signal(_) => {
2378                self.builtin_member_ty(&recv_ty, name, name_range, as_method)
2379            }
2380            // Accessing a member of an enum namespace (`State.IDLE`) yields the enum type itself —
2381            // an enum value (freely int-assignable via `ty::is_assignable`). Was `int`, which lost
2382            // the enum type and false-`INFERENCE_ON_VARIANT`'d a same-file `var x := State.IDLE`.
2383            Ty::Enum(er) => Ty::Enum(er.clone()),
2384            // A cross-file script reference: resolve the member against its (own) member table.
2385            Ty::ScriptRef(sref) => self.script_member_ty(*sref, name, as_method),
2386            // An inner-class value/instance: resolve against its own item-tree + `extends` chain.
2387            Ty::InnerClass(iref) => self.inner_class_member_ty(iref, name, as_method),
2388            _ => Ty::Variant,
2389        }
2390    }
2391
2392    /// Resolve `name` on an inner-class value/instance (`Ty::InnerClass`). `Inner.new()` constructs an
2393    /// instance (the same `InnerClass`); otherwise the inner class's own members (typed by their
2394    /// annotation — lossy, like the cross-file `ScriptRef` path: an inferred/unannotated member seams)
2395    /// then its `extends` chain. The seam (`Unknown`) for an unresolved member — never a false
2396    /// `UNSAFE_*`.
2397    fn inner_class_member_ty(
2398        &self,
2399        iref: &crate::ty::InnerClassRef,
2400        name: &str,
2401        as_method: bool,
2402    ) -> Ty {
2403        if name == "new" && as_method {
2404            return Ty::InnerClass(iref.clone());
2405        }
2406        self.inner_member_walk(iref, name, as_method, 0)
2407            .unwrap_or(Ty::Unknown)
2408    }
2409
2410    /// Walk an inner class's own members, then its `extends` base (an engine class, a `class_name`, or
2411    /// another inner/script class), for `name`. Depth-bounded like [`script_member_walk`].
2412    fn inner_member_walk(
2413        &self,
2414        iref: &crate::ty::InnerClassRef,
2415        name: &str,
2416        as_method: bool,
2417        depth: u32,
2418    ) -> Option<Ty> {
2419        if depth > 32 {
2420            return None;
2421        }
2422        let ft = self.db.file_text(FileId(iref.file))?;
2423        let tree = crate::queries::item_tree(self.db, ft);
2424        let inner = find_inner_class(&tree, &iref.path)?;
2425        if let Some(m) = inner.tree.member(name) {
2426            return self.inner_member_item_ty(m, as_method, iref);
2427        }
2428        // Not an own member — walk the inner class's `extends` base.
2429        let res_path = self.self_res_path();
2430        match resolve::resolve_base(self.db, self.api, &inner.tree, res_path.as_deref()) {
2431            Ty::Object(class) => self
2432                .api
2433                .lookup_member(class, name)
2434                .map(|m| self.member_ref_ty(&m, as_method)),
2435            Ty::ScriptRef(base) => self.script_member_walk(base, name, as_method, depth + 1),
2436            Ty::InnerClass(base) => self.inner_member_walk(&base, name, as_method, depth + 1),
2437            _ => None,
2438        }
2439    }
2440
2441    /// Type an inner class's own member by its written **annotation** (the inner body isn't inferred
2442    /// here — Increment 2 adds that). An unannotated `var`/`const` or an untyped `func` return seams.
2443    fn inner_member_item_ty(
2444        &self,
2445        m: &Member,
2446        as_method: bool,
2447        iref: &crate::ty::InnerClassRef,
2448    ) -> Option<Ty> {
2449        Some(match m {
2450            Member::Func(f) => {
2451                if as_method {
2452                    f.return_type.as_deref().map_or(Ty::Variant, |t| {
2453                        resolve::resolve_type_name(self.db, self.api, t)
2454                    })
2455                } else {
2456                    Ty::Callable
2457                }
2458            }
2459            Member::Var(v) => resolve::resolve_type_name(self.db, self.api, v.type_ref.as_deref()?),
2460            Member::Const(c) => {
2461                resolve::resolve_type_name(self.db, self.api, c.type_ref.as_deref()?)
2462            }
2463            Member::Signal(_) => Ty::Signal(None),
2464            Member::Enum(e) => Ty::Enum(EnumRef {
2465                qualified: e.name.clone()?,
2466                bitfield: false,
2467            }),
2468            // A nested inner class → `Ty::InnerClass` with the extended dotted path.
2469            Member::Class(c) => Ty::InnerClass(crate::ty::InnerClassRef {
2470                file: iref.file,
2471                path: SmolStr::new(format!("{}.{}", iref.path, c.name)),
2472            }),
2473        })
2474    }
2475
2476    /// A member of a cross-file script (`ScriptRef`): looked up in the script's own member table
2477    /// (M1). A member we don't model — e.g. one inherited from a base we don't resolve until M2 —
2478    /// yields the seam (`Unknown`), **never** an `UNSAFE_*` warning. `Class.new(...)` constructs
2479    /// an instance of the class.
2480    fn script_member_ty(&self, sref: ScriptRefId, name: &str, as_method: bool) -> Ty {
2481        if name == "new" {
2482            return Ty::ScriptRef(sref);
2483        }
2484        self.script_member_walk(sref, name, as_method, 0)
2485            .unwrap_or(Ty::Unknown)
2486    }
2487
2488    /// Walk a script class's `extends` chain for `name`: own members first, then a user base
2489    /// (another `ScriptRef`), then an engine base (the API table). Depth-bounded so a cyclic
2490    /// `extends` cannot loop. `None` = not found anywhere in the chain (the seam).
2491    fn script_member_walk(
2492        &self,
2493        sref: ScriptRefId,
2494        name: &str,
2495        as_method: bool,
2496        depth: u32,
2497    ) -> Option<Ty> {
2498        if depth > 32 {
2499            return None;
2500        }
2501        let file = self.db.file_text(FileId(sref.0))?;
2502        let sc = crate::queries::script_class(self.db, file);
2503        if let Some(m) = sc.member(name) {
2504            return Some(match m {
2505                crate::queries::MemberSig::Method(ret) => {
2506                    if as_method {
2507                        ret.clone()
2508                    } else {
2509                        Ty::Callable
2510                    }
2511                }
2512                crate::queries::MemberSig::Field(t) => t.clone(),
2513                crate::queries::MemberSig::Signal => Ty::Signal(None),
2514            });
2515        }
2516        // Not an own member — continue up the inheritance chain.
2517        match sc.base() {
2518            Ty::ScriptRef(base) => self.script_member_walk(*base, name, as_method, depth + 1),
2519            Ty::Object(class) => self
2520                .api
2521                .lookup_member(*class, name)
2522                .map(|m| self.member_ref_ty(&m, as_method)),
2523            _ => None,
2524        }
2525    }
2526
2527    /// Whether a value of type `sub` is statically a subtype of `sup` — composing user `ScriptRef`
2528    /// `extends` chains with the engine class table (M4, for `is`/`as` widen-only narrowing). A
2529    /// `ScriptRef` IS-A its native base (so `script_value is Node` holds), but Godot's asymmetry is
2530    /// honored: a native/script value is **not** a subtype of an *unrelated* user script.
2531    fn is_subtype(&self, sub: &Ty, sup: &Ty) -> bool {
2532        match (sub, sup) {
2533            (Ty::Object(a), Ty::Object(b)) => self.api.is_subclass(*a, *b),
2534            (Ty::ScriptRef(a), Ty::ScriptRef(b)) => self.script_is_subtype(*a, *b, 0),
2535            (Ty::ScriptRef(a), Ty::Object(b)) => self.script_extends_engine(*a, *b, 0),
2536            _ => false,
2537        }
2538    }
2539
2540    /// Whether script `sub` is `sup` or transitively extends it — walk the `extends` base chain by
2541    /// script identity (depth-bounded, like [`script_member_walk`](Self::script_member_walk)).
2542    fn script_is_subtype(&self, sub: ScriptRefId, sup: ScriptRefId, depth: u32) -> bool {
2543        if depth > 32 {
2544            return false;
2545        }
2546        if sub == sup {
2547            return true;
2548        }
2549        let Some(file) = self.db.file_text(FileId(sub.0)) else {
2550            return false;
2551        };
2552        match crate::queries::script_class(self.db, file).base() {
2553            Ty::ScriptRef(base) => self.script_is_subtype(*base, sup, depth + 1),
2554            _ => false,
2555        }
2556    }
2557
2558    /// Whether script `sub`'s `extends` chain reaches engine class `sup_native` at its native base.
2559    fn script_extends_engine(
2560        &self,
2561        sub: ScriptRefId,
2562        sup_native: gdscript_api::ClassId,
2563        depth: u32,
2564    ) -> bool {
2565        if depth > 32 {
2566            return false;
2567        }
2568        let Some(file) = self.db.file_text(FileId(sub.0)) else {
2569            return false;
2570        };
2571        match crate::queries::script_class(self.db, file).base() {
2572            Ty::ScriptRef(base) => self.script_extends_engine(*base, sup_native, depth + 1),
2573            Ty::Object(native) => self.api.is_subclass(*native, sup_native),
2574            _ => false,
2575        }
2576    }
2577
2578    fn emit_unsafe(&mut self, name: &str, recv: &Ty, range: TextRange, as_method: bool) {
2579        let recv_label = recv.label(self.api).unwrap_or_else(|| "?".to_owned());
2580        let (code, message) = if as_method {
2581            (
2582                WarningCode::UnsafeMethodAccess,
2583                format!(
2584                    "The method \"{name}()\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
2585                ),
2586            )
2587        } else {
2588            (
2589                WarningCode::UnsafePropertyAccess,
2590                format!(
2591                    "The property \"{name}\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
2592                ),
2593            )
2594        };
2595        self.warn(range, code, message);
2596    }
2597
2598    /// Whether the class's RESOLVED **engine** base declares a *value* member (var/const/signal)
2599    /// named `name` — the sound floor for `SHADOWED_VARIABLE_BASE_CLASS`. Only the engine base is
2600    /// consulted: an unresolved base (the cross-file seam) returns `false` (no warning), and the
2601    /// cross-file *user*-base `MemberSig` is lossy (no kind detail) so user-base shadowing stays
2602    /// deferred (see `TECH_DEBT.md`). Methods are excluded (matches the own-member shadow rule).
2603    fn engine_base_has_value_member(&self, name: &str) -> bool {
2604        let Ty::Object(base) = &self.class.base else {
2605            return false;
2606        };
2607        matches!(
2608            self.api.lookup_member(*base, name),
2609            Some(MemberRef::Property(_) | MemberRef::Const(_) | MemberRef::Signal(_))
2610        )
2611    }
2612
2613    /// Flag a deprecated member-kind misuse on a statically-resolved engine member:
2614    /// `PROPERTY_USED_AS_FUNCTION` / `CONSTANT_USED_AS_FUNCTION` when a property/const is *called*.
2615    /// Guarded against a Callable/Signal/uninformative-typed member (those can legitimately be
2616    /// invoked). `FUNCTION_USED_AS_PROPERTY` is intentionally NOT emitted — a bare `obj.method` is an
2617    /// idiomatic `Callable` reference (every signal `.connect`), indistinguishable from a misuse
2618    /// without call-context, so it would false-positive everywhere (see `TECH_DEBT.md`).
2619    fn check_member_kind_misuse(
2620        &mut self,
2621        m: &MemberRef,
2622        as_method: bool,
2623        name: &str,
2624        range: TextRange,
2625    ) {
2626        if !as_method {
2627            return;
2628        }
2629        let (code, kind, ty) = match m {
2630            MemberRef::Property(p) => (
2631                WarningCode::PropertyUsedAsFunction,
2632                "property",
2633                ty::resolve_tyref(self.api, &p.ty),
2634            ),
2635            MemberRef::Const(c) => (
2636                WarningCode::ConstantUsedAsFunction,
2637                "constant",
2638                ty::resolve_tyref(self.api, &c.ty),
2639            ),
2640            _ => return,
2641        };
2642        // A Callable/Signal-typed (or uninformative) member can be invoked — never flag it.
2643        if ty.is_uninformative() || matches!(ty, Ty::Callable | Ty::Signal(_)) {
2644            return;
2645        }
2646        self.warn(
2647            range,
2648            code,
2649            format!("The {kind} \"{name}\" is being called as if it were a function."),
2650        );
2651    }
2652
2653    /// Flag `STATIC_CALLED_ON_INSTANCE`: an engine static method called through an instance value
2654    /// rather than the type. Conservative + sound — fires only when the receiver is a **typed local
2655    /// instance** (a `Name` bound in `locals`), never a bare class name (`Class.static()` is
2656    /// correct) nor an expression we can't classify. Under-warns by design; zero false positives.
2657    fn check_static_on_instance(
2658        &mut self,
2659        receiver: ExprId,
2660        m: &MemberRef,
2661        as_method: bool,
2662        range: TextRange,
2663    ) {
2664        if !as_method {
2665            return;
2666        }
2667        let MemberRef::Method(sig) = m else {
2668            return;
2669        };
2670        if !sig.is_static {
2671            return;
2672        }
2673        let Expr::Name(rname) = self.body.expr(receiver) else {
2674            return;
2675        };
2676        if !self.locals.contains_key(rname) {
2677            return;
2678        }
2679        // A local that ALIASES a type/var (`var t := JSON; t.stringify()`) is not an instance —
2680        // calling a static method through it is valid (`t` holds the type, not an object). A bare
2681        // `Name` initializer marks such an alias; only a constructor/call init (or a param/field
2682        // with no init) is a true instance. Skipping the alias case fixes a false positive.
2683        if let Some(b) = self.bindings.iter().rev().find(|b| &b.name == rname)
2684            && let Some(init) = b.init
2685            && matches!(self.body.expr(init), Expr::Name(_))
2686        {
2687            return;
2688        }
2689        self.warn(
2690            range,
2691            WarningCode::StaticCalledOnInstance,
2692            "A static method is being called on an instance; call it on the type instead."
2693                .to_owned(),
2694        );
2695    }
2696
2697    fn member_ref_ty(&self, m: &MemberRef, as_method: bool) -> Ty {
2698        match m {
2699            MemberRef::Method(sig) => {
2700                if as_method {
2701                    ty::resolve_tyref(self.api, &sig.return_ty)
2702                } else {
2703                    Ty::Callable
2704                }
2705            }
2706            MemberRef::Property(p) => p.enum_of.as_ref().map_or_else(
2707                || ty::resolve_tyref(self.api, &p.ty),
2708                |q| {
2709                    Ty::Enum(EnumRef {
2710                        qualified: SmolStr::new(q),
2711                        bitfield: false,
2712                    })
2713                },
2714            ),
2715            MemberRef::Const(c) => ty::resolve_tyref(self.api, &c.ty),
2716            MemberRef::Signal(_) => Ty::Signal(None),
2717            MemberRef::Enum(_) => Ty::Variant,
2718        }
2719    }
2720
2721    fn builtin_member_ty(
2722        &mut self,
2723        recv: &Ty,
2724        name: &str,
2725        range: TextRange,
2726        as_method: bool,
2727    ) -> Ty {
2728        let Some(bid) = self.builtin_id_of(recv) else {
2729            return Ty::Variant;
2730        };
2731        if as_method {
2732            return if let Some(sig) = self.api.builtin_method(bid, name) {
2733                ty::resolve_tyref(self.api, &sig.return_ty)
2734            } else {
2735                self.emit_unsafe(name, recv, range, true);
2736                Ty::Variant
2737            };
2738        }
2739        if let Some(member) = self.api.builtin_member(bid, name) {
2740            return ty::resolve_tyref(self.api, &member.ty);
2741        }
2742        // Static constants (`Vector2.ZERO`, `Color.WHITE`) and enum values (`Variant.Type.*`).
2743        let data = self.api.builtin(bid);
2744        if let Some(c) = data.constants.iter().find(|c| c.name == name) {
2745            return ty::resolve_tyref(self.api, &c.ty);
2746        }
2747        if data
2748            .enums
2749            .iter()
2750            .any(|e| e.values.iter().any(|v| v.name == name))
2751        {
2752            return self.int_ty();
2753        }
2754        if self.api.builtin_method(bid, name).is_some() {
2755            return Ty::Callable;
2756        }
2757        self.emit_unsafe(name, recv, range, false);
2758        Ty::Variant
2759    }
2760
2761    /// The type of a class enum **value** accessed statically (`Control.PRESET_FULL_RECT`):
2762    /// the engine exposes enum values as class members, so search every (inherited) enum's
2763    /// values. Returns the value's **declaring enum type** (`Ty::Enum`) — mirroring how a
2764    /// `Class.Enum` *annotation* resolves (`resolve::resolve_named`), so an enum member assigned
2765    /// to a slot of that same enum is `Assign::Ok`, not a false `INT_AS_ENUM_WITHOUT_CAST`. (An
2766    /// enum value is still freely assignable to `int` — see `ty::is_assignable`.)
2767    fn class_enum_value(&self, class: gdscript_api::ClassId, name: &str) -> Option<Ty> {
2768        let mut cur = Some(class);
2769        while let Some(cid) = cur {
2770            let c = self.api.class(cid);
2771            if let Some(e) = c
2772                .enums
2773                .iter()
2774                .find(|e| e.values.iter().any(|v| v.name == name))
2775            {
2776                return Some(Ty::Enum(EnumRef {
2777                    qualified: SmolStr::new(format!("{}.{}", c.name, e.name)),
2778                    bitfield: e.is_bitfield,
2779                }));
2780            }
2781            cur = c.base;
2782        }
2783        None
2784    }
2785
2786    /// The builtin id backing a builtin / `Array` / `Dictionary` receiver.
2787    fn builtin_id_of(&self, ty: &Ty) -> Option<gdscript_api::BuiltinId> {
2788        match ty {
2789            Ty::Builtin(b) => Some(*b),
2790            Ty::Array(_) => self.api.builtin_by_name("Array"),
2791            Ty::Dict(..) => self.api.builtin_by_name("Dictionary"),
2792            Ty::Callable => self.api.builtin_by_name("Callable"),
2793            Ty::Signal(_) => self.api.builtin_by_name("Signal"),
2794            _ => None,
2795        }
2796    }
2797
2798    /// The element type of an indexing expression (Playbook §2 switch).
2799    fn index_ty(&self, base: &Ty) -> Ty {
2800        match base {
2801            Ty::Array(elem) => (**elem).clone(),
2802            Ty::Builtin(b) => self
2803                .api
2804                .builtin(*b)
2805                .indexing_return
2806                .as_ref()
2807                .map_or(Ty::Variant, |r| ty::resolve_tyref(self.api, r)),
2808            // Indexing through the seam stays on the seam (never warns).
2809            Ty::Unknown => Ty::Unknown,
2810            Ty::Error => Ty::Error,
2811            _ => Ty::Variant,
2812        }
2813    }
2814
2815    /// The loop variable's type for `for v in iter:` (Playbook §2 switch).
2816    fn loop_var_ty(&self, iter: &Ty) -> Ty {
2817        match iter {
2818            Ty::Array(elem) => (**elem).clone(),
2819            Ty::Builtin(b) => {
2820                let data = self.api.builtin(*b);
2821                if data.name == "int" {
2822                    // `for i in 5` / `for i in range(...)` → int.
2823                    self.int_ty()
2824                } else if let Some(r) = &data.indexing_return {
2825                    // `for c in "abc"` → String; `for s in packed_string_array` → String; …
2826                    ty::resolve_tyref(self.api, r)
2827                } else {
2828                    Ty::Variant
2829                }
2830            }
2831            // Iterating a seam value keeps the loop var on the seam (never warns).
2832            Ty::Unknown => Ty::Unknown,
2833            Ty::Error => Ty::Error,
2834            _ => Ty::Variant,
2835        }
2836    }
2837
2838    fn infer_lambda(&mut self, params: &[ParamBinding], body: &[body::StmtId]) {
2839        // Lambda params shadow within the body; restore the outer locals afterward. A `return`
2840        // inside the lambda is the *lambda's* return, not the enclosing function's — so disable
2841        // return checking (set the expected return to `Variant`) while walking the body.
2842        let saved_locals = self.locals.clone();
2843        let saved_ret = std::mem::replace(&mut self.return_ty, Ty::Variant);
2844        for p in params {
2845            let ty = self.param_ty(p);
2846            self.bindings.push(Binding {
2847                name: p.name.clone(),
2848                name_range: p.name_range,
2849                ty: ty.clone(),
2850                init: None,
2851                annotated: p.type_ref.is_some(),
2852                inferred_colon_eq: false,
2853                is_const: false,
2854                kind: BindingKind::Param,
2855            });
2856            self.locals.insert(p.name.clone(), ty);
2857        }
2858        self.infer_block(body);
2859        self.return_ty = saved_ret;
2860        self.locals = saved_locals;
2861    }
2862
2863    fn param_ty(&mut self, p: &ParamBinding) -> Ty {
2864        if let Some(ptr) = p.type_ref {
2865            return self.resolve_ptr_ty(ptr);
2866        }
2867        // An unannotated param infers from its default, else `Variant`.
2868        p.default
2869            .map_or(Ty::Variant, |e| self.infer_expr(e, &Expectation::None))
2870    }
2871
2872    // ---- name resolution (local → class member → inherited → global) ----
2873
2874    /// The `Ty`-producing half of the bare-name lookup. Its precedence is the **canonical order**
2875    /// documented on [`crate::def::resolve_name_to_def`] (local → own member → inherited member →
2876    /// engine global → `class_name` global → autoload) — kept in lockstep with that identity-producing
2877    /// copy by the `classify_and_infer_agree_*` tests (gdscript-ide). Unlike that copy, this one is
2878    /// woven with flow-narrowing and the `UNUSED`/`UNASSIGNED` side-effects (it runs mid-inference),
2879    /// which is why the two are intentionally separate functions rather than one.
2880    fn resolve_name(&mut self, id: ExprId, name: &str) -> Ty {
2881        // Record a *read* of a local/param for the `UNUSED_*` analysis (before the narrowing check,
2882        // so a narrowed read still counts as used). The direct LHS of an assignment (`x = …`) is a
2883        // WRITE, not a read — excluding it lets `UNUSED_VARIABLE` catch an assigned-but-never-read
2884        // local (Godot's precise behaviour). A compound `x += …` still reads `x` via its RHS NameRef
2885        // (a distinct expr), and a receiver / index target (`x.f()`, `x[i] = …`) is a read of `x`.
2886        if self.locals.contains_key(name) && !self.assign_lhs.contains(&id) {
2887            self.used_locals.insert(SmolStr::new(name));
2888        }
2889        // UNASSIGNED_VARIABLE (Workstream 2) — a *read* of a typed-no-init local that is not
2890        // definitely assigned on every path reaching here. Excludes the LHS of an assignment (a
2891        // write) and reads inside a lambda body (which `assigned_before` leaves `None`, unchecked).
2892        if self.is_func_body
2893            && self.needs_assignment.contains(name)
2894            && !self.assign_lhs.contains(&id)
2895            && let Some(cur) = self.cur_stmt
2896            && self
2897                .assigned
2898                .assigned_before(cur)
2899                .is_some_and(|a| !a.contains(name))
2900        {
2901            self.warn(
2902                self.range_of(id),
2903                WarningCode::UnassignedVariable,
2904                format!("The variable \"{name}\" may be used before it is assigned a value."),
2905            );
2906        }
2907        // Flow narrowing wins over the binding's declared type.
2908        if let Some(key) = self.narrow_key(id)
2909            && let Some(t) = self.narrowing.get(&key)
2910        {
2911            return t.clone();
2912        }
2913        if let Some(t) = self.locals.get(name) {
2914            return t.clone();
2915        }
2916        if let Some(item) = self.class.lookup(name) {
2917            return self.own_member_ty(item, false);
2918        }
2919        // Inherited members: an engine `Object` base via the API table, or a user `ScriptRef`
2920        // base via the script member walk (M2 — so a class extending another class_name sees its
2921        // inherited members).
2922        match self.class.base.clone() {
2923            Ty::Object(base) => {
2924                if let Some(m) = self.api.lookup_member(base, name) {
2925                    return self.member_ref_ty(&m, false);
2926                }
2927            }
2928            Ty::ScriptRef(base) => {
2929                if let Some(t) = self.script_member_walk(base, name, false, 0) {
2930                    return t;
2931                }
2932            }
2933            _ => {}
2934        }
2935        if let Some(g) = resolve::resolve_global(self.api, name) {
2936            return global_ty(&g);
2937        }
2938        // A project-global `class_name` used as a value — the class itself, for static access
2939        // (`V.fc()`) or as a constructor (`Player.new()`). Resolves to a `ScriptRef` via the
2940        // registry. Precedence (Godot `reduce_identifier`): `class_name` global ≫ autoload
2941        // singleton. So try `class_name` first, then a `*`-autoload, then the seam.
2942        let by_class = resolve::resolve_external(
2943            self.db,
2944            &resolve::ExternalRef::ClassName(SmolStr::new(name)),
2945        );
2946        if !by_class.is_unknown() {
2947            return by_class;
2948        }
2949        resolve::resolve_external(self.db, &resolve::ExternalRef::Autoload(SmolStr::new(name)))
2950    }
2951
2952    fn own_member_ty(&self, item: ClassItem, as_method: bool) -> Ty {
2953        match item {
2954            ClassItem::EnumVariant => self.int_ty(),
2955            ClassItem::Member(_) => match self.class.member(item) {
2956                Some(Member::Var(v)) => self.field_ty(&v.name, v.ptr),
2957                Some(Member::Const(c)) => self.field_ty(&c.name, c.ptr),
2958                Some(Member::Func(f)) => {
2959                    if as_method {
2960                        self.func_return_ty(f.return_type.as_deref())
2961                    } else {
2962                        Ty::Callable
2963                    }
2964                }
2965                Some(Member::Signal(_)) => Ty::Signal(None),
2966                // An inner `class Name:` used as a value → `Ty::InnerClass` (was the `Unknown` seam),
2967                // so `Inner.CONST` / `Inner.new()` / a typed instance's members resolve against its own
2968                // item-tree. The path is the inner class's name (resolved from the top-level scope;
2969                // nested inner classes get their dotted path once inner bodies are inferred).
2970                Some(Member::Class(c)) => match &self.self_ty {
2971                    Ty::ScriptRef(sref) => Ty::InnerClass(crate::ty::InnerClassRef {
2972                        file: sref.0,
2973                        path: c.name.clone(),
2974                    }),
2975                    _ => Ty::Unknown,
2976                },
2977                // A same-file named `enum State` used as a value/namespace → the enum type, so
2978                // `State.IDLE` (member access below) types as `State`, not a false-`INFERENCE_ON_
2979                // VARIANT` seam. An anonymous enum has no namespace name (its variants are direct
2980                // class constants), so it stays the seam.
2981                Some(Member::Enum(e)) => e.name.as_ref().map_or(Ty::Variant, |n| {
2982                    Ty::Enum(EnumRef {
2983                        qualified: n.clone(),
2984                        bitfield: false,
2985                    })
2986                }),
2987                None => Ty::Variant,
2988            },
2989        }
2990    }
2991
2992    /// The type of an own field (`var`/`const`): the type seeded by the field pre-pass (which
2993    /// captures the inferred type of `var n := 0`), falling back to the written annotation.
2994    fn field_ty(&self, name: &str, ptr: AstPtr) -> Ty {
2995        if let Some(t) = self.class.member_types.get(name) {
2996            return t.clone();
2997        }
2998        self.resolve_decl_annotation(ptr)
2999    }
3000
3001    /// Resolve a declaration's annotation (recovering its `TypeRef` node), else `Variant`.
3002    fn resolve_decl_annotation(&self, ptr: AstPtr) -> Ty {
3003        let Some(node) = ptr.to_node(self.root) else {
3004            return Ty::Variant;
3005        };
3006        cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
3007            .map_or(Ty::Variant, |t| {
3008                resolve::resolve_type_ref(self.db, self.api, &t)
3009            })
3010    }
3011
3012    // ---- narrowing ----
3013
3014    /// Build the narrowing env for a statement from the precomputed flow facts (Workstream 2).
3015    ///
3016    /// Only `Is` facts contribute a type (`NotNull`/`Not` are recorded by the flow pass but not yet
3017    /// consumed for typing — the 1.0 cut). The **widen-only + `is_uninformative`** soundness gate is
3018    /// preserved verbatim from the old `apply_narrowing`: `is`-narrowing is a deliberate divergence
3019    /// from upstream Godot (whose `is` does not flow-narrow), kept widen-only so it never produces a
3020    /// type Godot would reject — narrow only when the tested type is a downcast of the place's
3021    /// declared type, or the declared type is uninformative; never un-narrow a known subtype
3022    /// (`d: Derived; if d is Base` keeps `Derived`), never narrow to a type we couldn't resolve.
3023    fn facts_to_narrowing(&self, id: body::StmtId) -> FxHashMap<String, Ty> {
3024        let mut out = FxHashMap::default();
3025        if let Some(facts) = self.flow.facts_before(id) {
3026            for (place, nt) in facts.iter() {
3027                if let Some((key, ty)) = self.narrowing_entry(place, nt) {
3028                    out.insert(key, ty);
3029                }
3030            }
3031        }
3032        out
3033    }
3034
3035    /// Resolve one flow fact into a `(dotted-key, narrowed-type)` narrowing entry, applying the
3036    /// widen-only + `is_uninformative` soundness gate. `None` if the fact doesn't narrow a type
3037    /// (a `NotNull`/`Not`, an unresolvable/uninformative type, or an un-narrowing of a known subtype).
3038    fn narrowing_entry(&self, place: &Place, nt: &NarrowedTy) -> Option<(String, Ty)> {
3039        let NarrowedTy::Is(ptr) = nt else {
3040            return None;
3041        };
3042        let narrowed = self.resolve_ptr_ty(*ptr);
3043        if narrowed.is_uninformative() {
3044            return None;
3045        }
3046        // Gate against a local/param's declared type; for `self`-members / field chains the
3047        // `is_uninformative` check above is the soundness floor.
3048        if let Place::Local(n) = place
3049            && let Some(cur) = self.locals.get(n)
3050            && !cur.is_uninformative()
3051            && !self.is_subtype(&narrowed, cur)
3052        {
3053            return None;
3054        }
3055        Some((place.dotted_key(), narrowed))
3056    }
3057
3058    /// Apply a condition's short-circuit narrowing to the active env, for typing the RHS of an
3059    /// `and`/`or` (Workstream 2): `if x is T and x.method():` narrows `x` for `x.method()`.
3060    fn apply_condition_facts(&mut self, cond: ExprId, truthy: bool) {
3061        for (place, nt) in flow::condition_facts(self.body, cond, truthy) {
3062            if let Some((key, ty)) = self.narrowing_entry(&place, &nt) {
3063                self.narrowing.insert(key, ty);
3064            }
3065        }
3066    }
3067
3068    /// A dotted access-path key for narrowing (`x`, `self.field`, `a.b.c`), or `None` for a
3069    /// non-path expression.
3070    fn narrow_key(&self, id: ExprId) -> Option<String> {
3071        match self.body.expr(id) {
3072            Expr::Name(n) => Some(n.to_string()),
3073            Expr::SelfExpr => Some("self".to_owned()),
3074            Expr::Paren(inner) => self.narrow_key(*inner),
3075            Expr::Field { receiver, name, .. } => {
3076                Some(format!("{}.{name}", self.narrow_key(*receiver)?))
3077            }
3078            _ => None,
3079        }
3080    }
3081
3082    fn resolve_ptr_ty(&self, ptr: AstPtr) -> Ty {
3083        ptr.to_node(self.root).map_or(Ty::Variant, |n| {
3084            resolve::resolve_type_ref(self.db, self.api, &n)
3085        })
3086    }
3087
3088    // ---- helpers ----
3089
3090    /// The join (least upper bound) of two branch types — conservative: equal types collapse,
3091    /// a subtype widens to its supertype, else `Variant`.
3092    ///
3093    /// The three uninformative markers do NOT collapse to `Variant` — that would defeat the
3094    /// seam. They propagate by priority: `Error` (already diagnosed) → `Unknown` (the cross-file
3095    /// seam — must never warn or cascade) → `Variant` (the gradual top). So
3096    /// `x if c else <unknown>` stays `Unknown`, and `var y := (x if c else unknown)` does not
3097    /// fire a false `INFERENCE_ON_VARIANT`.
3098    fn join(&self, a: &Ty, b: &Ty) -> Ty {
3099        if a == b {
3100            return a.clone();
3101        }
3102        if a.is_error() || b.is_error() {
3103            return Ty::Error;
3104        }
3105        if a.is_unknown() || b.is_unknown() {
3106            return Ty::Unknown;
3107        }
3108        if a.is_variant() || b.is_variant() {
3109            return Ty::Variant;
3110        }
3111        if ty::is_assignable(self.api, a, b) == Assign::Ok {
3112            return b.clone();
3113        }
3114        if ty::is_assignable(self.api, b, a) == Assign::Ok {
3115            return a.clone();
3116        }
3117        Ty::Variant
3118    }
3119}
3120
3121/// Map a resolved global definition to the type of a bare reference to it.
3122fn global_ty(g: &GlobalDef) -> Ty {
3123    match g {
3124        GlobalDef::Const(t) => t.clone(),
3125        GlobalDef::Singleton(c) | GlobalDef::ClassType(c) => Ty::Object(*c),
3126        GlobalDef::BuiltinType(b) => Ty::Builtin(*b),
3127        // A bare function referenced as a value is a `Callable`; an enum namespace is opaque.
3128        GlobalDef::Builtin | GlobalDef::Utility => Ty::Callable,
3129        GlobalDef::GlobalEnum => Ty::Variant,
3130    }
3131}
3132
3133fn inference_on_variant_msg(kind: &str) -> String {
3134    format!(
3135        "The {kind} type is being inferred from a Variant value, so it will be typed as Variant."
3136    )
3137}
3138
3139/// The `extension_api.json` operator spelling for a binary operator.
3140fn op_symbol(op: BinOp) -> Option<&'static str> {
3141    Some(match op {
3142        BinOp::Add => "+",
3143        BinOp::Sub => "-",
3144        BinOp::Mul => "*",
3145        BinOp::Div => "/",
3146        BinOp::Mod => "%",
3147        BinOp::Pow => "**",
3148        BinOp::BitAnd => "&",
3149        BinOp::BitOr => "|",
3150        BinOp::BitXor => "^",
3151        BinOp::Shl => "<<",
3152        BinOp::Shr => ">>",
3153        _ => return None,
3154    })
3155}
3156
3157#[cfg(test)]
3158mod tests {
3159    use super::*;
3160    use crate::item_tree::item_tree;
3161    use gdscript_syntax::{SyntaxKind, parse};
3162
3163    struct Harness {
3164        result: InferenceResult,
3165        body: Body,
3166    }
3167
3168    /// Infer the (first) function in `src` against a fresh class scope.
3169    fn infer_first_func(src: &str) -> Harness {
3170        let api = gdscript_api::bundled();
3171        let db = gdscript_db::RootDatabase::default();
3172        let root = parse(src).syntax_node();
3173        let tree = item_tree(&root);
3174        let class = ClassScope::new(&db, api, &tree, None);
3175        let func = gdscript_syntax::ast::descendants(&root)
3176            .into_iter()
3177            .find(|n| n.kind() == SyntaxKind::FuncDecl)
3178            .expect("a function");
3179        let body = body::body_of_func(&func);
3180        let return_ty = cst::first_child(&func, |k| k == SyntaxKind::TypeRef)
3181            .map_or(Ty::Variant, |t| resolve::resolve_type_ref(&db, api, &t));
3182        let result = infer(&db, api, &root, &class, &body, return_ty, true);
3183        Harness { result, body }
3184    }
3185
3186    /// Every code inference produced — the ungated `diagnostics` plus the severity-free
3187    /// `raw_warnings` (the gateable Godot codes, post-W1-M0). Infer-level tests assert what the
3188    /// checker *records*; the gate-level resolution is tested in `crate::warnings`.
3189    /// The opt-in declaration-strictness codes — filtered out of [`codes`] / [`file_codes`] so they
3190    /// don't pollute the hundreds of focused fixtures (they fire on essentially every untyped /
3191    /// inferred local). A test that targets them reads the raw warnings directly (see
3192    /// `untyped_and_inferred_declarations_warn`).
3193    const DECLARATION_STRICTNESS: &[&str] = &["UNTYPED_DECLARATION", "INFERRED_DECLARATION"];
3194
3195    fn codes(h: &Harness) -> Vec<&str> {
3196        h.result
3197            .diagnostics
3198            .iter()
3199            .map(|d| d.code.as_str())
3200            .chain(h.result.raw_warnings.iter().map(|w| w.code.as_str()))
3201            .filter(|c| !DECLARATION_STRICTNESS.contains(c))
3202            .collect()
3203    }
3204
3205    /// Run the whole-file pass (Pass 1 field fixpoint + Pass 2 functions) and collect every
3206    /// diagnostic code (ungated diagnostics + raw gateable warnings). Drives `analyze_file`
3207    /// directly so the bounded member fixpoint runs.
3208    fn file_codes(src: &str) -> Vec<String> {
3209        let api = gdscript_api::bundled();
3210        let db = gdscript_db::RootDatabase::default();
3211        let root = parse(src).syntax_node();
3212        let fi = analyze_file(&db, api, &root, FileId(0));
3213        fi.diagnostics
3214            .iter()
3215            .map(|d| d.code.clone())
3216            .chain(fi.raw_warnings.iter().map(|w| w.code.as_str().to_owned()))
3217            .filter(|c| !DECLARATION_STRICTNESS.contains(&c.as_str()))
3218            .collect()
3219    }
3220
3221    #[test]
3222    fn integer_division_warns() {
3223        let h = infer_first_func("func f():\n\tvar x = 5 / 2\n");
3224        assert!(codes(&h).contains(&INTEGER_DIVISION));
3225    }
3226
3227    #[test]
3228    fn float_div_does_not_warn() {
3229        let h = infer_first_func("func f():\n\tvar x = 5.0 / 2\n");
3230        assert!(!codes(&h).contains(&INTEGER_DIVISION));
3231    }
3232
3233    #[test]
3234    fn type_mismatch_on_hard_annotation() {
3235        let h = infer_first_func("func f():\n\tvar s: String = 5\n");
3236        assert!(codes(&h).contains(&TYPE_MISMATCH));
3237    }
3238
3239    #[test]
3240    fn vector_scalar_compound_assign_is_not_a_mismatch() {
3241        // `v *= 0.5` desugars to `v = v * 0.5` : Vector2 — not the scalar float (the old collapse).
3242        let h = infer_first_func(
3243            "func f() -> Vector2:\n\tvar v := Vector2()\n\tv *= 0.5\n\treturn v\n",
3244        );
3245        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
3246    }
3247
3248    #[test]
3249    fn array_literal_to_packed_array_is_allowed() {
3250        let h = infer_first_func("func f():\n\tvar p: PackedStringArray = [\"a\", \"b\"]\n");
3251        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
3252    }
3253
3254    #[test]
3255    fn vector2i_to_vector2_is_allowed() {
3256        let h = infer_first_func("func f():\n\tvar v: Vector2 = Vector2i(1, 2)\n");
3257        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
3258    }
3259
3260    #[test]
3261    fn local_enum_member_access_types_as_the_enum_not_variant() {
3262        // `var x := State.IDLE` (a same-file enum) infers the enum type, not a Variant seam — so no
3263        // false INFERENCE_ON_VARIANT.
3264        let h = infer_first_func(
3265            "enum State { IDLE, RUN }\nfunc f():\n\tvar x := State.IDLE\n\treturn x\n",
3266        );
3267        assert!(
3268            !codes(&h).contains(&INFERENCE_ON_VARIANT),
3269            "{:?}",
3270            codes(&h)
3271        );
3272    }
3273
3274    #[test]
3275    fn lua_style_dict_key_is_not_an_assignment() {
3276        // `{ pos = "x" }` is a dict entry (key `pos`), not the statement `pos = "x"` — so it must not
3277        // check the value against the member `pos`'s type.
3278        let h = infer_first_func(
3279            "var pos: Vector2\nfunc f():\n\tvar d = { pos = \"x\" }\n\treturn d\n",
3280        );
3281        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
3282    }
3283
3284    #[test]
3285    fn narrowing_conversion_float_to_int() {
3286        let h = infer_first_func("func f():\n\tvar n: int = 1.5\n");
3287        assert!(codes(&h).contains(&NARROWING_CONVERSION));
3288    }
3289
3290    #[test]
3291    fn int_to_float_is_silent() {
3292        let h = infer_first_func("func f():\n\tvar x: float = 3\n\treturn x\n");
3293        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
3294    }
3295
3296    #[test]
3297    fn local_shadowing_a_param_warns_shadowed_variable() {
3298        let h = infer_first_func("func f(x):\n\tvar x = 1\n\treturn x\n");
3299        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
3300    }
3301
3302    #[test]
3303    fn local_shadowing_a_class_member_warns_shadowed_variable() {
3304        // The class scope (built from the whole file) sees the member `health`; the local shadows it.
3305        let h =
3306            infer_first_func("var health = 100\nfunc f():\n\tvar health = 1\n\treturn health\n");
3307        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
3308    }
3309
3310    #[test]
3311    fn non_shadowing_local_does_not_warn_shadowed_variable() {
3312        let h = infer_first_func("func f(x):\n\tvar y = 1\n\treturn x + y\n");
3313        assert!(!codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
3314    }
3315
3316    #[test]
3317    fn local_shadowing_a_base_member_warns_base_class() {
3318        // `position` is a Node2D property; a local of that name shadows the base member.
3319        let h =
3320            infer_first_func("extends Node2D\nfunc f():\n\tvar position = 1\n\treturn position\n");
3321        assert!(
3322            codes(&h).contains(&"SHADOWED_VARIABLE_BASE_CLASS"),
3323            "{:?}",
3324            codes(&h)
3325        );
3326    }
3327
3328    #[test]
3329    fn shadowing_an_unresolved_base_is_silent() {
3330        // No false positive when the base can't be resolved (the cross-file seam).
3331        let h = infer_first_func(
3332            "extends SomeUnknownThirdPartyClass\nfunc f():\n\tvar position = 1\n\treturn position\n",
3333        );
3334        assert!(
3335            !codes(&h).contains(&"SHADOWED_VARIABLE_BASE_CLASS"),
3336            "{:?}",
3337            codes(&h)
3338        );
3339    }
3340
3341    #[test]
3342    fn local_named_after_a_native_class_warns_shadowed_global() {
3343        let h = infer_first_func("func f():\n\tvar Node = 1\n\treturn Node\n");
3344        assert!(
3345            codes(&h).contains(&SHADOWED_GLOBAL_IDENTIFIER),
3346            "{:?}",
3347            codes(&h)
3348        );
3349    }
3350
3351    #[test]
3352    fn param_named_after_a_builtin_type_warns_shadowed_global() {
3353        let h = infer_first_func("func f(Vector2):\n\treturn Vector2\n");
3354        assert!(
3355            codes(&h).contains(&SHADOWED_GLOBAL_IDENTIFIER),
3356            "{:?}",
3357            codes(&h)
3358        );
3359    }
3360
3361    #[test]
3362    fn member_named_after_a_native_class_warns_shadowed_global() {
3363        let cs = file_codes("var Timer = null\n");
3364        assert!(
3365            cs.iter().any(|c| c == "SHADOWED_GLOBAL_IDENTIFIER"),
3366            "{cs:?}"
3367        );
3368    }
3369
3370    #[test]
3371    fn ordinary_local_does_not_warn_shadowed_global() {
3372        // A no-false-positive guard: a normal identifier is not a global.
3373        let h = infer_first_func("func f():\n\tvar count = 1\n\treturn count\n");
3374        assert!(
3375            !codes(&h).contains(&SHADOWED_GLOBAL_IDENTIFIER),
3376            "{:?}",
3377            codes(&h)
3378        );
3379    }
3380
3381    #[test]
3382    fn global_shadow_takes_precedence_over_variable_shadow() {
3383        // A member named after a built-in type (`Color`), shadowed by a local of the same name:
3384        // Godot emits SHADOWED_GLOBAL_IDENTIFIER (global wins), NOT SHADOWED_VARIABLE on the local.
3385        let cs = file_codes("var Color = null\nfunc f():\n\tvar Color = 1\n\treturn Color\n");
3386        assert!(
3387            cs.iter().any(|c| c == "SHADOWED_GLOBAL_IDENTIFIER"),
3388            "{cs:?}"
3389        );
3390        assert!(
3391            !cs.iter().any(|c| c == "SHADOWED_VARIABLE"),
3392            "the local's variable-shadow must be suppressed in favor of the global one: {cs:?}"
3393        );
3394    }
3395
3396    #[test]
3397    fn assert_true_warns_always_true() {
3398        let h = infer_first_func("func f():\n\tassert(true)\n");
3399        assert!(codes(&h).contains(&"ASSERT_ALWAYS_TRUE"), "{:?}", codes(&h));
3400    }
3401
3402    #[test]
3403    fn assert_false_warns_always_false() {
3404        let h = infer_first_func("func f():\n\tassert(false, \"nope\")\n");
3405        assert!(
3406            codes(&h).contains(&"ASSERT_ALWAYS_FALSE"),
3407            "{:?}",
3408            codes(&h)
3409        );
3410    }
3411
3412    #[test]
3413    fn assert_null_warns_always_false() {
3414        let h = infer_first_func("func f():\n\tassert(null)\n");
3415        assert!(
3416            codes(&h).contains(&"ASSERT_ALWAYS_FALSE"),
3417            "{:?}",
3418            codes(&h)
3419        );
3420    }
3421
3422    #[test]
3423    fn assert_on_a_variable_is_silent() {
3424        // No false positive: a runtime condition is not a constant.
3425        let h = infer_first_func("func f(x):\n\tassert(x)\n");
3426        assert!(
3427            !codes(&h).iter().any(|c| c.starts_with("ASSERT_ALWAYS")),
3428            "{:?}",
3429            codes(&h)
3430        );
3431    }
3432
3433    #[test]
3434    fn untyped_and_inferred_declarations_warn() {
3435        // The opt-in (default IGNORE) declaration-strictness codes, read from the raw warnings —
3436        // `codes()` filters them out so they don't pollute every other fixture.
3437        let h = infer_first_func("func f(p):\n\tvar a = 1\n\tvar b := 2\n\tvar c: int = 3\n");
3438        let raw: Vec<&str> = h
3439            .result
3440            .raw_warnings
3441            .iter()
3442            .map(|w| w.code.as_str())
3443            .collect();
3444        // The untyped param `p` and untyped `var a` — not the typed `var c` nor the inferred `var b`.
3445        let untyped = raw.iter().filter(|c| **c == "UNTYPED_DECLARATION").count();
3446        assert_eq!(untyped, 2, "only `p` and `a` are untyped: {raw:?}");
3447        let inferred = raw.iter().filter(|c| **c == "INFERRED_DECLARATION").count();
3448        assert_eq!(inferred, 1, "only `b` uses `:=`: {raw:?}");
3449    }
3450
3451    #[test]
3452    fn confusable_identifier_warns_on_a_mixed_script_local() {
3453        // `p\u{0430}ypal` — Latin letters with a Cyrillic `а` (U+0430): a homoglyph of ASCII `paypal`.
3454        let h = infer_first_func("func f():\n\tvar p\u{0430}ypal = 1\n\treturn p\u{0430}ypal\n");
3455        assert!(
3456            codes(&h).contains(&"CONFUSABLE_IDENTIFIER"),
3457            "{:?}",
3458            codes(&h)
3459        );
3460    }
3461
3462    #[test]
3463    fn an_ordinary_ascii_identifier_is_not_confusable() {
3464        let h = infer_first_func("func f():\n\tvar paypal = 1\n\treturn paypal\n");
3465        assert!(
3466            !codes(&h).contains(&"CONFUSABLE_IDENTIFIER"),
3467            "{:?}",
3468            codes(&h)
3469        );
3470    }
3471
3472    #[test]
3473    fn a_member_with_a_confusable_name_warns() {
3474        // Cyrillic `а` inside `balance`.
3475        let cs = file_codes("var b\u{0430}lance = 0\n");
3476        assert!(cs.iter().any(|c| c == "CONFUSABLE_IDENTIFIER"), "{cs:?}");
3477    }
3478
3479    #[test]
3480    fn an_assigned_but_never_read_local_is_unused() {
3481        // Precise read-vs-write: `x` is only assigned, never read → UNUSED_VARIABLE.
3482        let h = infer_first_func("func f():\n\tvar x = 1\n\tx = 2\n");
3483        assert!(codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
3484    }
3485
3486    #[test]
3487    fn a_read_local_is_not_unused() {
3488        let h = infer_first_func("func f() -> int:\n\tvar x = 1\n\tx = 2\n\treturn x\n");
3489        assert!(!codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
3490    }
3491
3492    #[test]
3493    fn unused_private_class_variable_warns() {
3494        let cs = file_codes("var _cache = 0\nfunc f():\n\tpass\n");
3495        assert!(
3496            cs.iter().any(|c| c == "UNUSED_PRIVATE_CLASS_VARIABLE"),
3497            "{cs:?}"
3498        );
3499    }
3500
3501    #[test]
3502    fn a_read_private_class_variable_is_silent() {
3503        let cs = file_codes("var _cache = 0\nfunc f() -> int:\n\treturn _cache\n");
3504        assert!(
3505            !cs.iter().any(|c| c == "UNUSED_PRIVATE_CLASS_VARIABLE"),
3506            "{cs:?}"
3507        );
3508    }
3509
3510    #[test]
3511    fn an_exported_private_var_is_not_unused_private() {
3512        // No false positive: an `@export`'d `_`-var is set externally (inspector / scene).
3513        let cs = file_codes("@export var _hidden = 0\n");
3514        assert!(
3515            !cs.iter().any(|c| c == "UNUSED_PRIVATE_CLASS_VARIABLE"),
3516            "{cs:?}"
3517        );
3518    }
3519
3520    #[test]
3521    fn onready_with_export_warns() {
3522        let cs = file_codes("@onready @export var n = null\n");
3523        assert!(cs.iter().any(|c| c == "ONREADY_WITH_EXPORT"), "{cs:?}");
3524    }
3525
3526    #[test]
3527    fn redundant_static_unload_warns_without_a_static_var() {
3528        let cs = file_codes("@static_unload\nclass_name Foo\nvar x = 1\n");
3529        assert!(cs.iter().any(|c| c == "REDUNDANT_STATIC_UNLOAD"), "{cs:?}");
3530    }
3531
3532    #[test]
3533    fn static_unload_with_a_static_var_is_silent() {
3534        let cs = file_codes("@static_unload\nstatic var pool = []\n");
3535        assert!(!cs.iter().any(|c| c == "REDUNDANT_STATIC_UNLOAD"), "{cs:?}");
3536    }
3537
3538    #[test]
3539    fn typed_local_read_before_assignment_warns() {
3540        let h = infer_first_func("func f() -> int:\n\tvar x: int\n\treturn x\n");
3541        assert!(
3542            codes(&h).contains(&"UNASSIGNED_VARIABLE"),
3543            "{:?}",
3544            codes(&h)
3545        );
3546    }
3547
3548    #[test]
3549    fn typed_local_assigned_then_read_does_not_warn() {
3550        let h = infer_first_func("func f() -> int:\n\tvar x: int\n\tx = 5\n\treturn x\n");
3551        assert!(
3552            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
3553            "{:?}",
3554            codes(&h)
3555        );
3556    }
3557
3558    #[test]
3559    fn typed_local_with_initializer_is_not_unassigned() {
3560        let h = infer_first_func("func f() -> int:\n\tvar x: int = 0\n\treturn x\n");
3561        assert!(
3562            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
3563            "{:?}",
3564            codes(&h)
3565        );
3566    }
3567
3568    #[test]
3569    fn untyped_local_is_not_unassigned_checked() {
3570        // An untyped `var x` is not an UNASSIGNED_VARIABLE candidate (no declared slot type).
3571        let h = infer_first_func("func f():\n\tvar x\n\tvar y = x\n\treturn y\n");
3572        assert!(
3573            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
3574            "{:?}",
3575            codes(&h)
3576        );
3577    }
3578
3579    #[test]
3580    fn typed_local_assigned_in_all_branches_then_read_does_not_warn() {
3581        // Both branches assign before the merge ⇒ definitely assigned ⇒ no warning (the join).
3582        let h = infer_first_func(
3583            "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",
3584        );
3585        assert!(
3586            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
3587            "{:?}",
3588            codes(&h)
3589        );
3590    }
3591
3592    #[test]
3593    fn typed_local_assigned_in_one_branch_then_read_warns() {
3594        // Assigned only in the `then` branch ⇒ may be unassigned at the read ⇒ warns (matches Godot).
3595        let h =
3596            infer_first_func("func f(c) -> int:\n\tvar x: int\n\tif c:\n\t\tx = 1\n\treturn x\n");
3597        assert!(
3598            codes(&h).contains(&"UNASSIGNED_VARIABLE"),
3599            "{:?}",
3600            codes(&h)
3601        );
3602    }
3603
3604    #[test]
3605    fn arm_after_wildcard_is_unreachable_pattern() {
3606        let h =
3607            infer_first_func("func f(x):\n\tmatch x:\n\t\t_:\n\t\t\tpass\n\t\t1:\n\t\t\tpass\n");
3608        assert!(
3609            codes(&h).contains(&"UNREACHABLE_PATTERN"),
3610            "{:?}",
3611            codes(&h)
3612        );
3613    }
3614
3615    #[test]
3616    fn arm_after_var_bind_is_unreachable_pattern() {
3617        let h = infer_first_func(
3618            "func f(x):\n\tmatch x:\n\t\tvar y:\n\t\t\treturn y\n\t\t1:\n\t\t\tpass\n",
3619        );
3620        assert!(
3621            codes(&h).contains(&"UNREACHABLE_PATTERN"),
3622            "{:?}",
3623            codes(&h)
3624        );
3625    }
3626
3627    #[test]
3628    fn arm_before_wildcard_is_not_unreachable() {
3629        let h =
3630            infer_first_func("func f(x):\n\tmatch x:\n\t\t1:\n\t\t\tpass\n\t\t_:\n\t\t\tpass\n");
3631        assert!(
3632            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
3633            "{:?}",
3634            codes(&h)
3635        );
3636    }
3637
3638    #[test]
3639    fn guarded_wildcard_is_not_a_catch_all() {
3640        // `_ when c:` is conditional — a following arm is NOT unreachable.
3641        let h = infer_first_func(
3642            "func f(x, c):\n\tmatch x:\n\t\t_ when c:\n\t\t\tpass\n\t\t1:\n\t\t\tpass\n",
3643        );
3644        assert!(
3645            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
3646            "{:?}",
3647            codes(&h)
3648        );
3649    }
3650
3651    #[test]
3652    fn multi_pattern_with_wildcard_is_conservatively_not_catch_all() {
3653        // `1, _:` IS a catch-all in Godot, but we conservatively under-warn (no false positive).
3654        let h =
3655            infer_first_func("func f(x):\n\tmatch x:\n\t\t1, _:\n\t\t\tpass\n\t\t2:\n\t\t\tpass\n");
3656        assert!(
3657            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
3658            "{:?}",
3659            codes(&h)
3660        );
3661    }
3662
3663    #[test]
3664    fn enum_local_without_default_warns() {
3665        let h = infer_first_func("func f():\n\tvar m: Tween.TweenProcessMode\n");
3666        assert!(
3667            codes(&h).contains(&"ENUM_VARIABLE_WITHOUT_DEFAULT"),
3668            "{:?}",
3669            codes(&h)
3670        );
3671    }
3672
3673    #[test]
3674    fn enum_member_without_default_warns() {
3675        let codes = file_codes("var err: Error\nfunc f():\n\tpass\n");
3676        assert!(
3677            codes.iter().any(|c| c == "ENUM_VARIABLE_WITHOUT_DEFAULT"),
3678            "{codes:?}"
3679        );
3680    }
3681
3682    #[test]
3683    fn native_virtual_override_with_clashing_param_type_warns() {
3684        // `_input(event: InputEvent)` is a Node virtual; `event: int` is an incompatible override.
3685        let codes = file_codes("extends Node\nfunc _input(event: int):\n\tpass\n");
3686        assert!(
3687            codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
3688            "{codes:?}"
3689        );
3690    }
3691
3692    #[test]
3693    fn native_virtual_override_with_correct_param_type_does_not_warn() {
3694        let codes = file_codes("extends Node\nfunc _input(event: InputEvent):\n\tpass\n");
3695        assert!(
3696            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
3697            "{codes:?}"
3698        );
3699    }
3700
3701    #[test]
3702    fn native_virtual_override_with_untyped_param_does_not_warn() {
3703        let codes = file_codes("extends Node\nfunc _input(event):\n\tpass\n");
3704        assert!(
3705            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
3706            "{codes:?}"
3707        );
3708    }
3709
3710    #[test]
3711    fn a_non_virtual_method_is_not_a_native_override() {
3712        let codes = file_codes("extends Node\nfunc my_helper(x: int):\n\treturn x\n");
3713        assert!(
3714            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
3715            "{codes:?}"
3716        );
3717    }
3718
3719    #[test]
3720    fn dotted_enum_override_param_does_not_false_warn() {
3721        // A valid override whose param is a dotted engine enum must NOT clash (enums are int-backed
3722        // and resolve to different qualified names on the annotation vs model side). Bug-hunt repro.
3723        let codes = file_codes(
3724            "extends MultiplayerPeerExtension\nfunc _set_transfer_mode(p_mode: MultiplayerPeer.TransferMode):\n\tpass\n",
3725        );
3726        assert!(
3727            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
3728            "{codes:?}"
3729        );
3730    }
3731
3732    #[test]
3733    fn unused_signal_warns() {
3734        let codes = file_codes("signal my_event\nfunc f():\n\tpass\n");
3735        assert!(codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
3736    }
3737
3738    #[test]
3739    fn emitted_signal_is_not_unused() {
3740        let codes = file_codes("signal my_event\nfunc f():\n\tmy_event.emit()\n");
3741        assert!(!codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
3742    }
3743
3744    #[test]
3745    fn signal_connected_by_string_is_not_unused() {
3746        let codes = file_codes("signal my_event\nfunc f():\n\tconnect(\"my_event\", Callable())\n");
3747        assert!(!codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
3748    }
3749
3750    #[test]
3751    fn enum_local_with_default_does_not_warn() {
3752        let h = infer_first_func(
3753            "func f():\n\tvar m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE\n\treturn m\n",
3754        );
3755        assert!(
3756            !codes(&h).contains(&"ENUM_VARIABLE_WITHOUT_DEFAULT"),
3757            "{:?}",
3758            codes(&h)
3759        );
3760    }
3761
3762    #[test]
3763    fn static_method_on_instance_warns() {
3764        // `JSON.stringify` is static; calling it through a JSON *instance* warns.
3765        let h =
3766            infer_first_func("func f():\n\tvar j := JSON.new()\n\tj.stringify({})\n\treturn j\n");
3767        assert!(
3768            codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
3769            "{:?}",
3770            codes(&h)
3771        );
3772    }
3773
3774    #[test]
3775    fn static_method_on_the_type_does_not_warn() {
3776        // `JSON.stringify(...)` (on the type) is the correct form — never flagged.
3777        let h = infer_first_func("func f():\n\tJSON.stringify({})\n");
3778        assert!(
3779            !codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
3780            "{:?}",
3781            codes(&h)
3782        );
3783    }
3784
3785    #[test]
3786    fn static_method_through_a_type_aliased_local_does_not_warn() {
3787        // `var t := JSON` aliases the TYPE; `t.stringify()` is valid, not static-on-instance.
3788        let h = infer_first_func("func f():\n\tvar t := JSON\n\tt.stringify({})\n");
3789        assert!(
3790            !codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
3791            "{:?}",
3792            codes(&h)
3793        );
3794    }
3795
3796    #[test]
3797    fn property_called_as_function_warns() {
3798        // `n.name` is a Node property; calling it is PROPERTY_USED_AS_FUNCTION.
3799        let h = infer_first_func("func f(n: Node):\n\tn.name()\n");
3800        assert!(
3801            codes(&h).contains(&"PROPERTY_USED_AS_FUNCTION"),
3802            "{:?}",
3803            codes(&h)
3804        );
3805    }
3806
3807    #[test]
3808    fn constant_called_as_function_warns() {
3809        // `NOTIFICATION_READY` is a Node constant; calling it is CONSTANT_USED_AS_FUNCTION.
3810        let h = infer_first_func("func f(n: Node):\n\tn.NOTIFICATION_READY()\n");
3811        assert!(
3812            codes(&h).contains(&"CONSTANT_USED_AS_FUNCTION"),
3813            "{:?}",
3814            codes(&h)
3815        );
3816    }
3817
3818    #[test]
3819    fn calling_a_real_method_is_not_a_kind_misuse() {
3820        let h = infer_first_func("func f(n: Node):\n\tn.get_parent()\n");
3821        assert!(
3822            codes(&h).iter().all(|c| !c.ends_with("_USED_AS_FUNCTION")),
3823            "{:?}",
3824            codes(&h)
3825        );
3826    }
3827
3828    #[test]
3829    fn reading_a_property_as_a_value_is_not_a_kind_misuse() {
3830        let h = infer_first_func("func f(n: Node):\n\tvar s = n.name\n\treturn s\n");
3831        assert!(
3832            codes(&h).iter().all(|c| !c.ends_with("_USED_AS_FUNCTION")),
3833            "{:?}",
3834            codes(&h)
3835        );
3836    }
3837
3838    #[test]
3839    fn enum_member_into_its_own_enum_slot_is_not_int_as_enum() {
3840        // `var m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE` is valid GDScript with no
3841        // cast — the enum member must type as its enum (not bare `int`), so `check_assign` sees
3842        // `Enum → Enum` (Ok). A regression here would false-warn on extremely common engine code.
3843        let h = infer_first_func(
3844            "func f():\n\tvar m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE\n\treturn m\n",
3845        );
3846        assert!(
3847            !codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
3848            "{:?}",
3849            codes(&h)
3850        );
3851    }
3852
3853    #[test]
3854    fn bare_int_into_enum_slot_still_warns() {
3855        // The fix must not over-suppress: a genuine uncast `int` into an enum slot still warns.
3856        let h = infer_first_func("func f():\n\tvar m: Tween.TweenProcessMode = 0\n\treturn m\n");
3857        assert!(
3858            codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
3859            "{:?}",
3860            codes(&h)
3861        );
3862    }
3863
3864    #[test]
3865    fn member_access_resolves_engine_property() {
3866        // In a Node script, bare `get_node(...)` resolves via the inherited base to Object(Node);
3867        // `get_parent()` is a real Node method → no UNSAFE.
3868        let h = infer_first_func(
3869            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.get_parent()\n",
3870        );
3871        assert!(
3872            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
3873            "{:?}",
3874            h.result.diagnostics
3875        );
3876    }
3877
3878    #[test]
3879    fn unsafe_method_on_known_type() {
3880        let h = infer_first_func(
3881            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.totally_bogus_method()\n",
3882        );
3883        assert!(
3884            codes(&h).contains(&UNSAFE_METHOD_ACCESS),
3885            "{:?}",
3886            h.result.diagnostics
3887        );
3888    }
3889
3890    #[test]
3891    fn is_narrowing_suppresses_unsafe() {
3892        // Without narrowing, `x.free()` on an untyped param would be unchecked anyway; with
3893        // `is Node` it is checked against Node and `free` IS a Node method → no UNSAFE.
3894        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.queue_free()\n");
3895        assert!(
3896            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
3897            "{:?}",
3898            h.result.diagnostics
3899        );
3900    }
3901
3902    #[test]
3903    fn is_narrowing_flags_real_missing_member() {
3904        // After `is Node`, x is Node; `.bogus()` is genuinely missing → UNSAFE.
3905        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.bogus_method()\n");
3906        assert!(codes(&h).contains(&UNSAFE_METHOD_ACCESS));
3907    }
3908
3909    #[test]
3910    fn early_return_is_guard_narrows_past_the_guard() {
3911        // `if not (x is Node): return` — the only non-returning path proves x is Node, so after the
3912        // guard a real Node method is safe and a missing one warns (Workstream 2, beats the engine).
3913        let safe =
3914            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.get_parent()\n");
3915        assert!(
3916            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
3917            "real Node method must not warn after the guard: {:?}",
3918            codes(&safe)
3919        );
3920        let bogus =
3921            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.bogus_method()\n");
3922        assert!(
3923            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
3924            "missing method must warn after the guard: {:?}",
3925            codes(&bogus)
3926        );
3927    }
3928
3929    #[test]
3930    fn and_short_circuit_narrows_the_rhs() {
3931        // `x is Node and x.<m>()` types the RHS under x: Node — a real method is safe, a missing
3932        // one warns. The engine does not narrow here (Workstream 2, beats the engine).
3933        let safe = infer_first_func("func f(x):\n\tif x is Node and x.get_parent():\n\t\tpass\n");
3934        assert!(
3935            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
3936            "real Node method in the and-rhs must not warn: {:?}",
3937            codes(&safe)
3938        );
3939        let bogus =
3940            infer_first_func("func f(x):\n\tif x is Node and x.bogus_method():\n\t\tpass\n");
3941        assert!(
3942            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
3943            "missing method in the and-rhs must warn: {:?}",
3944            codes(&bogus)
3945        );
3946    }
3947
3948    // ---- Workstream 1 M1: self-contained checks ----
3949
3950    #[test]
3951    fn empty_file_warns() {
3952        assert!(file_codes("").iter().any(|c| c == "EMPTY_FILE"));
3953        assert!(
3954            file_codes("# just a comment\n")
3955                .iter()
3956                .any(|c| c == "EMPTY_FILE")
3957        );
3958        assert!(
3959            file_codes("extends Node\n")
3960                .iter()
3961                .all(|c| c != "EMPTY_FILE")
3962        );
3963    }
3964
3965    #[test]
3966    fn unused_variable_and_parameter() {
3967        let h = infer_first_func("func f(unused_p):\n\tvar unused_v = 1\n");
3968        assert!(codes(&h).contains(&"UNUSED_PARAMETER"), "{:?}", codes(&h));
3969        assert!(codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
3970        // A used binding does not warn; a `_`-prefixed one is intentionally ignored.
3971        let used = infer_first_func("func f(p):\n\tvar v = p\n\treturn v\n");
3972        assert!(codes(&used).iter().all(|c| !c.starts_with("UNUSED")));
3973        let underscored = infer_first_func("func f(_ignored):\n\tpass\n");
3974        assert!(!codes(&underscored).contains(&"UNUSED_PARAMETER"));
3975    }
3976
3977    #[test]
3978    fn standalone_expression_and_ternary() {
3979        let expr = infer_first_func("func f(a, b):\n\ta + b\n");
3980        assert!(
3981            codes(&expr).contains(&"STANDALONE_EXPRESSION"),
3982            "{:?}",
3983            codes(&expr)
3984        );
3985        let tern = infer_first_func("func f(c):\n\t1 if c else 2\n");
3986        assert!(
3987            codes(&tern).contains(&"STANDALONE_TERNARY"),
3988            "{:?}",
3989            codes(&tern)
3990        );
3991        // A call statement has an effect — never flagged.
3992        let call = infer_first_func("func f(n):\n\tn.queue_free()\n");
3993        assert!(codes(&call).iter().all(|c| !c.starts_with("STANDALONE")));
3994    }
3995
3996    #[test]
3997    fn unreachable_code_after_return() {
3998        let h = infer_first_func("func f():\n\treturn\n\tprint(\"dead\")\n");
3999        assert!(codes(&h).contains(&"UNREACHABLE_CODE"), "{:?}", codes(&h));
4000    }
4001
4002    #[test]
4003    fn incompatible_ternary_warns() {
4004        // `"s" if c else 1` — String vs int, no common type.
4005        let h = infer_first_func("func f(c):\n\tvar x = \"s\" if c else 1\n\treturn x\n");
4006        assert!(
4007            codes(&h).contains(&"INCOMPATIBLE_TERNARY"),
4008            "{:?}",
4009            codes(&h)
4010        );
4011    }
4012
4013    #[test]
4014    fn variant_receiver_never_unsafe() {
4015        // Untyped param → Variant receiver → unchecked, no diagnostic.
4016        let h = infer_first_func("func f(x):\n\tx.anything_at_all()\n");
4017        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
4018    }
4019
4020    #[test]
4021    fn unsafe_call_argument_on_variant_into_typed_param() {
4022        // Passing an untyped (Variant) value to a typed own-method parameter needs an unsafe cast.
4023        let h = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n: Node2D):\n\tpass\n");
4024        assert!(
4025            codes(&h).contains(&UNSAFE_CALL_ARGUMENT),
4026            "{:?}",
4027            h.result.diagnostics
4028        );
4029    }
4030
4031    #[test]
4032    fn unsafe_call_argument_silent_on_safe_and_untyped() {
4033        // A subtype arg (upcast) is safe; an untyped parameter accepts anything — neither warns.
4034        let upcast =
4035            infer_first_func("func f(n: Node2D):\n\ttake(n)\nfunc take(n: Node):\n\tpass\n");
4036        assert!(
4037            !codes(&upcast).contains(&UNSAFE_CALL_ARGUMENT),
4038            "upcast is safe: {:?}",
4039            upcast.result.diagnostics
4040        );
4041        let untyped = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n):\n\tpass\n");
4042        assert!(
4043            !codes(&untyped).contains(&UNSAFE_CALL_ARGUMENT),
4044            "untyped param accepts anything: {:?}",
4045            untyped.result.diagnostics
4046        );
4047    }
4048
4049    #[test]
4050    fn inference_on_variant() {
4051        // `:=` from an untyped (Variant) param.
4052        let h = infer_first_func("func f(x):\n\tvar y := x\n");
4053        assert!(codes(&h).contains(&INFERENCE_ON_VARIANT));
4054    }
4055
4056    #[test]
4057    fn field_inferred_from_earlier_field_is_typed() {
4058        // W2-MEMBER-FIXPOINT: `b`'s initializer references the earlier field `a`. A single shallow
4059        // field pass would see `a` as `Variant` (seam) and fire INFERENCE_ON_VARIANT on `:= a`; the
4060        // bounded fixpoint seeds `a: int` so `a + 1` is `int` and `:=` is precise — no warning.
4061        let codes = file_codes("var a := 1\nvar b := a + 1\n");
4062        assert!(
4063            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
4064            "field `b` from earlier field `a` should type as int, not Variant: {codes:?}"
4065        );
4066    }
4067
4068    #[test]
4069    fn field_forward_reference_is_seamed_not_warned() {
4070        // A field referencing a *later* field still resolves through the fixpoint (both rounds
4071        // see each other's seeded type), and at worst lands on the conservative seam — never a
4072        // false INFERENCE_ON_VARIANT. (`b` precedes `a` lexically here.)
4073        let codes = file_codes("var b := a\nvar a := 1\n");
4074        assert!(
4075            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
4076            "forward field reference must not false-warn: {codes:?}"
4077        );
4078    }
4079
4080    #[test]
4081    fn standalone_inferred_field_unchanged() {
4082        // No-regression: a self-contained inferred field still types from its literal, no warning.
4083        let codes = file_codes("var n := 0\n");
4084        assert!(
4085            codes.is_empty(),
4086            "a literal-initialised field should produce no diagnostics: {codes:?}"
4087        );
4088    }
4089
4090    #[test]
4091    fn lambda_var_is_callable_not_variant() {
4092        let h = infer_first_func("func f():\n\tvar cb := func():\n\t\tpass\n");
4093        assert!(
4094            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4095            "{:?}",
4096            h.result.diagnostics
4097        );
4098    }
4099
4100    #[test]
4101    fn multiline_lambda_then_paren_line_no_false_warning() {
4102        // A multi-line lambda bound to a var, followed by a statement that begins with `(`.
4103        // The parser now ends the lambda at its dedent (the `(` line is its own statement), so
4104        // there is no spurious call-on-lambda and no false `INFERENCE_ON_VARIANT`.
4105        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";
4106        let h = infer_first_func(src);
4107        assert!(
4108            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4109            "{:?}",
4110            h.result.diagnostics
4111        );
4112    }
4113
4114    #[test]
4115    fn calling_a_callable_value_is_seam_not_variant() {
4116        // Invoking an arbitrary expression (here a parenthesized `Callable` value) reaches the
4117        // seam arm of `infer_call`: the return type isn't tracked, so the result is Unknown,
4118        // not `Variant`, and the inferred-on-Variant warning never fires.
4119        let src = "func f(cb: Callable):\n\tvar x := (cb)()\n\treturn x\n";
4120        let h = infer_first_func(src);
4121        assert!(
4122            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4123            "{:?}",
4124            h.result.diagnostics
4125        );
4126    }
4127
4128    #[test]
4129    fn ternary_with_seam_branch_does_not_collapse_to_variant() {
4130        // A ternary whose else-branch is the seam (`await` is untracked → Unknown) must `join`
4131        // to Unknown, NOT Variant — otherwise `var x := …` fires a false INFERENCE_ON_VARIANT.
4132        // (Regression: `join` used to absorb any uninformative branch to Variant.)
4133        let src =
4134            "func f(c: bool):\n\tvar x := 5 if c else await get_tree().process_frame\n\treturn x\n";
4135        let h = infer_first_func(src);
4136        assert!(
4137            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4138            "seam branch should keep the ternary on the seam: {:?}",
4139            h.result.diagnostics
4140        );
4141    }
4142
4143    #[test]
4144    fn await_a_coroutine_call_recovers_its_return_type() {
4145        // `await f()` yields the call's value, so await is identity on a non-signal operand:
4146        // `await make()` for `func make() -> int` types `x` as int (was the seam before).
4147        let src = "func g() -> int:\n\tvar x := await make()\n\treturn x\nfunc make() -> int:\n\treturn 5\n";
4148        let h = infer_first_func(src);
4149        assert!(
4150            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4151            "no false variant warning: {:?}",
4152            h.result.diagnostics
4153        );
4154        let api = gdscript_api::bundled();
4155        let x = &h.result.bindings[0];
4156        assert!(
4157            matches!(&x.ty, Ty::Builtin(b) if api.builtin(*b).name == "int"),
4158            "await make() should recover int, got {:?}",
4159            x.ty
4160        );
4161    }
4162
4163    #[test]
4164    fn await_a_signal_stays_the_seam() {
4165        // `await sig` yields the signal's payload (needs the Phase-3+ sig table) — must stay the seam,
4166        // never the Signal type itself, and never a false INFERENCE_ON_VARIANT.
4167        let src = "func f():\n\tvar x := await get_tree().process_frame\n\treturn x\n";
4168        let h = infer_first_func(src);
4169        assert!(
4170            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4171            "awaiting a signal must not warn: {:?}",
4172            h.result.diagnostics
4173        );
4174        assert!(
4175            matches!(&h.result.bindings[0].ty, Ty::Unknown),
4176            "awaiting a signal stays the seam, got {:?}",
4177            h.result.bindings[0].ty
4178        );
4179    }
4180
4181    #[test]
4182    fn for_var_over_packed_string_array_is_string() {
4183        // `for s in "a,b".split(",")` iterates a PackedStringArray → String, so `s.to_int()`
4184        // resolves and `var x := s` does not warn.
4185        let h = infer_first_func("func f():\n\tfor s in \"a,b\".split(\",\"):\n\t\tvar x := s\n");
4186        assert!(
4187            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4188            "{:?}",
4189            h.result.diagnostics
4190        );
4191    }
4192
4193    #[test]
4194    fn class_new_is_object_not_variant() {
4195        let h = infer_first_func("func f():\n\tvar s := GDScript.new()\n");
4196        assert!(
4197            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4198            "{:?}",
4199            h.result.diagnostics
4200        );
4201    }
4202
4203    #[test]
4204    fn unknown_seam_never_warns() {
4205        // `preload(...)` is Unknown; `:=` from it does NOT warn, and member access is unchecked.
4206        let h = infer_first_func("func f():\n\tvar s := preload(\"res://x.gd\")\n\ts.whatever()\n");
4207        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
4208    }
4209
4210    #[test]
4211    fn expr_types_are_memoized_for_hover() {
4212        let h = infer_first_func("func f():\n\tvar n := 42\n");
4213        // The `42` literal expr should be typed int.
4214        let has_int = h
4215            .result
4216            .expr_ty
4217            .values()
4218            .any(|t| matches!(t, Ty::Builtin(_)));
4219        assert!(has_int);
4220        // sanity: the body lowered at least one expr
4221        assert!(!h.body.exprs.is_empty());
4222    }
4223}