Skip to main content
The glua command runs the same parser, analyser and formatter as the extension, so a finding in CI is the same finding you saw in the editor. Use it to gate pull requests or format a whole addon in one go.

Installing

Published on npm as glua-cli. The analyser and the wiki dataset are bundled into it, so it pulls in no dependencies of its own.
To try it without adding it to a project:
Or build it from the repository:

glua init

Writes .glua.json and .gluafmtrc.json, seeded from the defaults the server already uses — so a fresh config describes what you have rather than changing anything the moment it lands.
An existing config is left alone and the command exits non-zero, so it is safe to run twice. Pass --force to replace one deliberately. Both files carry a $schema pointing at glua.bluejutzu.dev/schemas, so an editor completes and validates them without needing node_modules — a GMod addon usually has no Node project at all.

glua lint

Exits 1 when there are errors, or when --max-warnings is exceeded. Otherwise 0.

What it prints

Each finding comes with the line it is about and the part it is about underlined, because a line number alone is a lookup instruction and in a CI log there is no file to open:
--no-code-frames goes back to one line per finding. The machine formats (compact, github, json, sarif) are unaffected either way.

Where the time goes

Indexing covers the whole project even when you lint one file, since cross-file rules are only correct once the index has seen everything. When those two numbers are far apart, that is what you are paying for — which is what the cache below is for.

Caching

Each file’s facts — the globals it defines, the hooks it adds, the net messages it sends, who calls what — are written to .glua-cache keyed by a hash of the file’s contents. A later run reads them back instead of parsing the file again. On a 300-file addon: Linting one file gains most, because the other 299 are indexed purely so the cross-file rules are right, and none of that work changed.
Findings are never cached, only facts. net-never-received depends on every other file in the project, so a cached finding would be wrong the moment an unrelated file gained a net.Receive. Every finding is recomputed from the whole set on every run, cache or no cache.
The key is a content hash rather than a modification time, so a checkout, a branch switch or a restored backup does not invalidate anything, and touch is not a reason to redo the work. The directory writes its own .gitignore, and an upgrade of glua discards the cache rather than reading facts a different build wrote. Anything that goes wrong with it — corrupt file, read-only checkout — is a cache miss rather than an error. --no-cache skips it in both directions. glua fmt never touches it.

Linting stdin

An editor integration usually has a buffer that has not been saved, or has been saved with contents different from what is on disk. --stdin-filepath lints whatever comes in on stdin as though it were the file at that path:
The path decides the file’s realm and what the cross-file rules match it against — a net.Start typed into an unsaved sv_ file is still checked against the project’s other net.Receive calls. It does not need to exist on disk at all; the rest of the project is still indexed around it normally. Code frames quote the piped text, not whatever the path happens to hold on disk. --fix, --suppress-all and --prune-suppressions all write back to a real file, so none of them can be combined with --stdin-filepath.

Fixing

--fix applies only the quick fixes with a single correct outcome, then reports whatever is left:
What it will fix: a missing util.AddNetworkString in a server file, a missing AddCSLuaFile above an include, and a C-style compound assignment.

Safe and unsafe fixes

A fix is safe when the code does the same thing afterwards. It is unsafe when it very probably does what you wanted but the tool cannot promise it — the value now evaluates at a different moment, or the edit lands somewhere the tool had to guess. Hoisting a Material call out of HUDPaint is the clearest case: it is the right change nearly every time, and it moves the lookup from every frame to the moment the file loads. --fix applies only the safe ones, and says what it left behind:
This matters most where --fix runs with nobody watching — a pre-commit hook, a CI job, format-on-save. In the editor every fix is offered normally; the split is about what gets applied unattended.
--unsafe-fixes on its own exits 2. It only means something alongside --fix or --fix-dry-run.
It deliberately will not add a net.Receive stub, rename an unused local, wrap a call in a realm guard, or correct a hook name from a spelling suggestion. Each of those either guesses, changes control flow, or leaves a body for you to write — none of which should happen unattended.
Fixes run in passes, since resolving one can reveal another, capped at five so a pair that undo each other cannot spin. An identical fix asked for twice — two sends of the same unregistered message both wanting the same util.AddNetworkString line — is written once.
Fixing does not change the exit code rules. Anything left over is still reported, so an error no fix could settle still exits 1, and --max-warnings still applies to what remains. --fix cannot turn a failing build green.
The whole project gets indexed even when you lint a single file. Cross-file rules — an unhandled net message, a duplicate hook identifier, a missing AddCSLuaFile — are only correct once the index has seen everything.

Output formats

glua fmt

Files that do not parse are skipped and reported, never rewritten. Formatting broken code is how one problem becomes two.

glua rules

Lists every diagnostic code alongside its settings key. Worth knowing because the two are different: net-payload-mismatch is what you suppress inline, netReadWriteMismatch is what you set in .glua.json. Each code has a section in the rule reference explaining what it catches. Diagnostics link there directly, so in an editor you can click the code in the Problems panel rather than looking it up.

glua explain

What one rule means, without leaving the terminal:
Given a settings key instead of a code — glua explain unusedLocal — it says which code you meant (unused-local) rather than pretending nothing matched, since that mix-up is the one unused-suppression exists to catch. Exits 2 for anything it cannot resolve.

Configuration

The CLI reads exactly the same files as the editor — .glua.json, .gluafmtrc.json, .editorconfig, .prettierrc — resolved from --root or the working directory. See Configuration.

In GitHub Actions

The github format emits workflow annotations, so findings appear inline on the pull request diff.

Adopting on a codebase you inherited

Most Garry’s Mod code is code somebody else wrote. Running a linter over it for the first time produces hundreds of findings, none of which are the change you were making — and that is how a linter gets switched back off. A baseline draws a line under what already exists:
See the baseline.schema.json reference for what this file actually contains. Commit that file. From then on the rules are enforced on everything written after it, and the backlog waits until you want it:
The baseline counts findings per file and rule rather than recording line numbers, so moving code around does not invalidate it. A file with two unused locals accepts two; add a third and the third is reported. When you fix something the baseline was covering, it says so:
Pruning is a deliberate act, not an automatic one: a baseline quietly rewriting itself downward would let a rule silently stop being enforced. It is also worth running in CI as a check — a drifted baseline is a sign the code improved and nobody noticed.
--suppress-all and --prune-suppressions cannot be combined with --fix. Fixing the code and accepting the code are opposite decisions, and doing both in one pass makes it impossible to see which happened.

Code scanning

--format sarif writes SARIF 2.1.0, which GitHub code scanning ingests. Worth having over --format github: annotations live and die with one workflow run, whereas uploaded findings get a history, a place to be dismissed, and a diff between the pull request and the base branch.
Paths in the file are relative to --root, or to the directory you ran from. Run it from the repository root, or pass --root, so the uploaded locations line up with the files on the diff. continue-on-error is there so findings still upload when the lint exits non-zero.

Colour

Honours NO_COLOR and turns itself off when piped. --no-color disables it explicitly, FORCE_COLOR=1 forces it on through a pipe.