Skip to content

build: check that every //nolint names an enabled linter - #367

Merged
OmarAlJarrah merged 10 commits into
mainfrom
build/gate-nolint-linter-names
Aug 13, 2026
Merged

build: check that every //nolint names an enabled linter#367
OmarAlJarrah merged 10 commits into
mainfrom
build/gate-nolint-linter-names

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

nolintlint fails on a //nolint that suppresses nothing, but only when the linter it names is
enabled. golangci-lint's nolint filter drops nolintlint's "unused directive" issue outright for
a linter that is off — shouldPassIssue in pkg/result/processors/nolint_filter.go, under the
comment "don't expect disabled linters to cover their nolint statements". So a directive naming a
disabled linter, or one that does not exist at all, suppresses nothing and fails nothing, while
still reading as a live constraint on the code beneath it.

Nothing else in the gate closes that. golangci-lint has a Found unknown linters in //nolint directives warning, but it reaches even less than it looks: the filter parses a file only when it
has an issue in that file to filter, so a directive in a clean file is never read at all, and the
warning is printed by a run that exits 0 either way. Planting //nolint:notarealinter on a clean
file produces no warning and 0 issues.

scripts/check-nolint-linters.sh cross-checks the names in every //nolint against the set
golangci-lint reports as enabled, and the gate runs it as one more step after lint. The enabled
set comes from golangci-lint linters --json plus golangci-lint formatters --json, not from a
copy of .golangci.yml, so enabling or dropping a linter never needs an edit in the script.
Formatters are in the set because run reports their findings under their own name (File is not properly formatted (gci)), which makes //nolint:gci a real directive.

Both list calls run in the repo the script lives in, not in whatever directory the caller stood in.
golangci-lint discovers .golangci.yml relative to its own working directory, so an unanchored
call would answer for a config with nothing to do with the tree git grep is reading.

scripts/verify-nolint-grammar.sh drives the check over every grammar form and mutates it to prove
each case bites; the gate runs it ahead of the check, the way the coverage counter sits ahead of
the coverage gate.

//nolint:forcetypeassert at pass/validate_propids_test.go:86 was of exactly the kind the check
rejects: forcetypeassert has never been in the enabled set here, so it never suppressed anything
from the day it was written. #313 has since made that same edit on main for its own reasons,
so the line no longer appears in this diff.

Grammar, and where the check deliberately differs from golangci-lint

The parsing mirrors extractInlineRangeFromComment: strip leading / and spaces (so // nolint:x
counts), require nolint followed by a space, a colon, or the end, cut a trailing // reason,
split on commas, trim and lower-case each name. //nolint:a,b,c is checked name by name.

A directive that names no linter fails. It has no name to cross-check, which also makes it the one
way to write a suppression this check cannot see through, so it fails rather than passing silently.
The failure says which kind it is, because the two are opposites and the gate is the only thing
that will tell a reader them apart:

form what golangci-lint does with it reported as
//nolint, //nolint:all suppresses every enabled linter blanket suppression
//nolint:allfoo, //nolint:ALL suppresses every enabled linter blanket suppression
//nolint: suppresses nothing empty linter name
//nolint:errorlint, suppresses errorlint only empty linter name

The all rows are not a typo. extractInlineRangeFromComment tests HasPrefix(text, "nolint:all")
on the raw text before it splits, and compares the split-out names lower-cased — so
//nolint:allfoo blankets, //nolint:AllFoo is merely an unknown name, and //nolint:ALL
blankets by the second test after failing the first. The check mirrors all three.

One difference is deliberate: every // on a line is tried, not just the one opening the
comment.
Telling those apart needs a Go parser, since // also occurs inside string literals and
inside prose quoting a directive. Trying all of them over-reports — a directive spelled inside a
string literal is reported although golangci-lint would never see it — and never under-reports,
which is the direction a check written because another check missed something has to fail in.
Block comments need no such care: TrimLeft(text, "/ ") stops at the *, so golangci-lint ignores
/*nolint:x*/ too, and both agree.

A trailing CR is dropped before the grammar runs, because go/scanner drops it before the nolint
filter reads the comment. Without that, a CRLF file leaves a bare //nolint reading as nolint\r,
which matches no anchor — measured, golangci-lint honours that directive and the finding under it
disappears, so missing it would be the one under-report the design does not allow. The gate's
gofmt step rejects a CRLF file first, but this check does not lean on a step above it.

git grep -n output is split on its first two colons, which a path holding one would derail. Such
a tree is refused rather than misparsed: the split would keep the file and silently drop the line
number.

Test plan

Verified against golangci-lint 2.12.2 / go1.26.4, the version the gate pins.

Ground truth first. A throwaway module with a real errorlint violation, measuring what each
directive actually suppresses rather than what the source suggests it should:

planted errorlint still reported? nolintlint says
//nolint:errorlint // r no — suppressed nothing
//nolint:notarealinter yes nothing
//nolint:forcetypeassert yes nothing
//nolint: yes nothing
//nolint:errorlint, no — suppressed format complaint
//nolint:allfoo no — blanket require-specific
/*nolint:errorlint*/ yes — block comments are not directives nothing

The three bold rows are what this PR exists for: a directive that suppresses nothing, reported by
nothing. //nolint: is reported by nothing at all, not even a format complaint.

The check, against the same forms. scripts/verify-nolint-grammar.sh is that table plus the
configuration shapes, run as 24 cases against fixture repos with a known enabled set, each followed
by a mutation that must turn it red:

$ ./scripts/verify-nolint-grammar.sh
grammar and configuration
  ok: clean
  ok: enabled
  ok: formatter
  ok: disabled
  ok: unknown
  ok: bare
  ok: all
  ok: allprefix
  ok: all-in-list
  ok: all-uppercase
  ok: allprefix-mixedcase
  ok: emptyname
  ok: trailing-comma
  ok: mixedcase
  ok: spacecolon
  ok: leadingspace
  ok: reasoncut
  ok: extraslashes
  ok: notdirective
  ok: blockcomment
  ok: instring
  ok: crlf-bare
  ok: capped
  ok: no-formatters-config
  ok: no-linters-config

mutations, each of which must turn its case red
  ok: no-all-prefix turns 'allprefix' red
  ok: no-all-in-list turns 'all-in-list' red
  ok: no-empty-split turns 'emptyname' red
  ok: no-empty-name turns 'trailing-comma' red
  ok: no-lowercase turns 'mixedcase' red
  ok: no-trim turns 'spacecolon' red
  ok: no-slash-strip turns 'leadingspace' red
  ok: no-reason-cut turns 'reasoncut' red
  ok: no-scan-advance turns 'extraslashes' red
  ok: no-directive-anchor turns 'notdirective' red
  ok: no-cr-strip turns 'crlf-bare' red
  ok: no-enabled-lookup turns 'disabled' red
  ok: no-formatters-list turns 'formatter' red
  ok: no-cap turns 'capped' red
  ok: no-null-guard turns 'no-formatters-config' red
  ok: one-jq-call turns 'no-linters-config' red

the enabled set follows the repo, not the caller
  ok: the verdict is the same from the repo and from an unrelated directory

a path git grep -n output cannot be split
  ok: a colon in a tracked path is refused, and without the guard the line number is silently lost

guards no planted directive reaches
  ok: a missing tool is named rather than crashed on
  ok: an unreadable enabled set is refused, not read as nothing enabled

all checks passed

Confirming the verifier cannot pass vacuously. Four ways a case could look like coverage
without being any, each planted and each caught:

planted result
a case naming a fixture repo nothing built (mainmian) FAIL: disabled: no fixture repo named mian was built
a mutation naming a case the table does not carry FAIL: no-cap: names a case the table does not carry
a mutation anchor that no longer appears FAIL: no-cap: its anchor appears 0 times ..., want 1
a mutation anchor appearing more than once FAIL: no-lowercase: its anchor appears 5 times ..., want 1

Confirming the enabled set is derived, not effectively hardcoded. Editing .golangci.yml moves
the verdict on directives the script never mentions: dropping errorlint from enable reddens the
live //nolint:errorlint, and adding forcetypeassert turns a planted one green.

The branch as it stands, with every remaining directive naming an enabled linter:

$ ./scripts/check-nolint-linters.sh
nolint gate passed: 4 directive(s), 4 linter name(s), all enabled.

The rest of the gate, run in order: gofmt clean, go vet ./... clean, golangci-lint run
0 issues., both verifiers all checks passed, go build ./... clean, and
./scripts/check-coverage.sh all 6132 statements covered.

Scope

CLAUDE.md and README.md each introduce their command block as the checks CI's gate job runs,
so both gain the two new steps. README's three older disagreements with the gate — gofmt -l .,
a go test ./... step the gate does not have, and a per-package coverage claim the script does not
make — are not this change's and are filed as #415.

.golangci.yml's note on the nolintlint gap pointed at #306, which this closes; it now points at
the script instead.

Closes #306

OmarAlJarrah and others added 10 commits August 9, 2026 06:43
nolintlint reports a directive that suppresses nothing only when the linter
it names is enabled: the nolint filter drops nolintlint's "unused directive"
issue outright for a linter that is off, so a directive naming a disabled
linter -- or one that does not exist -- suppresses nothing, fails nothing,
and goes on reading as a live constraint on the code beneath it.

scripts/check-nolint-linters.sh cross-checks the names in every //nolint
against the set golangci-lint reports as enabled, and the gate runs it after
the lint step. The enabled set is asked of golangci-lint rather than copied
from .golangci.yml, so enabling or dropping a linter needs no edit there.

The gate now names one golangci-lint version for both steps: the check's
answer only describes the run above it if the two are the same build.

//nolint:forcetypeassert on pass/validate_propids_test.go:86 was of exactly
the kind the check rejects -- forcetypeassert has never been enabled here --
so it goes, keeping its justification as a plain comment.
Four things could make the check report a verdict about something other than
this tree, or describe a directive as doing the opposite of what it does.

The enabled set was asked of golangci-lint in whatever directory the caller
stood in, while the directives were read with `git -C "$repo_root" grep`. Run
from anywhere holding a different config, the check reported every real
directive here as naming a linter that is not running. Both list calls now run
in the repo the script lives in.

`jq -r '.Enabled[].name'` cannot iterate the null that golangci-lint emits when
a list is empty, and jq reports only its last input's status: with both files in
one call, a null linters list exited 0 and silently shrank the enabled set to
the formatters, so every directive read as disabled. A null formatters list — an
ordinary config with no formatters — killed the script outright with a raw jq
error. Each list is now read on its own with a `// []` default, and the existing
"no enabled linters" guard sits between them, where it can finally fire; before,
neither it nor its awk counterpart could be reached in either case.

Three of the four forms the check called blanket suppressions are not. Measured
against golangci-lint 2.12.2: `//nolint:` suppresses nothing at all, a stray
comma suppresses only the names beside it, and `//nolint:allfoo` does blanket —
because extractInlineRangeFromComment tests HasPrefix("nolint:all") before it
splits, which the check did not mirror. A directive that suppresses nothing was
being reported as suppressing everything, sending a reader looking for
over-suppression when the finding beneath it is live.

`scan` resumed two characters past each "//", which re-entered the same run of
slashes and read one `////nolint:x` as two directives, reporting it twice and
inflating both counts. It now resumes past the whole run.

Also refuses a tree in which a tracked path holds a colon rather than misparsing
`git grep -n` output, which folded the line number into the scanned text and
reported the finding at a location without one.
The gate script re-implements extractInlineRangeFromComment in awk, and every
verdict it reaches rests on that grammar agreeing with golangci-lint's. Nothing
held it to that. The tree carries four directives, all of one shape, so the
committed corpus exercises almost none of the reader: a name that is merely
disabled, an empty name, a blanket suppression spelled four different ways —
none of them appear, and a drift in any of them would be nobody's job to notice.

Twenty-four cases cover the grammar and the shapes around it that decide whether
the answer describes this repo: which config the enabled set comes from, a null
Enabled list, and a path git grep -n output cannot be split on. Each is followed
by a mutation that must turn it red, so a case that catches nothing fails here
rather than reading as coverage.

Two cases pin the case-sensitivity split that is easiest to get wrong.
golangci-lint tests the "all" prefix on the raw text but compares the split-out
names lower-cased, so `//nolint:allfoo` blankets while `//nolint:AllFoo` is
merely an unknown name, and `//nolint:ALL` blankets by the second test after
failing the first.

The checks that keep a case from passing vacuously are the ones that matter
most: a case naming a fixture nothing built, a mutation naming a case the table
does not carry, and an anchor appearing zero or many times all fail loudly. A
mutation whose anchor no longer appears changes nothing, and a target that
"survives" that is indistinguishable from a case that tests nothing.
The nolint step installed golangci-lint a second time, on the grounds that the
action "runs golangci-lint from a path of its own and does not put it on PATH".
It does put it on PATH: the bundle the runner executes at v8.0.0 calls
core.addPath(path.dirname(binPath)) in its entrypoint, which prepends that
directory to PATH for every later step in the job. Running the install block
locally proved the install works; it could not observe what the action exported,
which is why the claim stood.

Dropping the second install makes the check ask the same build that produced the
findings above it, rather than a separately downloaded copy that merely carries
the same version string, and takes a curl-piped-to-sh out of the job. The
version pin moves to the action's own input, where the one step that consumes it
is.

Adds the grammar verifier as its own step ahead of the check it drives, matching
how the coverage counter sits ahead of the coverage gate.
CLAUDE.md and README.md each introduce their command block as the checks CI's
gate job runs, so a step that is not in them makes the promise false. README.md
was missing the directive check as well as the grammar verifier; its three
older disagreements with the gate are separate and are filed as #415.

.golangci.yml described the gap nolintlint leaves as "GitHub #306", which this
change closes — leaving a reader at a resolved issue rather than at the script
that now covers it.
`sort -u` folds together whatever the locale calls equal, and outside C that
reaches past exact bytes — names differing only in case or punctuation can
collapse into one, and the name that lost would then read as a linter
golangci-lint is not running. Pinned to C, matching the failure listing in
check-coverage.sh.

The grammar fixtures also stop inheriting the caller's git configuration. A
global commit.gpgsign with no key on the runner would fail the fixture commit,
and the suite would read as a broken script rather than as a fixture that was
never built.
check_case bound the case name and never looked at it; the repo field is what
selects the fixture.
A stray comma leaves the names beside it working; only "//nolint:" on its own
suppresses nothing at all. The comment claimed the latter of both.
go/scanner drops a trailing CR from a // comment before the nolint filter ever
sees it, so on a CRLF file golangci-lint honours a bare //nolint — measured, the
finding under it disappears. The grammar here was looking at "nolint\r", which
matches neither anchor, and missed the directive entirely. That is an
under-report, the one direction this check is not allowed to fail in, and the
gate's gofmt step rejecting CRLF first does not make it right for a check meant
to stand on its own. Covered by a CRLF fixture and the mutation that removes the
strip.

Two guards no planted directive can reach are now driven directly: the tool
check, which runs before the script has read anything, and the awk-side empty
check, which only fires when awk cannot read the file the shell just found
non-empty. Both were reachable and neither was tested; removing either now turns
its check red.

The comment on the enabled-set sort claimed collation could fold two names into
one. It cannot for any name golangci-lint ships: glibc separates "gocritic",
"go-critic" and "GoCritic" under en_US.UTF-8. The pin stays as insurance; the
comment now says which it is.

Also drops the rule that skipped an empty line in the git grep output. No such
line can occur, and skipping it silently disagreed with the rule below it, which
treats any line that is not path:line:text as a broken invariant.
@OmarAlJarrah
OmarAlJarrah merged commit 29801f7 into main Aug 13, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the build/gate-nolint-linter-names branch August 13, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint: a nolint directive naming a disabled linter is reported by nothing

1 participant