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, MethodSig, 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/// Every name a body ever REBINDS (`x = …` / `x += …`) or INDEX-stores into (`s[i] = …`, at any
147/// index depth). An untyped local outside this set is effectively single-assignment: its
148/// initializer's inferred type is the only type the binding can ever hold, so it can soundly serve
149/// as the binding type (initializer narrowing, ADR-0007). The whole expression arena is scanned, so
150/// lambda bodies are included (conservative — invalidating costs a missed narrowing, never a false
151/// positive). A `Field` step anywhere in the write target means the store lands inside a member
152/// VALUE — the local's own binding type (and a tuple's positional types) are untouched, so those
153/// don't invalidate; an `Index` chain does (it can re-type a tuple position: `s[1] = 5`).
154fn collect_rebound_names(body: &Body) -> FxHashSet<SmolStr> {
155    let mut out = FxHashSet::default();
156    for e in &body.exprs {
157        let Expr::Bin {
158            op: BinOp::Assign,
159            lhs,
160            ..
161        } = e
162        else {
163            continue;
164        };
165        let mut cur = *lhs;
166        loop {
167            match body.expr(cur) {
168                Expr::Name(n) => {
169                    out.insert(n.clone());
170                    break;
171                }
172                Expr::Paren(inner) => cur = *inner,
173                Expr::Index { base, .. } => cur = *base,
174                // `(s as Array)[1] = …` still stores into `s` — see through the cast.
175                Expr::Cast { operand, .. } => cur = *operand,
176                _ => break,
177            }
178        }
179    }
180    out
181}
182
183/// Names whose VALUE may escape into a mutating alias: a call argument (`f(s)` — arrays pass by
184/// reference), another binding's initializer (`var t = s`), a plain-assign RHS, an array/dict
185/// literal element, … Computed as the complement of the read positions that CANNOT create such an
186/// alias: an `Index` base, a `Field` receiver, a `Call` callee, an assignment target, and a
187/// `return` operand (nothing in this body reads the local after `return`); parens are seen
188/// through. Used to WIDEN a tuple-typed binding to its runtime `Array[Variant]` form — a
189/// positional projection must never survive a possible aliased index-store (`var t = s; t[1] = x`),
190/// where a stale projection could fire a false `UNDEFINED_METHOD` on runtime-valid code.
191fn collect_escaping_names(body: &Body) -> FxHashSet<SmolStr> {
192    fn mark(safe: &mut FxHashSet<ExprId>, body: &Body, mut id: ExprId) {
193        loop {
194            safe.insert(id);
195            match body.expr(id) {
196                Expr::Paren(inner) => id = *inner,
197                _ => return,
198            }
199        }
200    }
201    let mut safe: FxHashSet<ExprId> = FxHashSet::default();
202    for e in &body.exprs {
203        match e {
204            Expr::Index { base, .. } => mark(&mut safe, body, *base),
205            Expr::Field { receiver, .. } => mark(&mut safe, body, *receiver),
206            Expr::Call { callee, .. } => mark(&mut safe, body, *callee),
207            Expr::Bin {
208                op: BinOp::Assign,
209                lhs,
210                ..
211            } => mark(&mut safe, body, *lhs),
212            _ => {}
213        }
214    }
215    for s in &body.stmts {
216        if let Stmt::Return(Some(e)) = s {
217            mark(&mut safe, body, *e);
218        }
219    }
220    let mut out = FxHashSet::default();
221    for (i, e) in body.exprs.iter().enumerate() {
222        if let Expr::Name(n) = e
223            && !safe.contains(&ExprId(u32::try_from(i).unwrap_or(u32::MAX)))
224        {
225            out.insert(n.clone());
226        }
227    }
228    out
229}
230
231/// Infer one function/initializer body against a class scope: walks the lowered [`body::Body`],
232/// resolving each expression's [`Ty`] (engine + cross-file members, scene-node paths, flow
233/// narrowing) and recording the bindings, diagnostics, and severity-free gateable warnings.
234/// Returns the [`InferenceResult`] the IDE features and the warning gate read.
235#[must_use]
236#[allow(
237    clippy::too_many_lines,
238    clippy::too_many_arguments,
239    reason = "the per-body inference orchestration reads best whole; the params are the fixed analysis inputs (db/api/tree/scope/body + result shape), not a config to bundle"
240)]
241pub fn infer(
242    db: &dyn Db,
243    api: &EngineApi,
244    root: &GdNode,
245    class: &ClassScope,
246    body: &Body,
247    return_ty: Ty,
248    is_func_body: bool,
249    owner: Option<&str>,
250) -> InferenceResult {
251    let self_ty = class.self_ty.clone();
252    let mut cx = Cx {
253        db,
254        api,
255        root,
256        body,
257        class,
258        self_ty,
259        return_ty,
260        expr_ty: FxHashMap::default(),
261        bindings: Vec::new(),
262        diagnostics: Vec::new(),
263        raw_warnings: Vec::new(),
264        locals: FxHashMap::default(),
265        used_locals: FxHashSet::default(),
266        narrowing: FxHashMap::default(),
267        flow: flow::analyze(body),
268        is_func_body,
269        owner,
270        assigned: flow::analyze_assigned(
271            body,
272            &body
273                .params
274                .iter()
275                .map(|p| p.name.clone())
276                .collect::<Vec<_>>(),
277        ),
278        cur_stmt: None,
279        needs_assignment: FxHashSet::default(),
280        assign_lhs: collect_assign_lhs(body),
281        rebound: collect_rebound_names(body),
282        escaping: collect_escaping_names(body),
283    };
284    // Parameters bind first (their defaults can reference earlier params).
285    let params = body.params.clone();
286    for p in &params {
287        let ty = cx.param_ty(p);
288        cx.bindings.push(Binding {
289            name: p.name.clone(),
290            name_range: p.name_range,
291            ty: ty.clone(),
292            init: None,
293            annotated: p.type_ref.is_some(),
294            inferred_colon_eq: false,
295            is_const: false,
296            kind: BindingKind::Param,
297        });
298        cx.locals.insert(p.name.clone(), ty);
299    }
300    if let Some(tail) = body.tail {
301        cx.infer_expr(tail, &Expectation::None);
302    }
303    let block = body.block.clone();
304    cx.infer_block(&block);
305
306    // UNUSED_* — a declared local/param/const never read. Only for a *function* body: a class-field
307    // initializer body would otherwise false-flag every field (the member is read in other methods,
308    // not in its own initializer). `_`-prefixed names + loop/match captures are excluded.
309    if is_func_body {
310        // Godot's exact wordings (probed on 4.7: p07/p20/q01) — the parameter form names the
311        // enclosing function; the local forms say "in the block".
312        let fn_name = owner.unwrap_or("<anonymous>");
313        let unused: Vec<(TextRange, WarningCode, String)> = cx
314            .bindings
315            .iter()
316            .filter_map(|b| {
317                if b.name.starts_with('_') || cx.used_locals.contains(&b.name) {
318                    return None;
319                }
320                let (code, msg) = match b.kind {
321                    BindingKind::Param => (
322                        WarningCode::UnusedParameter,
323                        format!(
324                            "The parameter \"{n}\" is never used in the function \"{fn_name}()\". If this is intended, prefix it with an underscore: \"_{n}\".",
325                            n = b.name
326                        ),
327                    ),
328                    BindingKind::Var if b.is_const => (
329                        WarningCode::UnusedLocalConstant,
330                        format!(
331                            "The local constant \"{n}\" is declared but never used in the block. If this is intended, prefix it with an underscore: \"_{n}\".",
332                            n = b.name
333                        ),
334                    ),
335                    BindingKind::Var => (
336                        WarningCode::UnusedVariable,
337                        format!(
338                            "The local variable \"{n}\" is declared but never used in the block. If this is intended, prefix it with an underscore: \"_{n}\".",
339                            n = b.name
340                        ),
341                    ),
342                    BindingKind::ForVar | BindingKind::MatchBind => return None,
343                };
344                Some((b.name_range, code, msg))
345            })
346            .collect();
347        for (range, code, msg) in unused {
348            cx.warn(range, code, msg);
349        }
350    }
351
352    // SHADOWED_GLOBAL_IDENTIFIER — a parameter / local / `for` / pattern-bind whose name collides
353    // with a project/engine global (built-in type/function, native class, engine singleton, project
354    // `class_name`, or autoload). Godot's `is_shadowing` fires for every local-scope binding. A local
355    // `var`/`const` that *also* shadows a param/member emits THIS instead of `SHADOWED_VARIABLE` (the
356    // global check wins in `gdscript_analyzer.cpp`; `infer_local_var` suppresses the variable-shadow
357    // when a global one applies, so the two never double-fire on one declaration).
358    if is_func_body {
359        let global_shadows: Vec<(TextRange, String)> = cx
360            .bindings
361            .iter()
362            .filter_map(|b| {
363                let kind = shadowed_global_kind(db, api, &b.name)?;
364                let what = match b.kind {
365                    BindingKind::Param => "parameter",
366                    BindingKind::Var if b.is_const => "constant",
367                    BindingKind::Var => "variable",
368                    BindingKind::ForVar => "for loop variable",
369                    BindingKind::MatchBind => "pattern bind",
370                };
371                Some((
372                    b.name_range,
373                    format!("The {what} \"{}\" has the same name as a {kind}.", b.name),
374                ))
375            })
376            .collect();
377        for (range, msg) in global_shadows {
378            cx.warn(range, WarningCode::ShadowedGlobalIdentifier, msg);
379        }
380    }
381
382    // UNTYPED_DECLARATION / INFERRED_DECLARATION — the opt-in declaration-strictness codes (default
383    // IGNORE; promoted to WARN under a strict / standalone run). Driven directly by the binding flags:
384    // a `var` declared with `:=` is INFERRED_DECLARATION; a `var` / parameter with neither a `: T`
385    // annotation nor `:=` is UNTYPED_DECLARATION. `const` (its value type is always statically known),
386    // `for` vars, and pattern binds (fixed by the iterable / scrutinee, not user-typeable) are excluded
387    // — they can't carry the static type Godot's strict check expects.
388    if is_func_body {
389        let decl_strictness: Vec<(TextRange, WarningCode, String)> = cx
390            .bindings
391            .iter()
392            .filter_map(|b| match b.kind {
393                BindingKind::Param if !b.annotated => Some((
394                    b.name_range,
395                    WarningCode::UntypedDeclaration,
396                    format!("Parameter \"{}\" has no static type.", b.name),
397                )),
398                BindingKind::Var if !b.is_const && b.inferred_colon_eq => Some((
399                    b.name_range,
400                    WarningCode::InferredDeclaration,
401                    format!(
402                        "Variable \"{}\" has an implicitly inferred static type.",
403                        b.name
404                    ),
405                )),
406                BindingKind::Var if !b.is_const && !b.annotated => Some((
407                    b.name_range,
408                    WarningCode::UntypedDeclaration,
409                    format!("Variable \"{}\" has no static type.", b.name),
410                )),
411                _ => None,
412            })
413            .collect();
414        for (range, code, msg) in decl_strictness {
415            cx.warn(range, code, msg);
416        }
417    }
418
419    // CONFUSABLE_IDENTIFIER — a parameter / local binding whose name mixes scripts in a spoofable
420    // way (the same UTS #39 check used for member names; ASCII names fast-path out).
421    let confusable_bindings: Vec<(TextRange, SmolStr)> = cx
422        .bindings
423        .iter()
424        .filter(|b| is_confusable_identifier(&b.name))
425        .map(|b| (b.name_range, b.name.clone()))
426        .collect();
427    for (range, name) in confusable_bindings {
428        cx.warn(
429            range,
430            WarningCode::ConfusableIdentifier,
431            format!(
432                "The identifier \"{name}\" has misleading characters and might be confused with something else."
433            ),
434        );
435    }
436
437    // UNREACHABLE_CODE — statements after a return/break/continue / diverging branch (Workstream
438    // 2). Godot only flags — and phrases — the after-`return` case (probes p08/r42 vs q04/q05),
439    // so that cause reproduces its exact text; the other causes are analyzer-extra coverage and
440    // keep a precise wording of their own.
441    let unreachable = cx.flow.unreachable_ranges(body);
442    for (range, cause) in unreachable {
443        let msg = match cause {
444            flow::UnreachableCause::AfterReturn => format!(
445                "Unreachable code (statement after return) in function \"{}()\".",
446                cx.owner.unwrap_or("<anonymous>")
447            ),
448            flow::UnreachableCause::Other => {
449                "Unreachable code (statement after a break, continue, or a diverging branch)."
450                    .to_owned()
451            }
452        };
453        cx.warn(range, WarningCode::UnreachableCode, msg);
454    }
455
456    // UNREACHABLE_PATTERN — a `match` arm after an unconditional catch-all (Workstream 2).
457    let unreachable_patterns = cx.flow.unreachable_pattern_ranges().to_vec();
458    for range in unreachable_patterns {
459        cx.warn(
460            range,
461            WarningCode::UnreachablePattern,
462            "Unreachable pattern (pattern after wildcard or bind).".to_owned(),
463        );
464    }
465
466    InferenceResult {
467        expr_ty: cx.expr_ty,
468        bindings: cx.bindings,
469        diagnostics: cx.diagnostics,
470        raw_warnings: cx.raw_warnings,
471    }
472}
473
474/// Convenience: recover a function node from its [`AstPtr`], lower its body, resolve its
475/// declared return type, and infer it.
476#[must_use]
477pub fn infer_func(
478    db: &dyn Db,
479    api: &EngineApi,
480    root: &GdNode,
481    class: &ClassScope,
482    ptr: AstPtr,
483) -> InferenceResult {
484    let Some(node) = ptr.to_node(root) else {
485        return InferenceResult::default();
486    };
487    let body = body::body_of_func(&node);
488    // The return-type annotation is the FuncDecl's direct `TypeRef` child (params' type refs
489    // are nested inside the ParamList, so they are not direct children).
490    let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
491        .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
492    let owner = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::Name)
493        .map(|n| n.text().to_string());
494    infer(
495        db,
496        api,
497        root,
498        class,
499        &body,
500        return_ty,
501        true,
502        owner.as_deref(),
503    )
504}
505
506/// One inferred unit of a file: a function body or a class field's initializer, with its
507/// lowered [`Body`] and [`InferenceResult`] (kept so position-based features — hover, inlay,
508/// member completion — can map a cursor back through the source map).
509#[derive(Debug, Clone, PartialEq, Eq)]
510pub struct Unit {
511    /// The source range this unit covers (the function decl or the field decl).
512    pub range: TextRange,
513    /// The lowered body.
514    pub body: Body,
515    /// The inference result.
516    pub result: InferenceResult,
517}
518
519/// The full single-file inference: the item tree, every inferred unit, and the merged §5
520/// diagnostics. The whole-file entry point the IDE layer consumes.
521#[derive(Debug, Clone, PartialEq, Eq, Default)]
522pub struct FileInference {
523    /// The lowered item tree.
524    pub tree: Arc<ItemTree>,
525    /// The inferred function/field units.
526    pub units: Vec<Unit>,
527    /// The ungated analyzer-native diagnostics, merged across units (`TYPE_MISMATCH`,
528    /// `INVALID_NODE_PATH`) plus the file-level `SHADOWED_GLOBAL_IDENTIFIER` / `CYCLIC_INHERITANCE`.
529    pub diagnostics: Vec<Diagnostic>,
530    /// The severity-free gateable Godot warnings, merged across units. The IDE layer resolves
531    /// these via [`crate::warnings::gate`] against the project's settings (Workstream 1).
532    pub raw_warnings: Vec<RawWarning>,
533}
534
535impl FileInference {
536    /// The innermost unit whose range contains `offset`.
537    #[must_use]
538    pub fn unit_at(&self, offset: u32) -> Option<&Unit> {
539        self.units
540            .iter()
541            .filter(|u| u.range.start <= offset && offset < u.range.end)
542            .min_by_key(|u| u.range.end - u.range.start)
543    }
544}
545
546/// Infer an entire file: lower its item tree, then infer every function body and every
547/// class-field initializer against a shared [`ClassScope`]. The single entry point for the
548/// IDE features (Playbook §6 — a pure `(api, parsed file) -> result` function).
549#[must_use]
550#[allow(clippy::too_many_lines)] // the two-pass field-fixpoint + function walk reads best whole
551pub fn analyze_file(db: &dyn Db, api: &EngineApi, root: &GdNode, file_id: FileId) -> FileInference {
552    let tree = item_tree(root);
553    let mut units = Vec::new();
554    let mut diagnostics = Vec::new();
555    let mut raw_warnings: Vec<RawWarning> = Vec::new();
556
557    // EMPTY_FILE — a script with no members, no `class_name`, and no `extends` (Workstream 1).
558    if tree.members.is_empty() && tree.class_name.is_none() && tree.extends.is_none() {
559        raw_warnings.push(RawWarning {
560            range: TextRange::new(0, 0),
561            code: WarningCode::EmptyFile,
562            message: "Empty script file.".to_owned(),
563        });
564    }
565    let mut member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
566    // `self` is the script's OWN class (a self-`ScriptRef`), not just its engine base — so member
567    // access on an aliased `self` resolves the file's own members (see `ClassScope::self_ty`).
568    let self_ref = Ty::ScriptRef(ScriptRefId(file_id.0));
569    // The file's own `res://` path, for anchoring relative `preload`/`extends` to its directory.
570    let res_path = db.file_text(file_id).and_then(|ft| ft.res_path(db));
571
572    // A declared `class_name` that collides with another global identifier (W2). Mirrors Godot's
573    // `gdscript_analyzer.cpp` uniqueness check over the global namespace, projected through the
574    // cross-file firewall (`class_name_collisions`) and the offset-free global resolvers — so it
575    // fires only when genuinely shadowing, never on the seam. Emitted once, at the decl's NAME.
576    if let Some(name) = tree.class_name.clone() {
577        // Godot's per-kind phrasings ("Class "Node" hides a native class." probed on 4.7, r29;
578        // the script-class/autoload variants follow `gdscript_analyzer.cpp`'s texts — they are
579        // not probeable headless because `--check-only` builds no global-class registry). Godot
580        // hard-ERRORS here; the analyzer keeps Warning severity (documented divergence — a
581        // registry seam false-positive must not block a build).
582        let hides = if resolve::resolve_global(api, &name).is_some() {
583            match resolve::resolve_global(api, &name) {
584                Some(GlobalDef::BuiltinType(_)) => Some("a built-in type"),
585                Some(GlobalDef::Builtin | GlobalDef::Utility) => Some("a built-in function"),
586                Some(_) => Some("a native class"),
587                None => None,
588            }
589        } else if is_autoload_singleton(db, &name) {
590            Some("an autoload singleton")
591        } else if collisions_contains(db, &name) {
592            Some("a global script class")
593        } else {
594            None
595        };
596        if let Some(kind) = hides
597            && let Some(range) = class_name_decl_range(root)
598        {
599            diagnostics.push(Diagnostic {
600                range,
601                severity: Severity::Warning,
602                code: SHADOWED_GLOBAL_IDENTIFIER.to_owned(),
603                message: format!("Class \"{name}\" hides {kind}."),
604                source: DiagnosticSource::Type,
605                fixes: Vec::new(),
606                tags: Vec::new(),
607            });
608        }
609        // CONFUSABLE_IDENTIFIER on the `class_name` itself (gated, unlike the hides-global check).
610        if is_confusable_identifier(&name)
611            && let Some(range) = class_name_decl_range(root)
612        {
613            raw_warnings.push(RawWarning {
614                range,
615                code: WarningCode::ConfusableIdentifier,
616                message: format!(
617                    "The identifier \"{name}\" has misleading characters and might be confused with something else."
618                ),
619            });
620        }
621    }
622
623    // A genuine `extends` cycle (D7): walk THIS file's base chain by `FileId`; if it returns to the
624    // start, the inheritance is cyclic (illegal in Godot). Reported once, at the file's own `extends`
625    // decl range. Only `extends` cycles are walked here (member lookup is the only thing that loops);
626    // `preload`/`load` cycles are legal at runtime and never reach this resolver. We start by stepping
627    // ONTO the user base — if the very first base is the start file (`extends "res://self.gd"`, or two
628    // files A↔B), the revisit-of-start check fires; a deep but ACYCLIC chain bottoms out at an engine
629    // `Object`/`Unknown` and never revisits, so it does not false-fire.
630    if extends_chain_is_cyclic(db, file_id)
631        && let Some(range) = extends_decl_range(root)
632    {
633        diagnostics.push(Diagnostic {
634            range,
635            severity: Severity::Warning,
636            code: CYCLIC_INHERITANCE.to_owned(),
637            message: "Cyclic class hierarchy: this class's `extends` chain returns to itself."
638                .to_owned(),
639            source: DiagnosticSource::Type,
640            tags: Vec::new(),
641            fixes: Vec::new(),
642        });
643    }
644
645    // File-level (member) warnings that need the whole item-tree, not a single body.
646    raw_warnings.extend(member_level_warnings(
647        db,
648        api,
649        root,
650        &tree,
651        res_path.as_deref(),
652    ));
653
654    // Pass 1 — class fields. Inferring each `var`/`const` seeds `member_types` so the function
655    // pass sees the *inferred* field type (`var n := 0` → `int`), not just the annotation.
656    //
657    // A field initializer may reference an *earlier* field (`var a := 1` then `var b := a + 1`),
658    // so a single shallow round sees the referent as `Variant`/seam. We run a BOUNDED fixpoint:
659    // each round re-infers every field against the prior round's `member_types`, until the map
660    // stops changing or we hit the round cap. Cheap (fields are few, types settle in a round or
661    // two) and deterministic. Only the final round's units/diagnostics are kept — earlier rounds
662    // are throwaway probes feeding the seed.
663    {
664        // Bound the iteration: a linear `a -> b -> c -> …` chain settles in O(n) rounds, but a
665        // small constant is enough in practice (the corpus settles in ≤2) and guarantees
666        // termination even if a type oscillated.
667        const MAX_ROUNDS: usize = 4;
668        let mut final_units: Vec<Unit> = Vec::new();
669        let mut final_diagnostics: Vec<Diagnostic> = Vec::new();
670        let mut final_raw_warnings: Vec<RawWarning> = Vec::new();
671        for _ in 0..MAX_ROUNDS {
672            let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
673            class.self_ty = self_ref.clone();
674            class.member_types.clone_from(&member_types);
675            let mut next_member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
676            final_units = Vec::new();
677            final_diagnostics = Vec::new();
678            final_raw_warnings = Vec::new();
679            for m in &tree.members {
680                let (ptr, range) = match m {
681                    Member::Var(v) => (v.ptr, v.range),
682                    Member::Const(c) => (c.ptr, c.range),
683                    _ => continue,
684                };
685                if let Some(unit) = unit_from_decl(db, api, root, &class, ptr, range) {
686                    if let (Some(name), Some(b)) = (m.name(), unit.result.bindings.first()) {
687                        next_member_types.insert(SmolStr::new(name), b.ty.clone());
688                    }
689                    final_diagnostics.extend(unit.result.diagnostics.iter().cloned());
690                    final_raw_warnings.extend(unit.result.raw_warnings.iter().cloned());
691                    final_units.push(unit);
692                }
693            }
694            if next_member_types == member_types {
695                break;
696            }
697            member_types = next_member_types;
698        }
699        diagnostics.extend(final_diagnostics);
700        raw_warnings.extend(final_raw_warnings);
701        units.extend(final_units);
702    }
703
704    // Pass 2 — functions, against a scope carrying the seeded field types.
705    {
706        let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
707        class.member_types = member_types;
708        class.self_ty = self_ref.clone();
709        for m in &tree.members {
710            let Member::Func(f) = m else { continue };
711            let Some(node) = f.ptr.to_node(root) else {
712                continue;
713            };
714            let body = body::body_of_func(&node);
715            let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
716                .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
717            let result = infer(db, api, root, &class, &body, return_ty, true, Some(&f.name));
718            diagnostics.extend(result.diagnostics.iter().cloned());
719            raw_warnings.extend(result.raw_warnings.iter().cloned());
720            units.push(Unit {
721                range: f.range,
722                body,
723                result,
724            });
725        }
726    }
727
728    // Pass 2b — inner-class method bodies. The top-level pass skips `Member::Class`, so inner `class
729    // Name:` methods were never inferred (no units / diagnostics / resolvable refs). Analyze them with
730    // `self` typed as the inner class, so `self.member` / bare member refs resolve against the inner
731    // item-tree + its `extends` chain; anything unresolved stays the seam (no false positive).
732    infer_inner_class_bodies(
733        db,
734        api,
735        root,
736        &tree,
737        file_id,
738        "",
739        res_path.as_deref(),
740        &mut units,
741        &mut diagnostics,
742        &mut raw_warnings,
743        0,
744    );
745
746    FileInference {
747        tree,
748        units,
749        diagnostics,
750        raw_warnings,
751    }
752}
753
754/// Infer the method bodies of every inner `class Name:` in `tree` (recursively), with `self` typed as
755/// the inner class. `path_prefix` is the dotted path to `tree`'s class (`""` at the top level), used
756/// to build each inner class's [`crate::ty::InnerClassRef`] path. Depth-bounded against pathological
757/// nesting. Inner-class *field* fixpoint pre-pass is intentionally skipped (an inner field types by
758/// annotation only — lossy, like the cross-file path).
759#[allow(
760    clippy::too_many_arguments,
761    reason = "threads the same analyze_file accumulators a free helper can't capture from a closure"
762)]
763fn infer_inner_class_bodies(
764    db: &dyn Db,
765    api: &EngineApi,
766    root: &GdNode,
767    tree: &ItemTree,
768    file_id: FileId,
769    path_prefix: &str,
770    res_path: Option<&str>,
771    units: &mut Vec<Unit>,
772    diagnostics: &mut Vec<Diagnostic>,
773    raw_warnings: &mut Vec<RawWarning>,
774    depth: u32,
775) {
776    if depth > 16 {
777        return;
778    }
779    for m in &tree.members {
780        let Member::Class(c) = m else { continue };
781        let inner_path = if path_prefix.is_empty() {
782            c.name.to_string()
783        } else {
784            format!("{path_prefix}.{}", c.name)
785        };
786        let mut class = ClassScope::new(db, api, &c.tree, res_path);
787        class.self_ty = Ty::InnerClass(crate::ty::InnerClassRef {
788            file: file_id.0,
789            path: SmolStr::new(&inner_path),
790        });
791        for im in &c.tree.members {
792            let Member::Func(f) = im else { continue };
793            let Some(node) = f.ptr.to_node(root) else {
794                continue;
795            };
796            let body = body::body_of_func(&node);
797            let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
798                .map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
799            let result = infer(db, api, root, &class, &body, return_ty, true, Some(&f.name));
800            diagnostics.extend(result.diagnostics.iter().cloned());
801            raw_warnings.extend(result.raw_warnings.iter().cloned());
802            units.push(Unit {
803                range: f.range,
804                body,
805                result,
806            });
807        }
808        infer_inner_class_bodies(
809            db,
810            api,
811            root,
812            &c.tree,
813            file_id,
814            &inner_path,
815            res_path,
816            units,
817            diagnostics,
818            raw_warnings,
819            depth + 1,
820        );
821    }
822}
823
824/// Class-level annotation checks (W1), unblocked by first-class item-tree annotations:
825/// `REDUNDANT_STATIC_UNLOAD` (`@static_unload` with no `static var`) and `MISSING_TOOL` (a non-`@tool`
826/// class extending a `@tool` user-script base).
827fn class_annotation_warnings(
828    db: &dyn Db,
829    api: &EngineApi,
830    root: &GdNode,
831    tree: &ItemTree,
832    res_path: Option<&str>,
833) -> Vec<RawWarning> {
834    let mut out = Vec::new();
835    // REDUNDANT_STATIC_UNLOAD — `@static_unload` on a class that declares no `static var`.
836    if let Some(unload) = tree.annotations.iter().find(|a| a.name == "static_unload")
837        && !tree
838            .members
839            .iter()
840            .any(|m| matches!(m, Member::Var(v) if v.is_static))
841    {
842        out.push(RawWarning {
843            range: unload.range,
844            code: WarningCode::RedundantStaticUnload,
845            message: "`@static_unload` is redundant on a class with no static variables."
846                .to_owned(),
847        });
848    }
849    // MISSING_TOOL — this class is not `@tool` but its (user-script) base is, so it will NOT run in
850    // the editor. Only the resolvable user-script base is checked (an engine base is never `@tool`).
851    if !has_annotation(&tree.annotations, "tool")
852        && user_base_is_tool(db, api, tree, res_path)
853        && let Some(range) = extends_decl_range(root)
854    {
855        out.push(RawWarning {
856            range,
857            code: WarningCode::MissingTool,
858            message: "This class extends a `@tool` script but is not itself `@tool` (it will not run in the editor)."
859                .to_owned(),
860        });
861    }
862    out
863}
864
865/// File-level (member) warnings that need the whole item-tree, not a single body (W1):
866/// `ENUM_VARIABLE_WITHOUT_DEFAULT`, `UNUSED_SIGNAL`, `UNUSED_PRIVATE_CLASS_VARIABLE`,
867/// `SHADOWED_GLOBAL_IDENTIFIER`, `CONFUSABLE_IDENTIFIER`, the annotation-lifecycle checks, and
868/// `NATIVE_METHOD_OVERRIDE` — each independent + conservative.
869#[allow(
870    clippy::too_many_lines,
871    reason = "a flat sequence of independent per-member warning checks; reads best as one walk"
872)]
873fn member_level_warnings(
874    db: &dyn Db,
875    api: &EngineApi,
876    root: &GdNode,
877    tree: &ItemTree,
878    res_path: Option<&str>,
879) -> Vec<RawWarning> {
880    let mut out = Vec::new();
881    let has_signal = tree.members.iter().any(|m| matches!(m, Member::Signal(_)));
882    // A `_`-prefixed, non-exported member var is an UNUSED_PRIVATE_CLASS_VARIABLE candidate.
883    let has_private_var = tree
884        .members
885        .iter()
886        .any(|m| matches!(m, Member::Var(v) if v.name.starts_with('_') && !v.is_exported));
887    // Only pay for the whole-file name scan when there is a signal or a private var to judge.
888    let uses = (has_signal || has_private_var).then(|| NameUses::collect(root));
889    // The resolved ENGINE base, for NATIVE_METHOD_OVERRIDE (an unresolved/user base ⇒ no check).
890    let engine_base = match resolve::resolve_base(db, api, tree, res_path) {
891        Ty::Object(c) => Some(c),
892        _ => None,
893    };
894
895    out.extend(class_annotation_warnings(db, api, root, tree, res_path));
896
897    for m in &tree.members {
898        // UNUSED_PRIVATE_CLASS_VARIABLE — a `_`-prefixed, non-exported member var never referenced
899        // anywhere in the file (same-file scan, like UNUSED_SIGNAL). Exported vars are set externally
900        // (inspector / scene), so they are excluded to keep the contract no-false-positive.
901        if let Member::Var(v) = m
902            && v.name.starts_with('_')
903            && !v.is_exported
904            && let Some(uses) = &uses
905            && !uses.is_referenced(&v.name)
906        {
907            out.push(RawWarning {
908                range: v.name_range,
909                code: WarningCode::UnusedPrivateClassVariable,
910                message: format!(
911                    "The class variable \"{}\" is declared but never used in the class.",
912                    v.name
913                ),
914            });
915        }
916        // ONREADY_WITH_EXPORT — `@onready` and `@export` on the same member (Godot raises this).
917        if let Member::Var(v) = m
918            && has_annotation(&v.annotations, "onready")
919            && v.is_exported
920        {
921            out.push(RawWarning {
922                range: v.name_range,
923                code: WarningCode::OnreadyWithExport,
924                message: "\"@onready\" will set the default value after \"@export\" takes effect and will override it.".to_owned(),
925            });
926        }
927        // SHADOWED_GLOBAL_IDENTIFIER — a value member (`var`/`const`/`signal`) whose name collides
928        // with a project/engine global. Godot's `is_shadowing` fires for member declarations too.
929        if let Some((name, range, what)) = member_value_decl(m)
930            && let Some(kind) = shadowed_global_kind(db, api, name)
931        {
932            out.push(RawWarning {
933                range,
934                code: WarningCode::ShadowedGlobalIdentifier,
935                message: format!("The {what} \"{name}\" has the same name as a {kind}."),
936            });
937        }
938        // CONFUSABLE_IDENTIFIER — any member name that mixes scripts in a spoofable way.
939        if let Some((name, range)) = member_decl_name(m)
940            && is_confusable_identifier(name)
941        {
942            out.push(RawWarning {
943                range,
944                code: WarningCode::ConfusableIdentifier,
945                message: format!(
946                    "The identifier \"{name}\" has misleading characters and might be confused with something else."
947                ),
948            });
949        }
950        match m {
951            // An enum-typed field with no initializer — MEMBER-only in Godot (an enum-typed
952            // LOCAL is tracked as UNASSIGNED_VARIABLE instead, see `infer_local_var`), and only
953            // when the enum declares NO `0` value (probed r46: `MouseButton` has `NONE = 0`,
954            // silent; r48: `MouseButtonMask` has none, warns).
955            Member::Var(v) if !v.has_init => {
956                if let Some(tref) = &v.type_ref
957                    && let Ty::Enum(eref) = resolve::resolve_type_name(db, api, tref)
958                    && enum_lacks_zero(api, &eref)
959                {
960                    out.push(RawWarning {
961                        range: v.name_range,
962                        code: WarningCode::EnumVariableWithoutDefault,
963                        message: format!(
964                            "The variable \"{}\" has an enum type and does not set an explicit default value. The default will be set to \"0\".",
965                            v.name
966                        ),
967                    });
968                }
969            }
970            // A signal never referenced in this file (emit/connect/string). Same-file only, like
971            // Godot — a signal connected purely from a scene/other file is invisible (the known
972            // limitation); the conservative scan only warns when the name appears nowhere else.
973            Member::Signal(s) => {
974                if let Some(uses) = &uses
975                    && !uses.is_referenced(&s.name)
976                {
977                    out.push(RawWarning {
978                        range: s.name_range,
979                        code: WarningCode::UnusedSignal,
980                        message: format!(
981                            "The signal \"{}\" is declared but never explicitly used in the class.",
982                            s.name
983                        ),
984                    });
985                }
986            }
987            // NATIVE_METHOD_OVERRIDE (ERROR-default) — an override of an engine VIRTUAL whose
988            // signature is clearly incompatible. Conservative to the extreme (a false positive is a
989            // loud error): warn ONLY on a *definite type clash* at an overlapping typed parameter —
990            // both the override's annotation and the virtual's param resolve to known engine types
991            // that are mutually NON-assignable. Arity, defaults/vararg, and variance subtleties are
992            // deliberately left to under-warn (see `TECH_DEBT.md`).
993            Member::Func(f) => {
994                if let Some(base) = engine_base
995                    && let Some(MemberRef::Method(vsig)) = api.lookup_member(base, &f.name)
996                    && vsig.is_virtual
997                {
998                    for (p, vp) in f.params.iter().zip(vsig.params.iter()) {
999                        let Some(ann) = &p.type_ref else { continue };
1000                        let pty = resolve::resolve_type_name(db, api, ann);
1001                        let vty = ty::resolve_tyref(api, &vp.ty);
1002                        if types_definitely_clash(api, &pty, &vty) {
1003                            out.push(RawWarning {
1004                                range: f.name_range,
1005                                code: WarningCode::NativeMethodOverride,
1006                                message: format!(
1007                                    "The override of the native virtual method \"{}\" has an incompatible type for parameter \"{}\".",
1008                                    f.name, p.name
1009                                ),
1010                            });
1011                            break; // one warning per overriding function
1012                        }
1013                    }
1014                }
1015            }
1016            _ => {}
1017        }
1018    }
1019    out
1020}
1021
1022/// Whether two **known** engine types are mutually non-assignable — a *definite* clash. An
1023/// uninformative type (`Variant`/`Unknown`) never clashes (gradual), and any assignable relation in
1024/// either direction (subtype, widening, enum/int, …) is treated as "related" (not a clash), so the
1025/// conservative `NATIVE_METHOD_OVERRIDE` only fires on genuinely unrelated types.
1026fn types_definitely_clash(api: &EngineApi, a: &Ty, b: &Ty) -> bool {
1027    if a.is_uninformative() || b.is_uninformative() {
1028        return false;
1029    }
1030    // Enums are int-backed and their qualified name resolves differently on the annotation side
1031    // (`resolve_type_name` → `Class.Enum`) than on the engine-model side (`resolve_tyref`), so an
1032    // enum "clash" is unreliable — never clash on an enum. (Fixes a false NATIVE_METHOD_OVERRIDE on
1033    // a valid dotted-enum-typed override param, e.g. `p_mode: MultiplayerPeer.TransferMode`.)
1034    if matches!(a, Ty::Enum(_)) || matches!(b, Ty::Enum(_)) {
1035        return false;
1036    }
1037    matches!(ty::is_assignable(api, a, b), Assign::No)
1038        && matches!(ty::is_assignable(api, b, a), Assign::No)
1039}
1040
1041/// Identifier-occurrence counts + string-literal contents across a file's CST — the file-wide
1042/// "is this name referenced anywhere?" check (drives `UNUSED_SIGNAL`).
1043struct NameUses {
1044    ident_counts: FxHashMap<SmolStr, u32>,
1045    strings: FxHashSet<SmolStr>,
1046}
1047
1048impl NameUses {
1049    fn collect(root: &GdNode) -> Self {
1050        let mut ident_counts: FxHashMap<SmolStr, u32> = FxHashMap::default();
1051        let mut strings: FxHashSet<SmolStr> = FxHashSet::default();
1052        for node in gdscript_syntax::ast::descendants(root) {
1053            for el in node.children_with_tokens() {
1054                let Some(tok) = el.into_token() else { continue };
1055                match tok.kind() {
1056                    gdscript_syntax::SyntaxKind::Ident => {
1057                        *ident_counts.entry(SmolStr::new(tok.text())).or_insert(0) += 1;
1058                    }
1059                    gdscript_syntax::SyntaxKind::String => {
1060                        strings.insert(SmolStr::new(tok.text().trim_matches(['"', '\''])));
1061                    }
1062                    _ => {}
1063                }
1064            }
1065        }
1066        Self {
1067            ident_counts,
1068            strings,
1069        }
1070    }
1071
1072    /// Whether `name` is referenced beyond its single declaration — a 2nd identifier occurrence, or
1073    /// any string literal naming it (covering `emit_signal("name")` / `connect("name", …)`).
1074    fn is_referenced(&self, name: &str) -> bool {
1075        self.ident_counts.get(name).copied().unwrap_or(0) > 1 || self.strings.contains(name)
1076    }
1077}
1078
1079/// Whether `name` is declared as a `class_name` by more than one file in the project (W2). Reads
1080/// the cross-file `class_name_collisions` firewall; `false` (no warning) when no source root is set
1081/// — single-file analysis cannot observe a duplicate.
1082fn collisions_contains(db: &dyn Db, name: &SmolStr) -> bool {
1083    db.source_root()
1084        .is_some_and(|root| crate::queries::class_name_collisions(db, root).contains(name))
1085}
1086
1087/// Whether `name` is a `*`-flagged autoload singleton (a bare global). `false` when no
1088/// `project.godot` is loaded — the seam, no warning.
1089fn is_autoload_singleton(db: &dyn Db, name: &str) -> bool {
1090    db.project_config().is_some_and(|config| {
1091        crate::queries::autoload_registry(db, config)
1092            .resolve_path(name)
1093            .is_some()
1094    })
1095}
1096
1097/// Whether the file's resolved **user-script** base carries the `@tool` annotation (for
1098/// `MISSING_TOOL`). An engine base or an unresolved base is never `@tool`. Firewall-safe: reads the
1099/// base's `item_tree` (signature-level — a base *body* edit leaves the annotation set unchanged).
1100fn user_base_is_tool(
1101    db: &dyn Db,
1102    api: &EngineApi,
1103    tree: &ItemTree,
1104    res_path: Option<&str>,
1105) -> bool {
1106    let Ty::ScriptRef(sref) = resolve::resolve_base(db, api, tree, res_path) else {
1107        return false;
1108    };
1109    let Some(ft) = db.file_text(FileId(sref.0)) else {
1110        return false;
1111    };
1112    has_annotation(&crate::queries::item_tree(db, ft).annotations, "tool")
1113}
1114
1115/// Whether `name` is registered as a global `class_name` by some file in the project. `false` when
1116/// no source root is set (single-file analysis can't observe the registry). Reads the firewalled
1117/// [`crate::queries::global_registry`].
1118fn is_registered_global_class(db: &dyn Db, name: &str) -> bool {
1119    db.source_root().is_some_and(|root| {
1120        crate::queries::global_registry(db, root)
1121            .resolve(name)
1122            .is_some()
1123    })
1124}
1125
1126/// Whether a declared identifier `name` shadows a project/engine **global**, returning Godot's
1127/// category label for the `SHADOWED_GLOBAL_IDENTIFIER` message, else `None`. Mirrors
1128/// `gdscript_analyzer.cpp`'s `is_shadowing` global checks: a built-in function, a built-in (Variant)
1129/// type, a native class, an engine singleton, a project `class_name` global, or a `*`-autoload
1130/// singleton. **Conservative:** bare global pseudo-constants (`PI`/`TAU`) and global enum namespaces
1131/// (`Error`/`Key`) are deliberately excluded — they are rare as user identifiers (and the tokenizer
1132/// treats the math constants as literals), so this only ever *under*-warns vs. Godot, never a false
1133/// positive. With no source root / `project.godot`, the cross-file/autoload arms are silent (seam).
1134/// Whether the enum behind `eref` declares NO `0` value — Godot's gate for
1135/// `ENUM_VARIABLE_WITHOUT_DEFAULT` (the implicit `0` default is fine when 0 IS a member:
1136/// `var e: MouseButton` is silent, `var e: MouseButtonMask` warns — probed r46/r48).
1137/// Unresolvable enums (script enums, unknown classes) return `false` so the warning never
1138/// fires on a seam.
1139fn enum_lacks_zero(api: &EngineApi, eref: &ty::EnumRef) -> bool {
1140    let info = match eref.qualified.split_once('.') {
1141        None => api.global_enum(&eref.qualified),
1142        Some((class, en)) => match resolve::resolve_global(api, class) {
1143            Some(GlobalDef::ClassType(cid)) => match api.lookup_member(cid, en) {
1144                Some(MemberRef::Enum(e)) => Some(e),
1145                _ => None,
1146            },
1147            _ => None,
1148        },
1149    };
1150    info.is_some_and(|e| !e.values.iter().any(|v| v.value == 0))
1151}
1152
1153/// The name-token range of a class member, for `SHADOWED_VARIABLE`'s "at line N" filler. `None`
1154/// for members without a single name token (enums / inner classes never reach the shadow check
1155/// as *value* members anyway).
1156fn member_name_range(m: &Member) -> Option<TextRange> {
1157    match m {
1158        Member::Var(v) => Some(v.name_range),
1159        Member::Const(c) => Some(c.name_range),
1160        Member::Signal(s) => Some(s.name_range),
1161        Member::Func(f) => Some(f.name_range),
1162        _ => None,
1163    }
1164}
1165
1166fn shadowed_global_kind(db: &dyn Db, api: &EngineApi, name: &str) -> Option<&'static str> {
1167    match resolve::resolve_global(api, name) {
1168        Some(GlobalDef::Builtin | GlobalDef::Utility) => return Some("built-in function"),
1169        Some(GlobalDef::BuiltinType(_)) => return Some("built-in type"),
1170        // Godot's `is_shadowing` labels engine singletons as native classes too (probed r41).
1171        Some(GlobalDef::ClassType(_) | GlobalDef::Singleton(_)) => return Some("native class"),
1172        // Bare pseudo-constants / global enums: intentionally not flagged (see doc above).
1173        Some(GlobalDef::Const(_) | GlobalDef::GlobalEnum) | None => {}
1174    }
1175    if is_registered_global_class(db, name) {
1176        return Some("global class");
1177    }
1178    if is_autoload_singleton(db, name) {
1179        return Some("autoload");
1180    }
1181    None
1182}
1183
1184/// The `(name, name range, kind noun)` of a *value-declaring* member (`var`/`const`/`signal`) — the
1185/// members that `SHADOWED_GLOBAL_IDENTIFIER` checks at the class level. `None` for funcs / enums /
1186/// inner classes (where the "shadow" framing is weaker, matching Godot's `is_shadowing` callers).
1187fn member_value_decl(m: &Member) -> Option<(&SmolStr, TextRange, &'static str)> {
1188    match m {
1189        Member::Var(v) => Some((&v.name, v.name_range, "variable")),
1190        Member::Const(c) => Some((&c.name, c.name_range, "constant")),
1191        Member::Signal(s) => Some((&s.name, s.name_range, "signal")),
1192        _ => None,
1193    }
1194}
1195
1196/// The declared `(name, name range)` of any named member (`func`/`var`/`const`/`signal`/named
1197/// `enum`/inner `class`), for the `CONFUSABLE_IDENTIFIER` scan. An anonymous `enum { … }` has no
1198/// name → `None`.
1199fn member_decl_name(m: &Member) -> Option<(&SmolStr, TextRange)> {
1200    match m {
1201        Member::Func(f) => Some((&f.name, f.name_range)),
1202        Member::Var(v) => Some((&v.name, v.name_range)),
1203        Member::Const(c) => Some((&c.name, c.name_range)),
1204        Member::Signal(s) => Some((&s.name, s.name_range)),
1205        Member::Class(c) => Some((&c.name, c.name_range)),
1206        Member::Enum(e) => e.name.as_ref().map(|n| (n, e.name_range)),
1207    }
1208}
1209
1210/// Whether `name` is a `CONFUSABLE_IDENTIFIER` — a non-ASCII identifier that mixes scripts in a
1211/// spoofable way (UTS #39 restriction level ≥ `MinimallyRestrictive`, e.g. a Latin identifier with a
1212/// Cyrillic/Greek homoglyph like `pаypal`). Pure-ASCII (the overwhelming majority) and legitimate
1213/// single-script / CJK-plus-Latin identifiers are never flagged. Mirrors the intent of Godot's
1214/// `TextServer` confusable check with zero false positives on ordinary code.
1215fn is_confusable_identifier(name: &str) -> bool {
1216    use unicode_security::RestrictionLevel as RL;
1217    use unicode_security::RestrictionLevelDetection;
1218    if name.is_ascii() {
1219        return false; // the fast path for ~all real identifiers
1220    }
1221    name.detect_restriction_level() >= RL::MinimallyRestrictive
1222}
1223
1224/// The NAME range of the file's `class_name` declaration, trimmed to the bare identifier (the
1225/// `Name` CST node absorbs leading inter-token trivia). `None` if the file declares no `class_name`
1226/// or the decl has no name token. Mirrors `item_tree::trimmed_name_range` / navigation's
1227/// `class_decl_target` (which lives in the IDE crate, hence this local CST scan).
1228fn class_name_decl_range(root: &GdNode) -> Option<TextRange> {
1229    use gdscript_syntax::SyntaxKind;
1230    let decl = gdscript_syntax::ast::descendants(root)
1231        .into_iter()
1232        .find(|n| n.kind() == SyntaxKind::ClassNameDecl)?;
1233    let name_node = decl.children().find(|c| c.kind() == SyntaxKind::Name)?;
1234    let r = cst::text_range_of(name_node);
1235    let text = name_node.text().to_string();
1236    let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
1237    let len = u32::try_from(text.trim().len()).unwrap_or(0);
1238    Some(TextRange::new(r.start + lead, r.start + lead + len))
1239}
1240
1241/// The byte range of the file's top-level `extends` declaration — the anchor for `CYCLIC_INHERITANCE`.
1242/// Two surface forms: a standalone `extends Target` (an [`ExtendsClause`] child of the `SourceFile`),
1243/// or the inline `class_name Name extends Target` (the `extends` keyword + target inside the
1244/// [`ClassNameDecl`]). Scans only the `SourceFile`'s DIRECT children, so an inner class's `extends`
1245/// (nested under `Class`/`ClassBody`) is never mistaken for the file's own. `None` if the file has no
1246/// top-level `extends`.
1247fn extends_decl_range(root: &GdNode) -> Option<TextRange> {
1248    use gdscript_syntax::SyntaxKind;
1249    for child in root.children() {
1250        match child.kind() {
1251            // Standalone `extends Target` — the whole clause is the anchor.
1252            SyntaxKind::ExtendsClause => return Some(cst::text_range_of(child)),
1253            // Inline `class_name Name extends Target` — anchor the `extends` keyword onward.
1254            SyntaxKind::ClassNameDecl => {
1255                if let Some(kw) = child.children().find(|c| c.kind() == SyntaxKind::ExtendsKw) {
1256                    let start = cst::text_range_of(kw).start;
1257                    let end = cst::text_range_of(child).end;
1258                    return Some(TextRange::new(start, end));
1259                }
1260            }
1261            _ => {}
1262        }
1263    }
1264    None
1265}
1266
1267/// Whether the file's `extends` inheritance chain transitively returns to itself (a genuine cycle).
1268/// Walks base-by-base by `FileId` from `start`, stepping only across user `ScriptRef` bases (an
1269/// engine `Object`/`Unknown` base ends the chain). A `FileId` revisit means a cycle. We stop as soon
1270/// as we either revisit a file (cycle) or hit a non-script base (acyclic) — a deep but acyclic chain
1271/// terminates without a revisit and is NOT flagged. Depth is also hard-capped as belt-and-suspenders
1272/// (the visited set already guarantees termination).
1273fn extends_chain_is_cyclic(db: &dyn Db, start: FileId) -> bool {
1274    use std::collections::HashSet;
1275    let mut visited: HashSet<FileId> = HashSet::new();
1276    visited.insert(start);
1277    let mut current = start;
1278    for _ in 0..=64 {
1279        let Some(file) = db.file_text(current) else {
1280            return false;
1281        };
1282        let base = crate::queries::script_class(db, file).base().clone();
1283        let Ty::ScriptRef(next) = base else {
1284            return false; // engine `Object` / `Unknown` base — chain ends, no cycle.
1285        };
1286        let next_id = FileId(next.0);
1287        if !visited.insert(next_id) {
1288            // Revisiting an already-seen file closes a cycle. We report the cycle for every file ON
1289            // it (each file's own `extends` is genuinely cyclic), so no need to special-case `start`.
1290            return true;
1291        }
1292        current = next_id;
1293    }
1294    false
1295}
1296
1297/// Infer a class field declaration as a single local-var statement (full annotation checks).
1298fn unit_from_decl(
1299    db: &dyn Db,
1300    api: &EngineApi,
1301    root: &GdNode,
1302    class: &ClassScope,
1303    ptr: AstPtr,
1304    range: TextRange,
1305) -> Option<Unit> {
1306    let node = ptr.to_node(root)?;
1307    let body = body::body_of_decl_stmt(&node);
1308    let result = infer(db, api, root, class, &body, Ty::Variant, false, None);
1309    Some(Unit {
1310        range,
1311        body,
1312        result,
1313    })
1314}
1315
1316/// The syntactic context of an assignability check — selects the verb of Godot's generic
1317/// type-mismatch text (`Cannot assign a value of type "A" as "B".` / `Cannot return a value of
1318/// type "A" as "B".`, probed p05/q28/p36).
1319#[derive(Clone, Copy)]
1320enum AssignCtx {
1321    Assign,
1322    Return,
1323}
1324
1325/// The statically-resolved signature of a callee, for call checking (arity + argument types).
1326/// `name` is the display name Godot bakes into its call diagnostics.
1327struct CallSignature {
1328    name: SmolStr,
1329    /// The fixed parameters' types, in order (a `...rest` vararg tail is NOT a slot here).
1330    params: Vec<Ty>,
1331    /// Parameters WITHOUT a default — Godot's "Expected at least N" (probed p10/p14).
1332    required: usize,
1333    /// A variadic tail accepts any surplus — never "too many" (probed p15 vs p21).
1334    is_vararg: bool,
1335}
1336
1337/// A [`CallSignature`] from an engine/builtin [`MethodSig`] (params carry defaults + vararg).
1338fn method_call_signature(api: &EngineApi, sig: &MethodSig) -> CallSignature {
1339    CallSignature {
1340        name: SmolStr::new(&sig.name),
1341        params: sig
1342            .params
1343            .iter()
1344            .map(|p| ty::resolve_tyref(api, &p.ty))
1345            .collect(),
1346        required: sig.params.iter().filter(|p| p.default.is_none()).count(),
1347        is_vararg: sig.is_vararg,
1348    }
1349}
1350
1351/// What type is expected of an expression (bidirectional checking).
1352enum Expectation {
1353    /// No expectation — pure synthesis.
1354    None,
1355    /// The expression is checked against this declared type.
1356    Has(Ty),
1357}
1358
1359/// Navigate a dotted inner-class path (`Inner` / `Outer.Inner`) from a file's top item-tree to the
1360/// target [`InnerClassItem`]. `None` if any segment isn't an inner class.
1361fn find_inner_class<'a>(tree: &'a ItemTree, path: &str) -> Option<&'a InnerClassItem> {
1362    let mut members: &'a [Member] = &tree.members;
1363    let mut found: Option<&'a InnerClassItem> = None;
1364    for seg in path.split('.') {
1365        found = members.iter().find_map(|m| match m {
1366            Member::Class(c) if c.name == seg => Some(c),
1367            _ => None,
1368        });
1369        members = &found?.tree.members;
1370    }
1371    found
1372}
1373
1374struct Cx<'a> {
1375    db: &'a dyn Db,
1376    api: &'a EngineApi,
1377    root: &'a GdNode,
1378    body: &'a Body,
1379    class: &'a ClassScope<'a>,
1380    self_ty: Ty,
1381    return_ty: Ty,
1382    expr_ty: FxHashMap<ExprId, Ty>,
1383    bindings: Vec<Binding>,
1384    diagnostics: Vec<Diagnostic>,
1385    /// Severity-free gateable warnings (Workstream 1), resolved by `gate()` downstream.
1386    raw_warnings: Vec<RawWarning>,
1387    /// Function-scoped local bindings (GDScript locals are function-, not block-, scoped).
1388    locals: FxHashMap<SmolStr, Ty>,
1389    /// The names of locals/params that were *read* during the walk — drives the `UNUSED_*` family (a
1390    /// declared binding whose name never appears here is unused). A bare assignment LHS (`x = …`) is a
1391    /// write, NOT a read, and is excluded (so an assigned-but-never-read local is correctly unused);
1392    /// a compound `x += …` still reads via its RHS, and a receiver / index target reads the base.
1393    used_locals: FxHashSet<SmolStr>,
1394    /// The active narrowing env for the current statement, keyed by a dotted access path. Rebuilt
1395    /// per statement from [`Cx::flow`] (Workstream 2) — not mutated ad-hoc anymore.
1396    narrowing: FxHashMap<String, Ty>,
1397    /// The precomputed per-body control-flow narrowing facts (Workstream 2). The checker consults
1398    /// `facts_before(stmt)` to build [`Cx::narrowing`]; it survives `else`/early-return/`and`-`or`.
1399    flow: FlowAnalysis,
1400    /// Whether this is a real function body (vs a class-field initializer body). Gates the
1401    /// body-only checks (`UNUSED_*`, `SHADOWED_VARIABLE`) so a field initializer doesn't, e.g.,
1402    /// "shadow itself" against its own member entry.
1403    is_func_body: bool,
1404    /// The declared name of the function whose body this is (`None` for a field-initializer
1405    /// body). Godot bakes it into `UNUSED_PARAMETER` / after-`return` `UNREACHABLE_CODE`
1406    /// messages (`… in the function "t()".`), so the wording needs it. A lambda inherits the
1407    /// enclosing function's name (its bindings live in the same body arena).
1408    owner: Option<&'a str>,
1409    /// Definite-assignment facts (Workstream 2) — the locals assigned before each statement, for
1410    /// `UNASSIGNED_VARIABLE`. Consulted at a read via [`Cx::cur_stmt`].
1411    assigned: flow::AssignedAnalysis,
1412    /// The statement currently being inferred (set in `infer_stmt`), so a read can look up
1413    /// [`Cx::assigned`].
1414    cur_stmt: Option<body::StmtId>,
1415    /// UNTYPED locals declared **without** an initializer — the only locals `UNASSIGNED_VARIABLE`
1416    /// considers. Godot 4 zero-initializes a *typed* `var x: int` (reading it is fine, probed
1417    /// q11); only the untyped `var x` holds "nil until first assignment" (probed r35). Grows as
1418    /// the walk passes each declaration.
1419    needs_assignment: FxHashSet<SmolStr>,
1420    /// Names that are the direct LHS of an assignment (`x = …`/`x += …`) — a *write*, not a read, so
1421    /// excluded from the `UNASSIGNED_VARIABLE` check even though inference resolves the LHS.
1422    assign_lhs: FxHashSet<ExprId>,
1423    /// Names ever rebound / index-stored anywhere in this body (see [`collect_rebound_names`]) —
1424    /// untyped locals OUTSIDE this set are effectively single-assignment and eligible for
1425    /// initializer narrowing (ADR-0007).
1426    rebound: FxHashSet<SmolStr>,
1427    /// Names whose value may escape into a mutating alias (see [`collect_escaping_names`]) —
1428    /// tuple-typed bindings for these are widened to their runtime `Array[Variant]` form.
1429    escaping: FxHashSet<SmolStr>,
1430}
1431
1432impl Cx<'_> {
1433    // ---- small type constructors ----
1434
1435    fn builtin(&self, name: &str) -> Ty {
1436        self.api
1437            .builtin_by_name(name)
1438            .map_or(Ty::Variant, Ty::Builtin)
1439    }
1440    fn int_ty(&self) -> Ty {
1441        self.builtin("int")
1442    }
1443    fn float_ty(&self) -> Ty {
1444        self.builtin("float")
1445    }
1446    fn bool_ty(&self) -> Ty {
1447        self.builtin("bool")
1448    }
1449    fn is_int(&self, ty: &Ty) -> bool {
1450        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "int")
1451    }
1452    fn is_float(&self, ty: &Ty) -> bool {
1453        matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "float")
1454    }
1455    fn is_numeric(&self, ty: &Ty) -> bool {
1456        self.is_int(ty) || self.is_float(ty)
1457    }
1458
1459    // ---- diagnostics ----
1460
1461    fn emit(&mut self, range: TextRange, severity: Severity, code: &str, message: String) {
1462        self.diagnostics.push(Diagnostic {
1463            range,
1464            severity,
1465            code: code.to_owned(),
1466            message,
1467            source: DiagnosticSource::Type,
1468            fixes: Vec::new(),
1469            tags: Vec::new(),
1470        });
1471    }
1472
1473    /// Record a gateable Godot warning, severity-free. The resolved severity (and whether it fires
1474    /// at all) is decided later by [`crate::warnings::gate`], keyed on the project's warning
1475    /// settings — so a settings edit never re-runs inference (Workstream 1, the salsa firewall).
1476    fn warn(&mut self, range: TextRange, code: WarningCode, message: String) {
1477        self.raw_warnings.push(RawWarning {
1478            range,
1479            code,
1480            message,
1481        });
1482    }
1483
1484    fn range_of(&self, id: ExprId) -> TextRange {
1485        self.body.source_map.expr_range(id)
1486    }
1487
1488    /// Run `is_assignable(from, to)` and raise the matching diagnostic. Safe to call
1489    /// unconditionally: `to` being `Variant`/`Unknown` yields `Ok`/no diagnostic. `ctx` selects
1490    /// Godot's verb — its generic texts are `Cannot assign a value of type "A" as "B".` and
1491    /// `Cannot return a value of type "A" as "B".` (probed p05/q28/p36; Godot prints a second,
1492    /// site-specific line too — the analyzer emits the generic one, which is common to every
1493    /// assignment shape).
1494    fn check_assign(&mut self, from: &Ty, to: &Ty, range: TextRange, ctx: AssignCtx) {
1495        match ty::is_assignable(self.api, from, to) {
1496            Assign::Narrowing => self.warn(
1497                range,
1498                WarningCode::NarrowingConversion,
1499                "Narrowing conversion (float is converted to int and loses precision).".to_owned(),
1500            ),
1501            Assign::No => {
1502                let to_label = to.label(self.api).unwrap_or_else(|| "?".to_owned());
1503                let from_label = from.label(self.api).unwrap_or_else(|| "?".to_owned());
1504                let verb = match ctx {
1505                    AssignCtx::Assign => "assign",
1506                    AssignCtx::Return => "return",
1507                };
1508                self.emit(
1509                    range,
1510                    Severity::Error,
1511                    TYPE_MISMATCH,
1512                    format!(
1513                        "Cannot {verb} a value of type \"{from_label}\" as \"{to_label}\"."
1514                    ),
1515                );
1516            }
1517            // `int` assigned to an enum slot without an explicit cast (the previously-dead arm).
1518            Assign::IntAsEnum => self.warn(
1519                range,
1520                WarningCode::IntAsEnumWithoutCast,
1521                "Integer used when an enum value is expected. If this is intended, cast the integer to the enum type using the \"as\" keyword."
1522                    .to_owned(),
1523            ),
1524            Assign::Ok | Assign::OkUnsafe => {}
1525        }
1526    }
1527
1528    /// Flag a statement whose expression has no effect: a bare value (`STANDALONE_EXPRESSION`) or a
1529    /// ternary used as a statement (`STANDALONE_TERNARY`). A call / await / assignment / `preload`
1530    /// has an effect and is never flagged.
1531    fn check_standalone(&mut self, e: ExprId) {
1532        if self.expr_has_side_effect(e) {
1533            return;
1534        }
1535        match self.body.expr(e) {
1536            Expr::Ternary { .. } => self.warn(
1537                self.range_of(e),
1538                WarningCode::StandaloneTernary,
1539                "Standalone ternary operator (the return value is being discarded).".to_owned(),
1540            ),
1541            // Not value-like statements / forms with subtle effects — never flag.
1542            Expr::Missing | Expr::Lambda { .. } | Expr::GetNode { .. } | Expr::Preload { .. } => {}
1543            _ => self.warn(
1544                self.range_of(e),
1545                WarningCode::StandaloneExpression,
1546                "Standalone expression (the line may have no effect).".to_owned(),
1547            ),
1548        }
1549    }
1550
1551    /// Whether evaluating an expression may have a side effect — a call, an `await`, a `preload`,
1552    /// or an assignment anywhere in the subtree. Used to suppress `STANDALONE_*` on effectful lines.
1553    fn expr_has_side_effect(&self, e: ExprId) -> bool {
1554        match self.body.expr(e) {
1555            Expr::Call { .. }
1556            | Expr::Await(_)
1557            | Expr::Preload { .. }
1558            | Expr::Bin {
1559                op: BinOp::Assign, ..
1560            } => true,
1561            Expr::Bin { lhs, rhs, .. }
1562            | Expr::In { lhs, rhs, .. }
1563            | Expr::Index {
1564                base: lhs,
1565                index: rhs,
1566            } => self.expr_has_side_effect(*lhs) || self.expr_has_side_effect(*rhs),
1567            Expr::Unary { operand, .. }
1568            | Expr::Paren(operand)
1569            | Expr::Cast { operand, .. }
1570            | Expr::Is { operand, .. } => self.expr_has_side_effect(*operand),
1571            Expr::Field { receiver, .. } => self.expr_has_side_effect(*receiver),
1572            Expr::Ternary {
1573                cond,
1574                then_branch,
1575                else_branch,
1576            } => {
1577                self.expr_has_side_effect(*cond)
1578                    || self.expr_has_side_effect(*then_branch)
1579                    || self.expr_has_side_effect(*else_branch)
1580            }
1581            Expr::Array(items) => items.iter().any(|&i| self.expr_has_side_effect(i)),
1582            Expr::Dict(entries) => entries.iter().any(|(k, v)| {
1583                self.expr_has_side_effect(*k) || v.is_some_and(|e| self.expr_has_side_effect(e))
1584            }),
1585            _ => false,
1586        }
1587    }
1588
1589    // ---- statements ----
1590
1591    fn infer_block(&mut self, block: &[body::StmtId]) {
1592        for &stmt in block {
1593            self.infer_stmt(stmt);
1594        }
1595    }
1596
1597    fn infer_stmt(&mut self, id: body::StmtId) {
1598        // Install the narrowing in force *before* this statement (Workstream 2). Recomputed per
1599        // statement from the precomputed flow facts — replaces the old ad-hoc `in_branch` frames.
1600        self.narrowing = self.facts_to_narrowing(id);
1601        self.cur_stmt = Some(id); // for the read-before-assign (UNASSIGNED_VARIABLE) check
1602        match self.body.stmt(id).clone() {
1603            Stmt::Expr(e) => {
1604                self.infer_expr(e, &Expectation::None);
1605                self.check_standalone(e);
1606            }
1607            Stmt::Var(v) => self.infer_local_var(&v),
1608            Stmt::Return(e) => {
1609                if let Some(e) = e {
1610                    let expected = if self.return_ty.is_uninformative() {
1611                        Expectation::None
1612                    } else {
1613                        Expectation::Has(self.return_ty.clone())
1614                    };
1615                    let t = self.infer_expr(e, &expected);
1616                    if let Expectation::Has(ret) = expected {
1617                        self.check_assign(&t, &ret, self.range_of(e), AssignCtx::Return);
1618                    }
1619                }
1620            }
1621            Stmt::If {
1622                cond,
1623                then_branch,
1624                elifs,
1625                else_branch,
1626            } => {
1627                // The branch narrowing now lives in the flow facts, so each sub-statement installs
1628                // its own via `infer_stmt`. Restore the if-level facts before each guard (a block
1629                // walk overwrites `self.narrowing`).
1630                let at_if = self.narrowing.clone();
1631                self.infer_expr(cond, &Expectation::None);
1632                self.infer_block(&then_branch);
1633                for (econd, eblock) in elifs {
1634                    self.narrowing.clone_from(&at_if);
1635                    self.infer_expr(econd, &Expectation::None);
1636                    self.infer_block(&eblock);
1637                }
1638                if let Some(eb) = else_branch {
1639                    self.infer_block(&eb);
1640                }
1641            }
1642            Stmt::While { cond, body } => {
1643                self.infer_expr(cond, &Expectation::None);
1644                self.infer_block(&body);
1645            }
1646            Stmt::For(f) => {
1647                let iter_ty = self.infer_expr(f.iter, &Expectation::None);
1648                let var_ty = f.var_type.as_ref().map_or_else(
1649                    || self.loop_var_ty(&iter_ty),
1650                    |ptr| self.resolve_ptr_ty(*ptr),
1651                );
1652                self.bindings.push(Binding {
1653                    name: f.var.clone(),
1654                    name_range: f.var_range,
1655                    ty: var_ty.clone(),
1656                    init: None,
1657                    annotated: f.var_type.is_some(),
1658                    inferred_colon_eq: false,
1659                    is_const: false,
1660                    kind: BindingKind::ForVar,
1661                });
1662                self.locals.insert(f.var.clone(), var_ty);
1663                self.infer_block(&f.body);
1664            }
1665            Stmt::Match { scrutinee, arms } => {
1666                let at_match = self.narrowing.clone();
1667                self.infer_expr(scrutinee, &Expectation::None);
1668                for arm in arms {
1669                    // Restore the match-level facts before each arm's guard (a prior arm's body
1670                    // walk overwrote `self.narrowing`).
1671                    self.narrowing.clone_from(&at_match);
1672                    for b in &arm.binds {
1673                        // Record the capture as a binding so navigation (find-refs / rename) sees
1674                        // it as a local that shadows a same-named member; the type is the Phase-2
1675                        // `Variant`.
1676                        self.bindings.push(Binding {
1677                            name: b.name.clone(),
1678                            name_range: b.range,
1679                            ty: Ty::Variant,
1680                            init: None,
1681                            annotated: false,
1682                            inferred_colon_eq: false,
1683                            is_const: false,
1684                            kind: BindingKind::MatchBind,
1685                        });
1686                        self.locals.insert(b.name.clone(), Ty::Variant);
1687                    }
1688                    if let Some(g) = arm.guard {
1689                        self.infer_expr(g, &Expectation::None);
1690                    }
1691                    self.infer_block(&arm.body);
1692                }
1693            }
1694            Stmt::Break | Stmt::Continue | Stmt::Pass => {}
1695            Stmt::Assert(cond) => {
1696                if let Some(cond) = cond {
1697                    self.infer_expr(cond, &Expectation::None);
1698                    self.check_assert_constant(cond);
1699                }
1700            }
1701        }
1702    }
1703
1704    /// `ASSERT_ALWAYS_TRUE` / `ASSERT_ALWAYS_FALSE` — fire when the assert condition is a constant
1705    /// with a known boolean value (Godot `resolve_assert`: a constant condition is booleanized and
1706    /// warned). Sound subset via [`Cx::const_bool_of`]: a literal `true`/`false`, or `null` (false).
1707    fn check_assert_constant(&mut self, cond: ExprId) {
1708        let Some(always) = self.const_bool_of(cond) else {
1709            return;
1710        };
1711        let (code, msg) = if always {
1712            (
1713                WarningCode::AssertAlwaysTrue,
1714                "The assert condition is always true, so this assert has no effect.",
1715            )
1716        } else {
1717            (
1718                WarningCode::AssertAlwaysFalse,
1719                "The assert condition is always false, so this assert will always fail.",
1720            )
1721        };
1722        self.warn(self.range_of(cond), code, msg.to_owned());
1723    }
1724
1725    /// The constant boolean value of `expr`, when it is a literal whose booleanization is known — a
1726    /// bool literal, or `null` (false). `None` for any other / non-constant expression (the sound
1727    /// default: no false `ASSERT_ALWAYS_*`). Mirrors Godot's `reduced_value.booleanize()` restricted
1728    /// to the literal forms (named-constant / arithmetic folding is deliberately not attempted).
1729    fn const_bool_of(&self, expr: ExprId) -> Option<bool> {
1730        match self.body.expr(expr) {
1731            Expr::Literal(Literal::Bool(b)) => Some(*b),
1732            Expr::Literal(Literal::Null) => Some(false),
1733            _ => None,
1734        }
1735    }
1736
1737    fn infer_local_var(&mut self, v: &body::LocalVar) {
1738        let annotated = v.type_ref.map(|p| self.resolve_ptr_ty(p));
1739        // The declared name is visible to its own initializer's LAMBDA bodies: `var f =
1740        // func(): f.call()` is legal GDScript (the closure captures `f`), and parser
1741        // recovery can swallow trailing statements into a broken initializer's inline body
1742        // (the unclosed-paren G8 shape — `var toggle = func(broken: pass` absorbs the rest
1743        // of the function). Both must resolve the name instead of cascading a false
1744        // UNDEFINED_IDENTIFIER on legal code / on top of the syntax error. Seam-typed
1745        // (`Unknown` claims nothing downstream) until the real binding type lands below.
1746        self.locals.insert(v.name.clone(), Ty::Unknown);
1747        let init_ty = v.init.map(|e| {
1748            let expected = annotated
1749                .as_ref()
1750                .map_or(Expectation::None, |t| Expectation::Has(t.clone()));
1751            self.infer_expr(e, &expected)
1752        });
1753        let range = v.init.map_or(v.name_range, |e| self.range_of(e));
1754        let binding_ty = self.local_binding_ty(v, annotated.as_ref(), init_ty.as_ref(), range);
1755        // SHADOWED_VARIABLE — a local `var`/`const` whose name shadows a parameter or an own class
1756        // member (a redeclared *local* is a Godot error, not handled here). Sound: only fires on a
1757        // genuine outer-scope shadow. The binding isn't pushed yet, so the `Param` scan can't see it.
1758        // Gated to a real function body — a class-field initializer's own `var n` is not a shadow.
1759        let shadows_param = self
1760            .bindings
1761            .iter()
1762            .any(|b| b.kind == BindingKind::Param && b.name == v.name);
1763        // Only a *value* member (var/const/signal, or an anon-enum constant) — not a method or a
1764        // type name, where the "shadow" framing is weaker — counts, to stay conservative.
1765        let shadows_member = match self.class.lookup(&v.name) {
1766            Some(ClassItem::EnumVariant) => true,
1767            Some(item) => matches!(
1768                self.class.member(item),
1769                Some(Member::Var(_) | Member::Const(_) | Member::Signal(_))
1770            ),
1771            None => false,
1772        };
1773        if self.is_func_body {
1774            let what = if v.is_const { "constant" } else { "variable" };
1775            // A global-identifier shadow takes precedence over a variable/base-class shadow (Godot's
1776            // `is_shadowing` checks globals first and returns), so the variable-shadow is emitted only
1777            // when the name does NOT shadow a global — the global one is emitted by the binding
1778            // post-pass in `infer`, keeping a single warning per declaration.
1779            if shadowed_global_kind(self.db, self.api, &v.name).is_none() {
1780                if shadows_param {
1781                    // Godot hard-errors on this shape ("There is already a parameter named …",
1782                    // probed r31 — a param and a local share one function scope). The analyzer
1783                    // keeps Warning severity under SHADOWED_VARIABLE (documented divergence: a
1784                    // warning here never blocks a build); the text is Godot's own.
1785                    self.warn(
1786                        v.name_range,
1787                        WarningCode::ShadowedVariable,
1788                        format!(
1789                            "There is already a parameter named \"{}\" declared in this scope.",
1790                            v.name
1791                        ),
1792                    );
1793                } else if shadows_member {
1794                    // Godot's wording names the shadowed member's 1-based line (probed q08). The
1795                    // range comes from the class scope's item tree; an anonymous-enum variant has
1796                    // no `Member` node, so it falls back to the line-less wording.
1797                    let msg = match self
1798                        .class
1799                        .lookup(&v.name)
1800                        .and_then(|item| self.class.member(item))
1801                        .and_then(member_name_range)
1802                    {
1803                        Some(r) => format!(
1804                            "The local {what} \"{}\" is shadowing an already-declared variable at line {} in the current class.",
1805                            v.name,
1806                            self.line_of(r)
1807                        ),
1808                        None => format!(
1809                            "The local {what} \"{}\" is shadowing an already-declared variable in the current class.",
1810                            v.name
1811                        ),
1812                    };
1813                    self.warn(v.name_range, WarningCode::ShadowedVariable, msg);
1814                } else if let Some((kind, base)) = self.engine_base_value_member_kind(&v.name) {
1815                    // An own-member shadow already won above; only a *base*-member shadow reaches
1816                    // here. Godot names the member kind and the base class (probed p23/q09/r38).
1817                    self.warn(
1818                        v.name_range,
1819                        WarningCode::ShadowedVariableBaseClass,
1820                        format!(
1821                            "The local {what} \"{}\" is shadowing an already-declared {kind} in the base class \"{base}\".",
1822                            v.name
1823                        ),
1824                    );
1825                }
1826            }
1827            // UNASSIGNED_VARIABLE candidates: an UNTYPED no-init local (Godot 4 zero-initializes
1828            // a typed `var x: int` and stays silent on a read — probed q11 vs r35), plus an
1829            // ENUM-typed no-init local — Godot tracks those as unassigned rather than raising
1830            // ENUM_VARIABLE_WITHOUT_DEFAULT, which is member-only (probed r47: `var e:
1831            // MouseButton` + read warns "used before being assigned").
1832            let enum_typed = matches!(annotated.as_ref(), Some(Ty::Enum(_)));
1833            if v.init.is_none() && (v.type_ref.is_none() || enum_typed) {
1834                self.needs_assignment.insert(v.name.clone());
1835            }
1836        }
1837        self.bindings.push(Binding {
1838            name: v.name.clone(),
1839            name_range: v.name_range,
1840            ty: binding_ty.clone(),
1841            init: v.init,
1842            annotated: v.type_ref.is_some(),
1843            inferred_colon_eq: v.is_inferred,
1844            is_const: v.is_const,
1845            kind: BindingKind::Var,
1846        });
1847        // A (re-)declaration's narrowing invalidation is handled by the flow analysis (Workstream 2).
1848        self.locals.insert(v.name.clone(), binding_ty);
1849    }
1850
1851    // ---- expressions ----
1852
1853    fn infer_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
1854        let ty = self.synth_expr(id, expected);
1855        self.expr_ty.insert(id, ty.clone());
1856        ty
1857    }
1858
1859    #[allow(clippy::too_many_lines)]
1860    fn synth_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
1861        match self.body.expr(id).clone() {
1862            Expr::Missing => Ty::Error,
1863            Expr::Literal(lit) => self.literal_ty(lit),
1864            Expr::Name(name) => self.resolve_name(id, &name),
1865            Expr::SelfExpr => self.self_ty.clone(),
1866            Expr::Super => self.class.base.clone(),
1867            Expr::Paren(inner) => self.infer_expr(inner, expected),
1868            Expr::Bin { op, lhs, rhs } => self.infer_bin(id, op, lhs, rhs),
1869            Expr::Unary { op, operand } => {
1870                let t = self.infer_expr(operand, &Expectation::None);
1871                match op {
1872                    UnOp::Not => self.bool_ty(),
1873                    UnOp::BitNot => self.int_ty(),
1874                    UnOp::Neg | UnOp::Pos => {
1875                        if t.is_uninformative() || self.is_numeric(&t) {
1876                            t
1877                        } else {
1878                            Ty::Variant
1879                        }
1880                    }
1881                }
1882            }
1883            Expr::Ternary {
1884                cond,
1885                then_branch,
1886                else_branch,
1887            } => {
1888                self.infer_expr(cond, &Expectation::None);
1889                let a = self.infer_expr(then_branch, expected);
1890                let b = self.infer_expr(else_branch, expected);
1891                // A `null` branch does not poison the other: `x if c else null` is nullable-`x`.
1892                if self.is_null(else_branch) {
1893                    a
1894                } else if self.is_null(then_branch) {
1895                    b
1896                } else {
1897                    let r = self.join(&a, &b);
1898                    // Both arms informative but with no common type (the join widened to Variant) —
1899                    // the ternary's two values are mutually incompatible.
1900                    if r.is_variant() && !a.is_uninformative() && !b.is_uninformative() {
1901                        self.warn(
1902                            self.range_of(id),
1903                            WarningCode::IncompatibleTernary,
1904                            "The values of the ternary conditional are not mutually compatible."
1905                                .to_owned(),
1906                        );
1907                    }
1908                    r
1909                }
1910            }
1911            Expr::Call { callee, args } => self.infer_call(callee, &args),
1912            Expr::Field {
1913                receiver,
1914                name,
1915                name_range,
1916            } => {
1917                self.infer_field(receiver, &name, name_range, /*as_method=*/ false)
1918            }
1919            Expr::Index { base, index } => {
1920                let base_ty = self.infer_expr(base, &Expectation::None);
1921                self.infer_expr(index, &Expectation::None);
1922                self.index_ty(&base_ty, index)
1923            }
1924            Expr::Is { operand, .. } => {
1925                self.infer_expr(operand, &Expectation::None);
1926                self.bool_ty()
1927            }
1928            Expr::Cast { operand, ty } => {
1929                self.infer_expr(operand, &Expectation::None);
1930                ty.map_or(Ty::Variant, |p| self.resolve_ptr_ty(p))
1931            }
1932            Expr::In { lhs, rhs, .. } => {
1933                self.infer_expr(lhs, &Expectation::None);
1934                self.infer_expr(rhs, &Expectation::None);
1935                self.bool_ty()
1936            }
1937            Expr::Await(operand) => {
1938                let operand_ty = self.infer_expr(operand, &Expectation::None);
1939                // `await coroutine()` yields the call's value, so await is **identity** on the operand
1940                // type (`await f()` for `func f() -> int` is `int`) — recovered here. `await signal`
1941                // instead yields the signal's emitted payload, which needs the Phase-3+ signal-signature
1942                // table; until then it's the seam (never `Variant`, so `var x := await sig` never warns).
1943                if matches!(operand_ty, Ty::Signal(_)) {
1944                    Ty::Unknown
1945                } else {
1946                    operand_ty
1947                }
1948            }
1949            Expr::Array(elems) => {
1950                // Checking mode: an expected `Array[T]` is pushed down onto the literal (so
1951                // `var a: Array[String] = []` / `[...]` is accepted). Otherwise the engine does
1952                // not infer a literal's element type past `Variant`.
1953                let pushed = match expected {
1954                    Expectation::Has(Ty::Array(e)) => Some((**e).clone()),
1955                    _ => None,
1956                };
1957                let elem_exp = pushed.clone().map_or(Expectation::None, Expectation::Has);
1958                for e in elems {
1959                    self.infer_expr(e, &elem_exp);
1960                }
1961                pushed.map_or_else(Ty::array_of_variant, |e| Ty::Array(Box::new(e)))
1962            }
1963            Expr::Dict(entries) => {
1964                let pushed = match expected {
1965                    Expectation::Has(Ty::Dict(k, v)) => Some(((**k).clone(), (**v).clone())),
1966                    _ => None,
1967                };
1968                let (kx, vx) = pushed
1969                    .clone()
1970                    .map_or((Expectation::None, Expectation::None), |(k, v)| {
1971                        (Expectation::Has(k), Expectation::Has(v))
1972                    });
1973                for (k, v) in entries {
1974                    self.infer_expr(k, &kx);
1975                    if let Some(v) = v {
1976                        self.infer_expr(v, &vx);
1977                    }
1978                }
1979                pushed.map_or_else(Ty::dict_of_variant, |(k, v)| {
1980                    Ty::Dict(Box::new(k), Box::new(v))
1981                })
1982            }
1983            Expr::Lambda { params, body } => {
1984                self.infer_lambda(&params, &body);
1985                Ty::Callable
1986            }
1987            Expr::Preload { arg, path } => {
1988                if let Some(arg) = arg {
1989                    self.infer_expr(arg, &Expectation::None);
1990                }
1991                // A constant string-literal path resolves to the declaring file's `ScriptRef`
1992                // (M3 — a SCRIPT meta-type in Godot; `X.new()`/`X.member` then resolve via the
1993                // usual `ScriptRef` walk). A non-constant argument (`preload(var)`) — which Godot
1994                // itself rejects — stays the seam, never a false diagnostic.
1995                match path {
1996                    // Anchor a relative `preload("sibling.gd")` to the importing file's directory
1997                    // before resolving (Godot anchors relative resource paths); absolute paths pass
1998                    // through, and a relative path with no anchor stays the seam.
1999                    Some(p) => {
2000                        match resolve::anchor_res_path(self.self_res_path().as_deref(), &p) {
2001                            Some(abs) => resolve::resolve_external(
2002                                self.db,
2003                                &resolve::ExternalRef::Preload(abs),
2004                            ),
2005                            None => Ty::Unknown,
2006                        }
2007                    }
2008                    None => Ty::Unknown,
2009                }
2010            }
2011            // `$Path`/`%Unique` — resolve the literal path against the owning scene to the node's
2012            // concrete type (Phase-4 M1); a computed/unresolvable path stays `Object(Node)`.
2013            Expr::GetNode { path, unique } => self.resolve_node_path(id, path.as_deref(), unique),
2014        }
2015    }
2016
2017    /// Whether `id` is the `null` literal.
2018    fn is_null(&self, id: ExprId) -> bool {
2019        matches!(self.body.expr(id), Expr::Literal(Literal::Null))
2020    }
2021
2022    fn literal_ty(&self, lit: Literal) -> Ty {
2023        match lit {
2024            Literal::Int(_) => self.int_ty(),
2025            Literal::Float | Literal::MathConst => self.float_ty(),
2026            Literal::Bool(_) => self.bool_ty(),
2027            Literal::Str => self.builtin("String"),
2028            Literal::StringName => self.builtin("StringName"),
2029            Literal::NodePath => self.builtin("NodePath"),
2030            // `null` is compatible everywhere; typing it `Variant` avoids false mismatches.
2031            Literal::Null => Ty::Variant,
2032        }
2033    }
2034
2035    fn node_ty(&self) -> Ty {
2036        self.api
2037            .class_by_name("Node")
2038            .map_or(Ty::Unknown, Ty::Object)
2039    }
2040
2041    // ---- scene-aware node-path typing (Phase-4 M1) ----
2042
2043    /// Resolve a `$Path`/`%Unique`/`get_node("…")` literal node path against the owning scene to the
2044    /// node's concrete type. A computed (`None`) path, no owning scene, an `..`/absolute escape, or a
2045    /// path that descends into an instanced sub-scene all degrade to `Object(Node)` — never a false
2046    /// positive. A *genuinely* absent in-scene node raises `INVALID_NODE_PATH` (M2), but only when
2047    /// the script attaches to exactly one scene (an ambiguous multi-scene attachment stays silent).
2048    fn resolve_node_path(&mut self, id: ExprId, path: Option<&str>, unique: bool) -> Ty {
2049        use gdscript_scene::NodePathResolution as R;
2050        let fallback = self.node_ty();
2051        let Some(path) = path else {
2052            return fallback; // computed `get_node(var)` — stays `Node`
2053        };
2054        // An absolute `/root/<Autoload>` access resolves to the autoload's type — singleton OR
2055        // loaded-but-not-global (both live at `/root/Name`). Independent of any owning scene. A deeper
2056        // tail (`/root/Name/Child`) would need to walk the autoload's own scene; left as the seam.
2057        if !unique && let Some(ty) = self.resolve_root_autoload_path(path) {
2058            return ty;
2059        }
2060        let Some(ctx) = self.owning_scene() else {
2061            return fallback; // no scene attaches this script (dynamic UI / single-file)
2062        };
2063        // Multi-scene attachment (M2 §6.3): a `$Path` may resolve to a different node type in each
2064        // attaching scene, so type it as the COMMON BASE across all of them (never the first-scene
2065        // type, which could be wrong for another scene). If any scene can't resolve it identically,
2066        // degrade to `Node` — never a false positive and never a false `INVALID_NODE_PATH`.
2067        if ctx.ambiguous {
2068            return self.union_node_ty(path, unique).unwrap_or(fallback);
2069        }
2070        let resolution = if unique {
2071            ctx.model.classify_unique(path)
2072        } else {
2073            ctx.model.classify_path_from(ctx.attach, path)
2074        };
2075        match resolution {
2076            R::Resolved(idx) => ctx
2077                .model
2078                .node(idx)
2079                .and_then(|n| self.scene_node_ty(&ctx.model, n, 0))
2080                .unwrap_or(fallback),
2081            R::Missing => {
2082                let what = if unique { "unique name" } else { "node path" };
2083                let sigil = if unique { "%" } else { "$" };
2084                self.emit(
2085                    self.range_of(id),
2086                    Severity::Warning,
2087                    INVALID_NODE_PATH,
2088                    format!("no {what} `{sigil}{path}` in the owning scene"),
2089                );
2090                fallback
2091            }
2092            // The path descends into an instanced sub-scene (`$Enemy/Sprite`): resolve the tail in
2093            // the sub-scene's own tree (`Sprite` typed by `enemy.tscn`). Any failure → `Node`.
2094            R::IntoInstance => {
2095                let walked = if unique {
2096                    ctx.model.resolve_unique_into_instance(path)
2097                } else {
2098                    ctx.model.resolve_into_instance(ctx.attach, path)
2099                };
2100                walked
2101                    .and_then(|(inst, tail)| {
2102                        let inst_node = ctx.model.node(inst)?;
2103                        self.resolve_into_instance_ty(&ctx.model, inst_node, &tail, 0)
2104                    })
2105                    .unwrap_or(fallback)
2106            }
2107            // An `..`/absolute escape out of the slice → `Node`, never a false warning.
2108            R::Escaped => fallback,
2109        }
2110    }
2111
2112    /// Union-type a `$Path`/`%Unique` across **every** scene that attaches this script (the rare
2113    /// multi-scene case): resolve the path in each scene, then take the COMMON BASE of the per-scene
2114    /// node types. `None` (→ caller degrades to `Node`) if any scene fails to resolve the path the
2115    /// same way — keeping the no-false-positive contract on an ambiguous attachment.
2116    fn union_node_ty(&self, path: &str, unique: bool) -> Option<Ty> {
2117        use gdscript_scene::NodePathResolution as R;
2118        let res_path = self.self_res_path()?;
2119        let root = self.db.source_root()?;
2120        let attaches = crate::queries::script_scene_attachments(self.db, root)
2121            .get(res_path.as_str())
2122            .cloned()?;
2123        let mut acc: Option<Ty> = None;
2124        for (scene_file, attach) in &attaches {
2125            let ft = self.db.file_text(*scene_file)?;
2126            let model = crate::queries::scene_model(self.db, ft);
2127            let resolution = if unique {
2128                model.classify_unique(path)
2129            } else {
2130                model.classify_path_from(*attach, path)
2131            };
2132            let R::Resolved(idx) = resolution else {
2133                return None; // a miss / escape / into-instance in some scene → bail to `Node`
2134            };
2135            let ty = model
2136                .node(idx)
2137                .and_then(|n| self.scene_node_ty(&model, n, 0))?;
2138            acc = Some(match acc {
2139                None => ty,
2140                Some(prev) => self.common_base(&prev, &ty),
2141            });
2142        }
2143        acc
2144    }
2145
2146    /// The common base of two scene-node types — the lowest engine class both descend from (walk
2147    /// `a`'s ancestor chain, return the first that `b` is a subclass of). Identical types collapse to
2148    /// themselves; a `ScriptRef` or any mixed pair degrades to the `Node` floor (the engine base for
2149    /// every scene node), which is always a sound supertype.
2150    fn common_base(&self, a: &Ty, b: &Ty) -> Ty {
2151        if a == b {
2152            return a.clone();
2153        }
2154        if let (Ty::Object(ca), Ty::Object(cb)) = (a, b) {
2155            let mut cur = Some(*ca);
2156            while let Some(c) = cur {
2157                if self.api.is_subclass(*cb, c) {
2158                    return Ty::Object(c);
2159                }
2160                cur = self.api.class(c).base;
2161            }
2162        }
2163        self.node_ty()
2164    }
2165
2166    /// Resolve an absolute `/root/<Autoload>` node path to the autoload's type (singleton or
2167    /// loaded-but-not-global — both are children of the scene-tree root). `None` for any other path,
2168    /// including a deeper tail (`/root/Name/Child`, which would need the autoload's own scene) — those
2169    /// degrade to the `Node` seam with no false positive.
2170    fn resolve_root_autoload_path(&self, path: &str) -> Option<Ty> {
2171        let name = path.strip_prefix("/root/")?;
2172        // Only the autoload node itself (no trailing segment) for now.
2173        if name.is_empty() || name.contains('/') {
2174            return None;
2175        }
2176        let ty = resolve::resolve_autoload_any(self.db, name);
2177        (!ty.is_uninformative()).then_some(ty)
2178    }
2179
2180    /// The owning-scene context for the current file (scene + attach node + multi-scene ambiguity).
2181    /// Recovered from `self_ty`, which `analyze_file` sets to the file's own `ScriptRef` (so no extra
2182    /// `FileId` threading).
2183    fn owning_scene(&self) -> Option<crate::queries::SceneContext> {
2184        let Ty::ScriptRef(sref) = &self.self_ty else {
2185            return None;
2186        };
2187        let ft = self.db.file_text(FileId(sref.0))?;
2188        crate::queries::scene_context(self.db, ft)
2189    }
2190
2191    /// The importing file's own `res://` path (from `self_ty`), for anchoring relative
2192    /// `preload`/`extends` paths to its directory. `None` when the file has no resource path.
2193    fn self_res_path(&self) -> Option<SmolStr> {
2194        let Ty::ScriptRef(sref) = &self.self_ty else {
2195            return None;
2196        };
2197        self.db.file_text(FileId(sref.0))?.res_path(self.db)
2198    }
2199
2200    /// The concrete `Ty` of a scene node, by precedence: an attached script's own class (most
2201    /// specific) wins; else the declared `type=` (native class or `class_name`); else — an instanced
2202    /// node (`instance=`, no own `type=`/script) — the **instanced sub-scene's root** type (M3,
2203    /// recursive). `None` for a node we can't sharpen (the caller degrades to `Node`).
2204    fn scene_node_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
2205        if let Some(script_ty) = self.node_script_ref(scene, node) {
2206            return Some(script_ty);
2207        }
2208        if let Some(decl) = node.decl_type.as_ref() {
2209            let ty = resolve::resolve_type_name(self.db, self.api, decl);
2210            if !ty.is_uninformative() {
2211                return Some(ty);
2212            }
2213        }
2214        self.instance_root_ty(scene, node, depth)
2215            .or_else(|| self.override_child_ty(scene, node, depth))
2216    }
2217
2218    /// An **override child** *under* an instance: a node added/overridden in the outer scene beneath
2219    /// an `instance=` boundary (`[node name="Sprite" parent="Enemy"]` over an instanced `enemy.tscn`),
2220    /// carrying no own `type=`/`script`/`instance=` — so its real type lives in the instanced
2221    /// sub-scene. Walk up to the nearest instance-boundary ancestor, then type the node by its same
2222    /// path *inside* that sub-scene (so the outer override of `enemy.tscn`'s `Sprite` types as the
2223    /// sub-scene's `Sprite`, not bare `Node`). `None` if the node is not under an instance (the
2224    /// caller then floors to `Node`, unchanged). Depth-bounded against an instancing cycle.
2225    fn override_child_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
2226        if depth >= 16 {
2227            return None;
2228        }
2229        let mut segs_rev: Vec<String> = vec![node.name.to_string()];
2230        let mut parent_idx = node.parent_idx?;
2231        let mut guard = 0u32;
2232        loop {
2233            let parent = scene.node(parent_idx)?;
2234            if parent.instance.is_some() {
2235                segs_rev.reverse();
2236                let rel = segs_rev.join("/");
2237                return self.resolve_into_instance_ty(scene, parent, &rel, depth + 1);
2238            }
2239            segs_rev.push(parent.name.to_string());
2240            parent_idx = parent.parent_idx?;
2241            guard += 1;
2242            if guard > 4096 {
2243                return None;
2244            }
2245        }
2246    }
2247
2248    /// An instanced node (`instance=ExtResource(id)`) takes the type of the instanced sub-scene's
2249    /// ROOT node — resolved recursively, so the root's own script / `type=` / nested instance all
2250    /// flow through (so `$Enemy` types as `enemy.tscn`'s root class, not bare `Node`). Depth-bounded
2251    /// against an instancing cycle (scene A instances B instances A).
2252    fn instance_root_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
2253        if depth >= 16 {
2254            return None;
2255        }
2256        let (sub, sub_root) = self.instance_subscene(scene, node)?;
2257        let root_node = sub.node(sub_root)?;
2258        self.scene_node_ty(&sub, root_node, depth + 1)
2259    }
2260
2261    /// The instanced sub-scene's model + its root index, for an instance node (`instance=ExtResource`
2262    /// → `res://` path → `FileId` → `scene_model`). The shared resolution step for both
2263    /// [`instance_root_ty`](Self::instance_root_ty) (the node's own type) and
2264    /// [`resolve_into_instance_ty`](Self::resolve_into_instance_ty) (paths that go *into* it).
2265    fn instance_subscene(
2266        &self,
2267        scene: &SceneModel,
2268        node: &SceneNode,
2269    ) -> Option<(Arc<SceneModel>, gdscript_scene::NodeIdx)> {
2270        let inst = node.instance.as_ref()?;
2271        let path = scene.ext_resources.get(inst)?.path.as_ref()?;
2272        let root = self.db.source_root()?;
2273        let file = crate::queries::res_path_registry(self.db, root)
2274            .get(path.as_str())
2275            .copied()?;
2276        let ft = self.db.file_text(file)?;
2277        let sub = crate::queries::scene_model(self.db, ft);
2278        let sub_root = sub.root?;
2279        Some((sub, sub_root))
2280    }
2281
2282    /// Type a node path that descends INTO an instanced sub-scene: `instance_node` is the boundary
2283    /// (an `instance=` node) and `tail` is the remaining path. Resolve `tail` from the sub-scene's
2284    /// root, recursing through further instance boundaries inside it. Depth-bounded against an
2285    /// instancing cycle. `None` (→ `Node`, no false warning) if the tail genuinely can't be typed.
2286    fn resolve_into_instance_ty(
2287        &self,
2288        scene: &SceneModel,
2289        instance_node: &SceneNode,
2290        tail: &str,
2291        depth: u32,
2292    ) -> Option<Ty> {
2293        if depth >= 16 {
2294            return None;
2295        }
2296        let (sub, sub_root) = self.instance_subscene(scene, instance_node)?;
2297        if let Some(idx) = sub.resolve_path_from(sub_root, tail) {
2298            let n = sub.node(idx)?;
2299            return self.scene_node_ty(&sub, n, depth + 1);
2300        }
2301        // The tail crosses a further instance boundary *inside* the sub-scene — keep descending.
2302        let (inner, inner_tail) = sub.resolve_into_instance(sub_root, tail)?;
2303        let inner_node = sub.node(inner)?;
2304        self.resolve_into_instance_ty(&sub, inner_node, &inner_tail, depth + 1)
2305    }
2306
2307    /// The `ScriptRef` of a node's attached `.gd` script (`script = ExtResource(id)` → its `res://`
2308    /// path → `FileId`), or `None` if it has no resolvable external script.
2309    fn node_script_ref(&self, scene: &SceneModel, node: &SceneNode) -> Option<Ty> {
2310        let path = scene
2311            .ext_resources
2312            .get(node.script.as_ref()?)?
2313            .path
2314            .as_ref()?;
2315        let root = self.db.source_root()?;
2316        let file = crate::queries::res_path_registry(self.db, root)
2317            .get(path.as_str())
2318            .copied()?;
2319        Some(Ty::ScriptRef(ScriptRefId(file.0)))
2320    }
2321
2322    fn infer_bin(&mut self, id: ExprId, op: BinOp, lhs: ExprId, rhs: ExprId) -> Ty {
2323        if op == BinOp::Assign {
2324            return self.infer_assign(lhs, rhs);
2325        }
2326        // Short-circuit narrowing (Workstream 2): the RHS of `a and b` is typed under `a`'s
2327        // then-facts; `a or b`'s RHS under `a`'s else-facts. Restore the env afterward.
2328        if matches!(op, BinOp::And | BinOp::Or) {
2329            self.infer_expr(lhs, &Expectation::None);
2330            let saved = self.narrowing.clone();
2331            self.apply_condition_facts(lhs, op == BinOp::And);
2332            self.infer_expr(rhs, &Expectation::None);
2333            self.narrowing = saved;
2334            return self.bool_ty();
2335        }
2336        let lt = self.infer_expr(lhs, &Expectation::None);
2337        let rt = self.infer_expr(rhs, &Expectation::None);
2338        if op.is_boolean() {
2339            return self.bool_ty();
2340        }
2341        // `int / int` discards the fractional part.
2342        if op == BinOp::Div && self.is_int(&lt) && self.is_int(&rt) {
2343            self.warn(
2344                self.range_of(id),
2345                WarningCode::IntegerDivision,
2346                "Integer division. Decimal part will be discarded.".to_owned(),
2347            );
2348            return self.int_ty();
2349        }
2350        self.bin_result(op, &lt, &rt)
2351    }
2352
2353    fn infer_assign(&mut self, lhs: ExprId, rhs: ExprId) -> Ty {
2354        let slot = self.infer_expr(lhs, &Expectation::None);
2355        let expected = if slot.is_uninformative() {
2356            Expectation::None
2357        } else {
2358            Expectation::Has(slot.clone())
2359        };
2360        let value = self.infer_expr(rhs, &expected);
2361        if !slot.is_uninformative() {
2362            self.check_assign(&value, &slot, self.range_of(rhs), AssignCtx::Assign);
2363        }
2364        // Assignment *invalidates* the place's narrowing (handled by the flow analysis, Workstream
2365        // 2); re-narrowing from the assigned value's type is a post-1.0 precision item.
2366        slot
2367    }
2368
2369    /// Resolve a binary operator's result type via the builtin operator table, with a numeric
2370    /// fallback. Comparison/logical operators are handled by the caller.
2371    fn bin_result(&self, op: BinOp, lt: &Ty, rt: &Ty) -> Ty {
2372        if let (Ty::Builtin(b), Some(sym)) = (lt, op_symbol(op)) {
2373            for o in self.api.builtin_operators(*b) {
2374                if o.op == sym
2375                    && let Some(right) = &o.right
2376                    && self.tyref_matches(right, rt)
2377                {
2378                    return ty::resolve_tyref(self.api, &o.result);
2379                }
2380            }
2381        }
2382        if self.is_numeric(lt) && self.is_numeric(rt) {
2383            return if self.is_float(lt) || self.is_float(rt) {
2384                self.float_ty()
2385            } else {
2386                self.int_ty()
2387            };
2388        }
2389        // A seam operand keeps the result on the seam (`a + unknown` is `Unknown`, not the
2390        // gradual `Variant`, so `var x := a + unknown` never warns).
2391        if lt.is_unknown() || rt.is_unknown() || lt.is_error() || rt.is_error() {
2392            return Ty::Unknown;
2393        }
2394        Ty::Variant
2395    }
2396
2397    fn tyref_matches(&self, tyref: &TyRef, ty: &Ty) -> bool {
2398        let resolved = ty::resolve_tyref(self.api, tyref);
2399        resolved.is_variant() || &resolved == ty
2400    }
2401
2402    fn infer_call(&mut self, callee: ExprId, args: &[ExprId]) -> Ty {
2403        // Argument expressions are always inferred (their own diagnostics + hover).
2404        for &a in args {
2405            self.infer_expr(a, &Expectation::None);
2406        }
2407        let ret = match self.body.expr(callee).clone() {
2408            Expr::Field {
2409                receiver,
2410                name,
2411                name_range,
2412            } => {
2413                self.infer_field(receiver, &name, name_range, /*as_method=*/ true)
2414            }
2415            Expr::Name(name) => {
2416                // Locals first (BUG A2), matching `resolve_name`'s canonical local→member→… order:
2417                // a bare-name call on a local/param (`var f = func(): …` then `f(0)`, a
2418                // `cb: Callable` param, a hook alias `var useState = Hooks.useState`) is a READ of
2419                // that binding — routed through `resolve_name` so it gets the FULL read treatment
2420                // (`used_locals` for `UNUSED_*`, the `UNASSIGNED_VARIABLE` definite-assignment
2421                // check, flow narrowing), not just a use mark. The local also shadows a same-named
2422                // own/inherited method (real GDScript scoping). Its call result is the seam:
2423                // `Ty::Callable` carries no signature, and `Variant` here would fire false
2424                // `INFERENCE_ON_VARIANT` on `var x := f()`.
2425                let ret = if self.locals.contains_key(name.as_str()) {
2426                    self.resolve_name(callee, &name);
2427                    Ty::Unknown
2428                } else if let Some(t) = self.resolve_call_name(&name) {
2429                    t
2430                } else {
2431                    // Every same-file tier missed. With a COMPLETE workspace this is provably a
2432                    // typo (`usseState(0)`) unless a cross-file global/autoload/member exists —
2433                    // `UNDEFINED_FUNCTION` (BUG A1); otherwise the silent seam as before.
2434                    self.maybe_warn_undefined_call(callee, &name);
2435                    Ty::Unknown
2436                };
2437                self.expr_ty.insert(callee, Ty::Callable);
2438                ret
2439            }
2440            // Calling an arbitrary expression — a `Callable` value or an immediately-invoked
2441            // lambda (`(func(): …).call()`): the callee's return type isn't tracked, so the
2442            // result is the seam (not `Variant`), and `var x := f()()` never warns.
2443            _ => {
2444                self.infer_expr(callee, &Expectation::None);
2445                Ty::Unknown
2446            }
2447        };
2448        // UNSAFE_CALL_ARGUMENT (Phase-2 §5): args + receiver are now inferred (in `expr_ty`), so
2449        // check each argument against the statically-resolved callee's parameter types.
2450        self.check_call_args(callee, args);
2451        ret
2452    }
2453
2454    /// Check a call's arguments against the statically-resolved callee — arity first
2455    /// (Godot hard-errors: probed p09/p10/p14/p21; a variadic tail absorbs any surplus, p15),
2456    /// then per-argument assignability: a definite mismatch is Godot's Invalid-argument
2457    /// compile error (probed p06), a Variant-into-typed pass stays the opt-in
2458    /// `UNSAFE_CALL_ARGUMENT` (probed q26). Unresolvable callees (`Callable` values,
2459    /// cross-file script methods, constructor overload sets, seam receivers) raise nothing.
2460    fn check_call_args(&mut self, callee: ExprId, args: &[ExprId]) {
2461        let Some(sig) = self.call_signature(callee) else {
2462            return;
2463        };
2464        if args.len() < sig.required {
2465            self.warn(
2466                self.range_of(callee),
2467                WarningCode::TooFewArguments,
2468                format!(
2469                    "Too few arguments for \"{}()\" call. Expected at least {} but received {}.",
2470                    sig.name,
2471                    sig.required,
2472                    args.len()
2473                ),
2474            );
2475        } else if !sig.is_vararg && args.len() > sig.params.len() {
2476            self.warn(
2477                self.range_of(callee),
2478                WarningCode::TooManyArguments,
2479                format!(
2480                    "Too many arguments for \"{}()\" call. Expected at most {} but received {}.",
2481                    sig.name,
2482                    sig.params.len(),
2483                    args.len()
2484                ),
2485            );
2486        }
2487        for (i, &arg) in args.iter().enumerate() {
2488            let Some(param_ty) = sig.params.get(i) else {
2489                break; // a vararg tail (the surplus itself was arity-checked above)
2490            };
2491            if param_ty.is_uninformative() || param_ty.is_variant() {
2492                continue; // an untyped parameter accepts anything safely
2493            }
2494            // A missing arg type defaults to the seam (never warns), not `Variant` (would warn).
2495            let arg_ty = self.expr_ty.get(&arg).cloned().unwrap_or(Ty::Unknown);
2496            let pl = param_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
2497            let al = arg_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
2498            match ty::is_assignable(self.api, &arg_ty, param_ty) {
2499                // Godot's site-specific compile error (p06 — it prints a generic `Cannot
2500                // pass …` line too; one diagnostic per site, and the specific text wins
2501                // here because the callee name is known).
2502                Assign::No => self.emit(
2503                    self.range_of(arg),
2504                    Severity::Error,
2505                    TYPE_MISMATCH,
2506                    format!(
2507                        "Invalid argument for \"{}()\" function: argument {} should be \"{pl}\" but is \"{al}\".",
2508                        sig.name,
2509                        i + 1
2510                    ),
2511                ),
2512                Assign::OkUnsafe => self.warn(
2513                    self.range_of(arg),
2514                    WarningCode::UnsafeCallArgument,
2515                    format!(
2516                        "The argument {} of the function \"{}()\" requires the subtype \"{pl}\" but the supertype \"{al}\" was provided.",
2517                        i + 1,
2518                        sig.name
2519                    ),
2520                ),
2521                // Narrowing / int-as-enum at an argument are deliberately left silent for
2522                // now (pre-existing behavior; escalating them needs its own corpus pass).
2523                Assign::Ok | Assign::Narrowing | Assign::IntAsEnum => {}
2524            }
2525        }
2526    }
2527
2528    /// The statically-resolved [`CallSignature`] of a callee. `None` when it isn't concretely
2529    /// resolvable here — a `Callable` value / shadowing local (BUG A2), a cross-file script
2530    /// method (params aren't modeled), a constructor (an overload set has no single arity), or
2531    /// a seam receiver: those raise no call diagnostics.
2532    fn call_signature(&self, callee: ExprId) -> Option<CallSignature> {
2533        match self.body.expr(callee) {
2534            Expr::Name(name) => self.name_call_signature(name),
2535            Expr::Field { receiver, name, .. } => match self.expr_ty.get(receiver)? {
2536                Ty::Object(class) => match self.api.lookup_member(*class, name)? {
2537                    MemberRef::Method(sig) => Some(method_call_signature(self.api, sig)),
2538                    _ => None,
2539                },
2540                // Builtin receivers: the closed tables carry full `MethodSig`s (defaults +
2541                // vararg), so calls check exactly like engine-class methods. ScriptRef /
2542                // seam receivers stay skipped (params not uniformly modeled).
2543                recv => {
2544                    let bid = self.builtin_id_of(recv)?;
2545                    let sig = self.api.builtin_method(bid, name)?;
2546                    Some(method_call_signature(self.api, sig))
2547                }
2548            },
2549            _ => None,
2550        }
2551    }
2552
2553    /// The [`CallSignature`] for a bare-name call (`foo(...)` / an inherited `method(...)` /
2554    /// a utility / a `@GDScript` pseudo-class function). Mirrors `resolve_call_name`'s tiers;
2555    /// constructors (`Vector2(...)`) are deliberately not covered (overload sets).
2556    fn name_call_signature(&self, name: &str) -> Option<CallSignature> {
2557        // A local/param shadows a same-named method for a bare-name call (BUG A2, mirroring
2558        // `infer_call`'s locals-first resolution) — the local `Callable`'s params aren't
2559        // modeled, so the shadowed method's signature must not check the call.
2560        if self.locals.contains_key(name) {
2561            return None;
2562        }
2563        if let Some(item) = self.class.lookup(name)
2564            && let Some(Member::Func(f)) = self.class.member(item)
2565        {
2566            return Some(CallSignature {
2567                name: f.name.clone(),
2568                params: f
2569                    .params
2570                    .iter()
2571                    .map(|p| {
2572                        p.type_ref.as_deref().map_or(Ty::Variant, |t| {
2573                            resolve::resolve_type_name(self.db, self.api, t)
2574                        })
2575                    })
2576                    .collect(),
2577                required: f.params.iter().filter(|p| !p.has_default).count(),
2578                // A `...rest` vararg param is not lowered into `params` (it has no fixed
2579                // slot), so `params` are exactly the fixed ones — the flag absorbs surplus.
2580                is_vararg: f.is_vararg,
2581            });
2582        }
2583        if let Ty::Object(base) = self.class.base
2584            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
2585        {
2586            return Some(method_call_signature(self.api, sig));
2587        }
2588        // Utility functions (`abs`, `clamp`, `absf`, …) — full signatures incl. the vararg
2589        // flag (`print` and friends never arity-error: probed p15 vs p21).
2590        if let Some(u) = self.api.utility(name) {
2591            return Some(CallSignature {
2592                name: SmolStr::new(&u.name),
2593                params: u
2594                    .params
2595                    .iter()
2596                    .map(|p| ty::resolve_tyref(self.api, &p.ty))
2597                    .collect(),
2598                required: u.params.iter().filter(|p| p.default.is_none()).count(),
2599                is_vararg: u.is_vararg,
2600            });
2601        }
2602        // `@GDScript` pseudo-class functions (`range`, `len`, `str`, …): the layer models
2603        // min/max arg counts only (their param types are looser — `Variant` slots keep the
2604        // type loop silent); `max_args: None` is variadic. `preload` never reaches here
2605        // (it lowers to its own expression kind).
2606        if let Some(f) = self.api.gdscript_builtin(name) {
2607            let fixed = usize::from(f.max_args.unwrap_or(f.min_args));
2608            return Some(CallSignature {
2609                name: SmolStr::new(f.name),
2610                params: vec![Ty::Variant; fixed],
2611                required: usize::from(f.min_args),
2612                is_vararg: f.max_args.is_none(),
2613            });
2614        }
2615        None
2616    }
2617
2618    /// Resolve a bare-name call (`foo(...)`): own method → utility/builtin fn → constructor.
2619    /// A local/param callee never reaches here — `infer_call`'s `Expr::Name` arm resolves locals
2620    /// first (BUG A2; this fn is `&self` and could not record the read into `used_locals`).
2621    /// `None` when every tier missed — the caller decides between the silent `Ty::Unknown` seam
2622    /// (cross-file / incomplete workspace) and `UNDEFINED_FUNCTION` (BUG A1; this fn is `&self`
2623    /// and cannot emit).
2624    fn resolve_call_name(&self, name: &str) -> Option<Ty> {
2625        if let Some(item) = self.class.lookup(name)
2626            && let Some(Member::Func(f)) = self.class.member(item)
2627        {
2628            return Some(self.func_call_return_ty(f));
2629        }
2630        // A bare call inside the class is `self.name(...)` — resolve against the inherited base.
2631        if let Ty::Object(base) = self.class.base
2632            && let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
2633        {
2634            return Some(ty::resolve_tyref(self.api, &sig.return_ty));
2635        }
2636        if let Some(u) = self.api.utility(name) {
2637            return Some(ty::resolve_tyref(self.api, &u.return_ty));
2638        }
2639        if let Some(f) = self.api.gdscript_builtin(name) {
2640            return Some(resolve::layer_to_ty(self.api, f.ret));
2641        }
2642        // A builtin / class name used as a constructor: `Vector2(...)` / `Array(...)`.
2643        // Normalize via `resolve_tyref` so `Array`/`Dictionary`/`Callable`/`Signal` land on
2644        // their dedicated `Ty` variants rather than `Builtin(...)`.
2645        if let Some(b) = self.api.builtin_by_name(name) {
2646            return Some(ty::resolve_tyref(self.api, &TyRef::Builtin(b)));
2647        }
2648        // Unresolved — a cross-file global / autoload / a method on a `class_name` base we can't
2649        // see, or a genuine typo. The caller disambiguates.
2650        None
2651    }
2652
2653    /// The soundness gate for the absence-based `UNDEFINED_*` codes (BUG A1). Emitting "defined
2654    /// nowhere" requires being able to SEE everywhere a definition could live, so all of:
2655    /// - the loader declared the workspace COMPLETE ([`gdscript_db::SourceRoot::complete`]) — a
2656    ///   lone file (or a partial load) can never prove absence of a cross-file `class_name`;
2657    /// - this is a top-level script class (`self_ty` is a `ScriptRef`) — an inner class's bare
2658    ///   names may bind to outer-class members this scope's lookup does not chain to;
2659    /// - the base chain is fully engine-native (`Ty::Object`) — a user-script base (`ScriptRef`)
2660    ///   or an unresolved `extends` could hide an inherited definition the walk can't rule out;
2661    /// - the project does not target an engine NEWER than the bundled API model — a newer minor
2662    ///   adds classes/utilities the model can't rule out (corpus: `DrawableTexture2D` on a 4.5
2663    ///   model). An undeclared version is trusted at the bundled model's own version.
2664    fn undefined_symbols_provable(&self) -> bool {
2665        matches!(self.self_ty, Ty::ScriptRef(_))
2666            && matches!(self.class.base, Ty::Object(_))
2667            && self.db.source_root().is_some_and(|r| r.complete(self.db))
2668            && crate::queries::project_engine_version(self.db)
2669                .is_none_or(|v| v <= crate::warnings::bundled_version())
2670    }
2671
2672    /// The trailing `name`-sized slice of `id`'s range — the exact identifier token. An
2673    /// `Expr::Name` node's range may include LEADING trivia (the CST attaches it to the node) but
2674    /// always ends at the identifier's last byte, so anchoring on the tail yields a precise
2675    /// squiggle instead of one that bleeds onto the previous line.
2676    fn name_token_range(&self, id: ExprId, name: &str) -> TextRange {
2677        let r = self.range_of(id);
2678        let len = u32::try_from(name.len()).unwrap_or(0);
2679        TextRange::new(r.end.saturating_sub(len).max(r.start), r.end)
2680    }
2681
2682    /// `UNDEFINED_FUNCTION` (BUG A1) for a bare-name call whose every same-file tier already
2683    /// missed (`resolve_call_name` returned `None`, and the name is not a local — the A2 branch).
2684    /// Cross-file tiers are checked here: a project `class_name`, a `*`-autoload, or an engine
2685    /// global keeps the silent seam (calling those is a *different* mistake than "undefined", and
2686    /// Godot reports it differently). A member of ANY kind (var/const/signal/enum/inner class) on
2687    /// the class or its engine base also keeps the seam — "defined but not callable" is not
2688    /// "undefined". Emission is gated by [`Self::undefined_symbols_provable`].
2689    fn maybe_warn_undefined_call(&mut self, callee: ExprId, name: &str) {
2690        if !self.undefined_symbols_provable() || self.class.lookup(name).is_some() {
2691            return;
2692        }
2693        if let Ty::Object(base) = self.class.base
2694            && self.api.lookup_member(base, name).is_some()
2695        {
2696            return;
2697        }
2698        if resolve::resolve_global(self.api, name).is_some()
2699            || is_registered_global_class(self.db, name)
2700            || is_autoload_singleton(self.db, name)
2701        {
2702            return;
2703        }
2704        self.warn(
2705            self.name_token_range(callee, name),
2706            WarningCode::UndefinedFunction,
2707            // Godot's exact compile-error text for a bare-name miss (probed p04) — the "base
2708            // self" filler is literal: a bare call resolves against the script's own class.
2709            format!("Function \"{name}()\" not found in base self."),
2710        );
2711    }
2712
2713    /// `UNDEFINED_IDENTIFIER` (BUG A1) at `resolve_name`'s final fallthrough — every tier
2714    /// (locals, own members, the engine base, engine globals, the project `class_name` registry,
2715    /// autoload singletons) already missed structurally by the time this is called, so with the
2716    /// [`Self::undefined_symbols_provable`] gate the name is provably undeclared.
2717    fn maybe_warn_undefined_identifier(&mut self, id: ExprId, name: &str) {
2718        if !self.undefined_symbols_provable() {
2719            return;
2720        }
2721        // A REGISTERED autoload can still resolve to the seam (`resolve_autoload` deliberately
2722        // yields `Unknown` for a scene-backed singleton — `Music="*res://music.tscn"` — whose
2723        // root-script type we can't see): registry presence means DEFINED, so never warn.
2724        if is_autoload_singleton(self.db, name) {
2725            return;
2726        }
2727        self.warn(
2728            self.name_token_range(id, name),
2729            WarningCode::UndefinedIdentifier,
2730            // Godot's exact compile-error text (probed p03).
2731            format!("Identifier \"{name}\" not declared in the current scope."),
2732        );
2733    }
2734
2735    fn func_return_ty(&self, annotation: Option<&str>) -> Ty {
2736        annotation.map_or(Ty::Variant, |t| {
2737            resolve::resolve_type_name(self.db, self.api, t)
2738        })
2739    }
2740
2741    /// The return type of CALLING an own `func`: a `## @return-tuple(T0, T1, …)` doc-tag (BUG A3)
2742    /// wins — its positional element types become a [`Ty::Tuple`], so a constant index projects
2743    /// the element's real type — else the plain annotation.
2744    fn func_call_return_ty(&self, f: &crate::item_tree::FuncItem) -> Ty {
2745        if let Some(names) = &f.tuple_return {
2746            return resolve::resolve_tuple_return(self.db, self.api, names);
2747        }
2748        self.func_return_ty(f.return_type.as_deref())
2749    }
2750
2751    /// Member access `receiver.name`. When `as_method`, resolve a method (and use its return
2752    /// type); otherwise resolve a property/const/etc. Raises `UNSAFE_*` only on a statically
2753    /// **known** receiver.
2754    fn infer_field(
2755        &mut self,
2756        receiver: ExprId,
2757        name: &str,
2758        name_range: TextRange,
2759        as_method: bool,
2760    ) -> Ty {
2761        let is_self = matches!(self.body.expr(receiver), Expr::SelfExpr);
2762        let recv_ty = self.infer_expr(receiver, &Expectation::None);
2763
2764        // `self.member` consults this file's own members first (Playbook §3.2).
2765        if is_self && let Some(item) = self.class.lookup(name) {
2766            return self.own_member_ty(item, as_method);
2767        }
2768
2769        match &recv_ty {
2770            // Uninformative receivers are unchecked and **propagate the seam**: a member of an
2771            // `Unknown` (cross-file) value is itself `Unknown` (never warns), a member of a
2772            // `Variant` is `Variant`, of an `Error` is `Error`. Collapsing `Unknown` to
2773            // `Variant` here would wrongly fire `INFERENCE_ON_VARIANT` on `var x := other.field`.
2774            t if t.is_uninformative() => recv_ty.clone(),
2775            Ty::Object(class) => {
2776                if name == "new" {
2777                    // `Class.new(...)` always constructs an instance of the class (some classes,
2778                    // e.g. GDScript, also carry a modeled `new` member — the constructor wins).
2779                    recv_ty.clone()
2780                } else if let Some(m) = self.api.lookup_member(*class, name) {
2781                    // `lookup_member` also resolves enum VALUES (`Control.PRESET_FULL_RECT` →
2782                    // `MemberRef::EnumValue`, typed as its enum by `member_ref_ty`) — the single
2783                    // source of truth for that rule.
2784                    self.check_member_kind_misuse(&m, as_method, name, name_range);
2785                    self.check_static_on_instance(receiver, &m, as_method, name_range);
2786                    self.member_ref_ty(&m, as_method)
2787                } else {
2788                    // Self with an Object base already checked own members above.
2789                    self.emit_unsafe(name, &recv_ty, name_range, as_method);
2790                    Ty::Variant
2791                }
2792            }
2793            // A tuple's members are its runtime `Array`'s (`size`/`append`/… via builtin_id_of).
2794            Ty::Builtin(_)
2795            | Ty::Array(_)
2796            | Ty::Tuple(_)
2797            | Ty::Dict(..)
2798            | Ty::Callable
2799            | Ty::Signal(_) => self.builtin_member_ty(&recv_ty, name, name_range, as_method),
2800            // Accessing a member of an enum namespace (`State.IDLE`) yields the enum type itself —
2801            // an enum value (freely int-assignable via `ty::is_assignable`). Was `int`, which lost
2802            // the enum type and false-`INFERENCE_ON_VARIANT`'d a same-file `var x := State.IDLE`.
2803            Ty::Enum(er) => Ty::Enum(er.clone()),
2804            // A cross-file script reference: resolve the member against its (own) member table.
2805            Ty::ScriptRef(sref) => self.script_member_ty(*sref, name, as_method),
2806            // An inner-class value/instance: resolve against its own item-tree + `extends` chain.
2807            Ty::InnerClass(iref) => self.inner_class_member_ty(iref, name, as_method),
2808            _ => Ty::Variant,
2809        }
2810    }
2811
2812    /// Resolve `name` on an inner-class value/instance (`Ty::InnerClass`). `Inner.new()` constructs an
2813    /// instance (the same `InnerClass`); otherwise the inner class's own members (typed by their
2814    /// annotation — lossy, like the cross-file `ScriptRef` path: an inferred/unannotated member seams)
2815    /// then its `extends` chain. The seam (`Unknown`) for an unresolved member — never a false
2816    /// `UNSAFE_*`.
2817    fn inner_class_member_ty(
2818        &self,
2819        iref: &crate::ty::InnerClassRef,
2820        name: &str,
2821        as_method: bool,
2822    ) -> Ty {
2823        if name == "new" && as_method {
2824            return Ty::InnerClass(iref.clone());
2825        }
2826        self.inner_member_walk(iref, name, as_method, 0)
2827            .unwrap_or(Ty::Unknown)
2828    }
2829
2830    /// Walk an inner class's own members, then its `extends` base (an engine class, a `class_name`, or
2831    /// another inner/script class), for `name`. Depth-bounded like [`script_member_walk`].
2832    fn inner_member_walk(
2833        &self,
2834        iref: &crate::ty::InnerClassRef,
2835        name: &str,
2836        as_method: bool,
2837        depth: u32,
2838    ) -> Option<Ty> {
2839        if depth > 32 {
2840            return None;
2841        }
2842        let ft = self.db.file_text(FileId(iref.file))?;
2843        let tree = crate::queries::item_tree(self.db, ft);
2844        let inner = find_inner_class(&tree, &iref.path)?;
2845        if let Some(m) = inner.tree.member(name) {
2846            return self.inner_member_item_ty(m, as_method, iref);
2847        }
2848        // Not an own member — walk the inner class's `extends` base.
2849        let res_path = self.self_res_path();
2850        match resolve::resolve_base(self.db, self.api, &inner.tree, res_path.as_deref()) {
2851            Ty::Object(class) => self
2852                .api
2853                .lookup_member(class, name)
2854                .map(|m| self.member_ref_ty(&m, as_method)),
2855            Ty::ScriptRef(base) => self.script_member_walk(base, name, as_method, depth + 1),
2856            Ty::InnerClass(base) => self.inner_member_walk(&base, name, as_method, depth + 1),
2857            _ => None,
2858        }
2859    }
2860
2861    /// Type an inner class's own member by its written **annotation** (the inner body isn't inferred
2862    /// here — Increment 2 adds that). An unannotated `var`/`const` or an untyped `func` return seams.
2863    fn inner_member_item_ty(
2864        &self,
2865        m: &Member,
2866        as_method: bool,
2867        iref: &crate::ty::InnerClassRef,
2868    ) -> Option<Ty> {
2869        Some(match m {
2870            Member::Func(f) => {
2871                if as_method {
2872                    f.return_type.as_deref().map_or(Ty::Variant, |t| {
2873                        resolve::resolve_type_name(self.db, self.api, t)
2874                    })
2875                } else {
2876                    Ty::Callable
2877                }
2878            }
2879            Member::Var(v) => resolve::resolve_type_name(self.db, self.api, v.type_ref.as_deref()?),
2880            Member::Const(c) => {
2881                resolve::resolve_type_name(self.db, self.api, c.type_ref.as_deref()?)
2882            }
2883            Member::Signal(_) => Ty::Signal(None),
2884            Member::Enum(e) => Ty::Enum(EnumRef {
2885                qualified: e.name.clone()?,
2886                bitfield: false,
2887            }),
2888            // A nested inner class → `Ty::InnerClass` with the extended dotted path.
2889            Member::Class(c) => Ty::InnerClass(crate::ty::InnerClassRef {
2890                file: iref.file,
2891                path: SmolStr::new(format!("{}.{}", iref.path, c.name)),
2892            }),
2893        })
2894    }
2895
2896    /// A member of a cross-file script (`ScriptRef`): looked up in the script's own member table
2897    /// (M1). A member we don't model — e.g. one inherited from a base we don't resolve until M2 —
2898    /// yields the seam (`Unknown`), **never** an `UNSAFE_*` warning. `Class.new(...)` constructs
2899    /// an instance of the class.
2900    fn script_member_ty(&self, sref: ScriptRefId, name: &str, as_method: bool) -> Ty {
2901        if name == "new" {
2902            return Ty::ScriptRef(sref);
2903        }
2904        self.script_member_walk(sref, name, as_method, 0)
2905            .unwrap_or(Ty::Unknown)
2906    }
2907
2908    /// Walk a script class's `extends` chain for `name`: own members first, then a user base
2909    /// (another `ScriptRef`), then an engine base (the API table). Depth-bounded so a cyclic
2910    /// `extends` cannot loop. `None` = not found anywhere in the chain (the seam).
2911    fn script_member_walk(
2912        &self,
2913        sref: ScriptRefId,
2914        name: &str,
2915        as_method: bool,
2916        depth: u32,
2917    ) -> Option<Ty> {
2918        if depth > 32 {
2919            return None;
2920        }
2921        let file = self.db.file_text(FileId(sref.0))?;
2922        let sc = crate::queries::script_class(self.db, file);
2923        if let Some(m) = sc.member(name) {
2924            return Some(match m {
2925                crate::queries::MemberSig::Method(ret) => {
2926                    if as_method {
2927                        ret.clone()
2928                    } else {
2929                        Ty::Callable
2930                    }
2931                }
2932                crate::queries::MemberSig::Field(t) => t.clone(),
2933                crate::queries::MemberSig::Signal => Ty::Signal(None),
2934            });
2935        }
2936        // Not an own member — continue up the inheritance chain.
2937        match sc.base() {
2938            Ty::ScriptRef(base) => self.script_member_walk(*base, name, as_method, depth + 1),
2939            Ty::Object(class) => self
2940                .api
2941                .lookup_member(*class, name)
2942                .map(|m| self.member_ref_ty(&m, as_method)),
2943            _ => None,
2944        }
2945    }
2946
2947    /// Whether a value of type `sub` is statically a subtype of `sup` — composing user `ScriptRef`
2948    /// `extends` chains with the engine class table (M4, for `is`/`as` widen-only narrowing). A
2949    /// `ScriptRef` IS-A its native base (so `script_value is Node` holds), but Godot's asymmetry is
2950    /// honored: a native/script value is **not** a subtype of an *unrelated* user script.
2951    fn is_subtype(&self, sub: &Ty, sup: &Ty) -> bool {
2952        match (sub, sup) {
2953            (Ty::Object(a), Ty::Object(b)) => self.api.is_subclass(*a, *b),
2954            (Ty::ScriptRef(a), Ty::ScriptRef(b)) => self.script_is_subtype(*a, *b, 0),
2955            (Ty::ScriptRef(a), Ty::Object(b)) => self.script_extends_engine(*a, *b, 0),
2956            _ => false,
2957        }
2958    }
2959
2960    /// Whether script `sub` is `sup` or transitively extends it — walk the `extends` base chain by
2961    /// script identity (depth-bounded, like [`script_member_walk`](Self::script_member_walk)).
2962    fn script_is_subtype(&self, sub: ScriptRefId, sup: ScriptRefId, depth: u32) -> bool {
2963        if depth > 32 {
2964            return false;
2965        }
2966        if sub == sup {
2967            return true;
2968        }
2969        let Some(file) = self.db.file_text(FileId(sub.0)) else {
2970            return false;
2971        };
2972        match crate::queries::script_class(self.db, file).base() {
2973            Ty::ScriptRef(base) => self.script_is_subtype(*base, sup, depth + 1),
2974            _ => false,
2975        }
2976    }
2977
2978    /// Whether script `sub`'s `extends` chain reaches engine class `sup_native` at its native base.
2979    fn script_extends_engine(
2980        &self,
2981        sub: ScriptRefId,
2982        sup_native: gdscript_api::ClassId,
2983        depth: u32,
2984    ) -> bool {
2985        if depth > 32 {
2986            return false;
2987        }
2988        let Some(file) = self.db.file_text(FileId(sub.0)) else {
2989            return false;
2990        };
2991        match crate::queries::script_class(self.db, file).base() {
2992            Ty::ScriptRef(base) => self.script_extends_engine(*base, sup_native, depth + 1),
2993            Ty::Object(native) => self.api.is_subclass(*native, sup_native),
2994            _ => false,
2995        }
2996    }
2997
2998    fn emit_unsafe(&mut self, name: &str, recv: &Ty, range: TextRange, as_method: bool) {
2999        let recv_label = recv.label(self.api).unwrap_or_else(|| "?".to_owned());
3000        let (code, message) = if as_method {
3001            (
3002                WarningCode::UnsafeMethodAccess,
3003                format!(
3004                    "The method \"{name}()\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
3005                ),
3006            )
3007        } else {
3008            (
3009                WarningCode::UnsafePropertyAccess,
3010                format!(
3011                    "The property \"{name}\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
3012                ),
3013            )
3014        };
3015        self.warn(range, code, message);
3016    }
3017
3018    /// The member kind + base-class label of a *value* member (property/constant/signal) on the
3019    /// RESOLVED **engine** base named `name` — the sound floor for `SHADOWED_VARIABLE_BASE_CLASS`
3020    /// and the two fillers of Godot's wording ("… an already-declared {kind} in the base class
3021    /// \"{base}\"."). Only the engine base is consulted: an unresolved base (the cross-file seam)
3022    /// returns `None` (no warning), and the cross-file *user*-base `MemberSig` is lossy (no kind
3023    /// detail) so user-base shadowing stays deferred (see `TECH_DEBT.md`). Methods are excluded
3024    /// (matches the own-member shadow rule; Godot flags those too — recorded in `TECH_DEBT.md` as
3025    /// deliberate under-warning). The label is the direct `extends` base — the class Godot names
3026    /// in the probes; a member inherited from deeper up still reports the direct base.
3027    fn engine_base_value_member_kind(&self, name: &str) -> Option<(&'static str, String)> {
3028        let Ty::Object(base) = &self.class.base else {
3029            return None;
3030        };
3031        let kind = match self.api.lookup_member(*base, name)? {
3032            MemberRef::Property(_) => "property",
3033            MemberRef::Const(_) => "constant",
3034            MemberRef::Signal(_) => "signal",
3035            _ => return None,
3036        };
3037        let label = Ty::Object(*base)
3038            .label(self.api)
3039            .unwrap_or_else(|| "?".to_owned());
3040        Some((kind, label))
3041    }
3042
3043    /// The 1-based line number of a byte offset in the file being inferred (Godot bakes the
3044    /// shadowed declaration's line into `SHADOWED_VARIABLE`'s message). Counts newlines through
3045    /// the CST text in chunks — no allocation of the whole file.
3046    #[allow(
3047        clippy::naive_bytecount,
3048        reason = "a cold diagnostic-message path over one chunk prefix; not worth a bytecount dependency"
3049    )]
3050    fn line_of(&self, range: TextRange) -> u32 {
3051        let mut line: u32 = 1;
3052        let mut seen: usize = 0;
3053        let target = range.start as usize;
3054        self.root.text().for_each_chunk(|chunk| {
3055            let remaining = target.saturating_sub(seen);
3056            if remaining > 0 {
3057                let take = remaining.min(chunk.len());
3058                line += u32::try_from(
3059                    chunk.as_bytes()[..take]
3060                        .iter()
3061                        .filter(|&&b| b == b'\n')
3062                        .count(),
3063                )
3064                .unwrap_or(0);
3065            }
3066            seen += chunk.len();
3067        });
3068        line
3069    }
3070
3071    /// Flag a deprecated member-kind misuse on a statically-resolved engine member:
3072    /// `PROPERTY_USED_AS_FUNCTION` / `CONSTANT_USED_AS_FUNCTION` when a property/const is *called*.
3073    /// Guarded against a Callable/Signal/uninformative-typed member (those can legitimately be
3074    /// invoked). `FUNCTION_USED_AS_PROPERTY` is intentionally NOT emitted — a bare `obj.method` is an
3075    /// idiomatic `Callable` reference (every signal `.connect`), indistinguishable from a misuse
3076    /// without call-context, so it would false-positive everywhere (see `TECH_DEBT.md`).
3077    fn check_member_kind_misuse(
3078        &mut self,
3079        m: &MemberRef,
3080        as_method: bool,
3081        name: &str,
3082        range: TextRange,
3083    ) {
3084        if !as_method {
3085            return;
3086        }
3087        let (code, ty) = match m {
3088            MemberRef::Property(p) => (
3089                WarningCode::PropertyUsedAsFunction,
3090                ty::resolve_tyref(self.api, &p.ty),
3091            ),
3092            MemberRef::Const(c) => (
3093                WarningCode::ConstantUsedAsFunction,
3094                ty::resolve_tyref(self.api, &c.ty),
3095            ),
3096            _ => return,
3097        };
3098        // A Callable/Signal-typed (or uninformative) member can be invoked — never flag it.
3099        if ty.is_uninformative() || matches!(ty, Ty::Callable | Ty::Signal(_)) {
3100            return;
3101        }
3102        // Godot's exact wording (probed q18/q19) — it names the member's TYPE, not its kind.
3103        // Godot hard-errors at these sites; the analyzer keeps the catalog's Warning default
3104        // (documented divergence).
3105        let ty_label = ty.label(self.api).unwrap_or_else(|| "?".to_owned());
3106        self.warn(
3107            range,
3108            code,
3109            format!("Name \"{name}\" called as a function but is a \"{ty_label}\"."),
3110        );
3111    }
3112
3113    /// Flag `STATIC_CALLED_ON_INSTANCE`: an engine static method called through an instance value
3114    /// rather than the type. Conservative + sound — fires only when the receiver is a **typed local
3115    /// instance** (a `Name` bound in `locals`), never a bare class name (`Class.static()` is
3116    /// correct) nor an expression we can't classify. Under-warns by design; zero false positives.
3117    fn check_static_on_instance(
3118        &mut self,
3119        receiver: ExprId,
3120        m: &MemberRef,
3121        as_method: bool,
3122        range: TextRange,
3123    ) {
3124        if !as_method {
3125            return;
3126        }
3127        let MemberRef::Method(sig) = m else {
3128            return;
3129        };
3130        if !sig.is_static {
3131            return;
3132        }
3133        let Expr::Name(rname) = self.body.expr(receiver) else {
3134            return;
3135        };
3136        if !self.locals.contains_key(rname) {
3137            return;
3138        }
3139        // A local that ALIASES a type/var (`var t := JSON; t.stringify()`) is not an instance —
3140        // calling a static method through it is valid (`t` holds the type, not an object). A bare
3141        // `Name` initializer marks such an alias; only a constructor/call init (or a param/field
3142        // with no init) is a true instance. Skipping the alias case fixes a false positive.
3143        if let Some(b) = self.bindings.iter().rev().find(|b| &b.name == rname)
3144            && let Some(init) = b.init
3145            && matches!(self.body.expr(init), Expr::Name(_))
3146        {
3147            return;
3148        }
3149        // Godot's exact wording (probed q20): names the method and the receiver's TYPE.
3150        let type_label = self
3151            .locals
3152            .get(rname)
3153            .and_then(|t| t.label(self.api))
3154            .unwrap_or_else(|| "?".to_owned());
3155        self.warn(
3156            range,
3157            WarningCode::StaticCalledOnInstance,
3158            format!(
3159                "The function \"{name}()\" is a static function but was called from an instance. Instead, it should be directly called from the type: \"{type_label}.{name}()\".",
3160                name = sig.name
3161            ),
3162        );
3163    }
3164
3165    fn member_ref_ty(&self, m: &MemberRef, as_method: bool) -> Ty {
3166        match m {
3167            MemberRef::Method(sig) => {
3168                if as_method {
3169                    ty::resolve_tyref(self.api, &sig.return_ty)
3170                } else {
3171                    Ty::Callable
3172                }
3173            }
3174            // An enum-typed property resolves its qualified enum name through the SAME resolver
3175            // annotations use (`resolve_type_name` handles both `Enum` and `Class.Enum`), so the
3176            // real `is_bitfield` is recovered — a hardcoded `bitfield: false` made
3177            // `size_flags_horizontal = SIZE_EXPAND_FILL` look like two DIFFERENT enums and
3178            // false-fired `INT_AS_ENUM_WITHOUT_CAST` on the same-enum assignment.
3179            MemberRef::Property(p) => p.enum_of.as_ref().map_or_else(
3180                || ty::resolve_tyref(self.api, &p.ty),
3181                |q| resolve::resolve_type_name(self.db, self.api, q),
3182            ),
3183            MemberRef::Const(c) => ty::resolve_tyref(self.api, &c.ty),
3184            MemberRef::Signal(_) => Ty::Signal(None),
3185            MemberRef::Enum(_) => Ty::Variant,
3186            // A class enum's VALUE (`Control.SIZE_EXPAND_FILL` / bare in a subclass): its
3187            // declaring ENUM type (mirroring `class_enum_value`), so an enum member into its own
3188            // enum slot stays `Assign::Ok` — never a false `INT_AS_ENUM_WITHOUT_CAST`. (Still
3189            // freely assignable to `int` via `ty::is_assignable`.)
3190            MemberRef::EnumValue { class, decl, .. } => Ty::Enum(EnumRef {
3191                qualified: SmolStr::new(format!("{}.{}", class, decl.name)),
3192                bitfield: decl.is_bitfield,
3193            }),
3194        }
3195    }
3196
3197    /// The binding type of a local `var`/`const` declaration (see [`Self::infer_local_var`]).
3198    fn local_binding_ty(
3199        &mut self,
3200        v: &body::LocalVar,
3201        annotated: Option<&Ty>,
3202        init_ty: Option<&Ty>,
3203        range: TextRange,
3204    ) -> Ty {
3205        match (annotated, init_ty) {
3206            // `var x: T = e` — hard slot; check the initializer against it.
3207            (Some(t), Some(init)) => {
3208                self.check_assign(init, t, range, AssignCtx::Assign);
3209                t.clone()
3210            }
3211            // `var x: T` (no init).
3212            (Some(t), None) => t.clone(),
3213            // `var x := e` — inferred (hard); guard the Variant / null cases.
3214            (None, Some(init)) if v.is_inferred => {
3215                if init.is_variant() {
3216                    self.warn(
3217                        range,
3218                        WarningCode::InferenceOnVariant,
3219                        inference_on_variant_msg(if v.is_const { "constant" } else { "variable" }),
3220                    );
3221                    Ty::Variant
3222                } else {
3223                    // `Unknown` (the seam) stays `Unknown` with no warning. A tuple that may be
3224                    // mutated through an alias or a store keeps only its runtime array form.
3225                    self.tuple_alias_guard(&v.name, init)
3226                }
3227            }
3228            // `var x = e` — untyped, soft → `Variant` BY DECLARATION; `const X = e` keeps the
3229            // inferred type. But an effectively single-assignment local (never rebound or
3230            // index-stored — [`collect_rebound_names`]) can only ever hold its initializer's
3231            // type, so that soundly serves as the binding type: initializer narrowing
3232            // (ADR-0007), which lets `var s = useState(0)` project `s[1]` as a `Callable`.
3233            // Uninformative initializers (`null`, the seam, `Variant`) stay `Variant`.
3234            (None, Some(init)) => {
3235                if v.is_const {
3236                    init.clone()
3237                } else if !init.is_uninformative() && !self.rebound.contains(&v.name) {
3238                    self.tuple_alias_guard(&v.name, init)
3239                } else {
3240                    Ty::Variant
3241                }
3242            }
3243            (None, None) => Ty::Variant,
3244        }
3245    }
3246
3247    /// A tuple-typed binding whose value may ESCAPE into a mutating alias (`var t = s`, `f(s)`,
3248    /// `[s]`, …) or that is ever rebound/index-stored keeps only its runtime `Array[Variant]`
3249    /// form: a positional projection must not survive a possible aliased store (`t[1] = x`) —
3250    /// a stale projection could fire a false `UNDEFINED_METHOD` on runtime-valid code. Non-tuple
3251    /// types pass through: aliasing can mutate their CONTENTS, never the binding's type.
3252    fn tuple_alias_guard(&self, name: &SmolStr, ty: &Ty) -> Ty {
3253        if matches!(ty, Ty::Tuple(_))
3254            && (self.escaping.contains(name) || self.rebound.contains(name))
3255        {
3256            return ty.widen_tuple();
3257        }
3258        ty.clone()
3259    }
3260
3261    fn builtin_member_ty(
3262        &mut self,
3263        recv: &Ty,
3264        name: &str,
3265        range: TextRange,
3266        as_method: bool,
3267    ) -> Ty {
3268        let Some(bid) = self.builtin_id_of(recv) else {
3269            return Ty::Variant;
3270        };
3271        if as_method {
3272            return if let Some(sig) = self.api.builtin_method(bid, name) {
3273                ty::resolve_tyref(self.api, &sig.return_ty)
3274            } else {
3275                self.emit_builtin_miss(name, recv, bid, range, true);
3276                Ty::Variant
3277            };
3278        }
3279        // A KEYED builtin (`Dictionary`): property access is subscript sugar (`d["some_key"]`)
3280        // for ANY name — the KEY wins even over real method names at runtime (probed on 4.7:
3281        // `{"size": 99}.size` is `99`, and `var n: int = d.size` parse-checks clean on a typed
3282        // Dictionary), so the sugar short-circuits the WHOLE member path, typed as the
3283        // dictionary's value type. Writes are the same sugar. A METHOD miss still errors — in
3284        // Godot too (`d.greet()` is "Nonexistent function 'greet' in base 'Dictionary'" at
3285        // runtime even when the key holds a Callable; probed). Corpus proof: 111 of 111
3286        // first-run UNDEFINED_PROPERTY hits across godot-demo-projects were this sugar shape.
3287        let data = self.api.builtin(bid);
3288        if let Ty::Dict(_, v) = recv {
3289            return (**v).clone();
3290        }
3291        if data.is_keyed {
3292            return Ty::Variant;
3293        }
3294        if let Some(member) = self.api.builtin_member(bid, name) {
3295            return ty::resolve_tyref(self.api, &member.ty);
3296        }
3297        // Static constants (`Vector2.ZERO`, `Color.WHITE`) and enum values (`Variant.Type.*`).
3298        if let Some(c) = data.constants.iter().find(|c| c.name == name) {
3299            return ty::resolve_tyref(self.api, &c.ty);
3300        }
3301        if data
3302            .enums
3303            .iter()
3304            .any(|e| e.values.iter().any(|v| v.name == name))
3305        {
3306            return self.int_ty();
3307        }
3308        if self.api.builtin_method(bid, name).is_some() {
3309            return Ty::Callable;
3310        }
3311        self.emit_builtin_miss(name, recv, bid, range, false);
3312        Ty::Variant
3313    }
3314
3315    /// A member miss on a BUILT-IN receiver: `UNDEFINED_METHOD` / `UNDEFINED_PROPERTY`, ERROR by
3316    /// default. Unlike `Object` receivers — where a script can attach members at runtime, so Godot
3317    /// itself stays silent and we keep the opt-in `UNSAFE_*` — the builtin member tables are closed
3318    /// and bundled, and Godot reports this exact case as a compile error — for a method miss 4.7
3319    /// prints BOTH `Cannot find member "x" in base "T".` and `Function "x()" not found in base
3320    /// T.` (one line each from its member- and call-checks); for a property miss only the member
3321    /// form (probes p01/p02/p32/p33, typed AND `:=`-inferred receivers alike; keyed builtins'
3322    /// property access is subscript sugar and handled above, never here). The analyzer emits ONE
3323    /// diagnostic per site: the call-check text for methods, the member text for properties.
3324    /// No workspace-completeness claim is needed (the table ships with the analyzer); the only
3325    /// gates are the engine version — a NEWER project engine may have added the member, so fall
3326    /// back to the opt-in `UNSAFE_*` signal there — and a `Nil` receiver, which is a nullability
3327    /// question, not a member-existence one (Godot is silent on untyped nulls).
3328    fn emit_builtin_miss(
3329        &mut self,
3330        name: &str,
3331        recv: &Ty,
3332        bid: gdscript_api::BuiltinId,
3333        range: TextRange,
3334        as_method: bool,
3335    ) {
3336        if crate::queries::project_engine_version(self.db)
3337            .is_some_and(|v| v > crate::warnings::bundled_version())
3338        {
3339            self.emit_unsafe(name, recv, range, as_method);
3340            return;
3341        }
3342        if self.api.builtin(bid).name == "Nil" {
3343            return;
3344        }
3345        let recv_label = recv.label(self.api).unwrap_or_else(|| "?".to_owned());
3346        let (code, message) = if as_method {
3347            (
3348                WarningCode::UndefinedMethod,
3349                // Godot's call-check text (probed p01/p32/p33) — the base is NOT quoted here.
3350                format!("Function \"{name}()\" not found in base {recv_label}."),
3351            )
3352        } else {
3353            (
3354                WarningCode::UndefinedProperty,
3355                // Godot's member-check text (probed p02) — the base IS quoted here.
3356                format!("Cannot find member \"{name}\" in base \"{recv_label}\"."),
3357            )
3358        };
3359        self.warn(range, code, message);
3360    }
3361
3362    /// The builtin id backing a builtin / `Array` / `Dictionary` receiver.
3363    fn builtin_id_of(&self, ty: &Ty) -> Option<gdscript_api::BuiltinId> {
3364        match ty {
3365            Ty::Builtin(b) => Some(*b),
3366            // A tuple IS an `Array` at runtime — its methods (`size`/`append`/…) are Array's.
3367            Ty::Array(_) | Ty::Tuple(_) => self.api.builtin_by_name("Array"),
3368            Ty::Dict(..) => self.api.builtin_by_name("Dictionary"),
3369            Ty::Callable => self.api.builtin_by_name("Callable"),
3370            Ty::Signal(_) => self.api.builtin_by_name("Signal"),
3371            _ => None,
3372        }
3373    }
3374
3375    /// The element type of an indexing expression (Playbook §2 switch). `index` is the subscript
3376    /// expression — a CONSTANT integer literal selects a [`Ty::Tuple`]'s positional element type
3377    /// (BUG A3: `useState(...)[1]` is the setter `Callable`, not `Variant`).
3378    fn index_ty(&self, base: &Ty, index: ExprId) -> Ty {
3379        match base {
3380            Ty::Array(elem) => (**elem).clone(),
3381            // A tuple projects the element at a constant in-bounds index; a dynamic or
3382            // out-of-bounds index degrades to the runtime element type (`Variant`), exactly like
3383            // the untyped `Array` the tuple is at runtime.
3384            Ty::Tuple(elems) => match self.body.expr(index) {
3385                Expr::Literal(body::Literal::Int(Some(i))) => usize::try_from(*i)
3386                    .ok()
3387                    .and_then(|i| elems.get(i).cloned())
3388                    .unwrap_or(Ty::Variant),
3389                _ => Ty::Variant,
3390            },
3391            Ty::Builtin(b) => self
3392                .api
3393                .builtin(*b)
3394                .indexing_return
3395                .as_ref()
3396                .map_or(Ty::Variant, |r| ty::resolve_tyref(self.api, r)),
3397            // Indexing through the seam stays on the seam (never warns).
3398            Ty::Unknown => Ty::Unknown,
3399            Ty::Error => Ty::Error,
3400            _ => Ty::Variant,
3401        }
3402    }
3403
3404    /// The loop variable's type for `for v in iter:` (Playbook §2 switch). Iterating a
3405    /// [`Ty::Tuple`] visits its runtime array's elements (`Variant` — positions are meaningless
3406    /// during iteration), which is the wildcard arm below.
3407    fn loop_var_ty(&self, iter: &Ty) -> Ty {
3408        match iter {
3409            Ty::Array(elem) => (**elem).clone(),
3410            Ty::Builtin(b) => {
3411                let data = self.api.builtin(*b);
3412                if data.name == "int" {
3413                    // `for i in 5` / `for i in range(...)` → int.
3414                    self.int_ty()
3415                } else if let Some(r) = &data.indexing_return {
3416                    // `for c in "abc"` → String; `for s in packed_string_array` → String; …
3417                    ty::resolve_tyref(self.api, r)
3418                } else {
3419                    Ty::Variant
3420                }
3421            }
3422            // Iterating a seam value keeps the loop var on the seam (never warns).
3423            Ty::Unknown => Ty::Unknown,
3424            Ty::Error => Ty::Error,
3425            _ => Ty::Variant,
3426        }
3427    }
3428
3429    fn infer_lambda(&mut self, params: &[ParamBinding], body: &[body::StmtId]) {
3430        // Lambda params shadow within the body; restore the outer locals afterward. A `return`
3431        // inside the lambda is the *lambda's* return, not the enclosing function's — so disable
3432        // return checking (set the expected return to `Variant`) while walking the body.
3433        let saved_locals = self.locals.clone();
3434        let saved_ret = std::mem::replace(&mut self.return_ty, Ty::Variant);
3435        for p in params {
3436            let ty = self.param_ty(p);
3437            self.bindings.push(Binding {
3438                name: p.name.clone(),
3439                name_range: p.name_range,
3440                ty: ty.clone(),
3441                init: None,
3442                annotated: p.type_ref.is_some(),
3443                inferred_colon_eq: false,
3444                is_const: false,
3445                kind: BindingKind::Param,
3446            });
3447            self.locals.insert(p.name.clone(), ty);
3448        }
3449        self.infer_block(body);
3450        self.return_ty = saved_ret;
3451        self.locals = saved_locals;
3452    }
3453
3454    fn param_ty(&mut self, p: &ParamBinding) -> Ty {
3455        if let Some(ptr) = p.type_ref {
3456            return self.resolve_ptr_ty(ptr);
3457        }
3458        // An unannotated param infers from its default, else `Variant`.
3459        p.default
3460            .map_or(Ty::Variant, |e| self.infer_expr(e, &Expectation::None))
3461    }
3462
3463    // ---- name resolution (local → class member → inherited → global) ----
3464
3465    /// The `Ty`-producing half of the bare-name lookup. Its precedence is the **canonical order**
3466    /// documented on [`crate::def::resolve_name_to_def`] (local → own member → inherited member →
3467    /// engine global → `class_name` global → autoload) — kept in lockstep with that identity-producing
3468    /// copy by the `classify_and_infer_agree_*` tests (gdscript-ide). Unlike that copy, this one is
3469    /// woven with flow-narrowing and the `UNUSED`/`UNASSIGNED` side-effects (it runs mid-inference),
3470    /// which is why the two are intentionally separate functions rather than one.
3471    fn resolve_name(&mut self, id: ExprId, name: &str) -> Ty {
3472        // Record a *read* of a local/param for the `UNUSED_*` analysis (before the narrowing check,
3473        // so a narrowed read still counts as used). The direct LHS of an assignment (`x = …`) is a
3474        // WRITE, not a read — excluding it lets `UNUSED_VARIABLE` catch an assigned-but-never-read
3475        // local (Godot's precise behaviour). A compound `x += …` still reads `x` via its RHS NameRef
3476        // (a distinct expr), and a receiver / index target (`x.f()`, `x[i] = …`) is a read of `x`.
3477        if self.locals.contains_key(name) && !self.assign_lhs.contains(&id) {
3478            self.used_locals.insert(SmolStr::new(name));
3479        }
3480        // UNASSIGNED_VARIABLE (Workstream 2) — a *read* of a typed-no-init local that is not
3481        // definitely assigned on every path reaching here. Excludes the LHS of an assignment (a
3482        // write) and reads inside a lambda body (which `assigned_before` leaves `None`, unchecked).
3483        if self.is_func_body
3484            && self.needs_assignment.contains(name)
3485            && !self.assign_lhs.contains(&id)
3486            && let Some(cur) = self.cur_stmt
3487            && self
3488                .assigned
3489                .assigned_before(cur)
3490                .is_some_and(|a| !a.contains(name))
3491        {
3492            self.warn(
3493                self.range_of(id),
3494                WarningCode::UnassignedVariable,
3495                format!("The variable \"{name}\" is used before being assigned a value."),
3496            );
3497        }
3498        // Flow narrowing wins over the binding's declared type.
3499        if let Some(key) = self.narrow_key(id)
3500            && let Some(t) = self.narrowing.get(&key)
3501        {
3502            return t.clone();
3503        }
3504        if let Some(t) = self.locals.get(name) {
3505            return t.clone();
3506        }
3507        if let Some(item) = self.class.lookup(name) {
3508            return self.own_member_ty(item, false);
3509        }
3510        // Inherited members: an engine `Object` base via the API table, or a user `ScriptRef`
3511        // base via the script member walk (M2 — so a class extending another class_name sees its
3512        // inherited members).
3513        match self.class.base.clone() {
3514            Ty::Object(base) => {
3515                if let Some(m) = self.api.lookup_member(base, name) {
3516                    return self.member_ref_ty(&m, false);
3517                }
3518            }
3519            Ty::ScriptRef(base) => {
3520                if let Some(t) = self.script_member_walk(base, name, false, 0) {
3521                    return t;
3522                }
3523            }
3524            _ => {}
3525        }
3526        if let Some(g) = resolve::resolve_global(self.api, name) {
3527            return global_ty(&g);
3528        }
3529        // A project-global `class_name` used as a value — the class itself, for static access
3530        // (`V.fc()`) or as a constructor (`Player.new()`). Resolves to a `ScriptRef` via the
3531        // registry. Precedence (Godot `reduce_identifier`): `class_name` global ≫ autoload
3532        // singleton. So try `class_name` first, then a `*`-autoload, then the seam.
3533        let by_class = resolve::resolve_external(
3534            self.db,
3535            &resolve::ExternalRef::ClassName(SmolStr::new(name)),
3536        );
3537        if !by_class.is_unknown() {
3538            return by_class;
3539        }
3540        let by_autoload =
3541            resolve::resolve_external(self.db, &resolve::ExternalRef::Autoload(SmolStr::new(name)));
3542        if by_autoload.is_unknown() {
3543            // EVERY tier missed. With a complete workspace this name is provably undeclared —
3544            // `UNDEFINED_IDENTIFIER` (BUG A1); otherwise the silent seam as before.
3545            self.maybe_warn_undefined_identifier(id, name);
3546        }
3547        by_autoload
3548    }
3549
3550    fn own_member_ty(&self, item: ClassItem, as_method: bool) -> Ty {
3551        match item {
3552            ClassItem::EnumVariant => self.int_ty(),
3553            ClassItem::Member(_) => match self.class.member(item) {
3554                Some(Member::Var(v)) => self.field_ty(&v.name, v.ptr),
3555                Some(Member::Const(c)) => self.field_ty(&c.name, c.ptr),
3556                Some(Member::Func(f)) => {
3557                    if as_method {
3558                        self.func_call_return_ty(f)
3559                    } else {
3560                        Ty::Callable
3561                    }
3562                }
3563                Some(Member::Signal(_)) => Ty::Signal(None),
3564                // An inner `class Name:` used as a value → `Ty::InnerClass` (was the `Unknown` seam),
3565                // so `Inner.CONST` / `Inner.new()` / a typed instance's members resolve against its own
3566                // item-tree. The path is the inner class's name (resolved from the top-level scope;
3567                // nested inner classes get their dotted path once inner bodies are inferred).
3568                Some(Member::Class(c)) => match &self.self_ty {
3569                    Ty::ScriptRef(sref) => Ty::InnerClass(crate::ty::InnerClassRef {
3570                        file: sref.0,
3571                        path: c.name.clone(),
3572                    }),
3573                    _ => Ty::Unknown,
3574                },
3575                // A same-file named `enum State` used as a value/namespace → the enum type, so
3576                // `State.IDLE` (member access below) types as `State`, not a false-`INFERENCE_ON_
3577                // VARIANT` seam. An anonymous enum has no namespace name (its variants are direct
3578                // class constants), so it stays the seam.
3579                Some(Member::Enum(e)) => e.name.as_ref().map_or(Ty::Variant, |n| {
3580                    Ty::Enum(EnumRef {
3581                        qualified: n.clone(),
3582                        bitfield: false,
3583                    })
3584                }),
3585                None => Ty::Variant,
3586            },
3587        }
3588    }
3589
3590    /// The type of an own field (`var`/`const`): the type seeded by the field pre-pass (which
3591    /// captures the inferred type of `var n := 0`), falling back to the written annotation.
3592    fn field_ty(&self, name: &str, ptr: AstPtr) -> Ty {
3593        if let Some(t) = self.class.member_types.get(name) {
3594            return t.clone();
3595        }
3596        self.resolve_decl_annotation(ptr)
3597    }
3598
3599    /// Resolve a declaration's annotation (recovering its `TypeRef` node), else `Variant`.
3600    fn resolve_decl_annotation(&self, ptr: AstPtr) -> Ty {
3601        let Some(node) = ptr.to_node(self.root) else {
3602            return Ty::Variant;
3603        };
3604        cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
3605            .map_or(Ty::Variant, |t| {
3606                resolve::resolve_type_ref(self.db, self.api, &t)
3607            })
3608    }
3609
3610    // ---- narrowing ----
3611
3612    /// Build the narrowing env for a statement from the precomputed flow facts (Workstream 2).
3613    ///
3614    /// Only `Is` facts contribute a type (`NotNull`/`Not` are recorded by the flow pass but not yet
3615    /// consumed for typing — the 1.0 cut). The **widen-only + `is_uninformative`** soundness gate is
3616    /// preserved verbatim from the old `apply_narrowing`: `is`-narrowing is a deliberate divergence
3617    /// from upstream Godot (whose `is` does not flow-narrow), kept widen-only so it never produces a
3618    /// type Godot would reject — narrow only when the tested type is a downcast of the place's
3619    /// declared type, or the declared type is uninformative; never un-narrow a known subtype
3620    /// (`d: Derived; if d is Base` keeps `Derived`), never narrow to a type we couldn't resolve.
3621    fn facts_to_narrowing(&self, id: body::StmtId) -> FxHashMap<String, Ty> {
3622        let mut out = FxHashMap::default();
3623        if let Some(facts) = self.flow.facts_before(id) {
3624            for (place, nt) in facts.iter() {
3625                if let Some((key, ty)) = self.narrowing_entry(place, nt) {
3626                    out.insert(key, ty);
3627                }
3628            }
3629        }
3630        out
3631    }
3632
3633    /// Resolve one flow fact into a `(dotted-key, narrowed-type)` narrowing entry, applying the
3634    /// widen-only + `is_uninformative` soundness gate. `None` if the fact doesn't narrow a type
3635    /// (a `NotNull`/`Not`, an unresolvable/uninformative type, or an un-narrowing of a known subtype).
3636    fn narrowing_entry(&self, place: &Place, nt: &NarrowedTy) -> Option<(String, Ty)> {
3637        let NarrowedTy::Is(ptr) = nt else {
3638            return None;
3639        };
3640        let narrowed = self.resolve_ptr_ty(*ptr);
3641        if narrowed.is_uninformative() {
3642            return None;
3643        }
3644        // Gate against a local/param's declared type; for `self`-members / field chains the
3645        // `is_uninformative` check above is the soundness floor.
3646        if let Place::Local(n) = place
3647            && let Some(cur) = self.locals.get(n)
3648            && !cur.is_uninformative()
3649            && !self.is_subtype(&narrowed, cur)
3650        {
3651            return None;
3652        }
3653        Some((place.dotted_key(), narrowed))
3654    }
3655
3656    /// Apply a condition's short-circuit narrowing to the active env, for typing the RHS of an
3657    /// `and`/`or` (Workstream 2): `if x is T and x.method():` narrows `x` for `x.method()`.
3658    fn apply_condition_facts(&mut self, cond: ExprId, truthy: bool) {
3659        for (place, nt) in flow::condition_facts(self.body, cond, truthy) {
3660            if let Some((key, ty)) = self.narrowing_entry(&place, &nt) {
3661                self.narrowing.insert(key, ty);
3662            }
3663        }
3664    }
3665
3666    /// A dotted access-path key for narrowing (`x`, `self.field`, `a.b.c`), or `None` for a
3667    /// non-path expression.
3668    fn narrow_key(&self, id: ExprId) -> Option<String> {
3669        match self.body.expr(id) {
3670            Expr::Name(n) => Some(n.to_string()),
3671            Expr::SelfExpr => Some("self".to_owned()),
3672            Expr::Paren(inner) => self.narrow_key(*inner),
3673            Expr::Field { receiver, name, .. } => {
3674                Some(format!("{}.{name}", self.narrow_key(*receiver)?))
3675            }
3676            _ => None,
3677        }
3678    }
3679
3680    fn resolve_ptr_ty(&self, ptr: AstPtr) -> Ty {
3681        ptr.to_node(self.root).map_or(Ty::Variant, |n| {
3682            resolve::resolve_type_ref(self.db, self.api, &n)
3683        })
3684    }
3685
3686    // ---- helpers ----
3687
3688    /// The join (least upper bound) of two branch types — conservative: equal types collapse,
3689    /// a subtype widens to its supertype, else `Variant`.
3690    ///
3691    /// The three uninformative markers do NOT collapse to `Variant` — that would defeat the
3692    /// seam. They propagate by priority: `Error` (already diagnosed) → `Unknown` (the cross-file
3693    /// seam — must never warn or cascade) → `Variant` (the gradual top). So
3694    /// `x if c else <unknown>` stays `Unknown`, and `var y := (x if c else unknown)` does not
3695    /// fire a false `INFERENCE_ON_VARIANT`.
3696    fn join(&self, a: &Ty, b: &Ty) -> Ty {
3697        if a == b {
3698            return a.clone();
3699        }
3700        if a.is_error() || b.is_error() {
3701            return Ty::Error;
3702        }
3703        if a.is_unknown() || b.is_unknown() {
3704            return Ty::Unknown;
3705        }
3706        if a.is_variant() || b.is_variant() {
3707            return Ty::Variant;
3708        }
3709        if ty::is_assignable(self.api, a, b) == Assign::Ok {
3710            return b.clone();
3711        }
3712        if ty::is_assignable(self.api, b, a) == Assign::Ok {
3713            return a.clone();
3714        }
3715        Ty::Variant
3716    }
3717}
3718
3719/// Map a resolved global definition to the type of a bare reference to it.
3720fn global_ty(g: &GlobalDef) -> Ty {
3721    match g {
3722        GlobalDef::Const(t) => t.clone(),
3723        GlobalDef::Singleton(c) | GlobalDef::ClassType(c) => Ty::Object(*c),
3724        GlobalDef::BuiltinType(b) => Ty::Builtin(*b),
3725        // A bare function referenced as a value is a `Callable`; an enum namespace is opaque.
3726        GlobalDef::Builtin | GlobalDef::Utility => Ty::Callable,
3727        GlobalDef::GlobalEnum => Ty::Variant,
3728    }
3729}
3730
3731fn inference_on_variant_msg(kind: &str) -> String {
3732    format!(
3733        "The {kind} type is being inferred from a Variant value, so it will be typed as Variant."
3734    )
3735}
3736
3737/// The `extension_api.json` operator spelling for a binary operator.
3738fn op_symbol(op: BinOp) -> Option<&'static str> {
3739    Some(match op {
3740        BinOp::Add => "+",
3741        BinOp::Sub => "-",
3742        BinOp::Mul => "*",
3743        BinOp::Div => "/",
3744        BinOp::Mod => "%",
3745        BinOp::Pow => "**",
3746        BinOp::BitAnd => "&",
3747        BinOp::BitOr => "|",
3748        BinOp::BitXor => "^",
3749        BinOp::Shl => "<<",
3750        BinOp::Shr => ">>",
3751        _ => return None,
3752    })
3753}
3754
3755#[cfg(test)]
3756mod tests {
3757    use super::*;
3758    use crate::item_tree::item_tree;
3759    use gdscript_syntax::{SyntaxKind, parse};
3760
3761    struct Harness {
3762        result: InferenceResult,
3763        body: Body,
3764    }
3765
3766    /// Infer the (first) function in `src` against a fresh class scope.
3767    fn infer_first_func(src: &str) -> Harness {
3768        let api = gdscript_api::bundled();
3769        let db = gdscript_db::RootDatabase::default();
3770        let root = parse(src).syntax_node();
3771        let tree = item_tree(&root);
3772        let class = ClassScope::new(&db, api, &tree, None);
3773        let func = gdscript_syntax::ast::descendants(&root)
3774            .into_iter()
3775            .find(|n| n.kind() == SyntaxKind::FuncDecl)
3776            .expect("a function");
3777        let body = body::body_of_func(&func);
3778        let return_ty = cst::first_child(&func, |k| k == SyntaxKind::TypeRef)
3779            .map_or(Ty::Variant, |t| resolve::resolve_type_ref(&db, api, &t));
3780        let owner =
3781            cst::first_child(&func, |k| k == SyntaxKind::Name).map(|n| n.text().to_string());
3782        let result = infer(
3783            &db,
3784            api,
3785            &root,
3786            &class,
3787            &body,
3788            return_ty,
3789            true,
3790            owner.as_deref(),
3791        );
3792        Harness { result, body }
3793    }
3794
3795    /// Every code inference produced — the ungated `diagnostics` plus the severity-free
3796    /// `raw_warnings` (the gateable Godot codes, post-W1-M0). Infer-level tests assert what the
3797    /// checker *records*; the gate-level resolution is tested in `crate::warnings`.
3798    /// The opt-in declaration-strictness codes — filtered out of [`codes`] / [`file_codes`] so they
3799    /// don't pollute the hundreds of focused fixtures (they fire on essentially every untyped /
3800    /// inferred local). A test that targets them reads the raw warnings directly (see
3801    /// `untyped_and_inferred_declarations_warn`).
3802    const DECLARATION_STRICTNESS: &[&str] = &["UNTYPED_DECLARATION", "INFERRED_DECLARATION"];
3803
3804    fn codes(h: &Harness) -> Vec<&str> {
3805        h.result
3806            .diagnostics
3807            .iter()
3808            .map(|d| d.code.as_str())
3809            .chain(h.result.raw_warnings.iter().map(|w| w.code.as_str()))
3810            .filter(|c| !DECLARATION_STRICTNESS.contains(c))
3811            .collect()
3812    }
3813
3814    /// Whole-file codes for `files[0]`, analyzed in PROJECT mode (BUG A1 harness): every
3815    /// `(res_path, text)` pair is loaded into the db, the source root is synced, and the loader's
3816    /// completeness claim is applied. `project_godot`, when given, feeds the autoload registry.
3817    fn project_codes(
3818        files: &[(&str, &str)],
3819        project_godot: Option<&str>,
3820        complete: bool,
3821    ) -> Vec<String> {
3822        let api = gdscript_api::bundled();
3823        let mut db = gdscript_db::RootDatabase::default();
3824        for (i, (path, text)) in files.iter().enumerate() {
3825            let id = FileId(u32::try_from(i).unwrap());
3826            db.set_file_text(id, text, salsa::Durability::LOW);
3827            db.set_file_path(id, path);
3828        }
3829        if let Some(cfg) = project_godot {
3830            db.set_project_config(cfg);
3831        }
3832        db.sync_source_root();
3833        db.set_workspace_complete(complete);
3834        let root = parse(files[0].1).syntax_node();
3835        let fi = analyze_file(&db, api, &root, FileId(0));
3836        fi.diagnostics
3837            .iter()
3838            .map(|d| d.code.clone())
3839            .chain(fi.raw_warnings.iter().map(|w| w.code.as_str().to_owned()))
3840            .filter(|c| !DECLARATION_STRICTNESS.contains(&c.as_str()))
3841            .collect()
3842    }
3843
3844    /// Run the whole-file pass (Pass 1 field fixpoint + Pass 2 functions) and collect every
3845    /// diagnostic code (ungated diagnostics + raw gateable warnings). Drives `analyze_file`
3846    /// directly so the bounded member fixpoint runs.
3847    fn file_codes(src: &str) -> Vec<String> {
3848        let api = gdscript_api::bundled();
3849        let db = gdscript_db::RootDatabase::default();
3850        let root = parse(src).syntax_node();
3851        let fi = analyze_file(&db, api, &root, FileId(0));
3852        fi.diagnostics
3853            .iter()
3854            .map(|d| d.code.clone())
3855            .chain(fi.raw_warnings.iter().map(|w| w.code.as_str().to_owned()))
3856            .filter(|c| !DECLARATION_STRICTNESS.contains(&c.as_str()))
3857            .collect()
3858    }
3859
3860    // ── Review-pass regressions (branch fix/guitkx-diagnostic-gaps) ──────────────────────────
3861
3862    #[test]
3863    fn bare_global_enum_value_types_as_its_enum_not_int() {
3864        // `MOUSE_BUTTON_LEFT` is a `MouseButton` value: assigning it to a `MouseButton` slot is
3865        // Enum→Enum (Ok) — the int typing regression fired a false INT_AS_ENUM_WITHOUT_CAST on
3866        // code Godot accepts.
3867        let h =
3868            infer_first_func("func f():\n\tvar b: MouseButton = MOUSE_BUTTON_LEFT\n\treturn b\n");
3869        assert!(
3870            !codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
3871            "{:?}",
3872            h.result.diagnostics
3873        );
3874    }
3875
3876    #[test]
3877    fn gdscript_builtin_surface_is_complete_no_undefined_function() {
3878        // The full @GDScript pseudo-class surface must resolve — `print_debug` etc. are absent
3879        // from every extracted table, and an uncovered one false-flags as an ERROR-severity
3880        // UNDEFINED_FUNCTION in a complete workspace.
3881        let src = "func f():\n\tprint_debug(\"x\")\n\tprint_stack()\n\tvar st = get_stack()\n\tvar o = ord(\"a\")\n\tvar c = convert(1, TYPE_STRING)\n\tvar t = type_exists(\"Node\")\n\tvar d = inst_to_dict(self)\n\tvar i = dict_to_inst(d)\n\treturn [st, o, c, t, d, i]\n";
3882        let c = project_codes(&[("res://main.gd", src)], None, true);
3883        assert!(
3884            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
3885            "every @GDScript builtin must resolve: {c:?}"
3886        );
3887    }
3888
3889    #[test]
3890    fn scene_autoload_singleton_is_never_undefined() {
3891        // A REGISTERED `*`-autoload backed by a .tscn resolves to the seam (its root script is
3892        // invisible) — registry presence means DEFINED, so the identifier must not flag.
3893        let c = project_codes(
3894            &[("res://main.gd", "func f():\n\treturn Music\n")],
3895            Some("[autoload]\nMusic=\"*res://music.tscn\"\n"),
3896            true,
3897        );
3898        assert!(
3899            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
3900            "a registered scene autoload is defined: {c:?}"
3901        );
3902    }
3903
3904    #[test]
3905    fn calling_an_unassigned_local_callable_still_flags_unassigned() {
3906        // The locals-first CALL branch routes through resolve_name, so the definite-assignment
3907        // check fires for a call-before-assignment exactly like a read-before-assignment. The
3908        // local is UNTYPED — a typed `var cb: Callable` is zero-initialized by Godot 4 and never
3909        // an UNASSIGNED_VARIABLE candidate (probed q11/r35).
3910        let h2 = infer_first_func("func f():\n\tvar cb\n\tcb()\n\tcb = Callable()\n");
3911        assert!(
3912            codes(&h2).contains(&"UNASSIGNED_VARIABLE"),
3913            "a bare call reads the local before assignment: {:?}",
3914            codes(&h2)
3915        );
3916    }
3917
3918    #[test]
3919    fn over_indent_recovered_statements_are_still_analyzed() {
3920        // The recovery Block's statements flatten into the function scope (GDScript locals are
3921        // function-scoped): the over-indented `5 / 2` must still produce INTEGER_DIVISION.
3922        let src =
3923            "func f():\n\tvar a = 1\n\t\tvar bad = 5 / 2\n\tvar b = 2\n\treturn [a, b, bad]\n";
3924        let c = file_codes(src);
3925        assert!(
3926            c.iter().any(|x| x == INTEGER_DIVISION),
3927            "recovered statements must be analyzed: {c:?}"
3928        );
3929    }
3930
3931    // ── BUG A3: `## @return-tuple(...)` → Ty::Tuple + constant-index projection ──────────────
3932
3933    const HOOK_DECL: &str =
3934        "## @return-tuple(Variant, Callable)\nstatic func useState(v):\n\treturn [v, Callable()]\n";
3935
3936    #[test]
3937    fn return_tuple_tag_projects_a_constant_index() {
3938        // `useState(0)[1]` is the setter: a REAL `Callable`, so a typo'd method on it is caught
3939        // (UNDEFINED_METHOD — a compile error in Godot on a builtin receiver) while the correct
3940        // `.call` stays silent. Both direct indexing and an `:=`-inferred local carry the tuple.
3941        let src = format!(
3942            "{HOOK_DECL}func f():\n\tvar s := useState(0)\n\ts[1].call(1)\n\ts[1].casll(1)\n\tuseState(0)[1].casll(2)\n"
3943        );
3944        let c = file_codes(&src);
3945        assert_eq!(
3946            c.iter().filter(|x| *x == "UNDEFINED_METHOD").count(),
3947            2,
3948            "exactly the two typo'd setter methods flag: {c:?}"
3949        );
3950    }
3951
3952    #[test]
3953    fn return_tuple_untyped_local_projects_via_initializer_narrowing() {
3954        // An UNTYPED `var s = useState(0)` local is a `Variant` variable in GDScript, but this one
3955        // is effectively single-assignment (never rebound or index-stored), so initializer
3956        // narrowing (ADR-0007) carries the tuple through it — the typo'd setter method flags even
3957        // in the verbatim user shape that motivated the whole workstream.
3958        let src =
3959            format!("{HOOK_DECL}func f():\n\tvar s = useState(0)\n\ts[1].casll(1)\n\treturn s\n");
3960        let c = file_codes(&src);
3961        assert!(
3962            c.iter().any(|x| x == "UNDEFINED_METHOD"),
3963            "initializer narrowing projects the tuple through the untyped local: {c:?}"
3964        );
3965    }
3966
3967    #[test]
3968    fn initializer_narrowing_is_invalidated_by_rebinds_and_index_stores() {
3969        // A later rebind means the initializer's type is NOT the only one the binding can hold —
3970        // back to Godot semantics (Variant, unchecked). Same for an index store (`s[1] = …` can
3971        // re-type a tuple position). A FIELD store mutates the value, not the binding — it keeps
3972        // the narrowing.
3973        let rebound = format!(
3974            "{HOOK_DECL}func f():\n\tvar s = useState(0)\n\ts = 5\n\ts[1].casll(1)\n\treturn s\n"
3975        );
3976        let c = file_codes(&rebound);
3977        assert!(
3978            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
3979            "a rebound local stays Variant: {c:?}"
3980        );
3981        let stored = format!(
3982            "{HOOK_DECL}func f():\n\tvar s = useState(0)\n\ts[1] = 5\n\ts[1].casll(1)\n\treturn s\n"
3983        );
3984        let c2 = file_codes(&stored);
3985        assert!(
3986            !c2.iter().any(|x| x.starts_with("UNDEFINED_")),
3987            "an index-stored local stays Variant: {c2:?}"
3988        );
3989        let lambda = format!(
3990            "{HOOK_DECL}func f():\n\tvar s = useState(0)\n\tvar g := func():\n\t\ts = 1\n\ts[1].casll(1)\n\treturn [s, g]\n"
3991        );
3992        let c3 = file_codes(&lambda);
3993        assert!(
3994            !c3.iter().any(|x| x.starts_with("UNDEFINED_")),
3995            "a lambda rebind conservatively invalidates: {c3:?}"
3996        );
3997        let field_store = "func f():\n\tvar v = Vector2.ONE\n\tv.x = 1.0\n\tv.zzz()\n\treturn v\n";
3998        let c4 = file_codes(field_store);
3999        assert!(
4000            c4.iter().any(|x| x == "UNDEFINED_METHOD"),
4001            "a field store keeps the narrowing (the binding type is untouched): {c4:?}"
4002        );
4003    }
4004
4005    #[test]
4006    fn initializer_narrowing_skips_null_and_seam_initializers() {
4007        // `var x = null` is a nullability question — Godot is silent (verified on 4.7) and so are
4008        // we. A cross-file seam initializer stays the silent seam.
4009        let c = file_codes("func f():\n\tvar x = null\n\tx.foo()\n");
4010        assert!(
4011            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
4012            "null initializers never narrow: {c:?}"
4013        );
4014        let c2 = file_codes("func f():\n\tvar x = Missing.thing()\n\tx.foo()\n");
4015        assert!(
4016            !c2.iter().any(|x| x.starts_with("UNDEFINED_")),
4017            "seam initializers never narrow: {c2:?}"
4018        );
4019    }
4020
4021    #[test]
4022    fn return_tuple_dynamic_or_oob_index_degrades_to_variant() {
4023        // A dynamic index (or an out-of-bounds constant) cannot select a position — it degrades
4024        // to the runtime element type (`Variant`), which never fires member checks.
4025        let src = format!(
4026            "{HOOK_DECL}func f(i: int):\n\tvar s := useState(0)\n\ts[i].casll(1)\n\ts[7].casll(1)\n"
4027        );
4028        let c = file_codes(&src);
4029        assert!(
4030            !c.iter().any(|x| x == "UNSAFE_METHOD_ACCESS"),
4031            "no confident projection without a constant in-bounds index: {c:?}"
4032        );
4033    }
4034
4035    #[test]
4036    fn return_tuple_widens_to_array_for_assignment_and_methods() {
4037        // At runtime the tuple IS an untyped Array: it assigns to an `Array` slot without a
4038        // mismatch, and Array methods (`size`) resolve on it.
4039        let src = format!(
4040            "{HOOK_DECL}func f():\n\tvar s := useState(0)\n\tvar a: Array = s\n\treturn [a, s.size()]\n"
4041        );
4042        let c = file_codes(&src);
4043        assert!(
4044            !c.iter()
4045                .any(|x| x == TYPE_MISMATCH || x == "UNSAFE_METHOD_ACCESS"),
4046            "a tuple behaves as its runtime Array: {c:?}"
4047        );
4048    }
4049
4050    #[test]
4051    fn return_tuple_resolves_cross_file_via_the_member_table() {
4052        // The guitkx shape once the virtual doc emits field calls: `Hooks.useState(...)` in ONE
4053        // file, the tagged hook in ANOTHER — the tuple flows through the script member table.
4054        let files = [
4055            (
4056                "res://main.gd",
4057                "func f():\n\tvar s := Hooks.useState(0)\n\ts[1].casll(1)\n",
4058            ),
4059            (
4060                "res://hooks.gd",
4061                "class_name Hooks\n## @return-tuple(Variant, Callable)\nstatic func useState(v):\n\treturn [v, Callable()]\n",
4062            ),
4063        ];
4064        let c = project_codes(&files, None, true);
4065        assert!(
4066            c.iter().any(|x| x == "UNDEFINED_METHOD"),
4067            "the typo'd setter method must flag cross-file: {c:?}"
4068        );
4069    }
4070
4071    #[test]
4072    fn return_tuple_tag_is_ignored_when_malformed() {
4073        // One name is not a tuple; the tag degrades to the plain (annotationless) return.
4074        let src = "## @return-tuple(Variant)\nstatic func one(v):\n\treturn [v]\nfunc f():\n\tvar s = one(0)\n\ts[0].casll(1)\n";
4075        let c = file_codes(src);
4076        assert!(
4077            !c.iter().any(|x| x == "UNSAFE_METHOD_ACCESS"),
4078            "a malformed tag must not project: {c:?}"
4079        );
4080    }
4081
4082    // ── BUG A1: UNDEFINED_FUNCTION / UNDEFINED_IDENTIFIER (complete-workspace gated) ─────────
4083
4084    #[test]
4085    fn undefined_function_fires_with_a_complete_workspace() {
4086        let c = project_codes(
4087            &[("res://main.gd", "func f():\n\tusseState(0)\n")],
4088            None,
4089            true,
4090        );
4091        assert!(
4092            c.iter().any(|x| x == "UNDEFINED_FUNCTION"),
4093            "a provable typo call must fire: {c:?}"
4094        );
4095    }
4096
4097    #[test]
4098    fn undefined_function_is_silent_without_the_completeness_claim() {
4099        // The same typo with the workspace NOT declared complete (a lone file / partial load, even
4100        // with a source root present) keeps the silent seam — absence is not provable.
4101        let c = project_codes(
4102            &[("res://main.gd", "func f():\n\tusseState(0)\n")],
4103            None,
4104            false,
4105        );
4106        assert!(
4107            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
4108            "incomplete workspace must stay silent: {c:?}"
4109        );
4110    }
4111
4112    #[test]
4113    fn undefined_function_skips_cross_file_class_names_and_autoloads() {
4114        // `Enemy` is a registered global class in ANOTHER file, `Music` a `*`-autoload singleton:
4115        // calling/reading them is never "undefined" (misusing them is a different mistake).
4116        let files = [
4117            (
4118                "res://main.gd",
4119                "func f():\n\tvar e = Enemy.new()\n\tMusic.play()\n\treturn e\n",
4120            ),
4121            ("res://enemy.gd", "class_name Enemy\nfunc hit(): pass\n"),
4122            ("res://music.gd", "func play(): pass\n"),
4123        ];
4124        let c = project_codes(
4125            &files,
4126            Some("[autoload]\nMusic=\"*res://music.gd\"\n"),
4127            true,
4128        );
4129        assert!(
4130            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
4131            "cross-file globals + autoloads must resolve: {c:?}"
4132        );
4133    }
4134
4135    #[test]
4136    fn undefined_function_skips_locals_engine_methods_utilities_and_members() {
4137        // A local Callable call (A2), an inherited engine method, a utility fn, and a bare call on
4138        // a member that exists as a VAR (defined-but-not-callable is not "undefined").
4139        let src = "extends Node\nvar handler: Callable\nfunc f():\n\tvar cb = func(): return 1\n\tcb()\n\tget_child(0)\n\tprint(\"x\")\n\thandler()\n";
4140        let c = project_codes(&[("res://main.gd", src)], None, true);
4141        assert!(
4142            !c.iter().any(|x| x == "UNDEFINED_FUNCTION"),
4143            "locals / engine methods / utilities / members must never flag: {c:?}"
4144        );
4145    }
4146
4147    #[test]
4148    fn undefined_function_is_silent_under_a_user_script_base() {
4149        // `extends Base` (a class_name in another file): the base chain is not engine-native, so
4150        // absence of an inherited method is not provable — the gate keeps the seam even though
4151        // `base_method` is defined only on the base.
4152        let files = [
4153            (
4154                "res://main.gd",
4155                "extends Base\nfunc f():\n\tbase_method()\n",
4156            ),
4157            (
4158                "res://base.gd",
4159                "class_name Base\nfunc base_method(): pass\n",
4160            ),
4161        ];
4162        let c = project_codes(&files, None, true);
4163        assert!(
4164            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
4165            "a ScriptRef base must gate emission off: {c:?}"
4166        );
4167    }
4168
4169    #[test]
4170    fn undefined_identifier_fires_and_respects_the_gate() {
4171        let fire = project_codes(
4172            &[("res://main.gd", "func f():\n\treturn nonexistent_thing\n")],
4173            None,
4174            true,
4175        );
4176        assert!(
4177            fire.iter().any(|x| x == "UNDEFINED_IDENTIFIER"),
4178            "a provable undeclared read must fire: {fire:?}"
4179        );
4180        let silent = project_codes(
4181            &[("res://main.gd", "func f():\n\treturn nonexistent_thing\n")],
4182            None,
4183            false,
4184        );
4185        assert!(
4186            !silent.iter().any(|x| x.starts_with("UNDEFINED_")),
4187            "incomplete workspace must stay silent: {silent:?}"
4188        );
4189    }
4190
4191    #[test]
4192    fn builtin_member_miss_is_undefined_method_or_property() {
4193        // Godot 4.7 ground truth (probed with --check-only): a member miss on a BUILTIN receiver
4194        // is a hard parse error — typed and `:=`-inferred alike, methods and properties alike —
4195        // while `Object` receivers stay silent (a script can attach members at runtime). Needs NO
4196        // completeness claim: the builtin tables are bundled and closed, hence `file_codes`.
4197        let c = file_codes("func f():\n\tvar c: Callable = Callable()\n\tc.casll()\n");
4198        assert!(c.iter().any(|x| x == "UNDEFINED_METHOD"), "{c:?}");
4199        let c2 = file_codes("func f():\n\tvar v := Vector2.ONE\n\treturn v.zzz\n");
4200        assert!(c2.iter().any(|x| x == "UNDEFINED_PROPERTY"), "{c2:?}");
4201        // int/float/bool genuinely have NO methods in Godot (probed: `i.to_float()` errors).
4202        let c3 = file_codes("func f():\n\tvar i: int = 5\n\ti.to_float()\n");
4203        assert!(c3.iter().any(|x| x == "UNDEFINED_METHOD"), "{c3:?}");
4204        // Valid members stay silent.
4205        let ok = file_codes("func f():\n\tvar s: String = \"a\"\n\treturn s.to_upper().length()\n");
4206        assert!(!ok.iter().any(|x| x.starts_with("UNDEFINED_")), "{ok:?}");
4207        // Object receivers keep the opt-in UNSAFE_* (Godot is silent there too).
4208        let obj = file_codes("func f():\n\tvar n: Node = Node.new()\n\tn.frobnicate()\n");
4209        assert!(!obj.iter().any(|x| x.starts_with("UNDEFINED_")), "{obj:?}");
4210        assert!(obj.iter().any(|x| x == "UNSAFE_METHOD_ACCESS"), "{obj:?}");
4211    }
4212
4213    #[test]
4214    fn dictionary_property_access_is_keyed_sugar_never_undefined() {
4215        // `d.some_key` on a Dictionary is subscript sugar (`d["some_key"]`) — Godot is silent on
4216        // ANY name, reads and writes alike (probed on 4.7). A METHOD miss on Dictionary still
4217        // errors in Godot and still flags here. The typed value projects.
4218        let c = file_codes(
4219            "func f():\n\tvar d: Dictionary = {}\n\tvar x = d.some_key\n\td.other_key = 5\n\treturn x\n",
4220        );
4221        assert!(
4222            !c.iter()
4223                .any(|x| x.starts_with("UNDEFINED_") || x.starts_with("UNSAFE_")),
4224            "keyed access is not a member miss: {c:?}"
4225        );
4226        let m = file_codes("func f():\n\tvar d: Dictionary = {}\n\td.casll()\n");
4227        assert!(
4228            m.iter().any(|x| x == "UNDEFINED_METHOD"),
4229            "a Dictionary METHOD miss is still a Godot compile error: {m:?}"
4230        );
4231    }
4232
4233    #[test]
4234    fn dictionary_key_wins_over_method_name_in_property_sugar() {
4235        // Probed on 4.7: `{"size": 99}.size` is 99 (the KEY, not the method), and
4236        // `var n: int = d.size` parse-checks clean on a typed Dictionary. The keyed sugar must
4237        // short-circuit the whole member path — including the method-name-as-Callable arm, which
4238        // would otherwise type `d.size` as Callable and fire a false TYPE_MISMATCH.
4239        let c = file_codes(
4240            "func f():\n\tvar d: Dictionary = {\"size\": 3}\n\tvar n: int = d.size\n\td.size = 5\n\treturn n\n",
4241        );
4242        assert!(
4243            !c.iter()
4244                .any(|x| x == "TYPE_MISMATCH" || x.starts_with("UNDEFINED_")),
4245            "the key wins over the method name in property sugar: {c:?}"
4246        );
4247    }
4248
4249    #[test]
4250    fn dictionary_method_call_on_a_key_is_a_real_runtime_error() {
4251        // Probed on 4.7: `d.greet()` with a Callable stored under "greet" CRASHES at runtime
4252        // ("Nonexistent function 'greet' in base 'Dictionary'") — method-call sugar does NOT
4253        // dispatch to callable values (you must write `d.greet.call()`). Narrowing the untyped
4254        // dict makes this a checkable receiver, and flagging it is a TRUE positive.
4255        let c = file_codes("func f():\n\tvar d = {\"greet\": 1}\n\td.greet()\n");
4256        assert!(
4257            c.iter().any(|x| x == "UNDEFINED_METHOD"),
4258            "method-call sugar does not dispatch to keys — a runtime crash: {c:?}"
4259        );
4260    }
4261
4262    #[test]
4263    fn tuple_narrowing_widens_when_the_local_escapes_into_an_alias() {
4264        // `var t = s` aliases the array; `t[1] = x` then re-types position 1 THROUGH the alias,
4265        // so a surviving projection on `s` would be stale — an escaped tuple keeps only its
4266        // runtime Array form (methods still checked, positions no longer projected). Applies to
4267        // `:=` bindings too. A cast in a store target (`(s as Array)[1] = …`) is still a store.
4268        let aliased = format!(
4269            "{HOOK_DECL}func f():\n\tvar s = useState(0)\n\tvar t = s\n\tt[1] = 5\n\ts[1].casll(1)\n\treturn [s, t]\n"
4270        );
4271        let c = file_codes(&aliased);
4272        assert!(
4273            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
4274            "an escaped tuple must not keep positional projections: {c:?}"
4275        );
4276        let arg = format!(
4277            "{HOOK_DECL}func g(a):\n\ta[1] = 5\nfunc f():\n\tvar s := useState(0)\n\tg(s)\n\ts[1].casll(1)\n"
4278        );
4279        let c2 = file_codes(&arg);
4280        assert!(
4281            !c2.iter().any(|x| x.starts_with("UNDEFINED_")),
4282            "a tuple passed as an argument (by reference) must widen: {c2:?}"
4283        );
4284        let cast_store = format!(
4285            "{HOOK_DECL}func f():\n\tvar s = useState(0)\n\t(s as Array)[1] = \"x\"\n\ts[1].casll(1)\n\treturn s\n"
4286        );
4287        let c3 = file_codes(&cast_store);
4288        assert!(
4289            !c3.iter().any(|x| x.starts_with("UNDEFINED_")),
4290            "a cast-wrapped index store still invalidates: {c3:?}"
4291        );
4292        // The motivating shape — index reads only — KEEPS the projection.
4293        let clean = format!("{HOOK_DECL}func f():\n\tvar s = useState(0)\n\ts[1].casll(1)\n");
4294        let c4 = file_codes(&clean);
4295        assert!(
4296            c4.iter().any(|x| x == "UNDEFINED_METHOD"),
4297            "index-read-only locals keep the projection: {c4:?}"
4298        );
4299    }
4300
4301    #[test]
4302    fn builtin_member_miss_falls_back_to_unsafe_on_a_newer_project_engine() {
4303        // A project declaring a NEWER engine than the bundled model may use builtin members we
4304        // don't know about — degrade to the opt-in UNSAFE_* signal instead of a false hard error.
4305        let files = [(
4306            "res://main.gd",
4307            "func f():\n\tvar c: Callable = Callable()\n\tc.casll()\n",
4308        )];
4309        let cfg = "[application]\nconfig/features=PackedStringArray(\"4.9\")\n";
4310        let c = project_codes(&files, Some(cfg), false);
4311        assert!(!c.iter().any(|x| x == "UNDEFINED_METHOD"), "{c:?}");
4312        assert!(c.iter().any(|x| x == "UNSAFE_METHOD_ACCESS"), "{c:?}");
4313    }
4314
4315    #[test]
4316    fn undefined_identifier_skips_lambda_captures_and_inner_class_bodies() {
4317        // A lambda reading an outer local (a capture) is declared; an inner-class body is outside
4318        // the gate (its bare names may bind outer-class members its scope doesn't chain to).
4319        let src = "static func outer_helper(): pass\nclass Inner:\n\tfunc g():\n\t\touter_helper()\nfunc f():\n\tvar captured = 1\n\tvar l = func(): return captured\n\treturn l\n";
4320        let c = project_codes(&[("res://main.gd", src)], None, true);
4321        assert!(
4322            !c.iter().any(|x| x.starts_with("UNDEFINED_")),
4323            "captures + inner-class bodies must never flag: {c:?}"
4324        );
4325    }
4326
4327    #[test]
4328    fn integer_division_warns() {
4329        let h = infer_first_func("func f():\n\tvar x = 5 / 2\n");
4330        assert!(codes(&h).contains(&INTEGER_DIVISION));
4331    }
4332
4333    #[test]
4334    fn float_div_does_not_warn() {
4335        let h = infer_first_func("func f():\n\tvar x = 5.0 / 2\n");
4336        assert!(!codes(&h).contains(&INTEGER_DIVISION));
4337    }
4338
4339    #[test]
4340    fn type_mismatch_on_hard_annotation() {
4341        let h = infer_first_func("func f():\n\tvar s: String = 5\n");
4342        assert!(codes(&h).contains(&TYPE_MISMATCH));
4343    }
4344
4345    #[test]
4346    fn vector_scalar_compound_assign_is_not_a_mismatch() {
4347        // `v *= 0.5` desugars to `v = v * 0.5` : Vector2 — not the scalar float (the old collapse).
4348        let h = infer_first_func(
4349            "func f() -> Vector2:\n\tvar v := Vector2()\n\tv *= 0.5\n\treturn v\n",
4350        );
4351        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
4352    }
4353
4354    #[test]
4355    fn array_literal_to_packed_array_is_allowed() {
4356        let h = infer_first_func("func f():\n\tvar p: PackedStringArray = [\"a\", \"b\"]\n");
4357        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
4358    }
4359
4360    #[test]
4361    fn vector2i_to_vector2_is_allowed() {
4362        let h = infer_first_func("func f():\n\tvar v: Vector2 = Vector2i(1, 2)\n");
4363        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
4364    }
4365
4366    #[test]
4367    fn local_enum_member_access_types_as_the_enum_not_variant() {
4368        // `var x := State.IDLE` (a same-file enum) infers the enum type, not a Variant seam — so no
4369        // false INFERENCE_ON_VARIANT.
4370        let h = infer_first_func(
4371            "enum State { IDLE, RUN }\nfunc f():\n\tvar x := State.IDLE\n\treturn x\n",
4372        );
4373        assert!(
4374            !codes(&h).contains(&INFERENCE_ON_VARIANT),
4375            "{:?}",
4376            codes(&h)
4377        );
4378    }
4379
4380    #[test]
4381    fn lua_style_dict_key_is_not_an_assignment() {
4382        // `{ pos = "x" }` is a dict entry (key `pos`), not the statement `pos = "x"` — so it must not
4383        // check the value against the member `pos`'s type.
4384        let h = infer_first_func(
4385            "var pos: Vector2\nfunc f():\n\tvar d = { pos = \"x\" }\n\treturn d\n",
4386        );
4387        assert!(!codes(&h).contains(&TYPE_MISMATCH), "{:?}", codes(&h));
4388    }
4389
4390    #[test]
4391    fn narrowing_conversion_float_to_int() {
4392        let h = infer_first_func("func f():\n\tvar n: int = 1.5\n");
4393        assert!(codes(&h).contains(&NARROWING_CONVERSION));
4394    }
4395
4396    #[test]
4397    fn int_to_float_is_silent() {
4398        let h = infer_first_func("func f():\n\tvar x: float = 3\n\treturn x\n");
4399        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
4400    }
4401
4402    #[test]
4403    fn local_shadowing_a_param_warns_shadowed_variable() {
4404        let h = infer_first_func("func f(x):\n\tvar x = 1\n\treturn x\n");
4405        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
4406    }
4407
4408    #[test]
4409    fn local_shadowing_a_class_member_warns_shadowed_variable() {
4410        // The class scope (built from the whole file) sees the member `health`; the local shadows it.
4411        let h =
4412            infer_first_func("var health = 100\nfunc f():\n\tvar health = 1\n\treturn health\n");
4413        assert!(codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
4414    }
4415
4416    #[test]
4417    fn non_shadowing_local_does_not_warn_shadowed_variable() {
4418        let h = infer_first_func("func f(x):\n\tvar y = 1\n\treturn x + y\n");
4419        assert!(!codes(&h).contains(&"SHADOWED_VARIABLE"), "{:?}", codes(&h));
4420    }
4421
4422    #[test]
4423    fn local_shadowing_a_base_member_warns_base_class() {
4424        // `position` is a Node2D property; a local of that name shadows the base member.
4425        let h =
4426            infer_first_func("extends Node2D\nfunc f():\n\tvar position = 1\n\treturn position\n");
4427        assert!(
4428            codes(&h).contains(&"SHADOWED_VARIABLE_BASE_CLASS"),
4429            "{:?}",
4430            codes(&h)
4431        );
4432    }
4433
4434    #[test]
4435    fn shadowing_an_unresolved_base_is_silent() {
4436        // No false positive when the base can't be resolved (the cross-file seam).
4437        let h = infer_first_func(
4438            "extends SomeUnknownThirdPartyClass\nfunc f():\n\tvar position = 1\n\treturn position\n",
4439        );
4440        assert!(
4441            !codes(&h).contains(&"SHADOWED_VARIABLE_BASE_CLASS"),
4442            "{:?}",
4443            codes(&h)
4444        );
4445    }
4446
4447    #[test]
4448    fn local_named_after_a_native_class_warns_shadowed_global() {
4449        let h = infer_first_func("func f():\n\tvar Node = 1\n\treturn Node\n");
4450        assert!(
4451            codes(&h).contains(&SHADOWED_GLOBAL_IDENTIFIER),
4452            "{:?}",
4453            codes(&h)
4454        );
4455    }
4456
4457    #[test]
4458    fn param_named_after_a_builtin_type_warns_shadowed_global() {
4459        let h = infer_first_func("func f(Vector2):\n\treturn Vector2\n");
4460        assert!(
4461            codes(&h).contains(&SHADOWED_GLOBAL_IDENTIFIER),
4462            "{:?}",
4463            codes(&h)
4464        );
4465    }
4466
4467    #[test]
4468    fn member_named_after_a_native_class_warns_shadowed_global() {
4469        let cs = file_codes("var Timer = null\n");
4470        assert!(
4471            cs.iter().any(|c| c == "SHADOWED_GLOBAL_IDENTIFIER"),
4472            "{cs:?}"
4473        );
4474    }
4475
4476    #[test]
4477    fn ordinary_local_does_not_warn_shadowed_global() {
4478        // A no-false-positive guard: a normal identifier is not a global.
4479        let h = infer_first_func("func f():\n\tvar count = 1\n\treturn count\n");
4480        assert!(
4481            !codes(&h).contains(&SHADOWED_GLOBAL_IDENTIFIER),
4482            "{:?}",
4483            codes(&h)
4484        );
4485    }
4486
4487    #[test]
4488    fn global_shadow_takes_precedence_over_variable_shadow() {
4489        // A member named after a built-in type (`Color`), shadowed by a local of the same name:
4490        // Godot emits SHADOWED_GLOBAL_IDENTIFIER (global wins), NOT SHADOWED_VARIABLE on the local.
4491        let cs = file_codes("var Color = null\nfunc f():\n\tvar Color = 1\n\treturn Color\n");
4492        assert!(
4493            cs.iter().any(|c| c == "SHADOWED_GLOBAL_IDENTIFIER"),
4494            "{cs:?}"
4495        );
4496        assert!(
4497            !cs.iter().any(|c| c == "SHADOWED_VARIABLE"),
4498            "the local's variable-shadow must be suppressed in favor of the global one: {cs:?}"
4499        );
4500    }
4501
4502    #[test]
4503    fn assert_true_warns_always_true() {
4504        let h = infer_first_func("func f():\n\tassert(true)\n");
4505        assert!(codes(&h).contains(&"ASSERT_ALWAYS_TRUE"), "{:?}", codes(&h));
4506    }
4507
4508    #[test]
4509    fn assert_false_warns_always_false() {
4510        let h = infer_first_func("func f():\n\tassert(false, \"nope\")\n");
4511        assert!(
4512            codes(&h).contains(&"ASSERT_ALWAYS_FALSE"),
4513            "{:?}",
4514            codes(&h)
4515        );
4516    }
4517
4518    #[test]
4519    fn assert_null_warns_always_false() {
4520        let h = infer_first_func("func f():\n\tassert(null)\n");
4521        assert!(
4522            codes(&h).contains(&"ASSERT_ALWAYS_FALSE"),
4523            "{:?}",
4524            codes(&h)
4525        );
4526    }
4527
4528    #[test]
4529    fn assert_on_a_variable_is_silent() {
4530        // No false positive: a runtime condition is not a constant.
4531        let h = infer_first_func("func f(x):\n\tassert(x)\n");
4532        assert!(
4533            !codes(&h).iter().any(|c| c.starts_with("ASSERT_ALWAYS")),
4534            "{:?}",
4535            codes(&h)
4536        );
4537    }
4538
4539    #[test]
4540    fn untyped_and_inferred_declarations_warn() {
4541        // The opt-in (default IGNORE) declaration-strictness codes, read from the raw warnings —
4542        // `codes()` filters them out so they don't pollute every other fixture.
4543        let h = infer_first_func("func f(p):\n\tvar a = 1\n\tvar b := 2\n\tvar c: int = 3\n");
4544        let raw: Vec<&str> = h
4545            .result
4546            .raw_warnings
4547            .iter()
4548            .map(|w| w.code.as_str())
4549            .collect();
4550        // The untyped param `p` and untyped `var a` — not the typed `var c` nor the inferred `var b`.
4551        let untyped = raw.iter().filter(|c| **c == "UNTYPED_DECLARATION").count();
4552        assert_eq!(untyped, 2, "only `p` and `a` are untyped: {raw:?}");
4553        let inferred = raw.iter().filter(|c| **c == "INFERRED_DECLARATION").count();
4554        assert_eq!(inferred, 1, "only `b` uses `:=`: {raw:?}");
4555    }
4556
4557    #[test]
4558    fn confusable_identifier_warns_on_a_mixed_script_local() {
4559        // `p\u{0430}ypal` — Latin letters with a Cyrillic `а` (U+0430): a homoglyph of ASCII `paypal`.
4560        let h = infer_first_func("func f():\n\tvar p\u{0430}ypal = 1\n\treturn p\u{0430}ypal\n");
4561        assert!(
4562            codes(&h).contains(&"CONFUSABLE_IDENTIFIER"),
4563            "{:?}",
4564            codes(&h)
4565        );
4566    }
4567
4568    #[test]
4569    fn an_ordinary_ascii_identifier_is_not_confusable() {
4570        let h = infer_first_func("func f():\n\tvar paypal = 1\n\treturn paypal\n");
4571        assert!(
4572            !codes(&h).contains(&"CONFUSABLE_IDENTIFIER"),
4573            "{:?}",
4574            codes(&h)
4575        );
4576    }
4577
4578    #[test]
4579    fn a_member_with_a_confusable_name_warns() {
4580        // Cyrillic `а` inside `balance`.
4581        let cs = file_codes("var b\u{0430}lance = 0\n");
4582        assert!(cs.iter().any(|c| c == "CONFUSABLE_IDENTIFIER"), "{cs:?}");
4583    }
4584
4585    #[test]
4586    fn an_assigned_but_never_read_local_is_unused() {
4587        // Precise read-vs-write: `x` is only assigned, never read → UNUSED_VARIABLE.
4588        let h = infer_first_func("func f():\n\tvar x = 1\n\tx = 2\n");
4589        assert!(codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
4590    }
4591
4592    #[test]
4593    fn a_read_local_is_not_unused() {
4594        let h = infer_first_func("func f() -> int:\n\tvar x = 1\n\tx = 2\n\treturn x\n");
4595        assert!(!codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
4596    }
4597
4598    #[test]
4599    fn unused_private_class_variable_warns() {
4600        let cs = file_codes("var _cache = 0\nfunc f():\n\tpass\n");
4601        assert!(
4602            cs.iter().any(|c| c == "UNUSED_PRIVATE_CLASS_VARIABLE"),
4603            "{cs:?}"
4604        );
4605    }
4606
4607    #[test]
4608    fn a_read_private_class_variable_is_silent() {
4609        let cs = file_codes("var _cache = 0\nfunc f() -> int:\n\treturn _cache\n");
4610        assert!(
4611            !cs.iter().any(|c| c == "UNUSED_PRIVATE_CLASS_VARIABLE"),
4612            "{cs:?}"
4613        );
4614    }
4615
4616    #[test]
4617    fn an_exported_private_var_is_not_unused_private() {
4618        // No false positive: an `@export`'d `_`-var is set externally (inspector / scene).
4619        let cs = file_codes("@export var _hidden = 0\n");
4620        assert!(
4621            !cs.iter().any(|c| c == "UNUSED_PRIVATE_CLASS_VARIABLE"),
4622            "{cs:?}"
4623        );
4624    }
4625
4626    #[test]
4627    fn onready_with_export_warns() {
4628        let cs = file_codes("@onready @export var n = null\n");
4629        assert!(cs.iter().any(|c| c == "ONREADY_WITH_EXPORT"), "{cs:?}");
4630    }
4631
4632    #[test]
4633    fn redundant_static_unload_warns_without_a_static_var() {
4634        let cs = file_codes("@static_unload\nclass_name Foo\nvar x = 1\n");
4635        assert!(cs.iter().any(|c| c == "REDUNDANT_STATIC_UNLOAD"), "{cs:?}");
4636    }
4637
4638    #[test]
4639    fn static_unload_with_a_static_var_is_silent() {
4640        let cs = file_codes("@static_unload\nstatic var pool = []\n");
4641        assert!(!cs.iter().any(|c| c == "REDUNDANT_STATIC_UNLOAD"), "{cs:?}");
4642    }
4643
4644    #[test]
4645    fn typed_local_read_before_assignment_does_not_warn() {
4646        // Godot 4 zero-initializes a typed `var x: int` — reading it is defined behavior and
4647        // Godot is silent (probed q11). Only UNTYPED no-init locals are candidates.
4648        let h = infer_first_func("func f() -> int:\n\tvar x: int\n\treturn x\n");
4649        assert!(
4650            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
4651            "{:?}",
4652            codes(&h)
4653        );
4654    }
4655
4656    #[test]
4657    fn untyped_local_assigned_then_read_does_not_warn() {
4658        let h = infer_first_func("func f():\n\tvar x\n\tx = 5\n\treturn x\n");
4659        assert!(
4660            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
4661            "{:?}",
4662            codes(&h)
4663        );
4664    }
4665
4666    #[test]
4667    fn typed_local_with_initializer_is_not_unassigned() {
4668        let h = infer_first_func("func f() -> int:\n\tvar x: int = 0\n\treturn x\n");
4669        assert!(
4670            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
4671            "{:?}",
4672            codes(&h)
4673        );
4674    }
4675
4676    #[test]
4677    fn untyped_local_read_before_assignment_warns() {
4678        // Godot's exact message + trigger (probed r35): the UNTYPED no-init local is the
4679        // UNASSIGNED_VARIABLE candidate (it holds nil until the first assignment).
4680        let h = infer_first_func("func f():\n\tvar x\n\tvar y = x\n\treturn y\n");
4681        assert!(
4682            codes(&h).contains(&"UNASSIGNED_VARIABLE"),
4683            "{:?}",
4684            codes(&h)
4685        );
4686        assert!(
4687            h.result
4688                .raw_warnings
4689                .iter()
4690                .any(|w| w.message == "The variable \"x\" is used before being assigned a value."),
4691            "Godot-verbatim wording expected: {:?}",
4692            h.result.raw_warnings
4693        );
4694    }
4695
4696    #[test]
4697    fn untyped_local_assigned_in_all_branches_then_read_does_not_warn() {
4698        // Both branches assign before the merge ⇒ definitely assigned ⇒ no warning (the join).
4699        let h = infer_first_func(
4700            "func f(c):\n\tvar x\n\tif c:\n\t\tx = 1\n\telse:\n\t\tx = 2\n\treturn x\n",
4701        );
4702        assert!(
4703            !codes(&h).contains(&"UNASSIGNED_VARIABLE"),
4704            "{:?}",
4705            codes(&h)
4706        );
4707    }
4708
4709    #[test]
4710    fn untyped_local_assigned_in_one_branch_then_read_warns() {
4711        // Assigned only in the `then` branch ⇒ may be unassigned (nil) at the read ⇒ warns.
4712        let h = infer_first_func("func f(c):\n\tvar x\n\tif c:\n\t\tx = 1\n\treturn x\n");
4713        assert!(
4714            codes(&h).contains(&"UNASSIGNED_VARIABLE"),
4715            "{:?}",
4716            codes(&h)
4717        );
4718    }
4719
4720    #[test]
4721    fn arm_after_wildcard_is_unreachable_pattern() {
4722        let h =
4723            infer_first_func("func f(x):\n\tmatch x:\n\t\t_:\n\t\t\tpass\n\t\t1:\n\t\t\tpass\n");
4724        assert!(
4725            codes(&h).contains(&"UNREACHABLE_PATTERN"),
4726            "{:?}",
4727            codes(&h)
4728        );
4729    }
4730
4731    #[test]
4732    fn arm_after_var_bind_is_unreachable_pattern() {
4733        let h = infer_first_func(
4734            "func f(x):\n\tmatch x:\n\t\tvar y:\n\t\t\treturn y\n\t\t1:\n\t\t\tpass\n",
4735        );
4736        assert!(
4737            codes(&h).contains(&"UNREACHABLE_PATTERN"),
4738            "{:?}",
4739            codes(&h)
4740        );
4741    }
4742
4743    #[test]
4744    fn arm_before_wildcard_is_not_unreachable() {
4745        let h =
4746            infer_first_func("func f(x):\n\tmatch x:\n\t\t1:\n\t\t\tpass\n\t\t_:\n\t\t\tpass\n");
4747        assert!(
4748            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
4749            "{:?}",
4750            codes(&h)
4751        );
4752    }
4753
4754    #[test]
4755    fn guarded_wildcard_is_not_a_catch_all() {
4756        // `_ when c:` is conditional — a following arm is NOT unreachable.
4757        let h = infer_first_func(
4758            "func f(x, c):\n\tmatch x:\n\t\t_ when c:\n\t\t\tpass\n\t\t1:\n\t\t\tpass\n",
4759        );
4760        assert!(
4761            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
4762            "{:?}",
4763            codes(&h)
4764        );
4765    }
4766
4767    #[test]
4768    fn multi_pattern_with_wildcard_is_conservatively_not_catch_all() {
4769        // `1, _:` IS a catch-all in Godot, but we conservatively under-warn (no false positive).
4770        let h =
4771            infer_first_func("func f(x):\n\tmatch x:\n\t\t1, _:\n\t\t\tpass\n\t\t2:\n\t\t\tpass\n");
4772        assert!(
4773            !codes(&h).contains(&"UNREACHABLE_PATTERN"),
4774            "{:?}",
4775            codes(&h)
4776        );
4777    }
4778
4779    #[test]
4780    fn enum_local_without_default_is_unassigned_tracked_not_warned() {
4781        // Godot's ENUM_VARIABLE_WITHOUT_DEFAULT is MEMBER-only (probed r47): an enum-typed
4782        // no-init LOCAL joins the UNASSIGNED_VARIABLE tracking instead, firing on a read.
4783        let h = infer_first_func("func f():\n\tvar m: Tween.TweenProcessMode\n\tprint(m)\n");
4784        assert!(
4785            !codes(&h).contains(&"ENUM_VARIABLE_WITHOUT_DEFAULT"),
4786            "{:?}",
4787            codes(&h)
4788        );
4789        assert!(
4790            codes(&h).contains(&"UNASSIGNED_VARIABLE"),
4791            "{:?}",
4792            codes(&h)
4793        );
4794    }
4795
4796    #[test]
4797    fn enum_member_without_default_warns_only_without_a_zero_value() {
4798        // `MouseButtonMask` declares no 0 value ⇒ the implicit default is invalid ⇒ warns
4799        // (probed r48); `Error` has `OK = 0` ⇒ Godot is silent (probed r46-shape).
4800        let codes = file_codes("var m: MouseButtonMask\nfunc f():\n\tpass\n");
4801        assert!(
4802            codes.iter().any(|c| c == "ENUM_VARIABLE_WITHOUT_DEFAULT"),
4803            "{codes:?}"
4804        );
4805        let with_zero = file_codes("var err: Error\nfunc f():\n\tpass\n");
4806        assert!(
4807            !with_zero
4808                .iter()
4809                .any(|c| c == "ENUM_VARIABLE_WITHOUT_DEFAULT"),
4810            "{with_zero:?}"
4811        );
4812    }
4813
4814    #[test]
4815    fn user_vararg_function_absorbs_surplus_arguments() {
4816        // A `...rest` tail has no fixed slot (not lowered into `params`); the `is_vararg`
4817        // flag must absorb any surplus so a 4.5-style vararg func never false-flags.
4818        let c = file_codes(
4819            "func v(a: int, ...rest) -> void:\n\tprint(a, rest)\n\nfunc t() -> void:\n\tv(1, 2, 3, 4)\n",
4820        );
4821        assert!(!c.iter().any(|x| x == "TOO_MANY_ARGUMENTS"), "{c:?}");
4822        // …while the fixed prefix still counts as required.
4823        let few = file_codes(
4824            "func v(a: int, ...rest) -> void:\n\tprint(a, rest)\n\nfunc t() -> void:\n\tv()\n",
4825        );
4826        assert!(few.iter().any(|x| x == "TOO_FEW_ARGUMENTS"), "{few:?}");
4827    }
4828
4829    #[test]
4830    fn gdscript_builtin_arity_uses_min_max() {
4831        // `range` is 1..=3 args in the `@GDScript` layer.
4832        let few = file_codes("func t() -> void:\n\tfor i in range():\n\t\tprint(i)\n");
4833        assert!(few.iter().any(|x| x == "TOO_FEW_ARGUMENTS"), "{few:?}");
4834        let many = file_codes("func t() -> void:\n\tfor i in range(1, 2, 3, 4):\n\t\tprint(i)\n");
4835        assert!(many.iter().any(|x| x == "TOO_MANY_ARGUMENTS"), "{many:?}");
4836    }
4837
4838    #[test]
4839    fn builtin_receiver_method_arity_checks() {
4840        // Builtin tables carry full `MethodSig`s — `String.substr(from, len = -1)`.
4841        let c = file_codes(
4842            "func t() -> void:\n\tvar s: String = \"abc\"\n\tprint(s.substr(1, 2, 3))\n",
4843        );
4844        assert!(c.iter().any(|x| x == "TOO_MANY_ARGUMENTS"), "{c:?}");
4845    }
4846
4847    #[test]
4848    fn shadowing_local_callable_is_not_arity_checked() {
4849        // BUG A2: a local `Callable` named like an own method must not be checked against
4850        // the shadowed method's signature.
4851        let c = file_codes(
4852            "func work(a: int) -> void:\n\tprint(a)\n\nfunc t() -> void:\n\tvar work = func(): pass\n\twork()\n",
4853        );
4854        assert!(!c.iter().any(|x| x == "TOO_FEW_ARGUMENTS"), "{c:?}");
4855    }
4856
4857    #[test]
4858    fn constructors_are_not_arity_checked() {
4859        // Constructor overload sets have no single arity — deliberately skipped
4860        // (documented under-warn; Godot does check them).
4861        let c = file_codes("func t() -> void:\n\tprint(Vector2(1.0, 2.0, 3.0, 4.0))\n");
4862        assert!(!c.iter().any(|x| x == "TOO_MANY_ARGUMENTS"), "{c:?}");
4863    }
4864
4865    #[test]
4866    fn unresolved_callee_is_not_arity_checked() {
4867        // The cross-file seam (workspace not complete): no signature, no arity claim.
4868        let c = file_codes("func t() -> void:\n\tsome_cross_file_fn(1, 2, 3)\n");
4869        assert!(
4870            !c.iter()
4871                .any(|x| x == "TOO_FEW_ARGUMENTS" || x == "TOO_MANY_ARGUMENTS"),
4872            "{c:?}"
4873        );
4874    }
4875
4876    #[test]
4877    fn native_virtual_override_with_clashing_param_type_warns() {
4878        // `_input(event: InputEvent)` is a Node virtual; `event: int` is an incompatible override.
4879        let codes = file_codes("extends Node\nfunc _input(event: int):\n\tpass\n");
4880        assert!(
4881            codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
4882            "{codes:?}"
4883        );
4884    }
4885
4886    #[test]
4887    fn native_virtual_override_with_correct_param_type_does_not_warn() {
4888        let codes = file_codes("extends Node\nfunc _input(event: InputEvent):\n\tpass\n");
4889        assert!(
4890            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
4891            "{codes:?}"
4892        );
4893    }
4894
4895    #[test]
4896    fn native_virtual_override_with_untyped_param_does_not_warn() {
4897        let codes = file_codes("extends Node\nfunc _input(event):\n\tpass\n");
4898        assert!(
4899            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
4900            "{codes:?}"
4901        );
4902    }
4903
4904    #[test]
4905    fn a_non_virtual_method_is_not_a_native_override() {
4906        let codes = file_codes("extends Node\nfunc my_helper(x: int):\n\treturn x\n");
4907        assert!(
4908            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
4909            "{codes:?}"
4910        );
4911    }
4912
4913    #[test]
4914    fn dotted_enum_override_param_does_not_false_warn() {
4915        // A valid override whose param is a dotted engine enum must NOT clash (enums are int-backed
4916        // and resolve to different qualified names on the annotation vs model side). Bug-hunt repro.
4917        let codes = file_codes(
4918            "extends MultiplayerPeerExtension\nfunc _set_transfer_mode(p_mode: MultiplayerPeer.TransferMode):\n\tpass\n",
4919        );
4920        assert!(
4921            !codes.iter().any(|c| c == "NATIVE_METHOD_OVERRIDE"),
4922            "{codes:?}"
4923        );
4924    }
4925
4926    #[test]
4927    fn unused_signal_warns() {
4928        let codes = file_codes("signal my_event\nfunc f():\n\tpass\n");
4929        assert!(codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
4930    }
4931
4932    #[test]
4933    fn emitted_signal_is_not_unused() {
4934        let codes = file_codes("signal my_event\nfunc f():\n\tmy_event.emit()\n");
4935        assert!(!codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
4936    }
4937
4938    #[test]
4939    fn signal_connected_by_string_is_not_unused() {
4940        let codes = file_codes("signal my_event\nfunc f():\n\tconnect(\"my_event\", Callable())\n");
4941        assert!(!codes.iter().any(|c| c == "UNUSED_SIGNAL"), "{codes:?}");
4942    }
4943
4944    #[test]
4945    fn enum_local_with_default_does_not_warn() {
4946        let h = infer_first_func(
4947            "func f():\n\tvar m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE\n\treturn m\n",
4948        );
4949        assert!(
4950            !codes(&h).contains(&"ENUM_VARIABLE_WITHOUT_DEFAULT"),
4951            "{:?}",
4952            codes(&h)
4953        );
4954    }
4955
4956    #[test]
4957    fn static_method_on_instance_warns() {
4958        // `JSON.stringify` is static; calling it through a JSON *instance* warns.
4959        let h =
4960            infer_first_func("func f():\n\tvar j := JSON.new()\n\tj.stringify({})\n\treturn j\n");
4961        assert!(
4962            codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
4963            "{:?}",
4964            codes(&h)
4965        );
4966    }
4967
4968    #[test]
4969    fn static_method_on_the_type_does_not_warn() {
4970        // `JSON.stringify(...)` (on the type) is the correct form — never flagged.
4971        let h = infer_first_func("func f():\n\tJSON.stringify({})\n");
4972        assert!(
4973            !codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
4974            "{:?}",
4975            codes(&h)
4976        );
4977    }
4978
4979    #[test]
4980    fn static_method_through_a_type_aliased_local_does_not_warn() {
4981        // `var t := JSON` aliases the TYPE; `t.stringify()` is valid, not static-on-instance.
4982        let h = infer_first_func("func f():\n\tvar t := JSON\n\tt.stringify({})\n");
4983        assert!(
4984            !codes(&h).contains(&"STATIC_CALLED_ON_INSTANCE"),
4985            "{:?}",
4986            codes(&h)
4987        );
4988    }
4989
4990    #[test]
4991    fn property_called_as_function_warns() {
4992        // `n.name` is a Node property; calling it is PROPERTY_USED_AS_FUNCTION.
4993        let h = infer_first_func("func f(n: Node):\n\tn.name()\n");
4994        assert!(
4995            codes(&h).contains(&"PROPERTY_USED_AS_FUNCTION"),
4996            "{:?}",
4997            codes(&h)
4998        );
4999    }
5000
5001    #[test]
5002    fn constant_called_as_function_warns() {
5003        // `NOTIFICATION_READY` is a Node constant; calling it is CONSTANT_USED_AS_FUNCTION.
5004        let h = infer_first_func("func f(n: Node):\n\tn.NOTIFICATION_READY()\n");
5005        assert!(
5006            codes(&h).contains(&"CONSTANT_USED_AS_FUNCTION"),
5007            "{:?}",
5008            codes(&h)
5009        );
5010    }
5011
5012    #[test]
5013    fn calling_a_real_method_is_not_a_kind_misuse() {
5014        let h = infer_first_func("func f(n: Node):\n\tn.get_parent()\n");
5015        assert!(
5016            codes(&h).iter().all(|c| !c.ends_with("_USED_AS_FUNCTION")),
5017            "{:?}",
5018            codes(&h)
5019        );
5020    }
5021
5022    #[test]
5023    fn reading_a_property_as_a_value_is_not_a_kind_misuse() {
5024        let h = infer_first_func("func f(n: Node):\n\tvar s = n.name\n\treturn s\n");
5025        assert!(
5026            codes(&h).iter().all(|c| !c.ends_with("_USED_AS_FUNCTION")),
5027            "{:?}",
5028            codes(&h)
5029        );
5030    }
5031
5032    #[test]
5033    fn enum_member_into_its_own_enum_slot_is_not_int_as_enum() {
5034        // `var m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE` is valid GDScript with no
5035        // cast — the enum member must type as its enum (not bare `int`), so `check_assign` sees
5036        // `Enum → Enum` (Ok). A regression here would false-warn on extremely common engine code.
5037        let h = infer_first_func(
5038            "func f():\n\tvar m: Tween.TweenProcessMode = Tween.TWEEN_PROCESS_IDLE\n\treturn m\n",
5039        );
5040        assert!(
5041            !codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
5042            "{:?}",
5043            codes(&h)
5044        );
5045    }
5046
5047    #[test]
5048    fn bare_int_into_enum_slot_still_warns() {
5049        // The fix must not over-suppress: a genuine uncast `int` into an enum slot still warns.
5050        let h = infer_first_func("func f():\n\tvar m: Tween.TweenProcessMode = 0\n\treturn m\n");
5051        assert!(
5052            codes(&h).contains(&"INT_AS_ENUM_WITHOUT_CAST"),
5053            "{:?}",
5054            codes(&h)
5055        );
5056    }
5057
5058    #[test]
5059    fn member_access_resolves_engine_property() {
5060        // In a Node script, bare `get_node(...)` resolves via the inherited base to Object(Node);
5061        // `get_parent()` is a real Node method → no UNSAFE.
5062        let h = infer_first_func(
5063            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.get_parent()\n",
5064        );
5065        assert!(
5066            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
5067            "{:?}",
5068            h.result.diagnostics
5069        );
5070    }
5071
5072    #[test]
5073    fn unsafe_method_on_known_type() {
5074        let h = infer_first_func(
5075            "extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.totally_bogus_method()\n",
5076        );
5077        assert!(
5078            codes(&h).contains(&UNSAFE_METHOD_ACCESS),
5079            "{:?}",
5080            h.result.diagnostics
5081        );
5082    }
5083
5084    #[test]
5085    fn is_narrowing_suppresses_unsafe() {
5086        // Without narrowing, `x.free()` on an untyped param would be unchecked anyway; with
5087        // `is Node` it is checked against Node and `free` IS a Node method → no UNSAFE.
5088        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.queue_free()\n");
5089        assert!(
5090            codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
5091            "{:?}",
5092            h.result.diagnostics
5093        );
5094    }
5095
5096    #[test]
5097    fn is_narrowing_flags_real_missing_member() {
5098        // After `is Node`, x is Node; `.bogus()` is genuinely missing → UNSAFE.
5099        let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.bogus_method()\n");
5100        assert!(codes(&h).contains(&UNSAFE_METHOD_ACCESS));
5101    }
5102
5103    #[test]
5104    fn early_return_is_guard_narrows_past_the_guard() {
5105        // `if not (x is Node): return` — the only non-returning path proves x is Node, so after the
5106        // guard a real Node method is safe and a missing one warns (Workstream 2, beats the engine).
5107        let safe =
5108            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.get_parent()\n");
5109        assert!(
5110            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
5111            "real Node method must not warn after the guard: {:?}",
5112            codes(&safe)
5113        );
5114        let bogus =
5115            infer_first_func("func f(x):\n\tif not (x is Node):\n\t\treturn\n\tx.bogus_method()\n");
5116        assert!(
5117            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
5118            "missing method must warn after the guard: {:?}",
5119            codes(&bogus)
5120        );
5121    }
5122
5123    #[test]
5124    fn and_short_circuit_narrows_the_rhs() {
5125        // `x is Node and x.<m>()` types the RHS under x: Node — a real method is safe, a missing
5126        // one warns. The engine does not narrow here (Workstream 2, beats the engine).
5127        let safe = infer_first_func("func f(x):\n\tif x is Node and x.get_parent():\n\t\tpass\n");
5128        assert!(
5129            codes(&safe).iter().all(|c| !c.starts_with("UNSAFE")),
5130            "real Node method in the and-rhs must not warn: {:?}",
5131            codes(&safe)
5132        );
5133        let bogus =
5134            infer_first_func("func f(x):\n\tif x is Node and x.bogus_method():\n\t\tpass\n");
5135        assert!(
5136            codes(&bogus).contains(&UNSAFE_METHOD_ACCESS),
5137            "missing method in the and-rhs must warn: {:?}",
5138            codes(&bogus)
5139        );
5140    }
5141
5142    // ---- Workstream 1 M1: self-contained checks ----
5143
5144    #[test]
5145    fn empty_file_warns() {
5146        assert!(file_codes("").iter().any(|c| c == "EMPTY_FILE"));
5147        assert!(
5148            file_codes("# just a comment\n")
5149                .iter()
5150                .any(|c| c == "EMPTY_FILE")
5151        );
5152        assert!(
5153            file_codes("extends Node\n")
5154                .iter()
5155                .all(|c| c != "EMPTY_FILE")
5156        );
5157    }
5158
5159    #[test]
5160    fn unused_variable_and_parameter() {
5161        let h = infer_first_func("func f(unused_p):\n\tvar unused_v = 1\n");
5162        assert!(codes(&h).contains(&"UNUSED_PARAMETER"), "{:?}", codes(&h));
5163        assert!(codes(&h).contains(&"UNUSED_VARIABLE"), "{:?}", codes(&h));
5164        // A used binding does not warn; a `_`-prefixed one is intentionally ignored.
5165        let used = infer_first_func("func f(p):\n\tvar v = p\n\treturn v\n");
5166        assert!(codes(&used).iter().all(|c| !c.starts_with("UNUSED")));
5167        let underscored = infer_first_func("func f(_ignored):\n\tpass\n");
5168        assert!(!codes(&underscored).contains(&"UNUSED_PARAMETER"));
5169    }
5170
5171    #[test]
5172    fn standalone_expression_and_ternary() {
5173        let expr = infer_first_func("func f(a, b):\n\ta + b\n");
5174        assert!(
5175            codes(&expr).contains(&"STANDALONE_EXPRESSION"),
5176            "{:?}",
5177            codes(&expr)
5178        );
5179        let tern = infer_first_func("func f(c):\n\t1 if c else 2\n");
5180        assert!(
5181            codes(&tern).contains(&"STANDALONE_TERNARY"),
5182            "{:?}",
5183            codes(&tern)
5184        );
5185        // A call statement has an effect — never flagged.
5186        let call = infer_first_func("func f(n):\n\tn.queue_free()\n");
5187        assert!(codes(&call).iter().all(|c| !c.starts_with("STANDALONE")));
5188    }
5189
5190    #[test]
5191    fn unreachable_code_after_return() {
5192        let h = infer_first_func("func f():\n\treturn\n\tprint(\"dead\")\n");
5193        assert!(codes(&h).contains(&"UNREACHABLE_CODE"), "{:?}", codes(&h));
5194    }
5195
5196    #[test]
5197    fn incompatible_ternary_warns() {
5198        // `"s" if c else 1` — String vs int, no common type.
5199        let h = infer_first_func("func f(c):\n\tvar x = \"s\" if c else 1\n\treturn x\n");
5200        assert!(
5201            codes(&h).contains(&"INCOMPATIBLE_TERNARY"),
5202            "{:?}",
5203            codes(&h)
5204        );
5205    }
5206
5207    #[test]
5208    fn variant_receiver_never_unsafe() {
5209        // Untyped param → Variant receiver → unchecked, no diagnostic.
5210        let h = infer_first_func("func f(x):\n\tx.anything_at_all()\n");
5211        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
5212    }
5213
5214    #[test]
5215    fn unsafe_call_argument_on_variant_into_typed_param() {
5216        // Passing an untyped (Variant) value to a typed own-method parameter needs an unsafe cast.
5217        let h = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n: Node2D):\n\tpass\n");
5218        assert!(
5219            codes(&h).contains(&UNSAFE_CALL_ARGUMENT),
5220            "{:?}",
5221            h.result.diagnostics
5222        );
5223    }
5224
5225    #[test]
5226    fn unsafe_call_argument_silent_on_safe_and_untyped() {
5227        // A subtype arg (upcast) is safe; an untyped parameter accepts anything — neither warns.
5228        let upcast =
5229            infer_first_func("func f(n: Node2D):\n\ttake(n)\nfunc take(n: Node):\n\tpass\n");
5230        assert!(
5231            !codes(&upcast).contains(&UNSAFE_CALL_ARGUMENT),
5232            "upcast is safe: {:?}",
5233            upcast.result.diagnostics
5234        );
5235        let untyped = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n):\n\tpass\n");
5236        assert!(
5237            !codes(&untyped).contains(&UNSAFE_CALL_ARGUMENT),
5238            "untyped param accepts anything: {:?}",
5239            untyped.result.diagnostics
5240        );
5241    }
5242
5243    #[test]
5244    fn inference_on_variant() {
5245        // `:=` from an untyped (Variant) param.
5246        let h = infer_first_func("func f(x):\n\tvar y := x\n");
5247        assert!(codes(&h).contains(&INFERENCE_ON_VARIANT));
5248    }
5249
5250    #[test]
5251    fn field_inferred_from_earlier_field_is_typed() {
5252        // W2-MEMBER-FIXPOINT: `b`'s initializer references the earlier field `a`. A single shallow
5253        // field pass would see `a` as `Variant` (seam) and fire INFERENCE_ON_VARIANT on `:= a`; the
5254        // bounded fixpoint seeds `a: int` so `a + 1` is `int` and `:=` is precise — no warning.
5255        let codes = file_codes("var a := 1\nvar b := a + 1\n");
5256        assert!(
5257            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
5258            "field `b` from earlier field `a` should type as int, not Variant: {codes:?}"
5259        );
5260    }
5261
5262    #[test]
5263    fn field_forward_reference_is_seamed_not_warned() {
5264        // A field referencing a *later* field still resolves through the fixpoint (both rounds
5265        // see each other's seeded type), and at worst lands on the conservative seam — never a
5266        // false INFERENCE_ON_VARIANT. (`b` precedes `a` lexically here.)
5267        let codes = file_codes("var b := a\nvar a := 1\n");
5268        assert!(
5269            !codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
5270            "forward field reference must not false-warn: {codes:?}"
5271        );
5272    }
5273
5274    #[test]
5275    fn standalone_inferred_field_unchanged() {
5276        // No-regression: a self-contained inferred field still types from its literal, no warning.
5277        let codes = file_codes("var n := 0\n");
5278        assert!(
5279            codes.is_empty(),
5280            "a literal-initialised field should produce no diagnostics: {codes:?}"
5281        );
5282    }
5283
5284    #[test]
5285    fn lambda_var_is_callable_not_variant() {
5286        let h = infer_first_func("func f():\n\tvar cb := func():\n\t\tpass\n");
5287        assert!(
5288            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5289            "{:?}",
5290            h.result.diagnostics
5291        );
5292    }
5293
5294    #[test]
5295    fn multiline_lambda_then_paren_line_no_false_warning() {
5296        // A multi-line lambda bound to a var, followed by a statement that begins with `(`.
5297        // The parser now ends the lambda at its dedent (the `(` line is its own statement), so
5298        // there is no spurious call-on-lambda and no false `INFERENCE_ON_VARIANT`.
5299        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";
5300        let h = infer_first_func(src);
5301        assert!(
5302            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5303            "{:?}",
5304            h.result.diagnostics
5305        );
5306    }
5307
5308    #[test]
5309    fn calling_a_callable_value_is_seam_not_variant() {
5310        // Invoking an arbitrary expression (here a parenthesized `Callable` value) reaches the
5311        // seam arm of `infer_call`: the return type isn't tracked, so the result is Unknown,
5312        // not `Variant`, and the inferred-on-Variant warning never fires.
5313        let src = "func f(cb: Callable):\n\tvar x := (cb)()\n\treturn x\n";
5314        let h = infer_first_func(src);
5315        assert!(
5316            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5317            "{:?}",
5318            h.result.diagnostics
5319        );
5320    }
5321
5322    #[test]
5323    fn calling_a_local_callable_param_is_a_use() {
5324        // Regression (BUG A2): a bare-name call `cb(0)` on a param never recorded a read — only
5325        // the value-read path (`resolve_name`) fed `used_locals` — so a param that was ONLY ever
5326        // called fired a false UNUSED_PARAMETER.
5327        let src = "func f(cb: Callable):\n\tcb(0)\n";
5328        let h = infer_first_func(src);
5329        assert!(
5330            !codes(&h).contains(&"UNUSED_PARAMETER"),
5331            "calling a param is a use: {:?}",
5332            h.result.diagnostics
5333        );
5334    }
5335
5336    #[test]
5337    fn calling_a_local_lambda_var_is_a_use() {
5338        // Regression (BUG A2), local-var flavor: `var lam = func(x): …` then `lam(5)` used to
5339        // fire UNUSED_VARIABLE on `lam` (the guitkx hook-stub symptom:
5340        // `var useState = Hooks.useState` + `useState(0)` flagged useState unused).
5341        let src = "func g():\n\tvar lam = func(x): return x + 1\n\tlam(5)\n";
5342        let h = infer_first_func(src);
5343        assert!(
5344            !codes(&h).contains(&"UNUSED_VARIABLE"),
5345            "calling a local is a use: {:?}",
5346            h.result.diagnostics
5347        );
5348    }
5349
5350    #[test]
5351    fn calling_a_local_callable_result_stays_the_seam() {
5352        // The call RESULT of a local Callable is the seam (Ty::Callable carries no signature) —
5353        // never `Variant`, so `var x := cb(0)` must not fire INFERENCE_ON_VARIANT.
5354        let src = "func f(cb: Callable):\n\tvar x := cb(0)\n\treturn x\n";
5355        let h = infer_first_func(src);
5356        assert!(
5357            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5358            "{:?}",
5359            h.result.diagnostics
5360        );
5361    }
5362
5363    #[test]
5364    fn local_callable_shadows_same_named_method_in_a_call() {
5365        // GDScript scoping: a local shadows an own/inherited method for a bare-name call, matching
5366        // resolve_name's canonical local-first order. So the call binds the LOCAL: it is a use (no
5367        // UNUSED_VARIABLE) and the shadowed method's signature must NOT arg-check the call (the
5368        // local Callable's params aren't modeled — no UNSAFE_CALL_ARGUMENT / TYPE_MISMATCH).
5369        let src = "func helper(x: int) -> int:\n\treturn x\nfunc f():\n\tvar helper = func(): return 1\n\thelper(\"not an int\")\n";
5370        let c = file_codes(src);
5371        assert!(
5372            !c.iter().any(|x| x == "UNUSED_VARIABLE"),
5373            "the shadowing local is used by the call: {c:?}"
5374        );
5375        assert!(
5376            !c.iter()
5377                .any(|x| x == "UNSAFE_CALL_ARGUMENT" || x == TYPE_MISMATCH),
5378            "the shadowed method's signature must not check the local's call: {c:?}"
5379        );
5380    }
5381
5382    #[test]
5383    fn ternary_with_seam_branch_does_not_collapse_to_variant() {
5384        // A ternary whose else-branch is the seam (`await` is untracked → Unknown) must `join`
5385        // to Unknown, NOT Variant — otherwise `var x := …` fires a false INFERENCE_ON_VARIANT.
5386        // (Regression: `join` used to absorb any uninformative branch to Variant.)
5387        let src =
5388            "func f(c: bool):\n\tvar x := 5 if c else await get_tree().process_frame\n\treturn x\n";
5389        let h = infer_first_func(src);
5390        assert!(
5391            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5392            "seam branch should keep the ternary on the seam: {:?}",
5393            h.result.diagnostics
5394        );
5395    }
5396
5397    #[test]
5398    fn await_a_coroutine_call_recovers_its_return_type() {
5399        // `await f()` yields the call's value, so await is identity on a non-signal operand:
5400        // `await make()` for `func make() -> int` types `x` as int (was the seam before).
5401        let src = "func g() -> int:\n\tvar x := await make()\n\treturn x\nfunc make() -> int:\n\treturn 5\n";
5402        let h = infer_first_func(src);
5403        assert!(
5404            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5405            "no false variant warning: {:?}",
5406            h.result.diagnostics
5407        );
5408        let api = gdscript_api::bundled();
5409        let x = &h.result.bindings[0];
5410        assert!(
5411            matches!(&x.ty, Ty::Builtin(b) if api.builtin(*b).name == "int"),
5412            "await make() should recover int, got {:?}",
5413            x.ty
5414        );
5415    }
5416
5417    #[test]
5418    fn await_a_signal_stays_the_seam() {
5419        // `await sig` yields the signal's payload (needs the Phase-3+ sig table) — must stay the seam,
5420        // never the Signal type itself, and never a false INFERENCE_ON_VARIANT.
5421        let src = "func f():\n\tvar x := await get_tree().process_frame\n\treturn x\n";
5422        let h = infer_first_func(src);
5423        assert!(
5424            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5425            "awaiting a signal must not warn: {:?}",
5426            h.result.diagnostics
5427        );
5428        assert!(
5429            matches!(&h.result.bindings[0].ty, Ty::Unknown),
5430            "awaiting a signal stays the seam, got {:?}",
5431            h.result.bindings[0].ty
5432        );
5433    }
5434
5435    #[test]
5436    fn for_var_over_packed_string_array_is_string() {
5437        // `for s in "a,b".split(",")` iterates a PackedStringArray → String, so `s.to_int()`
5438        // resolves and `var x := s` does not warn.
5439        let h = infer_first_func("func f():\n\tfor s in \"a,b\".split(\",\"):\n\t\tvar x := s\n");
5440        assert!(
5441            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5442            "{:?}",
5443            h.result.diagnostics
5444        );
5445    }
5446
5447    #[test]
5448    fn class_new_is_object_not_variant() {
5449        let h = infer_first_func("func f():\n\tvar s := GDScript.new()\n");
5450        assert!(
5451            !codes(&h).contains(&INFERENCE_ON_VARIANT),
5452            "{:?}",
5453            h.result.diagnostics
5454        );
5455    }
5456
5457    #[test]
5458    fn unknown_seam_never_warns() {
5459        // `preload(...)` is Unknown; `:=` from it does NOT warn, and member access is unchecked.
5460        let h = infer_first_func("func f():\n\tvar s := preload(\"res://x.gd\")\n\ts.whatever()\n");
5461        assert!(codes(&h).is_empty(), "{:?}", codes(&h));
5462    }
5463
5464    #[test]
5465    fn expr_types_are_memoized_for_hover() {
5466        let h = infer_first_func("func f():\n\tvar n := 42\n");
5467        // The `42` literal expr should be typed int.
5468        let has_int = h
5469            .result
5470            .expr_ty
5471            .values()
5472            .any(|t| matches!(t, Ty::Builtin(_)));
5473        assert!(has_int);
5474        // sanity: the body lowered at least one expr
5475        assert!(!h.body.exprs.is_empty());
5476    }
5477}