Make "GDScript strict mode" a real thing your CI can enforce.
gdstrict check .One command, and untyped variables, unsafe Variant access, and runaway functions stop reaching your main branch.
Strict typing that actually fails the build — checked by Godot itself. gdstrict drives Godot's own GDScriptAnalyzer headlessly and turns its warnings into a pass/fail gate. UNTYPED_DECLARATION, INFERRED_DECLARATION, the whole UNSAFE_* family — each mapped to error, warn, or off in your config. No reimplemented type system to drift out of sync: the checking is exactly as accurate as the engine, because it is the engine. The engine won't fail your build over these. gdstrict will.
Cyclomatic complexity, per function. Nothing else in the GDScript ecosystem measures this. gdstrict complexity scores every function in your project, and the max-complexity lint rule turns the score into a gate — so the 40-branch _physics_process gets caught while it's still a 12-branch one. Counting follows ruff's C901, so the numbers mean the same thing they do in your Python and TypeScript repos, and the JSON output carries line spans for computing CRAP scores against your coverage data.
21 lint rules, no Godot required. Naming conventions, dead code, redundant branches, Godot's canonical class member order, size limits. Runs in milliseconds across a whole project — fast enough for a pre-commit hook or format-on-save.
A formatter you never have to configure. Deterministic and idempotent, with automatic wrapping of long call chains, argument lists, and array/dictionary literals. One knob, line-length (default 100, matching Godot's style guide). Adopt it and stop arguing about style in code review.
Linux and macOS:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/mrf/gdstrict/releases/latest/download/gdstrict-installer.sh \
| shWindows:
irm https://github.com/mrf/gdstrict/releases/latest/download/gdstrict-installer.ps1 | iexOr grab a .tar.gz / .zip from the Releases page, or build from source with a Rust toolchain:
git clone https://github.com/mrf/gdstrict && cd gdstrict
cargo build --release # -> target/release/gdstrictOnly the strict-typing pass needs a Godot binary. Formatting, linting, and complexity never do.
gdstrict format . # rewrite in place
gdstrict format --check . # CI mode: write nothing, exit 1 if anything would change
gdstrict format --diff src/player.gd # show a unified diff
gdstrict format --line-length 120 . # override for this runDirectories are walked recursively, respecting .gdignore and gitignore rules so vendored and generated code stays untouched.
Exit codes: 0 clean · 1 a file would change (under --check), or an error.
gdstrict lint .
gdstrict lint --config path/to/gdstrict.toml src/The full naming-convention and code-quality rule set, run against the CST. No Godot needed. See Lint rules.
Exit codes: 0 no findings · 1 at least one finding, or an error.
gdstrict complexity . # file:line:column: name complexity
gdstrict complexity --min 10 src/ # only the functions worth looking at
gdstrict complexity --format json src/ # machine-readableA report, not a gate — it exits 0 no matter how bad the numbers are. The gate is the max-complexity lint rule.
Complexity starts at 1 and gains 1 for each if, elif, for, while, match arm, and nested lambda. else adds nothing (it's the default path, not a decision), and neither do ternaries or and/or — the model is statement-level, matching ruff's C901 / PyCQA mccabe. A lambda's branches count toward its enclosing function; methods of an inner class Inner are reported as Inner.method.
Each JSON record carries the function's line span, which makes CRAP scores (complexity² × (1 − coverage)³ + complexity) computable — join line..end_line against your coverage report's line hits. gdstrict does not read coverage itself.
[
{
"file": "src/player.gd",
"name": "_physics_process",
"line": 42,
"column": 0,
"end_line": 78,
"complexity": 13
}
]Exit codes: 0 report produced · 1 a file or config could not be read.
The aggregate CI gate: format-check, then lint, then strict typing.
gdstrict check . # everything
gdstrict check --godot /path/to/godot . # point at a specific Godot binary
gdstrict check --no-strict . # skip the type pass (no Godot needed)
gdstrict check --quiet . # summary and exit code onlyOnly error-level strict findings fail the gate; warn-level findings are printed and pass. Godot discovery order: --godot <path> → $GODOT → PATH.
Exit codes: 0 clean · 1 a violation · 2 operational error (bad path, invalid config, strict enabled with no Godot found).
One gdstrict.toml configures everything. Every key is optional — omit the file entirely and you get sensible defaults.
# Maximum line length before the formatter wraps (default: 100).
line-length = 100
# Strict-mode severity preset (only "strict" is known; the default).
preset = "strict"
[lint]
# Disable individual rules. All rules are on by default.
function-name-case = false
# Threshold rules take an integer, which sets their limit and keeps them on:
# max-complexity, max-line-length, function-arguments-number, max-public-methods.
max-complexity = 15
[warnings]
# Per-code severity for the strict pass: "error" | "warn" | "off".
# Overrides beat the preset; unlisted codes get the preset's default.
INTEGER_DIVISION = "off"
RETURN_VALUE_DISCARDED = "warn"Unknown top-level keys are rejected, so a typo like line_length fails loudly instead of silently doing nothing.
Discovery (highest precedence first): --line-length on the CLI → --config <file> → the nearest gdstrict.toml walking up from each input file (the black / ruff model) → built-in defaults.
| Code | What it catches |
|---|---|
UNTYPED_DECLARATION |
Variables, parameters, or return types with no static type annotation |
INFERRED_DECLARATION |
Variables inferred from a Variant value — typed as Variant, not the real type |
UNSAFE_CAST |
An explicit cast that may fail at runtime |
UNSAFE_METHOD_ACCESS |
Calling a method not present on the inferred (Variant) type |
UNSAFE_PROPERTY_ACCESS |
Accessing a property not present on the inferred (Variant) type |
UNSAFE_CALL_ARGUMENT |
Passing a Variant where a typed argument is expected |
RETURN_VALUE_DISCARDED |
Ignoring a function's return value |
Every other Godot warning code defaults to warn — surfaced, but not fatal. Promote, demote, or silence any of them with a [warnings] override.
All enabled by default; disable individually via [lint].
| Rule | What it checks |
|---|---|
function-name-case |
Function names must be snake_case (leading _ for private is fine) |
variable-name-case |
Variable names must be snake_case, including locals |
parameter-name-case |
Function and signal parameter names must be snake_case |
constant-name-case |
Constant names must be SCREAMING_SNAKE_CASE |
signal-name-case |
Signal names must be snake_case |
class-name-case |
Class and inner class names must be PascalCase |
enum-name-case |
Enum type names must be PascalCase |
enum-value-case |
Enum member names must be SCREAMING_SNAKE_CASE |
| Rule | What it checks |
|---|---|
unused-argument |
Arguments never referenced in the body (prefix _ to silence) |
unnecessary-pass |
A pass in a body that has other statements |
expression-not-assigned |
An expression statement whose result is discarded (calls and await exempt) |
no-else-return |
An else after an if body that always returns |
no-elif-return |
An elif after an if body that always returns |
comparison-with-itself |
A comparison with identical operands (e.g. x == x) |
duplicated-load |
A load()/preload() for a path that already appeared in the file |
| Rule | Default limit | What it checks |
|---|---|---|
class-definitions-order |
— | Godot's canonical member order: annotations → class_name → extends → signals → enums → constants → exported vars → public vars → private vars → onready vars → methods |
private-method-call |
— | Calling a private method (leading _) on another object |
max-line-length |
100 | Lines longer than line-length |
function-arguments-number |
10 | Functions with more parameters than the limit |
max-public-methods |
20 | Classes with more public methods than the limit |
max-complexity |
10 | Functions whose cyclomatic complexity exceeds the limit |
# .github/workflows/gdstrict.yml
name: GDScript quality
on: [push, pull_request]
jobs:
gdstrict:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mrf/gdstrict@mainThat runs format --check and lint on the repo root. Add the strict-typing pass by installing Godot:
- uses: mrf/gdstrict@main
with:
install-godot: 'true'
godot-version: '4.6.2' # pin to the version your project targets
run-check: 'true'Pin the Godot version. Its warning output is not a stable API, and gdstrict version-gates its diagnostic parser against tested releases.
| Input | Default | Description |
|---|---|---|
version |
latest |
Release tag to install (e.g. v0.1.0). Under cargo-install, a git ref instead |
install-method |
prebuilt |
prebuilt downloads a static release binary — no Rust toolchain. cargo-install builds from source, for arbitrary git refs |
install-godot |
false |
Install a headless Godot binary (Linux runners only). Required for run-check |
godot-version |
4.6.2 |
Godot release to install |
run-format-check |
true |
Run gdstrict format --check |
run-lint |
true |
Run gdstrict lint |
run-check |
false |
Run gdstrict check. Requires install-godot: true |
working-directory |
. |
Directory to run in. Must be a Godot project root |
Output: gdstrict-version — the installed version string.
repos:
- repo: https://github.com/mrf/gdstrict
rev: v0.1.0 # pin to a release tag
hooks:
- id: gdstrict-format # no Godot needed — good for every project
- id: gdstrict-lint # no Godot needed
- id: gdstrict-check # strict typing; needs Godot on PATHThese download a prebuilt binary for your platform on first use, cache it, and run it — no Rust toolchain, no compile step. The tag comes from the rev: you pin, so the hook and the binary stay in lockstep.
If Godot isn't on PATH, point the check hook at it:
- id: gdstrict-check
env:
GDSTRICT_GODOT: /path/to/godotOn a platform with no published binary, use the -cargo variants (gdstrict-format-cargo, gdstrict-lint-cargo, gdstrict-check-cargo), which build from source instead. GDSTRICT_BIN overrides everything and runs a binary you point it at.
- Install Run on Save.
- Copy
docs/editors/vscode/settings.jsonfrom this repo into your project's.vscode/. - Point the
cmdat yourgdstrictbinary.
Every .gd save now runs gdstrict format in the background. The full guide covers the check task and other options.
A Cargo workspace producing one binary. The pipeline is source → parse → { format | lint | strict }.
crates/
gdstrict-cli/ # args, config discovery, exit codes, output rendering
gdstrict-syntax/ # tree-sitter-gdscript: source to CST, error recovery, trivia
gdstrict-format/ # CST to canonical text: document IR + width-aware renderer
gdstrict-lint/ # syntactic style rules over the CST
gdstrict-strict/ # headless Godot driver: run the analyzer, parse diagnostics
fixtures/ # golden in/out pairs + idempotency corpus
The formatter uses a hand-written Wadler/Prettier-style document IR (Group, Indent, Line, SoftLine, Text) feeding a width-aware renderer — chosen over a query-based engine because auto-wrapping needs real layout control. Comments, blank lines, doc comments, annotations, backslash continuations, and multiline strings are all treated as significant trivia, with golden tests covering each.
Design rationale and milestones live in PLAN.md.
cargo test --workspace # full suite, incl. the idempotency gate
cargo fmt --all --check # formatting gate
cargo clippy --workspace --all-targets -- -D warnings # lint gateCI runs all three on Linux and builds/tests on Linux, macOS, and Windows. See .github/workflows/ci.yml.
MIT. See LICENSE.