gdscript-analyzer

gdscript-analyzer is a Rust library that parses and semantically understands GDScript (Godot 4.x) and exposes an engine-independent query API — completion, hover, diagnostics, go-to-definition, find-references, rename, type inference, and more — that any tool can embed: natively, in Node via napi-rs, in the browser via WebAssembly, or from other languages through a C ABI.

Think of it as "Roslyn / rust-analyzer for Godot": the reusable analysis brain, deliberately separated from any one editor or server.

A library, not a server

The single most important design decision is that this is a library, not an LSP server. The analysis engine is protocol-neutral: you give it file contents and byte offsets, and it returns plain old data (POD) structs. It knows nothing about LSP, JSON-RPC, or any particular editor.

Clients — a standalone LSP server, a CLI, a web playground, or a markup toolchain such as guitkx — each map those neutral results onto their own protocol. This is rust-analyzer's discipline: the ide crate "knows nothing about LSP"; only a thin server crate does. See ADR-0001 and plans/00-VISION-AND-SCOPE.md.

Who consumes it

In rough priority order:

  1. guitkx — the ReactiveUI-for-Godot markup toolchain, our first client and validation harness. It needs GDScript intelligence inside markup {expr} blocks without a running Godot editor.
  2. A standalone GDScript LSP server (gdscript-lsp) — both a real product and the reference client for the API.
  3. A CLI (gdscript-cli) — check / lint / format / symbols for CI and pre-commit hooks.
  4. A web playground — Rust→WASM, in-browser analysis of pasted GDScript.
  5. The wider community — other editors and other-language consumers.

Why it exists

Before this project, the only way to get semantic GDScript intelligence was to run the Godot editor and talk to its built-in LSP over TCP. Every other tool in the space was either syntactic-only, Python/.NET-locked, or editor-bound. The empty quadrant — **semantic-grade + engine-independent + Rust→multi-target

Project status — feature-complete, published on the 0.x line

The analyzer is feature-complete through its planned phases and ships on both registries (crates.io workspace + @gdscript-analyzer/core on npm, moving in lockstep — 0.5.x at the time of writing). Live today:

  • Diagnostics — parse + type errors and the gateable GDScript warning catalog, with Godot's own verbatim message texts (probed against the real 4.7 binary and golden-pinned), arity/argument checking on resolved calls, project-aware UNDEFINED_* absence diagnostics, LSP rendering tags (unused/unreachable code dims), and warning gating that matches the engine's defaults (see the Warning Reference).
  • The full LSP-grade query surface — type-aware hover, completion, document/workspace symbols, go-to-definition, find-references, rename, signature help, folding, inlay hints, code actions, semantic tokens.
  • The Godot 4.x engine model (generated from extension_api.json, with an auto-sync pipeline for new engine releases), scene-aware node-path typing, and a gdformat-compatible formatter.
  • Every consumer from the list above — the guitkx toolchain embeds it in production, gdscript-lsp and gdscript-cli ship as binaries, and the wasm package runs it in the browser.

Remaining work is integration-driven; see plans/ROADMAP.md and the repository's TECH_DEBT.md.

Next steps

Roadmap

The public, living view of where gdscript-analyzer is and where it's going. This is a direction-of-travel document, not a dated commitment — issues labeled C-tracking hold the fine-grained status. The detailed phase plans live in plans/.

Where we are

The analyzer is a headless, embeddable Rust library (rust-analyzer's architecture, applied to GDScript): a lossless CST, an incremental salsa query graph, gradual type inference with flow-sensitive narrowing, scene (.tscn) awareness, and a Godot-matching warning set — all behind one engine-/protocol-neutral gdscript-ide API, consumed by a CLI, an LSP server, a napi binding, and a wasm playground.

  • Phases 1–5 — shipped (0.x on crates.io + npm): lexer/parser, type inference + IDE features, cross-file + scene resolution, the CLI/LSP/napi/wasm clients, and the GA CI/CD + distribution pipeline.
  • Phase 6 — in progress (the road to 1.0): quality + stability + the API freeze.

Phase 6 — the road to 1.0

WorkstreamWhat it deliversStatus
W1 — warning setThe full Godot warning catalog behind an emit-then-gate seam (WarningCode + gate()), project-setting gating, and @warning_ignore[_start|_restore].Gate seam + most of the catalog landed (62 codes shipped); a handful of checks remain additive by design (tracked in TECH_DEBT.md).
W2 — flow narrowingA real per-body control-flow dataflow: is/!= null/early-return/and-or/else narrowing that beats the engine on is-guards (#93510), plus reachability.Landed (CFG + checker wiring + short-circuit + UNREACHABLE_CODE); match-arm narrowing + loop fixpoints deferred post-1.0.
W3 — formatterA gdscript-fmt crate: format / format_range with idempotence + semantics-preservation, wired into the CLI and LSP.Landed — ~100% byte-exact gdformat parity on the reference corpus; a couple of cosmetic reflow cases remain (TECH_DEBT.md).
W4 — performanceA tiered perf fixture corpus + criterion benches (cold / warm-keystroke / completion), memory profiling, and CI size/latency guards.Landed locally (cold + warm-keystroke benches show flat incremental cost); CI-service-gated regression guards (CodSpeed/dhat) deferred.
W5 — docsA generated warning reference (from the WarningCode catalog), a Configuration page, the contract page, and CI-built examples.Landed (warning reference, Configuration page, per-crate READMEs, doctests, this book); the contract page is intentionally reserved for W6.
W6 — API freeze#[non_exhaustive] on the consumer-matched types, a PR-time + release-time semver gate, and the published 1.0 contract.Held for last (irreversible — see the execution overview).
W7 — ecosystemLabels-as-code, issue forms, this roadmap, the ADR + RFC process (ADR-0004).Landed.

The 1.0 cut line: 1.0 is the freeze, not the feature-complete point. We ship Phase-6 improvements as 0.x releases (each consumable + dogfoodable), and reserve the 1.0 tag for when the public surface is stable — at which point #[non_exhaustive] and the semver policy lock it.

After 1.0 (direction, not commitment)

  • Narrowing precision (the multi-year tail): loop-carried fixpoints, aliasing, narrowing through call results, enum/discriminant narrowing — each a MINOR/PATCH quality change, never an API break.
  • The remaining warning checks + an opt-in Godot-differential CI harness.
  • Distribution reach: the napi musl/armv7/WASI matrix, a wasm-size regression guard.
  • Editor polish across the VS Code / Rider / Visual Studio extensions and the guitkx (ReactiveUI-Godot) consumer.

See TECH_DEBT.md for the honest, itemized backlog.

Install

Status: live. Every package below is published on crates.io and npm today (workspace 0.5.x). See plans/ROADMAP.md for what's still ahead on the road to 1.0.

Toolchain (contributors)

You do not need to manage the Rust toolchain by hand. The repository pins everything in rust-toolchain.toml (channel, components, and the wasm32-unknown-unknown target), so with rustup installed, the correct toolchain — including the wasm target used by the portability guard — is fetched automatically the first time you build:

rustup show   # confirms the pinned stable toolchain + wasm32-unknown-unknown

The minimum supported Rust version (MSRV) is 1.88.0 — the floor required by napi-rs v3. CI enforces it across the whole workspace.

From Rust (crates.io)

The public Rust crate is gdscript-ide — the AnalysisHost / Analysis surface that external Rust consumers depend on:

cargo add gdscript-ide

The lower-level crates (gdscript-base, gdscript-syntax, gdscript-api, …) are published too, but most consumers only need gdscript-ide. See Consuming from Rust.

From Node (npm)

The napi-rs native addon is published under the @gdscript-analyzer scope:

npm i @gdscript-analyzer/core
# pnpm add @gdscript-analyzer/core

Per-platform prebuilt binaries are delivered automatically via optionalDependencies (@gdscript-analyzer/core-linux-x64-gnu, -darwin-arm64, -win32-x64-msvc, …), so there is no native build step for consumers. See Consuming from Node.

From the browser (WASM)

A WebAssembly build ships as a separate npm package (@gdscript-analyzer/wasm) for in-page analysis (playgrounds, web editors). See Consuming from the Browser.

Versioning

crates.io and npm move in lockstep on a single shared version (currently 0.5.x). While in 0.x, a breaking change bumps the minor and a new feature is a patch (Cargo's 0.x SemVer reading). The contract every consumer builds on is the gdscript-ide API surface — it will be frozen (#[non_exhaustive], semver-stable) at the 1.0 cut; see the roadmap.

Quickstart

Status: live. The analysis API below is the real, shipped gdscript-ide surface (see plans/ROADMAP.md for what's still ahead on the road to 1.0).

The library is built around two types modeled on rust-analyzer:

  • AnalysisHost — the single mutable owner of analysis state, one per project. Its only mutation entry point is apply_change.
  • Analysis — a cheap, cloneable, immutable snapshot you run read queries against. Every query is cancellable: a newer change cancels in-flight reads.

All inputs are a FileId plus byte offsets; all results are POD structs (serde-serializable), with no lsp-types in the core. The client converts byte offsets to UTF-16 and codes to its protocol.

Analyze a single .gd string

// (marked `ignore` only because `mdbook test` can't resolve the external
// `gdscript-ide` crate from a bare fenced block — the code itself is real.)
use gdscript_ide::{AnalysisHost, Change, FileId};

fn main() {
    // 1. Create a host and push a file's contents through a Change.
    //    The library never reads the filesystem itself — text is injected.
    let mut host = AnalysisHost::new();
    let file = FileId(0);

    let source = r#"
extends Node

func _ready() -> void:
    var n := 1 + 1
    print(n)
"#;

    let mut change = Change::new();
    change.change_file(file, source);
    host.apply_change(change);

    // 2. Take an immutable snapshot and run read queries.
    let analysis = host.analysis();

    // Parse + type diagnostics for the file (POD; byte offsets).
    let diagnostics = analysis.diagnostics(file).unwrap();
    for d in &diagnostics {
        println!("{:?} @ {:?}: {}", d.severity, d.range, d.message);
    }

    // Document symbols (outline) for the file.
    let symbols = analysis.document_symbols(file).unwrap();
    println!("{} top-level symbols", symbols.len());
}

The same Analysis snapshot exposes completions, hover, goto_definition, find_references, rename, signature_help, semantic_tokens, inlay_hints, and more — one method per IDE feature, each returning POD. See Consuming from Rust for the full surface, and plans/01-ARCHITECTURE.md §2 for the authoritative API sketch.

What works today

Everything above is live: parse + type diagnostics, type-aware hover and completion, the full LSP-grade query surface (signature help, folding, inlay hints, code actions, semantic tokens), scene-aware node-path typing, and a gdformat-compatible formatter. See the Roadmap for what's still ahead on the road to 1.0.

Consuming from Rust

Status: live. This is the real, shipped gdscript-ide surface (plans/ROADMAP.md tracks what's still ahead on the road to 1.0, when this surface freezes).

Native Rust consumers depend on a single crate, gdscript-ide:

cargo add gdscript-ide

This is the crate we semver most carefully — it is the contract every other consumer (the napi addon, the wasm package, the LSP server, the CLI) is built on top of.

The AnalysisHost / Analysis model

/// The single mutable owner of analysis state. One per project/workspace.
pub struct AnalysisHost { /* ... */ }

impl AnalysisHost {
    pub fn new() -> Self;
    /// The ONLY mutation entry point: apply a batch of input changes
    /// (file added/edited/removed, project config, Godot version, scenes).
    pub fn apply_change(&mut self, change: Change);
    /// A cheap, cloneable, immutable, `Send` snapshot for read queries.
    pub fn analysis(&self) -> Analysis;
}

/// An immutable snapshot. Every query is cancellable.
pub struct Analysis { /* ... */ }

Analysis exposes one method per IDE feature, each taking a FileId + byte offsets and returning POD wrapped in Cancellable<T>:

analysis.diagnostics(file)?;         // Vec<Diagnostic>
analysis.completions(pos)?;          // Vec<CompletionItem>
analysis.hover(pos)?;                // Option<HoverResult>
analysis.signature_help(pos)?;       // Option<SignatureHelp>
analysis.goto_definition(pos)?;      // Vec<NavTarget>
analysis.find_references(pos)?;      // Vec<Reference>
analysis.rename(pos, "new_name")?;   // Result<SourceChange, RenameError>
analysis.document_symbols(file)?;    // Vec<DocumentSymbol>
analysis.workspace_symbols("q")?;    // Vec<NavTarget>
analysis.semantic_tokens(file)?;     // Vec<SemanticToken>
analysis.inlay_hints(file)?;         // Vec<InlayHint>
analysis.folding_ranges(file)?;      // Vec<FoldRange>
analysis.code_actions(pos)?;         // Vec<CodeAction>
analysis.format(file)?;              // Option<String> — the reformatted file
analysis.format_range(file, s, e)?;  // Option<(u32, u32, String)>
analysis.syntax_tree(file)?;         // Option<String> — debug CST dump

The rules of the surface

  • Inputs are injected. The host owns a virtual file system; you push text via apply_change. The library never touches std::fs — this is what keeps it portable to WASM (see the portability rules in plans/01-ARCHITECTURE.md §7).
  • Outputs are POD. A Diagnostic carries a byte TextRange, a code (e.g. GDSCRIPT_UNSAFE_CALL), a severity, a message, and optional fixes — never an lsp_types::Diagnostic. You convert at your boundary.
  • Cancellation. Reads return Cancellable<T>; a concurrent apply_change cancels in-flight reads (at Tier 2 this is salsa's cancellation). Re-issue.

Why a separate crate stack

The crates are layered so each depends only downward (base → syntax → api/db → hir → ide). Most consumers only ever name gdscript-ide; the lower crates are an implementation detail you can reach for if you are building specialized tooling. The full layering is in Crate layout and ADR-0001.

Consuming from Node

Status: live. @gdscript-analyzer/core is published and this is its real, shipped surface (plans/ROADMAP.md tracks what's still ahead on the road to 1.0).

Node consumers install the napi-rs native addon:

npm i @gdscript-analyzer/core
# pnpm add @gdscript-analyzer/core

The addon is a real native .node binary built with napi-rs v3 — no WASM overhead, full native speed. This is the path that powers Node-based LSP servers, including guitkx's. Per-platform prebuilt binaries (@gdscript-analyzer/core-linux-x64-gnu, -darwin-arm64, -win32-x64-msvc, …) are pulled in automatically through optionalDependencies, so there is no compile step for consumers.

napi vs wasm

There are two thin binding crates sharing one engine-neutral core (gdscript-session, a URI-keyed session over gdscript-ide): gdscript-ffi (napi-rs v3 → the Node native addon you're reading about here) and gdscript-wasm (wasm-bindgen → the browser package). Sharing gdscript-session means the two surfaces can't drift apart — only the thin #[napi]/#[wasm_bindgen] delegation differs. For Node you almost always want the native addon (this package): it is faster, has no SharedArrayBuffer/COOP-COEP requirements, and reads files on the host side. Reach for the wasm package only for the browser or a sandboxed/edge runtime where a native addon can't load. See ADR-0003 for the original binding decision and its amendment.

The shape across the boundary

The binding keeps a stateful, URI-keyed session alive inside Rust so the analysis cache survives edits. The JS side opens/changes/closes documents by URI and runs queries by URI + byte offset; results come back as native JS objects (via napi's serde_json::Value bridge — no client-side JSON.parse). The surface is intentionally small and flat — strings and structs cross the boundary by copy, so a query returns only its feature result, never a whole AST.

import { AnalysisHandle } from "@gdscript-analyzer/core";

const az = new AnalysisHandle();
az.openDocument(
  "inmemory://player.gd",
  "extends Node\n\nfunc _ready() -> void:\n\tprint(1 + 1)\n",
  "res://player.gd", // optional res:// path, or null
);

// Byte offsets in; native JS objects out. The client maps byte offsets -> UTF-16.
const diagnostics = az.diagnostics("inmemory://player.gd");
const symbols = az.documentSymbols("inmemory://player.gd");
const hover = az.hover("inmemory://player.gd", 42);
console.log(diagnostics, symbols, hover);

AnalysisHandle also exposes changeDocument/closeDocument, setProjectConfig/setWorkspaceComplete/setWarningOverride, and one method per IDE feature (completions, signatureHelp, codeActions, gotoDefinition, findReferences, rename, workspaceSymbols, semanticTokens, foldingRanges, inlayHints, format, formatRange, syntaxTree) — see the Node package README for the full, current list.

Position encoding (the footgun)

The core emits byte offsets. LSP uses UTF-16 code units. The binding glue ships a byte→UTF-16 converter (backed by gdscript-base's LineIndex) — do the conversion at the boundary, not in your application code. This is discussed in plans/01-ARCHITECTURE.md §4.

Consuming from the Browser

Status: live. @gdscript-analyzer/wasm is published and this is its real, shipped surface — try it in the playground (plans/ROADMAP.md tracks what's still ahead on the road to 1.0).

For in-page analysis — web playgrounds, browser-based editors (Monaco / CodeMirror), or any client that can't load a native addon — there is a WebAssembly build:

npm i @gdscript-analyzer/wasm

How the wasm build is produced

The shipped route is a dedicated bindings/wasm crate built with wasm-bindgen (wasm-pack build --target web), sharing the engine-neutral gdscript-session core with the Node binding — not the napi-rs wasm32-wasip1-threads target originally sketched in ADR-0003 (see that ADR's amendment note). This keeps the artifact small with no SharedArrayBuffer/COOP-COEP requirement — the approach Biome and Ruff take. See plans/01-ARCHITECTURE.md §4.

The shape across the boundary

As in Node, the wasm module holds a stateful, URI-keyed Analyzer session and returns results as native JS values (via serde-wasm-bindgen, JSON-compatible mode) per query. Strings and structs cross the boundary by copy, so a query returns only its feature result — never a full AST per call.

import init, { Analyzer } from "@gdscript-analyzer/wasm";

await init(); // load + instantiate the .wasm module

const az = new Analyzer();
az.openDocument("inmemory://main.gd", "extends Node\nfunc f(): pass\n", null);
const diagnostics = az.diagnostics("inmemory://main.gd"); // byte offsets in, native JS out

Analyzer also exposes changeDocument/closeDocument, setProjectConfig/setWorkspaceComplete/setWarningOverride, loadEngineApi(bytes) (to enable engine-class completion/hover — see below), and one method per IDE feature (completions, hover, signatureHelp, codeActions, gotoDefinition, findReferences, rename, workspaceSymbols, semanticTokens, foldingRanges, inlayHints, format, formatRange, syntaxTree) — see the wasm package README for the full, current list.

Engine data is shipped separately

The Godot engine model (extension_api.json) is several megabytes, so it is not include_bytes!'d into the wasm module. Fetch the bundled binary blob and hand it to loadEngineApi(bytes) to enable engine-class completion/hover — see the wasm package README for where to source it.

Portability is enforced from day one

The core crates must compile to wasm32 — no std::fs, no Instant::now()/SystemTime::now(), no threads in the hot path, and getrandom's JS backend only in the wasm binding. CI runs cargo check -p gdscript-ide --target wasm32-unknown-unknown on every PR. The full rules are in plans/01-ARCHITECTURE.md §7.

Editor / LSP Client Integration

Status: live. The standalone LSP server (gdscript-lsp) is shipped and spec-compliant. The per-editor setup snippets below are still being filled in — modeled on rust-analyzer's "Other Editors" documentation — contributions welcome (see CONTRIBUTING.md).

Because gdscript-analyzer is a library, not a server, there are two distinct ways to integrate it into an editor.

Option A — use the standalone LSP server

gdscript-lsp is a real, standalone, spec-compliant Language Server. Unlike the engine's built-in LSP, it does not require a running Godot editor, and it adds features the engine LSP lacks (semantic tokens, inlay hints, workspace symbols, rename). You point any LSP-capable editor at the server binary:

  • VS Code — a thin extension that spawns the server.
  • Neovimnvim-lspconfig / the built-in client.
  • Helix, Zed, Emacs (eglot/lsp-mode), Sublime (LSP) — standard LSP client configuration pointing at the gdscript-lsp executable.

Concrete per-editor setup snippets are being filled in here as they're validated.

Option B — embed the library directly

If you are building a tool that isn't an editor — a CI checker, a markup toolchain like guitkx, a web playground, or a custom intelligence feature — you embed the library rather than speaking LSP:

This is the path guitkx takes: it needs GDScript intelligence inside markup {expr} blocks via a source-map adapter — an analysis need, not an LSP need — served by the same library.

What a client is responsible for

Whichever option you choose, the client owns the protocol mapping the library deliberately stays out of:

  • Byte offsets → UTF-16. The core emits byte offsets; LSP uses UTF-16 code units. Convert at the boundary using the shipped converter.
  • POD codes → protocol shapes. A Diagnostic's code/severity/range maps to your protocol's diagnostic type.
  • Re-issuing cancelled reads. A concurrent edit cancels in-flight queries; the client re-issues.

See plans/01-ARCHITECTURE.md §2 for the full contract.

Configuration

The analyzer reads its project model and its warning configuration from your project.godot — the same file Godot uses. There is no separate analyzer config file; point a tool at a directory containing project.godot (or pass it explicitly) and the settings below apply. See the full list of codes in the Warning Reference.

Where settings come from

Settingproject.godot locationEffect
Master switch[debug]gdscript/warnings/enablefalse silences all warnings.
Treat as errors[debug]gdscript/warnings/treat_warnings_as_errorsEscalates every Warn to Error.
Exclude addons[debug]gdscript/warnings/exclude_addonsSuppresses warnings under res://addons/**.
Per-code level[debug]gdscript/warnings/<key>0 = ignore, 1 = warn, 2 = error.
Engine version[application]config/featuresGates version-specific (master-only) codes.

<key> is the code's lowercased name — e.g. INTEGER_DIVISIONgdscript/warnings/integer_division.

[application]
config/features=PackedStringArray("4.5")

[debug]
gdscript/warnings/enable=true
gdscript/warnings/treat_warnings_as_errors=false
gdscript/warnings/exclude_addons=true
gdscript/warnings/integer_division=2      ; promote to an error
gdscript/warnings/unused_parameter=0      ; silence

Default levels: standalone vs project

  • With a project.godot the analyzer follows Godot's own defaults (default_warning_levels): the type-strictness group (UNSAFE_*, UNTYPED_DECLARATION, …) is ignored by default, exactly like the engine. Your project.godot overrides win.
  • Standalone (no project.godot — a single file, a quick CLI check) the analyzer runs strict: the opt-in type-strictness group is promoted to Warn, so you see the analyzer's full value without configuring anything.

The per-code engine default and the earliest applicable Godot version are listed for every code in the Warning Reference.

Inline suppression — @warning_ignore

Suppress a warning at the source, exactly like Godot:

@warning_ignore("integer_division")
var ticks := total / per_second        # the next statement only

@warning_ignore_start("unused_parameter")
func _process(delta):                  # a region …
    pass
@warning_ignore_restore("unused_parameter")   # … until restored (or end of file)
  • @warning_ignore("a", "b") suppresses the listed codes over the single following statement/declaration.
  • @warning_ignore_start("a")@warning_ignore_restore("a") suppress a region; an unrestored _start runs to the end of the file.
  • The argument is the setting key (the lowercased code name). Unknown names are currently ignored.

Precedence

For a given warning, the resolved level is decided in this order: master switchper-code level (explicit override, else the engine/standalone default) → treat-as-errors (Warn → Error) → scope (exclude_addons) → @warning_ignore (overrides everything). The analyzer-native diagnostics that have no engine setting key — TYPE_MISMATCH, INVALID_NODE_PATH, CYCLIC_INHERITANCE — are always reported and are not gated.

Warning Reference

Every gateable GDScript warning the analyzer can emit, with its project.godot setting key, engine-default level, and the earliest Godot version it applies to. Configure these under [debug] as gdscript/warnings/<key> (0 = ignore, 1 = warn, 2 = error), or suppress inline with @warning_ignore("<key>"). See Configuration.

CodeSetting keyDefaultSinceDescription
ASSERT_ALWAYS_FALSEassert_always_falseWarn4.3An assert(...) condition is always false.
ASSERT_ALWAYS_TRUEassert_always_trueWarn4.3An assert(...) condition is always true.
CONFUSABLE_CAPTURE_REASSIGNMENTconfusable_capture_reassignmentWarn4.3A captured variable is reassigned inside a lambda.
CONFUSABLE_IDENTIFIERconfusable_identifierWarn4.3An identifier mixes scripts / uses confusable characters.
CONFUSABLE_LOCAL_DECLARATIONconfusable_local_declarationWarn4.3A local is declared after a same-name outer use.
CONFUSABLE_LOCAL_USAGEconfusable_local_usageWarn4.3A local shadowing a member is used before its declaration.
CONFUSABLE_TEMPORARY_MODIFICATIONconfusable_temporary_modificationWarnmasterA temporary value is modified in place.
CONSTANT_USED_AS_FUNCTIONconstant_used_as_functionWarn4.3A constant is called as if it were a function.
DEPRECATED_KEYWORDdeprecated_keywordWarn4.3A deprecated keyword (e.g. yield) is used.
EMPTY_FILEempty_fileWarn4.3The script file has no members, class_name, or extends.
ENUM_VARIABLE_WITHOUT_DEFAULTenum_variable_without_defaultWarn4.3An enum-typed variable has no explicit default value.
FUNCTION_USED_AS_PROPERTYfunction_used_as_propertyWarn4.3A function is accessed as if it were a property.
GET_NODE_DEFAULT_WITHOUT_ONREADYget_node_default_without_onreadyError4.3A get_node(...) default initializer should be @onready.
INCOMPATIBLE_TERNARYincompatible_ternaryWarn4.3The two values of a ternary conditional have no common type.
INFERENCE_ON_VARIANTinference_on_variantError4.3A type is inferred from a statically-Variant value.
INFERRED_DECLARATIONinferred_declarationIgnore4.3A declaration uses an inferred type (:=).
INTEGER_DIVISIONinteger_divisionWarn4.3Integer division discards the fractional part.
INT_AS_ENUM_WITHOUT_CASTint_as_enum_without_castWarn4.3An integer is assigned to an enum value without a cast.
INT_AS_ENUM_WITHOUT_MATCHint_as_enum_without_matchWarn4.3An integer is compared to an enum value in a match.
MISSING_AWAITmissing_awaitIgnoremasterAn awaitable call's result is not awaited.
MISSING_TOOLmissing_toolWarn4.3A class extends a @tool class but is not itself @tool.
NARROWING_CONVERSIONnarrowing_conversionWarn4.3A float is stored into an int, losing precision.
NATIVE_METHOD_OVERRIDEnative_method_overrideError4.3A native virtual method is overridden with an incompatible signature.
ONREADY_WITH_EXPORTonready_with_exportError4.3@onready and @export are used together on one member.
PROPERTY_USED_AS_FUNCTIONproperty_used_as_functionWarn4.3A property is called as if it were a function.
REDUNDANT_AWAITredundant_awaitWarn4.3await is applied to a non-coroutine, non-signal value.
REDUNDANT_STATIC_UNLOADredundant_static_unloadWarn4.3@static_unload is used on a class with no static variables.
RETURN_VALUE_DISCARDEDreturn_value_discardedIgnore4.3A non-void call's return value is discarded.
SHADOWED_GLOBAL_IDENTIFIERshadowed_global_identifierWarn4.3A class_name, member, or local shadows a global identifier.
SHADOWED_VARIABLEshadowed_variableWarn4.3A local shadows an outer local or parameter.
SHADOWED_VARIABLE_BASE_CLASSshadowed_variable_base_classWarn4.3A member shadows a member of a base class.
STANDALONE_EXPRESSIONstandalone_expressionWarn4.3An expression statement has no effect.
STANDALONE_TERNARYstandalone_ternaryWarn4.3A ternary conditional is used as a statement; its value is discarded.
STATIC_CALLED_ON_INSTANCEstatic_called_on_instanceWarn4.3A static method is called through an instance.
TOO_FEW_ARGUMENTStoo_few_argumentsError4.3A call passes fewer arguments than the callee's required parameters (a compile error in Godot). Only statically-resolved signatures are checked.
TOO_MANY_ARGUMENTStoo_many_argumentsError4.3A call passes more arguments than the callee accepts (a compile error in Godot). Variadic callees are never flagged.
UNASSIGNED_VARIABLEunassigned_variableWarn4.3An untyped or enum-typed local is read before it is assigned a value (a typed local is zero-initialized).
UNASSIGNED_VARIABLE_OP_ASSIGNunassigned_variable_op_assignWarn4.3A compound assignment (+=, …) is applied to a still-unassigned local.
UNDEFINED_FUNCTIONundefined_functionError4.3A called function is not defined anywhere in the loaded project (a compile error in Godot). Analyzer-specific code; fires only when the loader declared the workspace complete.
UNDEFINED_IDENTIFIERundefined_identifierError4.3An identifier is not declared anywhere in the loaded project (a compile error in Godot). Analyzer-specific code; fires only when the loader declared the workspace complete.
UNDEFINED_METHODundefined_methodError4.3A method called on a built-in type does not exist on it (a compile error in Godot; the bundled built-in tables are closed, so no completeness claim is needed).
UNDEFINED_PROPERTYundefined_propertyError4.3A property accessed on a built-in type does not exist on it (a compile error in Godot; the bundled built-in tables are closed, so no completeness claim is needed).
UNREACHABLE_CODEunreachable_codeWarn4.3A statement follows an unconditional return/break/continue (or an exhaustive match).
UNREACHABLE_PATTERNunreachable_patternWarn4.3A match pattern can never match (it follows a wildcard).
UNSAFE_CALL_ARGUMENTunsafe_call_argumentIgnore4.3An argument needs an unsafe implicit cast into the parameter type.
UNSAFE_CASTunsafe_castIgnore4.3A value is cast through Variant, which is unsafe.
UNSAFE_METHOD_ACCESSunsafe_method_accessIgnore4.3A method is not present on the inferred type (but may be on a subtype).
UNSAFE_PROPERTY_ACCESSunsafe_property_accessIgnore4.3A property is not present on the inferred type (but may be on a subtype).
UNSAFE_VOID_RETURNunsafe_void_returnWarn4.3A Variant value is returned from a -> void function.
UNTYPED_DECLARATIONuntyped_declarationIgnore4.3A declaration has no type annotation.
UNUSED_LOCAL_CONSTANTunused_local_constantWarn4.3A local constant is declared but never read.
UNUSED_PARAMETERunused_parameterWarn4.3A function parameter is never used (prefix it with _).
UNUSED_PRIVATE_CLASS_VARIABLEunused_private_class_variableWarn4.3A _-prefixed class member is never read within the class.
UNUSED_SIGNALunused_signalWarn4.3A signal is never emitted or connected in the file.
UNUSED_VARIABLEunused_variableWarn4.3A local variable is declared but never read.

Architecture

This page is a short orientation. The authoritative technical reference is plans/01-ARCHITECTURE.md, which fixes the crate layering, the public API shape, the FFI/WASM strategy, the incremental-computation plan, the engine data model, and the portability rules. Read it before making architecturally consequential changes — and record any such decision as a new ADR.

The big picture

gdscript-analyzer copies rust-analyzer's proven discipline:

  1. Layered crates, depending only downward. Lower crates know nothing about LSP or FFI. See Crate layout.
  2. A protocol-neutral analysis API. gdscript-ide exposes AnalysisHost + immutable Analysis snapshots; every result is POD with byte offsets, never lsp-types. Clients map POD → their protocol. (ADR-0001.)
  3. A parser we own. A hand-written, lossless, error-recovering recursive- descent parser producing a cstree CST. tree-sitter-gdscript is only the MVP bootstrap and a permanent differential test oracle, never the grammar-of-record. (ADR-0002.)
  4. One binding, two targets. A single gdscript-ffi crate compiles via napi-rs v3 to both a Node .node addon and a wasm32 target. (ADR-0003.)

Cross-cutting invariants

  • The core is portable to WASM. No std::fs, no Instant::now() / SystemTime::now(), no threads in the hot path, getrandom's JS backend only in the wasm binding. File contents and clocks are injected. CI enforces this with cargo check -p gdscript-ide --target wasm32-unknown-unknown on every PR — the single most important Phase-0 invariant after "it compiles."
  • Engine-neutral results. The library returns byte offsets + POD structs; clients convert to UTF-16 and their protocol shapes.
  • Stay synced with Godot, automatically. The engine model is generated from extension_api.json + doc XML and kept current by a sync workflow.
  • Incremental, later. The MVP recomputes whole files (they are small); salsa is adopted at Phase 3 when cross-file resolution makes per-keystroke full recompute untenable. Every derived computation is written as a pure (db, file) -> value function so the swap is localized.

Where to go next

Crate layout

The workspace is a flat crates/* virtual workspace (matklad's "Large Rust Workspaces"), with thin bindings/{node,wasm} packages and an xtask/ build crate. Each crate depends only downward — lower crates know nothing about the layers above them. The authoritative version is in plans/01-ARCHITECTURE.md §1.

The layer table

CrateResponsibilityDepends onwasm-safe?
gdscript-basePOD types: FileId, TextSize/TextRange, LineIndex, position/range conversions, the serde result structs shared with clients. No logic.
gdscript-syntaxLexer (logos) + indentation pre-pass + hand-written recursive-descent parser → lossless cstree CST + typed AST. Error recovery.base
gdscript-apiThe Godot engine model generated from extension_api.json + doc XML: classes, inheritance, methods, properties, signals, enums, singletons, utility functions, builtins — plus the hand-authored GDScript layer (keywords, annotations, builtins) the dump omits.base
gdscript-scene.tscn/.tres text parser → node-tree model for $Path/%Unique node-path typing.base
gdscript-fmtA gdformat-compatible formatter: format/format_range, safe-by-construction (falls back to the original source if it can't prove the reformat is meaning-preserving).base, syntax
gdscript-dbInput layer: a virtual file system (FileId → text, injected, never std::fs), the project model, apply_change. Salsa inputs + tracked queries for incremental recompute.base, syntax, api
gdscript-hirSemantic layer: lower AST → HIR, scope tree, name resolution, gradual type inference, the GDScript warning checks (62 codes and counting).base, syntax, api, db
gdscript-ideThe feature layer and public API: AnalysisHost + immutable Analysis, one method per IDE feature, POD results. The crate external Rust consumers depend on, and the wasm-check target.all above
gdscript-sessionInternal URI-keyed layer over gdscript-ideserde_json::Value results (document lifecycle, JSON serialization). Unit-tested natively; not a stable API — consumers depend on gdscript-ide. Shared by the napi and wasm bindings so they can't drift apart.ide
gdscript-ffiThe napi-rs v3 Node binding (@gdscript-analyzer/core) — a thin #[napi] delegator over gdscript-session. publish = false (packaged via bindings/node).sessionn/a (is the binding)
gdscript-wasm (bindings/wasm)The wasm-bindgen browser binding (@gdscript-analyzer/wasm) — a thin #[wasm_bindgen] delegator over gdscript-session.sessionn/a (is the binding)
gdscript-lspA real, standalone, spec-compliant LSP server binary. The only place that knows lsp-types/JSON-RPC. publish = false.idenative
gdscript-clicheck/lint/format/symbols for CI/pre-commit. publish = false.idenative
xtaskBuild automation: codegen-api, fixtures, dist, release helpers, the local ci gate.native

Dependency direction

base ◀── syntax ◀── db ◀── hir ◀── ide ◀── session ◀── ffi ◀── (bindings/node)
  ▲         ▲       ▲       ▲       ▲          └────── wasm ◀── (bindings/wasm)
  ├── api ──┘───────┘───────┘       ├── lsp
  ├── scene ────────────────────────┘
  └── fmt ──────────────────────────┘── cli

A crate may only use crates to its left. Adding an upward edge is an architectural change and should be questioned in review.

Current state

Every crate above is real, shipped code — not a stub. The core crates compile, lint, test, and pass the wasm portability check with substantial domain logic behind them (parsing, inference, warnings, formatting, scene typing, incremental recompute); gdscript-ffi/gdscript-wasm/gdscript-lsp/gdscript-cli are published binaries/packages. See CLAUDE.md and plans/ROADMAP.md for the current phase-by-phase status.

Publishing note

Internal crate names use the gdscript- prefix. The public Rust crate is gdscript-ide. The npm scope is @gdscript-analyzer/*. Non-library crates (gdscript-ffi, gdscript-session, gdscript-lsp, gdscript-cli, the bindings) carry publish = false — they ship as binaries or npm packages instead of crates.io crates.

Build & test

The exact commands a brand-new contributor runs. These mirror Workstream G of plans/PHASE-0-ECOSYSTEM-AND-TOOLING.md and the CONTRIBUTING.md at the repo root.

0. Prerequisites

You need rustup, Node ≥ 20, pnpm, and the napi-rs CLI. The pinned toolchain and the wasm32-unknown-unknown target auto-install from rust-toolchain.toml on first build. The cargo plugins are auto-installed by CI; install them locally as needed:

rustup show                                   # confirms toolchain + wasm32 target
cargo install cargo-deny cargo-llvm-cov cargo-hack
npm i -g @napi-rs/cli pnpm

The MSRV is 1.88.0 (the napi-rs v3 floor); CI checks the whole workspace against it with cargo hack check --rust-version.

1. Clone

git clone https://github.com/yanivkalfa/gdscript-analyzer
cd gdscript-analyzer

2. Build + test the workspace

cargo build --workspace
cargo test  --workspace

3. Run the full local gate

cargo xtask ci is the one-shot gate that mirrors ci.yml exactly: cargo fmt --checkcargo clippy -D warningscargo test --workspace → the wasm portability check → cargo deny check. This is the command the exit criteria reference — it must be green before you open a PR.

cargo xtask ci

4. The portability guard (on its own)

The single most important invariant after "it compiles": the public surface must build for the browser target.

cargo check -p gdscript-ide --target wasm32-unknown-unknown   # or: cargo wasm-check

5. Regenerate the engine-data artifact

cargo xtask codegen-api    # reads vendor/godot/<version>/extension_api.json

6. Build the napi (Node) package

cd bindings/node && pnpm install && napi build --platform --release && cd ../..
# or build all artifacts at once:
cargo xtask dist

7. Build the wasm package

# route A (primary): napi-rs wasm target, in bindings/node
napi build --platform --release --target wasm32-wasip1-threads
# route B (fallback): wasm-bindgen
wasm-pack build bindings/wasm --target web --profile wasm-release

8. Serve the docs

mdbook serve docs        # http://localhost:3000
# CI additionally runs `mdbook test` (validates Rust samples) + mdbook-linkcheck

Bootstrap checklist

  • rustup show lists the pinned stable toolchain + wasm32-unknown-unknown.
  • Node ≥ 20, pnpm, @napi-rs/cli installed.
  • cargo build --workspace succeeds.
  • cargo xtask ci is green (fmt, clippy, test, wasm-check, deny).
  • cargo xtask codegen-api produces the engine-data artifact.
  • cargo xtask dist builds the napi + wasm stubs.
  • mdbook serve docs renders this guide.
  • You have read Architecture, plans/ROADMAP.md, and the ADRs.

Conventions

  • Conventional Commits for PR titles (feat(syntax): …, fix(ide): …, !/BREAKING CHANGE: for breaks). Squash-merge uses the PR title.
  • Changesets are required for user-facing @gdscript-analyzer/* npm changes (pnpm changeset) — the Rust side derives its bump from commits, but the npm side reads .changeset/*.md.

Architecture Decision Records

An Architecture Decision Record (ADR) captures a single architecturally consequential decision — the context that forced it, the decision itself, and the consequences that follow — as a short, immutable, numbered document. The format here is Michael Nygard's: Title / Status / Context / Decision / Consequences. See the template.

ADRs are how we make the why behind the architecture durable. Code shows what we did; an ADR explains why we did it, what we considered, and what we gave up — so future contributors don't relitigate settled questions or accidentally violate an invariant without knowing it was deliberate.

The index

ADRTitleStatus
0001Rust + library-not-serverAccepted
0002Hand-written parser, tree-sitter as oracleAccepted
0003napi-rs v3 dual-target bindingAccepted
0004Lightweight RFC process + graduation triggerAccepted

The process

  1. When. Any decision that constrains the architecture — a crate boundary, a dependency with reach (parser, binding, incremental engine), a portability rule, the public API contract, the versioning model — lands as an ADR. Reversible, local choices do not need one.
  2. How. Copy template.md to the next number (NNNN-short-kebab-title.md), fill in Context / Decision / Consequences, add it to the index above and to SUMMARY.md, and submit it in the same PR as the change it justifies.
  3. Status lifecycle. ProposedAccepted (merged) → later possibly Deprecated or Superseded by ADR-NNNN. ADRs are append-only: you don't rewrite history, you supersede it with a new record that links back.
  4. Source. The three seeded ADRs distill decisions already settled in plans/00-VISION-AND-SCOPE.md and plans/01-ARCHITECTURE.md.

ADR-NNNN: short decision title

  • Status: Proposed | Accepted | Deprecated | Superseded by ADR-NNNN
  • Date: YYYY-MM-DD

Context

What is the situation that forces a decision? State the problem, the constraints, the forces in tension, and the options considered. This section is descriptive and value-neutral — anyone reading it should understand why a decision was necessary without yet knowing what we chose. Link to the relevant plan docs and research notes.

Decision

The decision, stated in active voice: "We will …". Be specific and singular — one ADR, one decision. Note the chief alternatives that were rejected and, in a sentence each, why.

Consequences

What becomes easier and what becomes harder as a result — both positive and negative, and any follow-on work or new constraints the decision creates. Describe the resulting context after the decision is applied, so a future reader can judge whether the trade-off still holds.

ADR-0001: Rust + library-not-server

  • Status: Accepted
  • Date: 2026-06-22

Context

The motivating problem is that there is no way to get semantic GDScript intelligence without a running Godot editor. Godot's built-in LSP requires the editor process and a TCP connection (:6005); every other tool in the space is syntactic-only, runtime-locked (Python, .NET), or otherwise editor-bound. The landscape analysis in plans/00-VISION-AND-SCOPE.md §2 found exactly one empty quadrant: semantic-grade + engine-independent + multi-target + library-first. Filling it requires two foundational choices.

Implementation language. A reusable analysis core must reach native (CLI, CI), Node (LSP servers, including guitkx's), the browser (web playgrounds), and ideally other languages (Python, C ABI). It must also be fast enough for keystroke-latency analysis on real projects, and credible enough to attract contributors to "the foundation." Candidates weighed were Rust, TypeScript, C#, and Python.

Shape. Even granting the language, the engine could be built as an LSP server (speaks JSON-RPC, owns the editor protocol) or as a protocol-neutral library (takes file contents + offsets, returns plain data). The first client, guitkx, needs GDScript intelligence inside markup {expr} blocks via a source-map adapter — which is an analysis need, not an LSP need. A server-shaped core could not serve it without contortions.

Decision

We will build gdscript-analyzer in Rust, as an engine- and protocol-neutral library — not as an LSP server.

  • Rust, because it reaches a superset of TypeScript's targets — native, Node via napi-rs, browser via WASM, other languages via PyO3 / C ABI — at full speed, and because every modern reusable analyzer (rust-analyzer, Biome, Ruff, oxc, swc) is Rust, which is itself a credibility and contribution signal. (TypeScript would run the analyzer on V8; C# is .NET-locked; Python is too slow and runtime-bound.)
  • A library, following rust-analyzer's discipline: the analysis engine takes a FileId + byte offsets and returns POD (plain-old-data, serde-serializable) results. It knows nothing about LSP, JSON-RPC, or any editor. The public surface is AnalysisHost + immutable Analysis snapshots in the gdscript-ide crate. Clients — a standalone LSP server, a CLI, a web playground, the guitkx adapter — each map our neutral results onto their own protocol. The LSP server is just one client, not the core.

See plans/01-ARCHITECTURE.md §1–2.

Consequences

Easier / positive.

  • One core reaches every target; no per-target rewrite.
  • guitkx becomes a first-class client (source-map adapter over the same library), which is the project's primary validation harness.
  • Results are protocol-neutral POD, so a CLI, an LSP server, and a browser playground all consume the identical API — and we can swap or add protocols without touching the analysis core.
  • Being Rust gives native performance and access to the proven analyzer ecosystem (cstree, salsa, logos).

Harder / negative — the constraints this creates.

  • Strict portability rules. Because the core must compile to wasm32, it may not use std::fs, Instant::now()/SystemTime::now(), or threads in the hot path. File contents and clocks are injected; the client (or the native binding) does the I/O. CI enforces this with cargo check -p gdscript-ide --target wasm32-unknown-unknown on every PR.
  • A position-encoding seam. The core emits byte offsets; LSP wants UTF-16. Each client converts at its boundary (a known footgun, handled in gdscript-base's LineIndex).
  • No lsp-types in the core, ever. Diagnostics carry our own codes and byte ranges; clients translate. This is more glue per client but keeps the contract clean.
  • Rust's compile times and learning curve are a contributor cost we accept for the reach and performance.

ADR-0002: Hand-written parser, tree-sitter as oracle

  • Status: Accepted
  • Date: 2026-06-22

Context

A "Roslyn for Godot" needs a parser it fully controls: lossless (every byte, including comments and whitespace, recoverable from the tree), error-recovering (an IDE parses broken code on every keystroke), and able to produce precise diagnostics. GDScript adds a specific hazard — Python-like significant indentation — that must be handled deliberately. See plans/01-ARCHITECTURE.md §6 and the parsing-strategy research it cites.

There is an existing grammar, tree-sitter-gdscript (MIT, mature, with Rust and WASM bindings). It is tempting to adopt it as the parser. But tree-sitter has properties that disqualify it as the grammar-of-record for this project: it is effectively single-maintainer, is manually synced to Godot (so it lags the engine), models comments lossily, and gives us limited control over error recovery and diagnostic quality. At the same time, throwing it away entirely would forfeit a valuable, independent reference implementation we could test against.

The forces in tension: ship something parsing quickly vs. own the grammar long-term, and avoid reinventing a grammar vs. not depending on an external grammar we can't steer.

Decision

We will own a hand-written, lossless, error-recovering recursive-descent parser, and use tree-sitter-gdscript only as a bootstrap and a permanent differential test oracle — never as the grammar-of-record.

  • End state: a hand-written recursive-descent parser producing a cstree CST (chosen over rowan for Send + Sync + interning, which suits our concurrency), with a typed AST layer on top. The lexer is logos-based, with a hand-written indentation pre-pass that injects INDENT/DEDENT/NEWLINE (indent stack + bracket-depth counter to suppress significance inside ()[]{}, backslash line-continuation, tab/space rules), isolating the significant-indentation risk into one tested module.
  • A Parser trait sits in front of the implementation. For a week-1 MVP we may wrap tree-sitter-gdscript behind that trait to get something parsing immediately, then swap in our hand-written backend.
  • tree-sitter is demoted to a permanent oracle: we run differential tests comparing our trees against tree-sitter's on a large corpus. It is the reference grammar and the regression net — never the grammar we ship.

Consequences

Easier / positive.

  • Full control over grammar, error recovery, losslessness, and diagnostic messages — the prerequisites for an IDE-grade analyzer and for matching Godot's own warning messages later.
  • cstree gives us a concurrent-friendly, interned CST that fits the AnalysisHost/Analysis snapshot model.
  • The Parser trait lets Phase 1 start producing real output (document symbols, folding) via the tree-sitter backend before the hand-written parser is finished — value-earliest, with a safe migration path.
  • Keeping tree-sitter as a differential oracle gives us an independent, continuously-run correctness check that catches grammar regressions for the life of the project.

Harder / negative — the constraints this creates.

  • Writing and maintaining a hand-written parser with good error recovery is substantial, ongoing work (this is essentially all of Phase 1).
  • The indentation pre-pass is subtle and must be thoroughly fixture-tested; it is the highest-risk part of the lexer.
  • We must keep the tree-sitter dependency (and its attribution — the verbatim Copyright (c) 2016 Max Brunsfeld line in THIRD-PARTY-NOTICES.md) for as long as it serves as the oracle, even though it is not shipped as the parser.
  • Differential testing requires reconciling two trees with different shapes, which itself needs a normalization layer.

ADR-0003: napi-rs v3 dual-target binding

  • Status: Accepted
  • Date: 2026-06-22

Context

The Rust core must be consumable from Node (LSP servers, including guitkx's, and CLIs that live in the JS ecosystem) and from the browser (web playgrounds, in-page analysis). These are two different runtime targets with different constraints:

  • Node wants a native addon — full speed, no WASM overhead, free filesystem access on the host side.
  • The browser needs WebAssembly, and ideally a small artifact that does not require SharedArrayBuffer / COOP-COEP headers (which many static hosts can't set).

The naive approach is to write and maintain two separate bindings (a napi addon and a wasm-bindgen module), duplicating the FFI surface and its serialization glue. That doubles maintenance and invites the two surfaces to drift. See plans/01-ARCHITECTURE.md §4 and the WASM/bindings research it cites.

A key enabling fact: napi-rs v3 can compile the same binding source to both a Node native .node addon and a wasm32-wasip1-threads target — "you don't need to write two different bindings." This collapses the duplication, at the cost of pinning the MSRV to napi-rs v3's floor (Rust 1.88.0).

Decision

We will write one binding crate, gdscript-ffi, on napi-rs v3, and compile it to both the Node .node addon and the wasm32 target from a single source. wasm-bindgen is kept as a documented, optional fallback — not the primary path.

  • gdscript-ffi is the only crate with napi/wasm glue. It holds a stateful AnalysisHandle (so the analysis cache survives edits across calls), exposes a small, flat surface (applyChange, per-feature queries, plus a stateless one-shot analyze), and passes JSON POD by copy across the boundary (serde / serde-wasm-bindgen). It never returns a whole AST per call — only the feature result.
  • Node consumers get the native addon (@gdscript-analyzer/core) with per-platform prebuilt binaries via optionalDependencies.
  • Browser consumers get the napi-rs wasm target as the primary route.
  • Fallback: a dedicated bindings/wasm crate using wasm-bindgen (wasm-pack build --target web) is retained and documented, to be used if and when we want a smaller artifact with no SharedArrayBuffer requirement (Biome / Ruff's approach). The choice between the napi-wasm target and the wasm-bindgen fallback is made per measured bundle size in Phase 5.
  • MSRV is pinned to 1.88.0 — napi-rs v3's floor — for the whole workspace, and CI enforces it.

Consequences

Easier / positive.

  • One FFI surface to write, test, and evolve — Node and browser cannot drift apart because they are the same source.
  • Native speed on Node (the guitkx / LSP path) with no WASM penalty.
  • The stateful AnalysisHandle keeps the incremental cache alive across edits on both targets, which is what makes keystroke-latency analysis possible from JS.
  • Keeping wasm-bindgen as a documented fallback means we are not locked in if the napi-wasm artifact turns out too large or too constrained for static hosting.

Harder / negative — the constraints this creates.

  • MSRV is dictated by the binding (1.88.0). A core-crate dependency that raises its MSRV above ours fails the msrv CI job; MSRV bumps are deliberate and ADR-worthy.
  • The napi cross-compile matrix (zig, musl, QEMU, per-platform packaging) is fragile toolchain-wise; we mitigate by wiring it in Phase 0 against an empty binding so failures are toolchain-only, not logic.
  • Maintaining the wasm-bindgen fallback is a second (if dormant) path to keep compiling.
  • The "strings and structs cross by copy" rule must be respected by every query — returning large payloads (e.g. a full AST) per call would be a performance cliff on the WASM boundary.

Amendment (as-shipped, post-Phase-5)

The napi-wasm route described above was not what shipped. In practice the browser binding is a separate crate (bindings/wasm, published as @gdscript-analyzer/wasm) built with wasm-bindgen, not the napi-rs wasm32-wasip1-threads target — i.e. the "documented fallback" above became the actual, only browser path. What was kept from this decision: one shared, engine-neutral core (gdscript-session) that both the napi binding (gdscript-ffi) and the wasm-bindgen binding (bindings/wasm) delegate to, so the two surfaces still can't drift apart — the sharing just moved one layer down (a session crate, not a single FFI crate compiled twice). The MSRV consequence (1.88.0, napi-rs v3's floor) still applies, since gdscript-ffi is still on napi-rs v3 for Node. This ADR's Context and Decision are kept verbatim as the historical record; treat this section, not the body above, as authoritative for the current architecture.

ADR-0004: A lightweight RFC process, graduating from issues only when needed

  • Status: Accepted
  • Date: 2026-06-28

Context

As the analyzer approaches 1.0 (Phase 6), it acquires a frozen public contract (gdscript-ide + the re-exported gdscript-base PODs + the FFI JSON — see the Workstream-6 API-stabilization playbook) and an outside audience. Two pressures appear:

  1. Some changes are now consequential enough to deserve a written, reviewable decision before code — a new public type, a breaking change to a result struct, a new warning that diverges from the engine, a change to the warning-gating semantics, or anything that touches the 1.0 contract.
  2. But the project is small, and a heavyweight RFC repo + formal comment-period machinery (à la rust-lang/rfcs) would be pure overhead at this scale — most changes are still fine to land straight from a well-described issue + PR.

We already have: numbered issue forms (01-bug-report, 02-feature-or-diagnostic, 03-proposal), an ADR mechanism (this directory), and labels-as-code (.github/labels.yml, including meta-rfc). The question is when a change must escalate from "an issue + a PR" to "a written proposal + an ADR", and how that escalation is triggered observably rather than by gut feel.

Decision

We will run a two-tier, issue-based RFC process — no separate rfcs repo, no formal FCP — with an explicit, observable graduation trigger:

  • Tier 1 (default): an issue + PR. Most changes. The 03-proposal form captures intent; review happens on the PR. No ADR required.

  • Tier 2 (RFC): a proposal issue labeled meta-rfc, resolved by an ADR in this directory. A change must graduate to Tier 2 when any of these objective triggers holds:

    • it adds to or changes the frozen 1.0 public surface (a new public type/enum/field, or a breaking change to one) — i.e. anything cargo-semver-checks would flag as minor/major post-1.0;
    • it adds or changes a warning's default level / identity, or introduces an intentional divergence from the engine checker (label godot-divergence);
    • it changes a cross-cutting policy: the semver policy, the warning-gating model, the supported-Godot-version matrix, or the MSRV.

    The graduation is recorded: the proposal issue gets meta-rfc, and the decision lands as a numbered ADR (Accepted/Rejected) that the PR links. The ADR — not a comment thread — is the durable record.

Rejected alternatives: a dedicated rfcs repository with a formal final-comment-period and a steering committee (too heavy for the project's size — revisit if contributor volume grows); and "no process, ADRs at author discretion" (the status quo — rejected because, post-1.0, the contract changes are exactly the ones that must not be decided implicitly in a PR diff).

Consequences

  • Easier: contributors get a bright line for when a written proposal is required (the three triggers), and reviewers can point to it. The 1.0 contract can't drift via an un-discussed PR — a surface change without a linked ADR is a review stop.
  • Easier: the audit trail is uniform — every consequential decision is a numbered ADR, discoverable in one directory, not scattered across issue comments.
  • Harder: a little more ceremony for the subset of changes that hit a trigger (write the proposal, get the ADR merged). Mitigated by keeping Tier 1 the default — the overhead applies only to genuinely contract-affecting work.
  • Follow-on: the meta-rfc label and the 03-proposal issue form are the entry points; Workstream 6's PR-time cargo-semver-checks gate is the mechanical backstop that catches a surface change whose author forgot to graduate it (the gate fails → the PR must add an ADR
    • a deliberate version bump).

ADR-0005: Absence-based diagnostics gate on a loader-asserted complete workspace

  • Status: Accepted
  • Date: 2026-07-02

Context

The analyzer's "Unknown seam" deliberately silences every unresolved name so that cross-file symbols the host has not loaded never false-flag. The cost: calling a genuinely undeclared function (usseState(0)) produces zero diagnostics — Godot itself errors on it, and the .guitkx LSP built on this analyzer cannot catch obvious typos live.

Reporting "defined nowhere" is an absence proof: it requires seeing everywhere a definition could live. No signal the database already had can establish that:

  • source_root().is_some() is true after one lone file is opened;
  • project_config().is_some() is true for a single-file CLI run (the project.godot walk-up discovery finds it) while only one file is loaded;
  • per-name registry hits (global_registry) prove presence, never absence.

Options considered: (a) emit whenever a project.godot is present (unsound — the single-file case above false-flags every cross-file class_name); (b) heuristics on the name shape (rejected outright — fragile); (c) an explicit, loader-owned completeness assertion.

Decision

We will add a complete: bool field on the SourceRoot salsa input — a claim only the loader can truthfully make — plumbed as Change::set_workspace_complete / setWorkspaceComplete through the session and the napi/wasm bindings, and gate the new UNDEFINED_FUNCTION / UNDEFINED_IDENTIFIER codes (ERROR-default: they are compile errors in Godot) on it plus per-emission guards: a top-level script class, a fully engine-native base chain, a project engine version not newer than the bundled model, and a per-name miss of every resolution tier (locals, members, engine base, engine globals, class_name registry, autoload registry).

The CLI earns the claim by loading the whole project root as context (targets keep exclusive reporting) with a Godot-faithful walk.gitignore deliberately not honored, .gdignore treated as Godot's directory marker, dot-directories skipped — and withholds it whenever any filesystem target resolves to a different project root, stdin is involved, any file fails to read, or the project contains .gdextension/C# sources (runtime-registered classes are invisible to the analyzer).

Rejected alternative: project-config presence as the gate — demonstrated unsound on the single-file invocation; validated instead against all 138 godot-demo-projects (216 first-run false positives driven to 0 by root-cause fixes, none by weakening the gate).

Consequences

Easier: the guitkx LSP (which feeds the whole project) can arm live undefined-symbol detection by one call; any host that cannot honestly claim completeness gets exactly the old silent-seam behavior — soundness by default. Harder: the claim is trust-based — a host that lies gets false positives (documented on every plumbing surface); deep CLI loads read every project .gd even for one target (the documented load→fan-out design, and per-file commands use a shallow load); and the bundled engine model must track the latest stable Godot or newer-engine projects are gated off (multi-version bundling is the standing GODOT-SYNC plan).

ADR-0006: ## @return-tuple(...) doc-tag and the synthesized Ty::Tuple

  • Status: Accepted
  • Date: 2026-07-02

Context

React-style GDScript libraries return fixed-shape pairs — ReactiveUI's useState returns [value, setter: Callable] — but GDScript has no tuple syntax: the best possible annotation, -> Array, erases the per-position types, so useState(0)[1] types as Variant and a typo'd setter method (.casll()) is uncheckable. Options considered: (a) hardcode the known library signatures in the analyzer (couples a standalone "Roslyn for Godot" to one third-party library); (b) infer function return shapes from return [a, b] bodies (violates the annotation-only return invariant and changes typing project-wide); (c) a declaration channel libraries opt into.

Decision

We will support a ## @return-tuple(T0, T1, …) doc-comment tag on any func — inert in Godot (a comment), so annotating never breaks a real build — parsed into the item tree and resolved by one shared mapping (resolve::resolve_tuple_return) on both the same-file and the cross-file (script member table) call paths. It produces a new Ty::Tuple(Vec<Ty>): a synthesized, source-only positional type no annotation can name, widen-only everywhere non-positional — it assigns exactly as its runtime Array[Variant] form, labels as Array, iterates as Variant, and exposes Array's methods — while a constant integer index projects the element's real type.

Consequences

Easier: sliced[1].casll() is checkable wherever the call resolves to the tagged signature (direct indexing, :=-inferred locals, cross-file member calls), and any library can adopt the convention without analyzer changes. Harder: an UNTYPED var s = useState(0) local is a Variant variable by GDScript semantics (only := infers) — Godot cannot check through it and neither do we until assignment-carried flow narrowing lands (tracked in TECH_DEBT.md); and the widen-only rule means a tuple never rejects anything its array form would accept, so the tag can sharpen but never break existing code.

ADR-0007: Initializer narrowing for effectively-single-assignment untyped locals

  • Status: Accepted
  • Date: 2026-07-02

Context

An untyped var s = useState(0) local is a Variant variable by GDScript semantics (only := infers), so nothing checks through it — the verbatim user shape that motivated the @return-tuple work (sliced[1].casll()) stayed silent even after ADR-0006 landed. Options considered: (a) assignment re-narrowing through the flow framework — assessed post-1.0: flow runs pre-inference, so carrying the RHS's inferred type into the facts needs a flow⇄inference fixpoint; (b) telling users to write := — a documentation answer to a tooling problem; (c) a sound subset that needs no fixpoint.

Decision

We will narrow an untyped local's binding type to its initializer's inferred type exactly when the local is effectively single-assignment: one pre-pass over the body's expression arena (collect_rebound_names, lambda bodies included) proves the name is never the root of a plain rebind (x = …, compound included) nor of an index-store chain (s[1] = …, which can re-type a tuple position). A Field step anywhere in a write target (v.x = 1) mutates the value, not the binding, and does not invalidate. Uninformative initializers (null, the cross-file seam, Variant) never narrow — Godot is silent on untyped nulls (verified on 4.7) and nullability is not this ADR's question. Because eligible locals are single-assignment, the narrowed type is the only type the binding can ever hold: it is stored directly as the binding type, with no joins, no invalidation points, and no interaction with the flow framework.

Consequences

Easier: var s = useState(0) projects s[1] as a checkable Callable (with ADR-0006 + ADR-0008 this makes the typo'd setter a default-on error, cross-file); hover/inlay show the real type instead of Variant. This is a beyond-Godot value-add of the same class as is-narrowing (Godot itself never checks through untyped locals). Harder: a rebound or index-stored local soundly falls back to Variant — checking those needs the assessed flow⇄inference fixpoint, which this ADR deliberately does not attempt. Aliased content mutation (var t = s; t[1] = x, or f(s) where the callee index-stores the array it received by reference) cannot rebind the local but CAN re-type a tuple position, so a second arena pass (collect_escaping_names) widens any tuple-typed binding whose bare name appears outside the alias-free read positions (an Index base, a Field receiver, a Call callee, a return operand) to its runtime Array[Variant] form — positional projections never survive a possible aliased store, for :=-inferred tuples as well. Corpus over 138 godot-demo-projects: zero new diagnostics versus the 0.5.3 baseline.

ADR-0008: Member misses on built-in receivers are errors (UNDEFINED_METHOD / UNDEFINED_PROPERTY)

  • Status: Accepted
  • Date: 2026-07-02

Context

c.casll() on a Callable emitted only the opt-in UNSAFE_METHOD_ACCESS — silent under project defaults — while Godot itself hard-errors (Function "casll()" not found in base Callable). Probed on 4.7 with --check-only: builtin receivers error for methods and properties, typed and :=-inferred alike; Object and script receivers stay silent (a script can attach members at runtime); Dictionary property access is silent for any name, reads and writes (keyed-subscript sugar), while a Dictionary method miss still errors. The TECH_DEBT "closed-builtin receiver severity study" asked exactly this question.

Decision

We will emit UNDEFINED_METHOD / UNDEFINED_PROPERTY (ERROR by default) from builtin_member_ty when a member miss occurs on a built-in receiver. Unlike UNDEFINED_FUNCTION/UNDEFINED_IDENTIFIER (ADR-0005), these need no workspace-completeness claim: the builtin member tables ship with the analyzer and are closed. Gates: a project declaring a newer engine than the bundled model falls back to the opt-in UNSAFE_* (the member may exist there); Nil receivers stay silent (a nullability question, not member existence). Keyed builtins short-circuit the whole property path: d.some_key on a Dictionary is subscript sugar typed as the dictionary's value type, and the key wins even over real method names ({"size": 99}.size is 99 at runtime — probed) — never a diagnostic. A Dictionary method miss still errors: method-call sugar does not dispatch to callable values (d.greet() crashes at runtime even when the key holds a Callable — probed), so flagging it is a true positive. Object and script receivers keep the opt-in UNSAFE_* (Godot parity).

Consequences

Easier: the .casll() class of typo is a default-on error with a precise squiggle — including Godot-3→4 renames (upper() vs to_upper()) — and dict.key now types as the value type instead of Variant. Harder: strict-mode users lose the (false) UNSAFE_PROPERTY_ACCESS they previously saw on Dictionary keyed access — that was a pre-existing false positive, now fixed; and a project on a newer engine gets the weaker opt-in signal for genuinely-new members, by design. Corpus proof: the first run surfaced 111 UNDEFINED_PROPERTY hits across 138 godot-demo-projects — all of them the Dictionary sugar shape — and zero after the keyed short-circuit; zero UNDEFINED_METHOD false positives throughout.

Addendum (2026-07-03 — T4.1 message parity)

Godot 4.7 prints two lines for one builtin method miss — Cannot find member "casll" in base "Callable". (its member check) and Function "casll()" not found in base Callable. (its call check) — and only the member line for a property miss; a Dictionary method miss gets only the call line (the keyed sugar absorbs the member check). Earlier notes each quoted ONE of the two as "the" native phrasing and looked contradictory — both were real. The analyzer emits one diagnostic per site and mirrors the kind-matching text verbatim: the call-check wording for UNDEFINED_METHOD, the member wording for UNDEFINED_PROPERTY. The golden table (crates/gdscript-hir/src/godot_messages_tests.rs) pins both against the probed binary.