Skip to content

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

Open
OmarAlJarrah wants to merge 1 commit into
mainfrom
build/gate-nolint-linter-names
Open

build: check that every //nolint names an enabled linter#367
OmarAlJarrah wants to merge 1 commit 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.

The gate now names one golangci-lint version for both steps. The check asks golangci-lint which
linters are enabled, so its answer only describes the run above it if the two are the same build;
pinning also means a golangci-lint release can no longer redden main without a commit.

//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. It goes, keeping its justification as a plain comment. #313 makes the
identical edit to that line for its own reasons, so the two merge without conflict in either order.

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.

Two choices are deliberate and are written down at the code:

  • A directive that names no linter fails. Bare //nolint, //nolint:all, //nolint:, and a
    stray trailing comma all resolve to "suppress every enabled linter". There is 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. (nolintlint's require-specific covers the same
    ground when it is enabled; this check does not depend on that.)
  • 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.

Test plan

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

Each form was planted on pass/validate_propids_test.go:86 and the file restored afterwards.

1. //nolint:forcetypeassert — a real linter that is not enabled

$ sed -n 86p pass/validate_propids_test.go
	base := doc.Types["t/m"].(*ir.Model) //nolint:forcetypeassert // validDoc builds it
$ ./scripts/check-nolint-linters.sh; echo $?
NOLINT FAIL: pass/validate_propids_test.go:86: //nolint names "forcetypeassert", which golangci-lint is not running
nolint gate failed: 1 problem(s) across 7 directive(s).
1

2. //nolint:notarealinter — no such linter

$ sed -n 86p pass/validate_propids_test.go
	base := doc.Types["t/m"].(*ir.Model) //nolint:notarealinter // validDoc builds it
$ ./scripts/check-nolint-linters.sh; echo $?
NOLINT FAIL: pass/validate_propids_test.go:86: //nolint names "notarealinter", which golangci-lint is not running
nolint gate failed: 1 problem(s) across 7 directive(s).
1

3. //nolint:unparam — an enabled linter, which must not be flagged

$ sed -n 86p pass/validate_propids_test.go
	base := doc.Types["t/m"].(*ir.Model) //nolint:unparam // validDoc builds it
$ ./scripts/check-nolint-linters.sh; echo $?
nolint gate passed: 7 directive(s), 7 linter name(s), all enabled.
0

4. //nolint naming nothing

$ sed -n 86p pass/validate_propids_test.go
	base := doc.Types["t/m"].(*ir.Model) //nolint // validDoc builds it
$ ./scripts/check-nolint-linters.sh; echo $?
NOLINT FAIL: pass/validate_propids_test.go:86: //nolint names no linter, so it suppresses every enabled one
nolint gate failed: 1 problem(s) across 7 directive(s).
1

The branch as it stands, with the six remaining directives all naming enabled linters:

$ ./scripts/check-nolint-linters.sh; echo $?
nolint gate passed: 6 directive(s), 6 linter name(s), all enabled.
0

Confirming the hole is real, not inferred from the source. With a cleaned lint cache, both
forms planted at the same line leave golangci-lint run green:

planted at pass/validate_propids_test.go:86 golangci-lint run
//nolint:forcetypeassert 0 issues., exit 0
//nolint:notarealinter 0 issues., exit 0

No Found unknown linters warning appeared for either, consistent with the filter never parsing a
file that has no issue in it.

Confirming the enabled set is derived, not effectively hardcoded. Editing .golangci.yml moves
the verdict on directives the script never mentions:

.golangci.yml edit result
drop errorlint from enable red on load.go:482 and load.go:508, the two live //nolint:errorlint
add forcetypeassert to enable, planted directive restored green

Grammar cases, each planted at the same line, [R] = rejected, [A] = accepted:

planted
//nolint:errorlint,forcetypeassert [R] names forcetypeassert
// nolint:notarealinter (leading space) [R]
//nolint:ForceTypeAssert (mixed case) [R], lower-cased first
//nolint: forcetypeassert (space after the colon) [R]
//nolint: and //nolint:errorlint, [R] names no linter
//nolint:all [R] names no linter
//nolint:errorlint // see //nolint:bogus [R] on bogus, the documented over-report
//nolintfoo:bar [A] not a directive, matching ^nolint( |:|$)
//nolint:gci (enabled formatter) [A]
//nolint:errorlint [A]

Fixing a defect the cases found. //nolint: first passed: awk's split of the empty string
yields no fields, so the name loop never ran. Fixed, and re-planting it now reddens (row above).

The CI step's run block was executed verbatim locally (with a darwin build in place of linux),
proving the install line and the PATH it hands the script both work — the action installs
golangci-lint at a path of its own and does not put it on PATH, so the step installs the pinned
version for itself:

golangci/golangci-lint info found version: 2.12.2 for v2.12.2/darwin/arm64
golangci/golangci-lint info installed .../bin/golangci-lint
nolint gate passed: 6 directive(s), 6 linter name(s), all enabled.

The rest of the gate, with golangci-lint cache clean first: gofmt clean, go vet ./...
clean, golangci-lint run 0 issues., go build ./... clean, ./scripts/check-coverage.sh
all 4942 statements covered.

On the runner, where the version pin, the env reference in with:, and the presence of jq
are all things a local run cannot establish. The step takes about a second:

env:
  GOLANGCI_LINT_VERSION: v2.12.2
golangci/golangci-lint info found version: 2.12.2 for v2.12.2/linux/amd64
golangci/golangci-lint info installed /home/runner/work/_temp/bin/golangci-lint
nolint gate passed: 6 directive(s), 6 linter name(s), all enabled.

Scope

CLAUDE.md's list of gate commands gains the new one; it is written as "the same checks CI's gate
job runs, in that order", so leaving it at five would make it wrong.

Not done here: the script assumes no tracked path contains a colon, since it splits git grep -n
output on the first two. A path that did would be reported as an unparsable line rather than
skipped.

Closes #306

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.
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