Skip to main content

gdscript_hir/
queries.rs

1//! The salsa-tracked entry points for the semantic layer, layered on `gdscript-db`'s
2//! [`parse`](gdscript_db::parse) query.
3//!
4//! These are the memoized queries the IDE layer drives. The heavy lifting stays in
5//! [`crate::item_tree`] / [`crate::infer`] as plain `(parsed file) -> value` functions; this
6//! module only wraps them so their results are cached per revision and recomputed
7//! incrementally. [`item_tree`] is the **firewall** query (Playbook §4): it reads only the
8//! parse, never a function body, so an unchanged set of signatures backdates across body edits.
9//!
10//! Phase-3 note: cross-file resolution (the `resolve_external` seam) is still `Ty::Unknown`
11//! here — M1 threads `&dyn Db` + `FileId` into inference to light it up. M0 only swaps the
12//! cache engine; the single-file results are byte-identical.
13
14use std::sync::Arc;
15
16use gdscript_db::{Db, FileText, ProjectConfig, SourceRoot, parse};
17use rustc_hash::{FxHashMap, FxHashSet};
18use smol_str::SmolStr;
19
20use gdscript_base::FileId;
21use gdscript_scene::{NodeIdx, SceneModel};
22
23use crate::infer::FileInference;
24use crate::item_tree::{ItemTree, Member};
25use crate::ty::{ScriptRefId, Ty};
26use crate::warnings::{SuppressionMap, WarningSettings};
27
28/// The item tree for `file` (signatures only — the body-edit firewall). Memoized; recomputes
29/// when the parse changes but backdates when the resulting signatures are unchanged.
30#[salsa::tracked]
31pub fn item_tree(db: &dyn Db, file: FileText) -> Arc<ItemTree> {
32    crate::item_tree::item_tree(&parse(db, file).syntax_node())
33}
34
35/// Whole-file inference for `file`. With no engine model available (`wasm32`, until the host
36/// wires the fetched blob in) this is an empty result — matching the Phase-2 graceful path.
37#[salsa::tracked]
38pub fn analyze_file(db: &dyn Db, file: FileText) -> Arc<FileInference> {
39    match db.engine() {
40        Some(api) => Arc::new(crate::infer::analyze_file(
41            db,
42            api,
43            &parse(db, file).syntax_node(),
44            file.file_id(db),
45        )),
46        None => Arc::new(FileInference::default()),
47    }
48}
49
50/// The project-wide global `class_name` registry: each registered name → the file declaring it.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct GlobalRegistry {
53    classes: FxHashMap<SmolStr, FileText>,
54}
55
56impl GlobalRegistry {
57    /// The file declaring `name` as a global `class_name`, if any.
58    #[must_use]
59    pub fn resolve(&self, name: &str) -> Option<FileText> {
60        self.classes.get(name).copied()
61    }
62
63    /// The number of registered global classes.
64    #[must_use]
65    pub fn len(&self) -> usize {
66        self.classes.len()
67    }
68
69    /// Whether no global class is registered.
70    #[must_use]
71    pub fn is_empty(&self) -> bool {
72        self.classes.is_empty()
73    }
74
75    /// Every registered `(class_name, declaring file)` pair (for workspace symbols).
76    pub fn iter(&self) -> impl Iterator<Item = (&SmolStr, FileText)> + '_ {
77        self.classes.iter().map(|(k, v)| (k, *v))
78    }
79}
80
81/// A file's `class_name`, if it declares one — the **offset-free projection** of its item tree
82/// that [`global_registry`] depends on. It reads only `item_tree(file).class_name` (never a byte
83/// range), so a body edit re-runs `item_tree` but this query *backdates* (its value is
84/// unchanged), leaving the registry — and everything cross-file — undisturbed by a keystroke.
85#[salsa::tracked]
86pub fn file_class_name(db: &dyn Db, file: FileText) -> Option<SmolStr> {
87    item_tree(db, file).class_name.clone()
88}
89
90/// The project-wide global `class_name` registry. Keyed on the [`SourceRoot`] file-set input and
91/// the per-file [`file_class_name`] projections. A duplicate `class_name` keeps the first by
92/// `FileId` order (the file set is sorted), so resolution is deterministic. Collision *diagnostics*
93/// (warning at each duplicate declaration) are the separate [`class_name_collisions`] projection.
94#[salsa::tracked]
95pub fn global_registry(db: &dyn Db, root: SourceRoot) -> Arc<GlobalRegistry> {
96    let mut classes = FxHashMap::default();
97    for &file in root.files(db) {
98        if let Some(name) = file_class_name(db, file) {
99            classes.entry(name).or_insert(file);
100        }
101    }
102    Arc::new(GlobalRegistry { classes })
103}
104
105/// The set of global `class_name`s declared by **more than one** file in `root` — the shadowing
106/// diagnostic's cross-file half. Mirrors [`global_registry`]'s firewall exactly: it reads only the
107/// offset-free [`file_class_name`] projection of each file (never a body or byte range), so a
108/// keystroke never rebuilds it. `global_registry` keeps the *first* declarer silently; this query
109/// names the duplicates so [`crate::infer::analyze_file`] can warn at each colliding declaration.
110#[salsa::tracked]
111pub fn class_name_collisions(db: &dyn Db, root: SourceRoot) -> Arc<FxHashSet<SmolStr>> {
112    let mut seen: FxHashSet<SmolStr> = FxHashSet::default();
113    let mut dups: FxHashSet<SmolStr> = FxHashSet::default();
114    for &file in root.files(db) {
115        if let Some(name) = file_class_name(db, file)
116            && !seen.insert(name.clone())
117        {
118            dups.insert(name);
119        }
120    }
121    Arc::new(dups)
122}
123
124/// The project-wide `res:// path → FileId` registry (M3): the map `preload("res://x.gd")` and
125/// `extends "res://x.gd"` resolve through. Keyed on the [`SourceRoot`] file-set input and each
126/// file's `res_path` salsa-input field. `res_path` is a *separate* input field from `text`
127/// (salsa tracks input fields individually), so this registry **backdates across body edits**
128/// exactly like [`global_registry`] — a keystroke never rebuilds it. A duplicate path keeps the
129/// first by `FileId` order (the file set is sorted), matching `global_registry`'s policy.
130#[salsa::tracked]
131pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
132    let mut map = FxHashMap::default();
133    for &file in root.files(db) {
134        if let Some(path) = file.res_path(db) {
135            map.entry(path).or_insert_with(|| file.file_id(db));
136        }
137    }
138    Arc::new(map)
139}
140
141/// The project's autoload **singletons** (`*`-flagged `[autoload]` entries) — the bare names that
142/// resolve as globals in code. Maps each singleton name → its resource path (M4). Non-singleton
143/// autoloads are deliberately excluded (loaded-but-not-global). Keyed on [`ProjectConfig`] alone
144/// (it iterates only the config text), so it backdates across every `.gd` keystroke.
145#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct AutoloadRegistry {
147    /// `*`-flagged autoloads — the bare names that resolve as globals in code.
148    singletons: FxHashMap<SmolStr, SmolStr>,
149    /// Non-`*` autoloads — loaded at `/root/Name` but NOT global identifiers. Tracked so a
150    /// `get_node("/root/Name")` access can still resolve them (a singleton lives there too).
151    loaded: FxHashMap<SmolStr, SmolStr>,
152}
153
154impl AutoloadRegistry {
155    /// The resource path of the **singleton** (`*`-flagged) autoload named `name`, if any — the only
156    /// autoloads exposed as bare-name globals. A non-singleton name returns `None` here (it is not a
157    /// global); use [`resolve_any_path`](AutoloadRegistry::resolve_any_path) for `/root/Name`.
158    #[must_use]
159    pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
160        self.singletons.get(name)
161    }
162
163    /// The resource path of **any** autoload named `name` — singleton or loaded-but-not-global. Both
164    /// live at `/root/Name`, so this is what a `get_node("/root/Name")` access resolves through.
165    #[must_use]
166    pub fn resolve_any_path(&self, name: &str) -> Option<&SmolStr> {
167        self.singletons.get(name).or_else(|| self.loaded.get(name))
168    }
169
170    /// The number of registered singleton autoloads.
171    #[must_use]
172    pub fn len(&self) -> usize {
173        self.singletons.len()
174    }
175
176    /// Whether no singleton autoload is registered.
177    #[must_use]
178    pub fn is_empty(&self) -> bool {
179        self.singletons.is_empty()
180    }
181}
182
183/// The project-wide autoload-singleton registry, parsed from `project.godot` (M4). Only
184/// `*`-flagged entries become globals; a duplicate name keeps the first (deterministic).
185#[salsa::tracked]
186pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
187    let mut singletons = FxHashMap::default();
188    let mut loaded = FxHashMap::default();
189    for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
190        if e.is_singleton {
191            singletons.entry(e.name).or_insert(e.path);
192        } else {
193            loaded.entry(e.name).or_insert(e.path);
194        }
195    }
196    Arc::new(AutoloadRegistry { singletons, loaded })
197}
198
199/// The Godot engine `(major, minor)` declared by `project.godot`'s `[application]`
200/// `config/features`, or `None` if unspecified. Keyed on [`ProjectConfig`] alone (MEDIUM
201/// durability), so it backdates across `.gd` body edits — the same cross-file firewall as
202/// [`autoload_registry`].
203///
204/// Phase-5 plumbing: the value is exposed for engine-API-model selection, but only ONE engine model
205/// is bundled today (`gdscript_api::GODOT_VERSION`), so it is currently informational. Phase 6
206/// (multi-version bundling via the Godot-sync job) will use it to pick the matching `ApiInput`,
207/// snapping to the nearest bundled minor and defaulting to the newest when absent.
208#[salsa::tracked]
209pub fn engine_version(db: &dyn Db, config: ProjectConfig) -> Option<(u32, u32)> {
210    crate::project::parse_engine_version(config.project_godot_text(db))
211}
212
213/// Convenience over [`engine_version`]: the project's declared engine `(major, minor)`, or `None`
214/// when there is no `project.godot` or it declares no version.
215#[must_use]
216pub fn project_engine_version(db: &dyn Db) -> Option<(u32, u32)> {
217    engine_version(db, db.project_config()?)
218}
219
220/// The project's resolved warning settings, parsed from `project.godot`'s
221/// `debug/gdscript/warnings/*` (Workstream 1). Keyed on [`ProjectConfig`] alone (MEDIUM
222/// durability) and reads no `.gd` body, so **editing a warning level invalidates only this query +
223/// the downstream gate, never `analyze_file`/`item_tree`/`infer`** — the salsa-cacheability
224/// invariant the gating seam depends on (W1 §3.4/§6).
225#[salsa::tracked]
226pub fn warning_settings(db: &dyn Db, config: ProjectConfig) -> Arc<WarningSettings> {
227    let text = config.project_godot_text(db);
228    let engine =
229        crate::project::parse_engine_version(text).unwrap_or_else(crate::warnings::bundled_version);
230    Arc::new(crate::project::parse_warning_settings(text, engine))
231}
232
233/// The per-file `@warning_ignore[_start|_restore]` suppression map (Workstream 1). Keyed on the
234/// file's parse — CST byte ranges are stable across incremental edits — so it recomputes only when
235/// the file text changes, never on a warning-setting edit.
236#[salsa::tracked]
237pub fn suppression_map(db: &dyn Db, file: FileText) -> Arc<SuppressionMap> {
238    Arc::new(crate::warnings::build_suppression_map(
239        &parse(db, file).syntax_node(),
240        file.text(db),
241    ))
242}
243
244/// One member of a script class, as a cross-file reference sees it (a resolved type, never a
245/// byte range).
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum MemberSig {
248    /// A method — its resolved return type.
249    Method(Ty),
250    /// A `var` / `const` — its resolved type.
251    Field(Ty),
252    /// A signal.
253    Signal,
254}
255
256/// A script class's own members, by name, plus its resolved `extends` base — the **offset-free
257/// projection** a cross-file reference resolves against. Reads only `item_tree` signatures (+
258/// annotation/base resolution), never bodies or byte ranges, so it backdates on body edits (the
259/// cross-file firewall). Member lookup walks the base chain (M2): own members here, inherited
260/// ones via [`base`](ScriptClass::base).
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct ScriptClass {
263    members: FxHashMap<SmolStr, MemberSig>,
264    base: Ty,
265}
266
267impl ScriptClass {
268    /// The signature of the member named `name`, if the class declares one *itself* (not
269    /// inherited — the caller walks [`base`](ScriptClass::base) for inherited members).
270    #[must_use]
271    pub fn member(&self, name: &str) -> Option<&MemberSig> {
272        self.members.get(name)
273    }
274
275    /// The resolved `extends` base: an engine `Object`, a user `ScriptRef`, or `Unknown`.
276    #[must_use]
277    pub fn base(&self) -> &Ty {
278        &self.base
279    }
280}
281
282/// The `class_name` behind a [`ScriptRef`](crate::ty::Ty::ScriptRef), for display (hover /
283/// inlay). `Ty::label` cannot resolve this on its own — it has only the engine model, not the
284/// project registry.
285#[must_use]
286pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
287    let file = db.file_text(FileId(sref.0))?;
288    file_class_name(db, file)
289}
290
291/// The member table of the script in `file`. Member types are resolved against the engine model
292/// and the registry (a member typed as another `class_name` resolves to its `ScriptRef`).
293#[salsa::tracked]
294pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
295    let tree = item_tree(db, file);
296    let Some(api) = db.engine() else {
297        return Arc::new(ScriptClass {
298            members: FxHashMap::default(),
299            base: Ty::Unknown,
300        });
301    };
302    let resolve_ann = |ann: Option<&str>| -> Ty {
303        ann.map_or(Ty::Variant, |t| {
304            crate::resolve::resolve_type_name(db, api, t)
305        })
306    };
307    let mut members = FxHashMap::default();
308    for m in &tree.members {
309        let Some(name) = m.name() else { continue };
310        let sig = match m {
311            // A `## @return-tuple(T0, T1, …)` doc-tag (BUG A3) wins over the plain annotation:
312            // the method's call result is a `Ty::Tuple`, so a constant index on it projects the
313            // element's real type cross-file too (`Hooks.useState(...)[1]` → `Callable`).
314            Member::Func(f) => MemberSig::Method(f.tuple_return.as_ref().map_or_else(
315                || resolve_ann(f.return_type.as_deref()),
316                |names| crate::resolve::resolve_tuple_return(db, api, names),
317            )),
318            Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
319            // `const X = preload("res://…")` (no annotation) resolves cross-file to the preloaded
320            // script's `ScriptRef` (the SCRIPT meta-type) — the same resolution the declaring file does
321            // same-file, which the offset-free projection otherwise drops. A relative path is anchored
322            // to this file's dir. An explicit annotation wins.
323            Member::Const(c) => MemberSig::Field(
324                c.type_ref
325                    .is_none()
326                    .then_some(c.preload_path.as_deref())
327                    .flatten()
328                    .and_then(|raw| {
329                        crate::resolve::anchor_res_path(file.res_path(db).as_deref(), raw)
330                    })
331                    .map_or_else(
332                        || resolve_ann(c.type_ref.as_deref()),
333                        |abs| {
334                            crate::resolve::resolve_external(
335                                db,
336                                &crate::resolve::ExternalRef::Preload(abs),
337                            )
338                        },
339                    ),
340            ),
341            Member::Signal(_) => MemberSig::Signal,
342            // Enums + inner classes aren't modeled as instance members yet (M2+).
343            Member::Enum(_) | Member::Class(_) => continue,
344        };
345        members.insert(SmolStr::new(name), sig);
346    }
347    // The resolved `extends` base — a user `ScriptRef` (another class_name / "res://…") walks
348    // into the inheritance chain; an engine `Object` ends it at the API table.
349    let base = crate::resolve::resolve_base(db, api, &tree, file.res_path(db).as_deref());
350    Arc::new(ScriptClass { members, base })
351}
352
353// ---- M1: scenes (.tscn/.tres) ------------------------------------------------------------
354
355/// Whether a `res://` path is a *text* scene/resource we parse (`.tscn`/`.tres`). Binary
356/// `.scn`/`.res` are detected-and-degraded by the parser, but we don't waste a parse on a `.gd`.
357#[must_use]
358pub fn is_scene_path(path: &str) -> bool {
359    let ext = path.rsplit('.').next().unwrap_or("");
360    ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
361}
362
363/// The parsed [`SceneModel`] for `file` (M1) — memoized; recomputes only when the file text
364/// changes. A non-scene file (a `.gd`, or no `res://` path) yields an empty model (so the query is
365/// total). The pure `gdscript_scene::parse_scene` is the cache body; this just wraps + gates it.
366#[salsa::tracked]
367pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
368    let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
369    if is_scene {
370        Arc::new(gdscript_scene::parse_scene(file.text(db)))
371    } else {
372        Arc::new(gdscript_scene::parse_scene(""))
373    }
374}
375
376/// Where a script (`.gd`) is attached in a scene: the owning scene file + the node carrying the
377/// `script = ExtResource(...)`. `$Path` in that script resolves relative to this node.
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub struct SceneAttach {
380    /// The owning scene's file.
381    pub scene: FileId,
382    /// The node the script attaches to (the `$`-path base).
383    pub node: NodeIdx,
384    /// Whether the script attaches to **more than one** scene (the first kept here). When `true`,
385    /// a `$Path` valid in another scene must not be flagged `INVALID_NODE_PATH` (no false positive).
386    pub ambiguous: bool,
387}
388
389/// The project-wide **script → owning scene** index (M1): each `.gd`'s `res://` path → the (first)
390/// scene + node that attaches it. Built by scanning every scene's `ext_resources` for a
391/// `type="Script"` reference. Keyed on the [`SourceRoot`] file-set + each scene file's text (via
392/// [`scene_model`]); a `.gd` **body** edit never touches a `.tscn` text, so this **backdates across
393/// `.gd` keystrokes** — the firewall (a scene edit correctly invalidates it). A duplicate (one
394/// script in many scenes) keeps the first by `FileId` order (the slice's single-scene policy).
395#[salsa::tracked]
396pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
397    let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
398    for &file in root.files(db) {
399        if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
400            continue;
401        }
402        let model = scene_model(db, file);
403        let scene = file.file_id(db);
404        for (i, node) in model.nodes.iter().enumerate() {
405            let Some(script_id) = node.script.as_ref() else {
406                continue;
407            };
408            let Some(path) = model
409                .ext_resources
410                .get(script_id)
411                .and_then(|e| e.path.clone())
412            else {
413                continue;
414            };
415            let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
416            match map.get_mut(&path) {
417                // already attached by an earlier scene → ambiguous (keep the first).
418                Some(existing) => existing.ambiguous = true,
419                None => {
420                    map.insert(
421                        path,
422                        SceneAttach {
423                            scene,
424                            node,
425                            ambiguous: false,
426                        },
427                    );
428                }
429            }
430        }
431    }
432    Arc::new(map)
433}
434
435/// **Every** scene that attaches the script at `res_path` — `(scene file, attach node)` pairs in
436/// scan order. Single-scene scripts have one entry; the rare multi-scene script has several (the
437/// basis for `$Path` **union typing**, M2 §6.3 — type a path as the common base across all scenes).
438/// Same firewall as [`script_scene_index`] (keyed on the scene texts, backdates across `.gd` edits).
439#[salsa::tracked]
440pub fn script_scene_attachments(
441    db: &dyn Db,
442    root: SourceRoot,
443) -> Arc<FxHashMap<SmolStr, Vec<(FileId, NodeIdx)>>> {
444    let mut map: FxHashMap<SmolStr, Vec<(FileId, NodeIdx)>> = FxHashMap::default();
445    for &file in root.files(db) {
446        if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
447            continue;
448        }
449        let model = scene_model(db, file);
450        let scene = file.file_id(db);
451        for (i, node) in model.nodes.iter().enumerate() {
452            let Some(path) = node
453                .script
454                .as_ref()
455                .and_then(|id| model.ext_resources.get(id))
456                .and_then(|e| e.path.clone())
457            else {
458                continue;
459            };
460            map.entry(path)
461                .or_default()
462                .push((scene, NodeIdx(u32::try_from(i).unwrap_or(u32::MAX))));
463        }
464    }
465    Arc::new(map)
466}
467
468/// The owning-scene context for the script in `file` (M1): the scene's [`FileId`], the parsed
469/// scene, and the attach node, so `$Path`/`%Unique`/`get_node("…")` can resolve (and go-to-def can
470/// jump into the `.tscn`). `None` when the project has no scene attaching this script (the
471/// overwhelmingly common single-file / dynamic-UI case → node paths stay `Node`).
472#[must_use]
473pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
474    let res_path = file.res_path(db)?;
475    let root = db.source_root()?;
476    let attach = *script_scene_index(db, root).get(res_path.as_str())?;
477    let scene_file = db.file_text(attach.scene)?;
478    Some(SceneContext {
479        scene: attach.scene,
480        model: scene_model(db, scene_file),
481        attach: attach.node,
482        ambiguous: attach.ambiguous,
483    })
484}
485
486/// The resolved owning-scene context for a script — the scene file, its model, the attach node, and
487/// whether the attachment is ambiguous (multi-scene). Returned by [`scene_context`].
488#[derive(Debug, Clone)]
489pub struct SceneContext {
490    /// The owning scene's file.
491    pub scene: FileId,
492    /// The parsed scene model.
493    pub model: Arc<SceneModel>,
494    /// The node the script attaches to (the `$`-path base).
495    pub attach: NodeIdx,
496    /// Whether the script attaches to multiple scenes (suppresses `INVALID_NODE_PATH`).
497    pub ambiguous: bool,
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use gdscript_base::FileId;
504    use gdscript_db::RootDatabase;
505    use salsa::Durability;
506
507    fn db_with(src: &str) -> (RootDatabase, FileText) {
508        let mut db = RootDatabase::default();
509        db.set_file_text(FileId(0), src, Durability::LOW);
510        let ft = db.file_text(FileId(0)).unwrap();
511        (db, ft)
512    }
513
514    #[test]
515    fn tracked_item_tree_matches_the_plain_fn() {
516        let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
517        let tree = item_tree(&db, ft);
518        assert_eq!(tree.class_name.as_deref(), Some("Foo"));
519        // Memoized: a second query is the same Arc value.
520        assert_eq!(item_tree(&db, ft), tree);
521    }
522
523    #[test]
524    fn tracked_analyze_file_runs_inference() {
525        let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
526        let fi = analyze_file(&db, ft);
527        // The engine model is present on native, so inference produced a unit.
528        assert!(!fi.units.is_empty());
529        assert!(fi.diagnostics.is_empty());
530    }
531
532    // ---- the body-edit firewall (the M0 CI gate, Playbook §4) -----------------------------
533    //
534    // A query that reads only `item_tree` (signatures) must NOT recompute when a function body
535    // changes: editing a body changes the parse, `item_tree` re-validates but its value is
536    // unchanged, so salsa BACKDATES it and dependents are spared. We witness this with a counter
537    // bumped inside a signature-only tracked query (a standard salsa test idiom — the counter is
538    // test-only impurity that does not affect the result). `class_name_witness` is also the seed
539    // of M1's global `class_name` registry.
540
541    use std::sync::atomic::{AtomicU32, Ordering};
542
543    static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
544
545    /// Depends ONLY on `item_tree` (never on a body). Counts its own executions.
546    #[salsa::tracked]
547    fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
548        WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
549        item_tree(db, file).class_name.clone()
550    }
551
552    #[test]
553    fn body_edit_does_not_invalidate_signature_queries() {
554        let mut db = RootDatabase::default();
555        db.set_file_text(
556            FileId(0),
557            "class_name Foo\nfunc f():\n\tvar a := 1\n",
558            Durability::LOW,
559        );
560        let ft = db.file_text(FileId(0)).unwrap();
561
562        // Warm the cache.
563        assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
564        let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
565
566        // Edit ONLY a function body, keeping byte length (`1` -> `2`): signatures are unchanged,
567        // so `item_tree` backdates and the firewall holds.
568        db.set_file_text(
569            FileId(0),
570            "class_name Foo\nfunc f():\n\tvar a := 2\n",
571            Durability::LOW,
572        );
573        assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
574
575        assert_eq!(
576            WITNESS_RUNS.load(Ordering::SeqCst),
577            runs_after_warm,
578            "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
579        );
580    }
581
582    #[test]
583    fn global_registry_resolves_class_names_across_files() {
584        let mut db = RootDatabase::default();
585        db.set_file_text(
586            FileId(0),
587            "class_name Player\nfunc f():\n\tpass\n",
588            Durability::LOW,
589        );
590        db.set_file_text(
591            FileId(1),
592            "class_name Enemy\nvar hp := 10\n",
593            Durability::LOW,
594        );
595        db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
596        db.sync_source_root();
597        let root = db.source_root().unwrap();
598
599        let reg = global_registry(&db, root);
600        assert_eq!(reg.len(), 2);
601        assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
602        assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
603        assert!(reg.resolve("Nonexistent").is_none());
604    }
605
606    // The TRUE downstream firewall (the M1 reframe of the pinned M0 limitation): a body edit must
607    // not invalidate the project-wide registry. `file_class_name` is offset-free, so even a
608    // *length-changing* body edit — which shifts `item_tree`'s byte ranges and forces it to
609    // re-execute — leaves `file_class_name` backdating (its value, the class name, is unchanged).
610    // The registry, and every consumer of it, is therefore untouched by a keystroke.
611
612    static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
613
614    /// Test-only consumer of the registry; re-runs iff the registry's value actually changes.
615    #[salsa::tracked]
616    fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
617        REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
618        global_registry(db, root).len()
619    }
620
621    #[test]
622    fn body_edit_does_not_invalidate_the_global_registry() {
623        let mut db = RootDatabase::default();
624        db.set_file_text(
625            FileId(0),
626            "class_name Player\nfunc f():\n\tvar a := 1\n",
627            Durability::LOW,
628        );
629        db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
630        db.sync_source_root();
631        let root = db.source_root().unwrap();
632
633        assert_eq!(observe_registry(&db, root), 2);
634        let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
635
636        // A length-CHANGING body edit (`1` -> `123456`) — NO sync_source_root (a body edit is not
637        // a structure change). The class name is unchanged, so the registry must not recompute.
638        db.set_file_text(
639            FileId(0),
640            "class_name Player\nfunc f():\n\tvar a := 123456\n",
641            Durability::LOW,
642        );
643
644        assert_eq!(observe_registry(&db, root), 2);
645        assert_eq!(
646            REGISTRY_OBSERVED.load(Ordering::SeqCst),
647            runs,
648            "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
649        );
650    }
651
652    #[test]
653    fn cross_file_class_name_member_resolves() {
654        let mut db = RootDatabase::default();
655        db.set_file_text(
656            FileId(0),
657            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
658            Durability::LOW,
659        );
660        db.set_file_text(
661            FileId(1),
662            "func use_it():\n\tvar w := Widget.make()\n",
663            Durability::LOW,
664        );
665        db.sync_source_root();
666
667        let file1 = db.file_text(FileId(1)).unwrap();
668        let fi = analyze_file(&db, file1);
669        let api = db.engine().unwrap();
670
671        // `w := Widget.make()` resolves `Widget` (a cross-file class_name) to its ScriptRef, then
672        // its `make` method to its `int` return type.
673        let unit = fi
674            .units
675            .iter()
676            .find(|u| !u.result.bindings.is_empty())
677            .expect("a unit with a binding");
678        assert_eq!(
679            unit.result.bindings[0].ty.label(api).as_deref(),
680            Some("int")
681        );
682        assert!(
683            fi.diagnostics.is_empty(),
684            "unexpected diagnostics: {:?}",
685            fi.diagnostics
686        );
687    }
688
689    // ---- W2: class_name collision / shadowing diagnostics ---------------------------------
690
691    use crate::infer::SHADOWED_GLOBAL_IDENTIFIER;
692
693    fn shadow_codes(fi: &Arc<FileInference>) -> Vec<&str> {
694        fi.diagnostics
695            .iter()
696            .filter(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
697            .map(|d| d.code.as_str())
698            .collect()
699    }
700
701    #[test]
702    fn class_name_collisions_names_only_the_duplicates() {
703        let mut db = RootDatabase::default();
704        db.set_file_text(FileId(0), "class_name Dup\n", Durability::LOW);
705        db.set_file_text(FileId(1), "class_name Dup\n", Durability::LOW);
706        db.set_file_text(FileId(2), "class_name Unique\n", Durability::LOW);
707        db.sync_source_root();
708        let root = db.source_root().unwrap();
709
710        let cols = class_name_collisions(&db, root);
711        assert!(cols.contains(&SmolStr::new("Dup")));
712        assert!(
713            !cols.contains(&SmolStr::new("Unique")),
714            "a singly-declared class_name is not a collision",
715        );
716        assert_eq!(cols.len(), 1);
717    }
718
719    #[test]
720    fn missing_tool_warns_when_extending_a_tool_base_without_tool() {
721        let mut db = RootDatabase::default();
722        db.set_file_text(FileId(0), "@tool\nclass_name ToolBase\n", Durability::LOW);
723        db.set_file_text(
724            FileId(1),
725            "extends ToolBase\nfunc f():\n\tpass\n",
726            Durability::LOW,
727        );
728        db.sync_source_root();
729        let derived = analyze_file(&db, db.file_text(FileId(1)).unwrap());
730        assert!(
731            derived
732                .raw_warnings
733                .iter()
734                .any(|w| w.code.as_str() == "MISSING_TOOL"),
735            "{:?}",
736            derived
737                .raw_warnings
738                .iter()
739                .map(|w| w.code.as_str())
740                .collect::<Vec<_>>()
741        );
742        // The base itself IS `@tool` → no warning.
743        let base = analyze_file(&db, db.file_text(FileId(0)).unwrap());
744        assert!(
745            !base
746                .raw_warnings
747                .iter()
748                .any(|w| w.code.as_str() == "MISSING_TOOL")
749        );
750    }
751
752    #[test]
753    fn a_tool_class_extending_a_tool_base_is_silent() {
754        let mut db = RootDatabase::default();
755        db.set_file_text(FileId(0), "@tool\nclass_name ToolBase\n", Durability::LOW);
756        db.set_file_text(FileId(1), "@tool\nextends ToolBase\n", Durability::LOW);
757        db.sync_source_root();
758        let derived = analyze_file(&db, db.file_text(FileId(1)).unwrap());
759        assert!(
760            !derived
761                .raw_warnings
762                .iter()
763                .any(|w| w.code.as_str() == "MISSING_TOOL")
764        );
765    }
766
767    #[test]
768    fn duplicate_class_name_warns_at_both_declarations() {
769        let mut db = RootDatabase::default();
770        db.set_file_text(
771            FileId(0),
772            "class_name Dup\nfunc f():\n\tpass\n",
773            Durability::LOW,
774        );
775        db.set_file_text(FileId(1), "class_name Dup\nvar x := 1\n", Durability::LOW);
776        db.sync_source_root();
777
778        for fid in [0, 1] {
779            let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
780            assert!(
781                shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
782                "file {fid} should warn on the duplicate class_name: {:?}",
783                fi.diagnostics
784            );
785            // The warning points at the NAME (`Dup` at offset 11), not byte 0 or the keyword.
786            let d = fi
787                .diagnostics
788                .iter()
789                .find(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
790                .unwrap();
791            assert_eq!(d.range, gdscript_base::TextRange::new(11, 14));
792        }
793    }
794
795    #[test]
796    fn class_name_shadowing_an_engine_class_warns() {
797        let mut db = RootDatabase::default();
798        // `Node` is an engine class — declaring `class_name Node` shadows it.
799        db.set_file_text(
800            FileId(0),
801            "class_name Node\nfunc f():\n\tpass\n",
802            Durability::LOW,
803        );
804        db.sync_source_root();
805
806        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
807        assert!(
808            shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
809            "class_name Node must warn (shadows the engine class): {:?}",
810            fi.diagnostics
811        );
812    }
813
814    #[test]
815    fn class_name_shadowing_a_builtin_type_warns() {
816        let mut db = RootDatabase::default();
817        // `Vector2` is a builtin Variant type — a `class_name Vector2` hides it.
818        db.set_file_text(FileId(0), "class_name Vector2\n", Durability::LOW);
819        db.sync_source_root();
820
821        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
822        assert!(
823            shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
824            "{:?}",
825            fi.diagnostics
826        );
827    }
828
829    #[test]
830    fn class_name_shadowing_a_star_autoload_warns() {
831        let mut db = RootDatabase::default();
832        db.set_file_text(
833            FileId(0),
834            "class_name Game\nfunc f():\n\tpass\n",
835            Durability::LOW,
836        );
837        db.set_file_path(FileId(0), "res://game.gd");
838        // A `*`-singleton named `Game` — the class_name now hides the autoload global.
839        db.set_project_config("[autoload]\nGame=\"*res://other.gd\"\n");
840        db.sync_source_root();
841
842        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
843        assert!(
844            shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
845            "class_name Game must warn (shadows the `*Game` autoload): {:?}",
846            fi.diagnostics
847        );
848    }
849
850    #[test]
851    fn unique_non_shadowing_class_name_does_not_warn() {
852        // No false positive: a one-of-a-kind name that is no engine/builtin/autoload symbol.
853        let mut db = RootDatabase::default();
854        db.set_file_text(
855            FileId(0),
856            "class_name MyVeryOwnUniquePlayer\nfunc f():\n\tpass\n",
857            Durability::LOW,
858        );
859        db.set_file_text(
860            FileId(1),
861            "class_name AnotherUniqueEnemy\n",
862            Durability::LOW,
863        );
864        db.sync_source_root();
865
866        for fid in [0, 1] {
867            let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
868            assert!(
869                shadow_codes(&fi).is_empty(),
870                "file {fid}: a unique class_name must not warn: {:?}",
871                fi.diagnostics
872            );
873        }
874    }
875
876    #[test]
877    fn unknown_member_on_script_ref_is_seam_not_warning() {
878        let mut db = RootDatabase::default();
879        db.set_file_text(
880            FileId(0),
881            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
882            Durability::LOW,
883        );
884        db.set_file_text(
885            FileId(1),
886            "func use_it():\n\tWidget.not_a_member()\n",
887            Durability::LOW,
888        );
889        db.sync_source_root();
890
891        let file1 = db.file_text(FileId(1)).unwrap();
892        let fi = analyze_file(&db, file1);
893        // A member we don't model is the seam (Unknown) — never UNSAFE_METHOD_ACCESS.
894        assert!(
895            fi.diagnostics.is_empty(),
896            "a missing member on a ScriptRef must not warn: {:?}",
897            fi.diagnostics
898        );
899    }
900
901    #[test]
902    fn inherited_members_resolve_through_user_and_engine_bases() {
903        let mut db = RootDatabase::default();
904        // Derived -> Base (user) -> Node (engine) -> … -> Object.
905        db.set_file_text(
906            FileId(0),
907            "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
908            Durability::LOW,
909        );
910        db.set_file_text(
911            FileId(1),
912            "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
913            Durability::LOW,
914        );
915        db.set_file_text(
916            FileId(2),
917            "func use_it():\n\tvar d: Derived\n\tvar own := d.own()\n\tvar from_base := d.base_method()\n\tvar from_engine := d.get_instance_id()\n",
918            Durability::LOW,
919        );
920        db.sync_source_root();
921        let api = db.engine().unwrap();
922
923        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
924        let unit = fi
925            .units
926            .iter()
927            .find(|u| u.result.bindings.len() >= 4)
928            .expect("use_it unit with 4 bindings");
929        // [0]=d, [1]=own (own member), [2]=base_method (user base), [3]=get_instance_id (engine base).
930        assert_eq!(
931            unit.result.bindings[1].ty.label(api).as_deref(),
932            Some("String")
933        );
934        assert_eq!(
935            unit.result.bindings[2].ty.label(api).as_deref(),
936            Some("int")
937        );
938        assert_eq!(
939            unit.result.bindings[3].ty.label(api).as_deref(),
940            Some("int")
941        );
942        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
943    }
944
945    #[test]
946    fn cyclic_extends_flags_each_cycle_member_and_terminates() {
947        use crate::infer::CYCLIC_INHERITANCE;
948        let mut db = RootDatabase::default();
949        // A extends B extends A — illegal in Godot. The member walk must not loop, AND each file on
950        // the cycle must be flagged `CYCLIC_INHERITANCE` at its own `extends` decl.
951        db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
952        db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
953        // A third, ACYCLIC file that merely USES `A` — it is not on the cycle, so it must stay clean.
954        db.set_file_text(
955            FileId(2),
956            "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
957            Durability::LOW,
958        );
959        db.sync_source_root();
960
961        // Each cycle member is flagged exactly once, at its own `extends`.
962        for id in [FileId(0), FileId(1)] {
963            let fi = analyze_file(&db, db.file_text(id).unwrap());
964            let cyclic: Vec<_> = fi
965                .diagnostics
966                .iter()
967                .filter(|d| d.code == CYCLIC_INHERITANCE)
968                .collect();
969            assert_eq!(cyclic.len(), 1, "file {id:?}: {:?}", fi.diagnostics);
970        }
971
972        // The user file is off the cycle — `a.nope()` walks A->B->A->… and bottoms out at the seam
973        // (no panic/hang, no diagnostic on this file).
974        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
975        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
976    }
977
978    #[test]
979    fn cyclic_extends_via_res_path_two_files_flags_no_hang() {
980        use crate::infer::CYCLIC_INHERITANCE;
981        let mut db = RootDatabase::default();
982        // a.gd extends "res://b.gd"; b.gd extends "res://a.gd" — a 2-file `res://` path cycle.
983        set_with_path(&mut db, 0, "res://a.gd", "extends \"res://b.gd\"\n");
984        set_with_path(&mut db, 1, "res://b.gd", "extends \"res://a.gd\"\n");
985        db.sync_source_root();
986
987        for id in [FileId(0), FileId(1)] {
988            let fi = analyze_file(&db, db.file_text(id).unwrap());
989            assert!(
990                fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
991                "file {id:?} expected CYCLIC_INHERITANCE: {:?}",
992                fi.diagnostics
993            );
994        }
995    }
996
997    #[test]
998    fn deep_acyclic_extends_chain_does_not_false_fire() {
999        use crate::infer::CYCLIC_INHERITANCE;
1000        let mut db = RootDatabase::default();
1001        // A 5-deep ACYCLIC chain bottoming out at an engine base: C0 -> C1 -> ... -> C4 -> Node.
1002        // None revisits the start, so NONE may be flagged `CYCLIC_INHERITANCE`.
1003        db.set_file_text(FileId(0), "class_name C0\nextends C1\n", Durability::LOW);
1004        db.set_file_text(FileId(1), "class_name C1\nextends C2\n", Durability::LOW);
1005        db.set_file_text(FileId(2), "class_name C2\nextends C3\n", Durability::LOW);
1006        db.set_file_text(FileId(3), "class_name C3\nextends C4\n", Durability::LOW);
1007        db.set_file_text(FileId(4), "class_name C4\nextends Node\n", Durability::LOW);
1008        db.sync_source_root();
1009
1010        for id in (0..5).map(FileId) {
1011            let fi = analyze_file(&db, db.file_text(id).unwrap());
1012            assert!(
1013                !fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
1014                "file {id:?} false-fired CYCLIC_INHERITANCE: {:?}",
1015                fi.diagnostics
1016            );
1017        }
1018    }
1019
1020    // ---- M3: res:// path map + preload / extends "res://…" const-aliasing -----------------
1021
1022    /// Add a file with both its text and its `res://` path (the loader's add-time pair).
1023    fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
1024        db.set_file_text(FileId(id), src, Durability::LOW);
1025        db.set_file_path(FileId(id), path);
1026    }
1027
1028    #[test]
1029    fn res_path_registry_maps_paths_to_files() {
1030        let mut db = RootDatabase::default();
1031        set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
1032        set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
1033        db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); // no res:// path
1034        db.sync_source_root();
1035        let root = db.source_root().unwrap();
1036
1037        let reg = res_path_registry(&db, root);
1038        assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
1039        assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
1040        assert!(reg.get("res://missing.gd").is_none());
1041        // A file with no path contributes nothing.
1042        assert_eq!(reg.len(), 2);
1043    }
1044
1045    // The res:// path firewall: a body edit must not rebuild the path registry. `res_path` is a
1046    // *separate* salsa-input field from `text`, so even a length-changing body edit (which
1047    // re-runs `item_tree`) leaves `res_path` — and the registry — untouched.
1048
1049    static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
1050
1051    #[salsa::tracked]
1052    fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
1053        RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
1054        res_path_registry(db, root).len()
1055    }
1056
1057    #[test]
1058    fn body_edit_does_not_invalidate_the_res_path_registry() {
1059        let mut db = RootDatabase::default();
1060        set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
1061        db.sync_source_root();
1062        let root = db.source_root().unwrap();
1063
1064        assert_eq!(observe_res_registry(&db, root), 1);
1065        let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
1066
1067        // Length-CHANGING body edit, NO path re-set, NO sync_source_root: the path is unchanged,
1068        // so the registry must not recompute.
1069        db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
1070
1071        assert_eq!(observe_res_registry(&db, root), 1);
1072        assert_eq!(
1073            RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
1074            runs,
1075            "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
1076        );
1077    }
1078
1079    #[test]
1080    fn preload_const_resolves_to_script_ref_members() {
1081        let mut db = RootDatabase::default();
1082        set_with_path(
1083            &mut db,
1084            0,
1085            "res://widget.gd",
1086            "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
1087        );
1088        set_with_path(
1089            &mut db,
1090            1,
1091            "res://main.gd",
1092            "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
1093        );
1094        db.sync_source_root();
1095        let api = db.engine().unwrap();
1096
1097        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1098        let unit = fi
1099            .units
1100            .iter()
1101            .find(|u| u.result.bindings.len() >= 2)
1102            .expect("use_it unit with 2 bindings");
1103        // W.make() → int; W.new() → an instance of Widget (a ScriptRef).
1104        assert_eq!(
1105            unit.result.bindings[0].ty.label(api).as_deref(),
1106            Some("int")
1107        );
1108        assert!(
1109            matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
1110            "W.new() should be a script instance, got {:?}",
1111            unit.result.bindings[1].ty
1112        );
1113        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1114    }
1115
1116    #[test]
1117    fn cross_file_preload_const_member_resolves() {
1118        // The xfile-preload-const fix: another file reading `Holder.W` where
1119        // `const W = preload("res://widget.gd")` resolves W to the preloaded script. Previously the
1120        // offset-free script_class projection saw only the const's (absent) annotation → Variant.
1121        let mut db = RootDatabase::default();
1122        set_with_path(
1123            &mut db,
1124            0,
1125            "res://widget.gd",
1126            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1127        );
1128        set_with_path(
1129            &mut db,
1130            1,
1131            "res://holder.gd",
1132            "class_name Holder\nconst W = preload(\"res://widget.gd\")\n",
1133        );
1134        set_with_path(
1135            &mut db,
1136            2,
1137            "res://user.gd",
1138            "func use_it():\n\tvar a := Holder.W.make()\n",
1139        );
1140        db.sync_source_root();
1141        let api = db.engine().unwrap();
1142
1143        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1144        let unit = fi
1145            .units
1146            .iter()
1147            .find(|u| !u.result.bindings.is_empty())
1148            .expect("use_it unit");
1149        // Holder.W → Widget's ScriptRef → .make() → int (cross-file, through the const).
1150        assert_eq!(
1151            unit.result.bindings[0].ty.label(api).as_deref(),
1152            Some("int"),
1153            "Holder.W.make() should resolve cross-file to int, got {:?}",
1154            unit.result.bindings[0].ty
1155        );
1156        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1157    }
1158
1159    #[test]
1160    fn preload_of_script_without_class_name_resolves() {
1161        // The key distinction from M1: preload resolves by PATH, so a script with *no* class_name
1162        // (absent from the global_registry) is still resolved.
1163        let mut db = RootDatabase::default();
1164        set_with_path(
1165            &mut db,
1166            0,
1167            "res://helper.gd",
1168            "func help() -> String:\n\treturn \"x\"\n",
1169        );
1170        set_with_path(
1171            &mut db,
1172            1,
1173            "res://main.gd",
1174            "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
1175        );
1176        db.sync_source_root();
1177        let api = db.engine().unwrap();
1178
1179        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1180        let unit = fi
1181            .units
1182            .iter()
1183            .find(|u| u.result.bindings.len() >= 2)
1184            .expect("use_it unit");
1185        assert!(
1186            matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1187            "preload of a class_name-less script must still resolve: {:?}",
1188            unit.result.bindings[0].ty
1189        );
1190        assert_eq!(
1191            unit.result.bindings[1].ty.label(api).as_deref(),
1192            Some("String")
1193        );
1194        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1195    }
1196
1197    #[test]
1198    fn extends_res_path_inherits_members() {
1199        let mut db = RootDatabase::default();
1200        // base.gd has NO class_name — reachable only by its res:// path.
1201        set_with_path(
1202            &mut db,
1203            0,
1204            "res://base.gd",
1205            "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1206        );
1207        set_with_path(
1208            &mut db,
1209            1,
1210            "res://derived.gd",
1211            "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1212        );
1213        set_with_path(
1214            &mut db,
1215            2,
1216            "res://main.gd",
1217            "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n\tvar c := d.get_instance_id()\n",
1218        );
1219        db.sync_source_root();
1220        let api = db.engine().unwrap();
1221
1222        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1223        let unit = fi
1224            .units
1225            .iter()
1226            .find(|u| u.result.bindings.len() >= 4)
1227            .expect("use_it unit with 4 bindings");
1228        // own() (own member), base_method() (via the res:// user base), get_instance_id() (the
1229        // engine base behind base.gd).
1230        assert_eq!(
1231            unit.result.bindings[1].ty.label(api).as_deref(),
1232            Some("String")
1233        );
1234        assert_eq!(
1235            unit.result.bindings[2].ty.label(api).as_deref(),
1236            Some("int")
1237        );
1238        assert_eq!(
1239            unit.result.bindings[3].ty.label(api).as_deref(),
1240            Some("int")
1241        );
1242        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1243    }
1244
1245    #[test]
1246    fn relative_extends_path_anchors_to_importing_dir() {
1247        let mut db = RootDatabase::default();
1248        // base.gd under entities/, reachable only by path (no class_name).
1249        set_with_path(
1250            &mut db,
1251            0,
1252            "res://entities/base.gd",
1253            "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1254        );
1255        // derived.gd in the SAME dir uses a RELATIVE `extends "base.gd"` (anchored to entities/).
1256        set_with_path(
1257            &mut db,
1258            1,
1259            "res://entities/derived.gd",
1260            "class_name Derived\nextends \"base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1261        );
1262        set_with_path(
1263            &mut db,
1264            2,
1265            "res://main.gd",
1266            "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n",
1267        );
1268        db.sync_source_root();
1269        let api = db.engine().unwrap();
1270        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1271        let unit = fi
1272            .units
1273            .iter()
1274            .find(|u| u.result.bindings.len() >= 3)
1275            .expect("use_it unit with 3 bindings (d, a, b)");
1276        // bindings: [0]=`d: Derived`, [1]=own() (own member), [2]=base_method() (relative-extends base).
1277        assert_eq!(
1278            unit.result.bindings[1].ty.label(api).as_deref(),
1279            Some("String")
1280        );
1281        assert_eq!(
1282            unit.result.bindings[2].ty.label(api).as_deref(),
1283            Some("int"),
1284            "base_method() must resolve through the relative `extends \"base.gd\"`"
1285        );
1286        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1287    }
1288
1289    #[test]
1290    fn dangling_preload_is_seam_not_panic() {
1291        let mut db = RootDatabase::default();
1292        set_with_path(
1293            &mut db,
1294            0,
1295            "res://main.gd",
1296            "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
1297        );
1298        db.sync_source_root();
1299        // An unresolvable path → the seam (Unknown): no diagnostic, no panic.
1300        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1301        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1302    }
1303
1304    #[test]
1305    fn non_gd_preload_resource_stays_seam() {
1306        // A `preload` of a non-`.gd` resource must NOT resolve to a script `ScriptRef`, even if the
1307        // path is in the res:// registry — typing a `.tscn`/PackedScene as a script would wrongly
1308        // accept `.new()`/member access (scene-root typing is Phase 4). Defensive gate (the loader
1309        // indexes only `.gd` today, but a future scene-ingesting loader must not mis-type this).
1310        let mut db = RootDatabase::default();
1311        set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
1312        set_with_path(
1313            &mut db,
1314            1,
1315            "res://main.gd",
1316            "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
1317        );
1318        db.sync_source_root();
1319
1320        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1321        let unit = fi
1322            .units
1323            .iter()
1324            .find(|u| !u.result.bindings.is_empty())
1325            .expect("f unit");
1326        assert!(
1327            !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1328            "a non-.gd preload must stay the seam, got {:?}",
1329            unit.result.bindings[0].ty
1330        );
1331        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1332    }
1333
1334    #[test]
1335    fn load_literal_stays_opaque_not_aliased_to_preload() {
1336        let mut db = RootDatabase::default();
1337        set_with_path(
1338            &mut db,
1339            0,
1340            "res://widget.gd",
1341            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1342        );
1343        set_with_path(
1344            &mut db,
1345            1,
1346            "res://main.gd",
1347            "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
1348        );
1349        db.sync_source_root();
1350
1351        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1352        let unit = fi
1353            .units
1354            .iter()
1355            .find(|u| !u.result.bindings.is_empty())
1356            .expect("use_it unit");
1357        // `load(...)` is an ordinary runtime call returning an opaque Resource — it must NOT be
1358        // aliased to `preload` (no script ScriptRef, no static `.new()` typing).
1359        assert!(
1360            !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1361            "load() must stay opaque, not alias preload: {:?}",
1362            unit.result.bindings[0].ty
1363        );
1364        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1365    }
1366
1367    #[test]
1368    fn is_narrows_to_a_user_class_cross_file() {
1369        // `if x is Widget:` narrows `x` to the user `ScriptRef`, so `x.make()` resolves to its
1370        // cross-file return type — the is/as-over-user-types path (already works once ScriptRef
1371        // is informative; M4 just gates it). `int` here PROVES narrowing: without it `x` stays
1372        // Variant and `x.make()` would be Variant.
1373        let mut db = RootDatabase::default();
1374        db.set_file_text(
1375            FileId(0),
1376            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1377            Durability::LOW,
1378        );
1379        db.set_file_text(
1380            FileId(1),
1381            "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
1382            Durability::LOW,
1383        );
1384        db.sync_source_root();
1385        let api = db.engine().unwrap();
1386
1387        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1388        // (bindings include the param `x`; assert *some* binding — the `n` one — is int.)
1389        assert!(
1390            fi.units
1391                .iter()
1392                .flat_map(|u| &u.result.bindings)
1393                .any(|b| b.ty.label(api).as_deref() == Some("int")),
1394            "`x.make()` after `is Widget` should narrow + resolve to int",
1395        );
1396        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1397    }
1398
1399    #[test]
1400    fn as_casts_to_a_user_class_cross_file() {
1401        // `(x as Widget).make()` types the cast as the user `ScriptRef`, so `.make()` → int.
1402        let mut db = RootDatabase::default();
1403        db.set_file_text(
1404            FileId(0),
1405            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1406            Durability::LOW,
1407        );
1408        db.set_file_text(
1409            FileId(1),
1410            "func use_it(x):\n\tvar n := (x as Widget).make()\n",
1411            Durability::LOW,
1412        );
1413        db.sync_source_root();
1414        let api = db.engine().unwrap();
1415
1416        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1417        assert!(
1418            fi.units
1419                .iter()
1420                .flat_map(|u| &u.result.bindings)
1421                .any(|b| b.ty.label(api).as_deref() == Some("int")),
1422            "`(x as Widget).make()` should resolve to int",
1423        );
1424        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1425    }
1426
1427    #[test]
1428    fn renaming_a_files_path_reindexes_the_registry() {
1429        // A path change (rename) DOES update the registry (it is not a body edit).
1430        let mut db = RootDatabase::default();
1431        set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
1432        db.sync_source_root();
1433        let root = db.source_root().unwrap();
1434        assert_eq!(
1435            res_path_registry(&db, root).get("res://old.gd"),
1436            Some(&FileId(0))
1437        );
1438
1439        db.set_file_path(FileId(0), "res://new.gd");
1440        let root = db.source_root().unwrap();
1441        let reg = res_path_registry(&db, root);
1442        assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
1443        assert!(reg.get("res://old.gd").is_none());
1444    }
1445
1446    // ---- M4: autoloads (project.godot [autoload]) + is/as widen-only narrowing --------------
1447
1448    #[test]
1449    fn star_autoload_scene_resolves_via_its_root_script() {
1450        // A `*`-autoload pointing at a `.tscn` whose root has an attached script resolves to that
1451        // script (the singleton-scene pattern) — `Music.volume()` → int, no false UNSAFE. This was
1452        // deferred to Phase 4 (scene ingestion); now closed.
1453        let mut db = RootDatabase::default();
1454        // music.gd (no class_name — resolved by the scene root's script= path).
1455        db.set_file_text(
1456            FileId(0),
1457            "func volume() -> int:\n\treturn 5\n",
1458            Durability::LOW,
1459        );
1460        db.set_file_path(FileId(0), "res://music.gd");
1461        // music.tscn: a root Node with script=music.gd.
1462        db.set_file_text(
1463            FileId(1),
1464            "[gd_scene format=3]\n\
1465             [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
1466             [node name=\"Music\" type=\"Node\"]\n\
1467             script = ExtResource(\"1\")\n",
1468            Durability::LOW,
1469        );
1470        db.set_file_path(FileId(1), "res://music.tscn");
1471        db.set_file_text(
1472            FileId(2),
1473            "func f():\n\tvar v := Music.volume()\n",
1474            Durability::LOW,
1475        );
1476        db.set_file_path(FileId(2), "res://main.gd");
1477        db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1478        db.sync_source_root();
1479        let api = db.engine().unwrap();
1480
1481        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1482        let unit = fi
1483            .units
1484            .iter()
1485            .find(|u| !u.result.bindings.is_empty())
1486            .expect("f unit");
1487        assert_eq!(
1488            unit.result.bindings[0].ty.label(api).as_deref(),
1489            Some("int"),
1490            "Music.volume() should resolve via the scene root's script",
1491        );
1492        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1493    }
1494
1495    #[test]
1496    fn star_autoload_scene_resolves_via_script_class_shortcut() {
1497        // A `*`-autoload `.tscn` whose root has NO `script=` ext_resource but carries the header
1498        // `script_class="…"` shortcut resolves through the class_name registry (the recorded
1499        // shortcut, without a script ext_resource). Autoload name `Audio` ≠ class_name `MusicPlayer`
1500        // so the resolution can ONLY go via the scene's script_class shortcut.
1501        let mut db = RootDatabase::default();
1502        db.set_file_text(
1503            FileId(0),
1504            "class_name MusicPlayer\nfunc volume() -> int:\n\treturn 5\n",
1505            Durability::LOW,
1506        );
1507        db.set_file_path(FileId(0), "res://music.gd");
1508        db.set_file_text(
1509            FileId(1),
1510            "[gd_scene format=3 script_class=\"MusicPlayer\"]\n[node name=\"Root\" type=\"Node\"]\n",
1511            Durability::LOW,
1512        );
1513        db.set_file_path(FileId(1), "res://music.tscn");
1514        db.set_file_text(
1515            FileId(2),
1516            "func f():\n\tvar v := Audio.volume()\n",
1517            Durability::LOW,
1518        );
1519        db.set_file_path(FileId(2), "res://main.gd");
1520        db.set_project_config("[autoload]\nAudio=\"*res://music.tscn\"\n");
1521        db.sync_source_root();
1522        let api = db.engine().unwrap();
1523
1524        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1525        let unit = fi
1526            .units
1527            .iter()
1528            .find(|u| !u.result.bindings.is_empty())
1529            .expect("f unit");
1530        assert_eq!(
1531            unit.result.bindings[0].ty.label(api).as_deref(),
1532            Some("int"),
1533            "Audio.volume() should resolve via the scene's script_class= shortcut",
1534        );
1535        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1536    }
1537
1538    #[test]
1539    fn engine_version_from_project_config_is_firewalled_against_body_edits() {
1540        let mut db = RootDatabase::default();
1541        db.set_file_text(FileId(0), "func f():\n\tpass\n", Durability::LOW);
1542        db.set_file_path(FileId(0), "res://main.gd");
1543        db.set_project_config("[application]\nconfig/features=PackedStringArray(\"4.6\")\n");
1544        db.sync_source_root();
1545        assert_eq!(project_engine_version(&db), Some((4, 6)));
1546
1547        // A `.gd` body edit must NOT change the project's declared engine version (the query is
1548        // keyed on ProjectConfig alone — the cross-file firewall).
1549        db.set_file_text(FileId(0), "func f():\n\tvar x := 1\n", Durability::LOW);
1550        db.sync_source_root();
1551        assert_eq!(project_engine_version(&db), Some((4, 6)));
1552
1553        // No `project.godot` → no declared version.
1554        let empty = RootDatabase::default();
1555        assert_eq!(project_engine_version(&empty), None);
1556    }
1557
1558    #[test]
1559    fn star_autoload_gdscript_resolves_as_global_and_members() {
1560        let mut db = RootDatabase::default();
1561        // `game.gd` has NO class_name — the autoload resolves it by PATH (not the class registry).
1562        db.set_file_text(
1563            FileId(0),
1564            "func score() -> int:\n\treturn 0\n",
1565            Durability::LOW,
1566        );
1567        db.set_file_path(FileId(0), "res://game.gd");
1568        db.set_file_text(
1569            FileId(1),
1570            "func f():\n\tvar s := Game.score()\n",
1571            Durability::LOW,
1572        );
1573        db.set_file_path(FileId(1), "res://main.gd");
1574        db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1575        db.sync_source_root();
1576        let api = db.engine().unwrap();
1577
1578        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1579        let unit = fi
1580            .units
1581            .iter()
1582            .find(|u| !u.result.bindings.is_empty())
1583            .expect("f unit");
1584        // `Game` (a *-singleton) resolves to its ScriptRef; `Game.score()` → int.
1585        assert_eq!(
1586            unit.result.bindings[0].ty.label(api).as_deref(),
1587            Some("int")
1588        );
1589        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1590    }
1591
1592    #[test]
1593    fn non_star_autoload_is_not_a_global() {
1594        let mut db = RootDatabase::default();
1595        db.set_file_text(
1596            FileId(0),
1597            "func score() -> int:\n\treturn 0\n",
1598            Durability::LOW,
1599        );
1600        db.set_file_path(FileId(0), "res://game.gd");
1601        db.set_file_text(
1602            FileId(1),
1603            "func f():\n\tvar s := Game.score()\n",
1604            Durability::LOW,
1605        );
1606        db.set_file_path(FileId(1), "res://main.gd");
1607        // No leading `*` → loaded-but-not-global; the bare name `Game` must NOT resolve.
1608        db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1609        db.sync_source_root();
1610        let api = db.engine().unwrap();
1611
1612        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1613        let unit = fi
1614            .units
1615            .iter()
1616            .find(|u| !u.result.bindings.is_empty())
1617            .expect("f unit");
1618        // `Game` → seam (Unknown), so `s` is uninformative (no `int`); and NO diagnostic.
1619        assert_eq!(unit.result.bindings[0].ty.label(api), None);
1620        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1621    }
1622
1623    #[test]
1624    fn tscn_autoload_is_the_seam_never_false_warns() {
1625        let mut db = RootDatabase::default();
1626        // A scene (`.tscn`) autoload: typing it `Node` would false-warn on the root script's own
1627        // members, so it stays the seam (scene-root typing is Phase 4).
1628        db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1629        db.set_file_path(FileId(0), "res://main.gd");
1630        db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1631        db.sync_source_root();
1632
1633        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1634        // `Hud.play_song()` on a seam receiver → no diagnostic (no false UNSAFE_METHOD_ACCESS).
1635        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1636    }
1637
1638    // The autoload firewall: a `.gd` body edit must not rebuild the autoload registry, which is
1639    // keyed only on the `ProjectConfig` input (not on file text).
1640
1641    static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1642
1643    #[salsa::tracked]
1644    fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1645        AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1646        autoload_registry(db, config).len()
1647    }
1648
1649    #[test]
1650    fn autoload_registry_firewalled_against_body_edits() {
1651        let mut db = RootDatabase::default();
1652        db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1653        db.set_file_path(FileId(0), "res://game.gd");
1654        db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1655        db.sync_source_root();
1656        let config = db.project_config().unwrap();
1657
1658        assert_eq!(observe_autoload_registry(&db, config), 1);
1659        let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1660
1661        // Length-changing `.gd` body edit, NO set_project_config: the autoload registry must not
1662        // recompute (its sole input — ProjectConfig — is untouched).
1663        db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1664
1665        assert_eq!(observe_autoload_registry(&db, config), 1);
1666        assert_eq!(
1667            AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1668            runs,
1669            "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1670        );
1671    }
1672
1673    // The W1 gating firewall (the M0 load-bearing test): editing a `debug/gdscript/warnings/*`
1674    // level must re-run only `warning_settings` (+ the downstream gate in `type_diagnostics`),
1675    // NEVER the cached `analyze_file` (inference). Severity is resolved downstream, so a settings
1676    // edit leaves inference's `raw_warnings` untouched.
1677
1678    static ANALYZE_OBSERVED: AtomicU32 = AtomicU32::new(0);
1679
1680    #[salsa::tracked]
1681    fn observe_analyze_file(db: &dyn gdscript_db::Db, file: FileText) -> usize {
1682        ANALYZE_OBSERVED.fetch_add(1, Ordering::SeqCst);
1683        analyze_file(db, file).raw_warnings.len()
1684    }
1685
1686    #[test]
1687    fn warning_level_edit_does_not_invalidate_analyze_file() {
1688        use crate::warnings::{WarnLevel, WarningCode};
1689
1690        let mut db = RootDatabase::default();
1691        db.set_file_text(
1692            FileId(0),
1693            "func f():\n\tvar x = 5 / 2\n\treturn x\n",
1694            Durability::LOW,
1695        );
1696        db.set_file_path(FileId(0), "res://game.gd");
1697        db.set_project_config(
1698            "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=2\n",
1699        );
1700        db.sync_source_root();
1701        let file = db.file_text(FileId(0)).unwrap();
1702        let config = db.project_config().unwrap();
1703
1704        // Prime: analyze_file runs once and records the gateable raw warnings — INTEGER_DIVISION
1705        // plus UNTYPED_DECLARATION (the untyped `var x`).
1706        assert_eq!(observe_analyze_file(&db, file), 2);
1707        let runs = ANALYZE_OBSERVED.load(Ordering::SeqCst);
1708        assert_eq!(
1709            warning_settings(&db, config)
1710                .per_code
1711                .get(&WarningCode::IntegerDivision),
1712            Some(&WarnLevel::Error),
1713        );
1714
1715        // Edit ONLY the warning level (the `[autoload]` line is byte-identical). analyze_file must
1716        // not recompute — its inputs (file text, engine, the autoload registry's *value*) are
1717        // unchanged; only `warning_settings` re-runs.
1718        db.set_project_config(
1719            "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=1\n",
1720        );
1721        assert_eq!(observe_analyze_file(&db, file), 2);
1722        assert_eq!(
1723            ANALYZE_OBSERVED.load(Ordering::SeqCst),
1724            runs,
1725            "REGRESSION: a warning-level edit re-ran analyze_file — the W1 gating firewall broke",
1726        );
1727        // The setting itself DID change (the test is not vacuous): the gate now sees WARN.
1728        assert_eq!(
1729            warning_settings(&db, config)
1730                .per_code
1731                .get(&WarningCode::IntegerDivision),
1732            Some(&WarnLevel::Warn),
1733        );
1734    }
1735
1736    #[test]
1737    fn aliased_self_resolves_own_members_no_false_unsafe() {
1738        // `var me := self; me.own()` must resolve `own` via the file's OWN members — self is the
1739        // script's own class (a self-ScriptRef), not just its engine base. Before the fix `me` was
1740        // typed as the base (`Node`), so `me.own()` false-warned UNSAFE_METHOD_ACCESS.
1741        let mut db = RootDatabase::default();
1742        db.set_file_text(
1743            FileId(0),
1744            "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1745            Durability::LOW,
1746        );
1747        db.sync_source_root();
1748        let api = db.engine().unwrap();
1749
1750        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1751        // `me.own()` resolves to int (own member via aliased self) — proves it isn't the seam.
1752        assert!(
1753            fi.units
1754                .iter()
1755                .flat_map(|u| &u.result.bindings)
1756                .any(|b| b.ty.label(api).as_deref() == Some("int")),
1757            "aliased self.own() should resolve to int",
1758        );
1759        assert!(
1760            fi.diagnostics.is_empty(),
1761            "no false UNSAFE on aliased self: {:?}",
1762            fi.diagnostics
1763        );
1764    }
1765
1766    #[test]
1767    fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1768        let mut db = RootDatabase::default();
1769        db.set_file_text(
1770            FileId(0),
1771            "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1772            Durability::LOW,
1773        );
1774        db.set_file_text(
1775            FileId(1),
1776            "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1777            Durability::LOW,
1778        );
1779        // (a) untyped `x` + `is Derived` → narrow to Derived → `x.own_m()` resolves (String).
1780        // (b) `d: Derived` + `is Base` → widen-only: d STAYS Derived → `d.own_m()` resolves (String).
1781        db.set_file_text(
1782            FileId(2),
1783            "func use_it(x):\n\tif x is Derived:\n\t\tvar a := x.own_m()\n\tvar d: Derived\n\tif d is Base:\n\t\tvar b := d.own_m()\n",
1784            Durability::LOW,
1785        );
1786        db.sync_source_root();
1787        let api = db.engine().unwrap();
1788
1789        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1790        let strings = fi
1791            .units
1792            .iter()
1793            .flat_map(|u| &u.result.bindings)
1794            .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1795            .count();
1796        // Both `own_m()` calls resolve to String: proves narrow-to-Derived AND no un-narrow-to-Base.
1797        assert!(
1798            strings >= 2,
1799            "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1800        );
1801        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1802    }
1803
1804    // ---- M1: scene-aware node-path typing ($Path / %Unique) -------------------------------
1805
1806    /// A db with file 0 = a scene and file 1 = its attached script, both with res:// paths.
1807    fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1808        let mut db = RootDatabase::default();
1809        db.set_file_text(FileId(0), scene_text, Durability::LOW);
1810        db.set_file_path(FileId(0), "res://main.tscn");
1811        db.set_file_text(FileId(1), gd_text, Durability::LOW);
1812        db.set_file_path(FileId(1), "res://main.gd");
1813        db.sync_source_root();
1814        db
1815    }
1816
1817    fn binding_labels(db: &RootDatabase) -> Vec<String> {
1818        let api = db.engine().unwrap();
1819        let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1820        assert!(
1821            fi.diagnostics.is_empty(),
1822            "unexpected diags: {:?}",
1823            fi.diagnostics
1824        );
1825        fi.units
1826            .iter()
1827            .flat_map(|u| &u.result.bindings)
1828            .filter_map(|b| b.ty.label(api))
1829            .collect()
1830    }
1831
1832    const SCENE: &str = "[gd_scene format=3]\n\
1833        [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1834        [node name=\"Root\" type=\"Control\"]\n\
1835        script = ExtResource(\"1\")\n\
1836        [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1837        [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1838        [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1839        unique_name_in_owner = true\n";
1840
1841    #[test]
1842    fn dollar_path_types_to_the_concrete_node() {
1843        // `$Panel/Box/Btn` → Button (not bare Node) — the killer feature, zero annotations.
1844        let db = scene_db(
1845            SCENE,
1846            "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1847        );
1848        assert!(
1849            binding_labels(&db).iter().any(|l| l == "Button"),
1850            "$Panel/Box/Btn should type as Button",
1851        );
1852    }
1853
1854    #[test]
1855    fn unique_name_path_types_to_the_concrete_node() {
1856        // `%Btn` resolves via unique_name_in_owner → Button.
1857        let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1858        assert!(
1859            binding_labels(&db).iter().any(|l| l == "Button"),
1860            "%Btn should type as Button"
1861        );
1862    }
1863
1864    #[test]
1865    fn onready_var_from_a_node_path_is_typed() {
1866        // `@onready var x := $Path` types `x` from the resolved node at the decl site. (`:=` is the
1867        // typed form; plain `=` stays `Variant` per Godot's gradual typing — Phase-2 rule.)
1868        let db = scene_db(
1869            SCENE,
1870            "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1871        );
1872        assert!(
1873            binding_labels(&db).iter().any(|l| l == "Button"),
1874            "@onready var := $Path should type to Button",
1875        );
1876    }
1877
1878    #[test]
1879    fn get_node_string_literal_types_like_dollar() {
1880        // `get_node("Panel/Box/Btn")` (string literal) types identically to `$Panel/Box/Btn`.
1881        let db = scene_db(
1882            SCENE,
1883            "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1884        );
1885        assert!(
1886            binding_labels(&db).iter().any(|l| l == "Button"),
1887            "get_node(\"...\") should type as Button",
1888        );
1889    }
1890
1891    #[test]
1892    fn self_get_node_string_literal_types_like_dollar() {
1893        // `self.get_node("…")` (explicit self = the attach node) types like the bare form; a foreign
1894        // receiver `obj.get_node("…")` stays a normal call → `Node` (can't resolve another node's path).
1895        let db = scene_db(
1896            SCENE,
1897            "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1898        );
1899        assert!(
1900            binding_labels(&db).iter().any(|l| l == "Button"),
1901            "self.get_node(\"...\") should type as Button",
1902        );
1903    }
1904
1905    #[test]
1906    fn attached_script_refines_the_node_type() {
1907        // A node `type="Button"` + `script=Fancy.gd (class_name Fancy)` → `$That` is `Fancy`, so
1908        // `$That.fancy()` resolves to its cross-file return type (proving the script refine).
1909        let mut db = RootDatabase::default();
1910        db.set_file_text(
1911            FileId(0),
1912            "[gd_scene format=3]\n\
1913             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1914             [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1915             [node name=\"Root\" type=\"Control\"]\n\
1916             script = ExtResource(\"1\")\n\
1917             [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1918             script = ExtResource(\"2\")\n",
1919            Durability::LOW,
1920        );
1921        db.set_file_path(FileId(0), "res://main.tscn");
1922        db.set_file_text(
1923            FileId(1),
1924            "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1925            Durability::LOW,
1926        );
1927        db.set_file_path(FileId(1), "res://main.gd");
1928        db.set_file_text(
1929            FileId(2),
1930            "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1931            Durability::LOW,
1932        );
1933        db.set_file_path(FileId(2), "res://fancy.gd");
1934        db.sync_source_root();
1935        assert!(
1936            binding_labels(&db).iter().any(|l| l == "int"),
1937            "$That.fancy() should resolve via the attached script Fancy",
1938        );
1939    }
1940
1941    #[test]
1942    fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1943        // A computed `get_node(var)` and a `$Nope` with no owning scene both stay `Node` — never a
1944        // false node-path warning.
1945        let mut db = RootDatabase::default();
1946        db.set_file_text(
1947            FileId(1),
1948            // `p: NodePath` so the computed `get_node(p)` still exercises node-path resolution but
1949            // without an (orthogonal, legitimate) UNSAFE_CALL_ARGUMENT on an untyped Variant arg —
1950            // that warning has its own tests in `infer`.
1951            "extends Node\nfunc f(p: NodePath):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1952            Durability::LOW,
1953        );
1954        db.set_file_path(FileId(1), "res://lone.gd");
1955        db.sync_source_root();
1956        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1957        assert!(
1958            fi.diagnostics.is_empty(),
1959            "no false node-path warnings: {:?}",
1960            fi.diagnostics
1961        );
1962    }
1963
1964    // ---- M2: INVALID_NODE_PATH (the no-false-positive contract) ----------------------------
1965
1966    fn has_invalid_node_path(db: &RootDatabase) -> bool {
1967        let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1968        fi.diagnostics
1969            .iter()
1970            .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1971    }
1972
1973    #[test]
1974    fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1975        let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1976        assert!(
1977            has_invalid_node_path(&db),
1978            "$Nope is absent in the one owning scene → warn"
1979        );
1980    }
1981
1982    #[test]
1983    fn escape_and_absolute_paths_never_warn() {
1984        // `..` and absolute `/root/…` escape the scene slice — silent, never INVALID_NODE_PATH.
1985        let db = scene_db(
1986            SCENE,
1987            "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1988        );
1989        assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1990    }
1991
1992    #[test]
1993    fn path_descending_into_an_instanced_subscene_never_warns() {
1994        // Root > Player(instance=…). `$Player/Gun` misses below an instance we don't recurse into —
1995        // silent (the node may well exist inside the sub-scene).
1996        let db = scene_db(
1997            "[gd_scene format=3]\n\
1998             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1999             [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
2000             [node name=\"Root\" type=\"Control\"]\n\
2001             script = ExtResource(\"1\")\n\
2002             [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
2003            "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
2004        );
2005        assert!(
2006            !has_invalid_node_path(&db),
2007            "into-instance miss must not warn"
2008        );
2009    }
2010
2011    #[test]
2012    fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
2013        // main.gd attaches to BOTH a.tscn (child Alpha) and b.tscn (child Beta). `$Beta` is absent in
2014        // a.tscn (kept first) but present in b.tscn → ambiguous → no false INVALID_NODE_PATH.
2015        let mut db = RootDatabase::default();
2016        db.set_file_text(
2017            FileId(0),
2018            "[gd_scene format=3]\n\
2019             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2020             [node name=\"Root\" type=\"Control\"]\n\
2021             script = ExtResource(\"1\")\n\
2022             [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
2023            Durability::LOW,
2024        );
2025        db.set_file_path(FileId(0), "res://a.tscn");
2026        db.set_file_text(
2027            FileId(2),
2028            "[gd_scene format=3]\n\
2029             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2030             [node name=\"Root\" type=\"Control\"]\n\
2031             script = ExtResource(\"1\")\n\
2032             [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
2033            Durability::LOW,
2034        );
2035        db.set_file_path(FileId(2), "res://b.tscn");
2036        db.set_file_text(
2037            FileId(1),
2038            "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
2039            Durability::LOW,
2040        );
2041        db.set_file_path(FileId(1), "res://main.gd");
2042        db.sync_source_root();
2043        assert!(
2044            !has_invalid_node_path(&db),
2045            "ambiguous multi-scene attachment must not warn"
2046        );
2047    }
2048
2049    // ---- M3: instanced sub-scene recursion ------------------------------------------------
2050
2051    #[test]
2052    fn instanced_node_recurses_into_the_subscene_root_script() {
2053        // main.tscn: Root(script=main.gd) > Enemy(instance=enemy.tscn). enemy.tscn's root carries
2054        // script=enemy.gd (class_name Enemy, `hp() -> int`). `$Enemy.hp()` must recurse into the
2055        // sub-scene root, refine to the Enemy script, and resolve the cross-file method → `int`
2056        // (proving the instance recursion + script refine; a bare `Node` would have no `hp()`).
2057        let mut db = RootDatabase::default();
2058        db.set_file_text(
2059            FileId(0),
2060            "[gd_scene format=3]\n\
2061             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2062             [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2063             [node name=\"Root\" type=\"Control\"]\n\
2064             script = ExtResource(\"1\")\n\
2065             [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
2066            Durability::LOW,
2067        );
2068        db.set_file_path(FileId(0), "res://main.tscn");
2069        db.set_file_text(
2070            FileId(1),
2071            "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
2072            Durability::LOW,
2073        );
2074        db.set_file_path(FileId(1), "res://main.gd");
2075        db.set_file_text(
2076            FileId(2),
2077            "[gd_scene format=3]\n\
2078             [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
2079             [node name=\"Enemy\" type=\"Button\"]\n\
2080             script = ExtResource(\"1\")\n",
2081            Durability::LOW,
2082        );
2083        db.set_file_path(FileId(2), "res://enemy.tscn");
2084        db.set_file_text(
2085            FileId(3),
2086            "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
2087            Durability::LOW,
2088        );
2089        db.set_file_path(FileId(3), "res://enemy.gd");
2090        db.sync_source_root();
2091        assert!(
2092            binding_labels(&db).iter().any(|l| l == "int"),
2093            "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
2094        );
2095    }
2096
2097    #[test]
2098    fn path_into_an_instanced_subscene_types_the_inner_node() {
2099        // main.tscn: Root(script=main.gd) > Enemy(instance=enemy.tscn). enemy.tscn: Enemy(Node2D) >
2100        // Sprite(Sprite2D). M3 typed `$Enemy` itself; this §4b step continues the walk INTO the
2101        // sub-scene, so `$Enemy/Sprite` types as `Sprite2D` (the inner node), not bare `Node`.
2102        // `$Enemy/Nope` stays `Node` with NO false INVALID_NODE_PATH (binding_labels asserts zero
2103        // diagnostics).
2104        let mut db = RootDatabase::default();
2105        db.set_file_text(
2106            FileId(0),
2107            "[gd_scene format=3]\n\
2108             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2109             [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2110             [node name=\"Root\" type=\"Control\"]\n\
2111             script = ExtResource(\"1\")\n\
2112             [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
2113            Durability::LOW,
2114        );
2115        db.set_file_path(FileId(0), "res://main.tscn");
2116        db.set_file_text(
2117            FileId(1),
2118            "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n\tvar n := $Enemy/Nope\n",
2119            Durability::LOW,
2120        );
2121        db.set_file_path(FileId(1), "res://main.gd");
2122        db.set_file_text(
2123            FileId(2),
2124            "[gd_scene format=3]\n\
2125             [node name=\"Enemy\" type=\"Node2D\"]\n\
2126             [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
2127            Durability::LOW,
2128        );
2129        db.set_file_path(FileId(2), "res://enemy.tscn");
2130        db.sync_source_root();
2131        assert!(
2132            binding_labels(&db).iter().any(|l| l == "Sprite2D"),
2133            "$Enemy/Sprite should type as the sub-scene's Sprite (Sprite2D)",
2134        );
2135    }
2136
2137    #[test]
2138    fn override_child_under_an_instance_types_from_the_subscene() {
2139        // The "hard tail" (burndown Stage 4.23): main.tscn instances enemy.tscn at `Enemy` AND adds
2140        // an OVERRIDE child `[node name="Sprite" parent="Enemy"]` — a node with no own
2141        // `type=`/script/instance (it only carries property overrides). It RESOLVES to that outer
2142        // node (not IntoInstance), which used to floor to bare `Node`; now its type is taken from the
2143        // same-pathed node inside the instanced sub-scene → `Sprite2D`.
2144        let mut db = RootDatabase::default();
2145        db.set_file_text(
2146            FileId(0),
2147            "[gd_scene format=3]\n\
2148             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2149             [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2150             [node name=\"Root\" type=\"Control\"]\n\
2151             script = ExtResource(\"1\")\n\
2152             [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n\
2153             [node name=\"Sprite\" parent=\"Enemy\"]\n\
2154             modulate = Color(1, 0, 0, 1)\n",
2155            Durability::LOW,
2156        );
2157        db.set_file_path(FileId(0), "res://main.tscn");
2158        db.set_file_text(
2159            FileId(1),
2160            "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n",
2161            Durability::LOW,
2162        );
2163        db.set_file_path(FileId(1), "res://main.gd");
2164        db.set_file_text(
2165            FileId(2),
2166            "[gd_scene format=3]\n\
2167             [node name=\"Enemy\" type=\"Node2D\"]\n\
2168             [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
2169            Durability::LOW,
2170        );
2171        db.set_file_path(FileId(2), "res://enemy.tscn");
2172        db.sync_source_root();
2173        assert!(
2174            binding_labels(&db).iter().any(|l| l == "Sprite2D"),
2175            "an override child under an instance types from the sub-scene (Sprite2D), got: {:?}",
2176            binding_labels(&db),
2177        );
2178    }
2179
2180    #[test]
2181    fn inner_class_value_and_members_type_via_its_item_tree() {
2182        // Burndown Stage 4.24 inc.1: an inner `class Inner:` value/instance types instead of seaming
2183        // (`Ty::InnerClass`, was `Ty::Unknown`). `Inner.new()` constructs an instance; member access
2184        // resolves against the inner class's own item-tree (by annotation) + its `extends` chain — so
2185        // `x.hp`/`x.ping()` type as `int` (no false `INFERENCE_ON_VARIANT`), and a member inherited
2186        // from the inner class's engine base (`extends Node` → `Object.get_class`) resolves to `String`.
2187        let mut db = RootDatabase::default();
2188        db.set_file_text(
2189            FileId(0),
2190            "class Inner extends Node:\n\
2191             \tvar hp: int = 5\n\
2192             \tfunc ping() -> int:\n\
2193             \t\treturn 1\n\
2194             \tfunc combo() -> int:\n\
2195             \t\tvar s := self.get_class()\n\
2196             \t\treturn hp + ping()\n\
2197             func f():\n\
2198             \tvar x := Inner.new()\n\
2199             \tvar a := x.hp\n\
2200             \tvar b := x.ping()\n\
2201             \tvar c := x.get_class()\n",
2202            Durability::LOW,
2203        );
2204        db.sync_source_root();
2205        let ft = db.file_text(FileId(0)).unwrap();
2206        let fi = analyze_file(&db, ft);
2207        assert!(
2208            fi.diagnostics.is_empty(),
2209            "no hard diagnostics expected: {:?}",
2210            fi.diagnostics
2211        );
2212        let api = db.engine().unwrap();
2213        let labels: Vec<String> = fi
2214            .units
2215            .iter()
2216            .flat_map(|u| &u.result.bindings)
2217            .filter_map(|b| b.ty.label(api))
2218            .collect();
2219        assert!(
2220            labels.iter().filter(|l| *l == "int").count() >= 2,
2221            "x.hp and x.ping() should both type as int: {labels:?}"
2222        );
2223        assert!(
2224            labels.iter().any(|l| l == "String"),
2225            "x.get_class() should resolve via the inner class's `extends Node` chain to String: {labels:?}"
2226        );
2227    }
2228
2229    // ---- Phase-4 hunt fixes: `%`-segment paths (no false INVALID_NODE_PATH) ----------------
2230
2231    #[test]
2232    fn unique_name_subpath_resolves_to_the_child_without_warning() {
2233        // `%Box/Btn`: resolve the unique `%Box`, then walk `/Btn` to its Button child — idiomatic
2234        // Godot. Must type as Button and NOT raise INVALID_NODE_PATH (the bare-map lookup of the
2235        // whole joined "Box/Btn" used to miss → false warning).
2236        let db = scene_db(
2237            "[gd_scene format=3]\n\
2238             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2239             [node name=\"Root\" type=\"Control\"]\n\
2240             script = ExtResource(\"1\")\n\
2241             [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
2242             unique_name_in_owner = true\n\
2243             [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
2244            "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
2245        );
2246        assert!(
2247            binding_labels(&db).iter().any(|l| l == "Button"),
2248            "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
2249        );
2250    }
2251
2252    #[test]
2253    fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
2254        // `get_node("%Btn")` and `$"%Btn"` are unique-name lookups (the `%` prefix lives inside the
2255        // string), NOT a child literally named "%Btn". Must type as Button with no INVALID_NODE_PATH.
2256        let db = scene_db(
2257            SCENE,
2258            "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
2259        );
2260        let labels = binding_labels(&db);
2261        assert!(
2262            labels.iter().filter(|l| *l == "Button").count() >= 2,
2263            "both %Btn string forms should resolve to Button: {labels:?}",
2264        );
2265    }
2266}