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