forge lint is the part of forge that says no. Everything else in forge makes the right thing easy to write; lint makes the wrong thing fail loudly, at the moment it is written, with the file, the line and the fix. It runs the linters you would expect for Go, protobuf and TypeScript (golangci-lint, buf, eslint, tsc), and around them about a hundred and twenty rules of forge’s own. Those rules exist because each one is a bug that compiled, passed its tests and then did the wrong thing in production: a filter that silently never filters, a migration that locks a table, a log line with no trace id, a hand-edit to a file the next generate overwrites.
This page is the map. Reading the output explains the report and the verdict line, Where it runs covers your terminal, task ci and CI, and Silencing a rule covers the escape hatches. The warn-before-gate rollout explains why a forge upgrade does not turn your build red. The rule reference lists every rule, with the bug it prevents and a bad-then-fixed example.
Running it
forge lint is report-only. It writes nothing unless you pass --fix, and when a formatter would have changed a file it prints the diff and fails. That is deliberate: a lint that rewrote files used to reformat files that another pull request owned, in shared checkouts. --fix formats your Go (never generated files), rewrites untraced log calls and constructors (see tracing), and runs prettier and eslint --fix on your frontends. Every Go rewrite is type-checked afterwards, and a fix that leaves its package failing is put back and reported. It never applies golangci-lint’s own fixes, because those are code changes and stay findings for you to make.
Lint is heavy, so it queues for one of the machine’s heavy slots. A waiting for a heavy slot line is a queue, not a hang.
The lanes
A bareforge lint runs a fixed list of lanes in order and skips the ones that do not apply to your project (no db/migrations/, no frontends, a feature turned off in forge.yaml). A lane is one linter with one verdict line. Naming a lane with its flag runs just that lane, and naming several runs each under one verdict.
Five lanes run only when you name them, because they are whole-project questions or slow:
--frontend-stores, --proto-options, --vendored-protos, --config-reach and --slow-tests. --exported-vars is part of --contract. forge project capabilities prints the live flag list for the forge you are running, including the ones --help hides.
A lane that gates fails the run when it reports an error. An advisory lane reports warnings, which never fail the run unless you raise a rule with lint.rules or run with --strict, which escalates advisory findings to errors.
Flags worth knowing
A gating lane that could not run, such as a missing
golangci-lint or a frontend with no node_modules, fails the run. A check that did not run is not a check that passed. forge tools install golangci-lint buf installs the missing tools.
forge lint runs under the forge version your forge.yaml pins in forge_version. If the binary on your path is a different version it says so and tells you how to run the pinned one with go run github.com/reliant-labs/forge/cmd/forge@<pin> lint. --allow-version-skew (or FORGE_ALLOW_VERSION_SKEW=1) proceeds anyway. Inside Reliant, reliant forge is the embedded forge, so use the go run form when your pin differs.Reading the output
Every lane prints one header line and then its findings. This run came from a scratch project with a deliberately unsafe migration and a misspelled proto marker:marker file:line [rule] message, with the fix on the next line after an arrow. ✗ is an error and ⚠ is a warning. The bracketed rule ID is the name you use everywhere else: in the reference, in lint.rules, and in a forge:lint-disable comment. The lane header says whether the lane failed (❌), passed with warnings (⚠️) or passed (✓).
Lint exits 1 when any gating lane reports an error, and 0 otherwise. The last line of the report is the verdict. Read it first:
--quiet hides advisory findings and says how many it hid: the same run above prints 2 advisory findings not shown (--quiet) in its verdict. Here is --quiet on a project with an untraced log call:
--conventions --quiet catching a list request whose filter has no field presence. customer is a plain string, so an unset filter and an empty filter are the same value on the wire and the generated filter never applies:
--json gives the same findings in a stable shape:
ok is false exactly when the text run would exit non-zero. A lane that could not run appears as a finding too, with a rule ID ending in -unavailable, so a consumer can tell “clean” from “did not run”. The grace object is explained in the rollout.
These captures come from a scratch project made with
forge project new shop --service order, on forge v1.0.14. Progress lines (⏳ waiting for a heavy slot, still running) are trimmed.Where lint runs
Lint is the same code in every place; the difference is who runs it and what it blocks. On your machine, and in your agent’s loop.forge lint directly, or task lint, which delegates to the pinned forge. The documented loop is forge generate && forge lint && go build ./... && task test. Iterate with forge lint --quiet --scope <the paths you touched>; it is the fast version.
In task ci. task ci runs task ci:checks, then the tests, then the integration tests, and prints one summary table. The scaffolded ci.yml calls the same tasks, so CI and your terminal give the same verdict. The checks lane runs these lint-shaped rows by name: golangci-lint, buf-lint, migration-safety (which is forge lint --migration-safety), and for each frontend lint-<name>, typecheck-<name> and css-health-<name>. verify-generated runs forge generate and fails if anything changed, which is the other half of the generated-file rules. forge ci gate --list prints the exact rows for your project.
In the release workflow. The scaffolded release.yml runs forge lint --gate-json gates/lint.json as its lint step. The gate document records what the run proved: how many gating lanes ran, which could not, which were skipped. A run where nothing could execute records as skipped, never passed. forge env deploy --gate reads that evidence. In CI, CI=true keeps lint report-only, so the release is cut from the commit that was reviewed and not from a checkout lint repaired.
Silencing a rule
A rule you disagree with has three off-switches, from coarsest to most local. Reach for the most local one: a repo-wide setting silently swallows every future finding for that rule, including the one you would have wanted to see.1
Repo-wide: lint.rules in forge.yaml
Maps a rule ID to Only forge’s own rules are addressable. golangci-lint, buf and eslint keep their own config files. A key that names no gradable rule is itself an error, so a typo cannot silently turn a rule off. This is real output for
error, warn or off. The key "*" applies to every forge rule, and an exact rule ID beats it.forge-tracing-log-without-context (a misspelling of …-without-ctx):2
One file: forge:lint-disable-file
A comment in the file, in that file’s comment syntax (
//, -- or #):3
One line: forge:lint-disable-next-line
forge:lint-disable on the same line works too, and //nolint:<rule> is honored for forge rules with golangci’s own positional meaning.forge lint --show-suppressed lists every suppression in the project with its file, line and reason.
Some rules have their own in-file escape hatch, named in the rule’s fix text and in the reference: -- forge:allow-destructive and -- forge:allow-unsafe-not-null in a migration, -- forge:allow-in-transaction: <why>, //forge:exclude-contract: <why>, //forge:not-a-component: <why>, and // forge:optional-checked on an optional-dependency dereference. Rules that read your config have a dial there instead: lint.handler_file_max_loc, lint.frontend.typecheck (error, warn or off), and database.migration_safety with unsafe_add_column, destructive_change and volatile_default.
Why this matters more when an agent writes the code
A person who writes a bad query usually feels it: the test fails, they read the error, they learn. An agent does not carry that memory to the next session, and it has no instinct that astring filter field is wrong or that slog.Info loses the trace. What it has is a loop: edit, run a command, read the result, edit again. Lint is the command in that loop whose output is built to be read by something that does not already know the rule.
Every forge finding names the rule, the file and line, what is wrong, and the exact change to make, and many name a command that makes it (forge lint --tracing --fix, forge fix connect-errors, forge fix narrow-deps). That turns “the build is red” into one more turn of the loop instead of an investigation. A large share of the rules cover failures that give no signal at all. A misspelled forge: marker compiles and does nothing. A filter without optional runs and never filters. A migration numbered below the merged head applies nowhere and reports the schema current. A model reading its own diff will not find these, and tests rarely do, so a mechanical check has to.
Two properties make this work for unattended agents. The check is deterministic, so the same tree gives the same answer. And it fails closed: a lane that cannot run fails the run instead of passing silently, and --gate-json records exactly what was proved. See observability for the production half, where the same idea applies to a running system.
Where to go next
The rollout
Why a new rule warns for one release, and how to gate early or stay in the grace period.
Every rule
The full index, then one reference page per family with bad-and-fixed examples.