結論

  • 0 issues. / exit 0 は「違反が無い」ではなく「報告まで残った指摘が無い」。golangci-lint は ファイルの読み込み => linter の検査 => 検査後の後処理 の 3 段階で動き、指摘はどの段階でも消える
  • 後処理: issues.new: true は差分で追加された行の指摘だけを残す。クリーンな checkout では HEAD~ との差分しか見ない。issues.fix: true は直せる指摘をファイルへ書き込み、報告から外す
  • 抑止コメント: gosec の指摘を抑止するコメントは 2 種類ある。#nosec は gosec が読むので、gosec 単体でも golangci-lint 経由でも効く。//nolint:gosec は golangci-lint が読むので、golangci-lint 経由でだけ効く。# を抜いた //nosec は gosec も golangci-lint も読まず、どの実行でも指摘が出る。抑止されるのは名指しした ID だけ
  • 読み込み: build tag 付きのファイルは、run.build-tags に入れないと型検査にも載らない
  • 検査: staticcheck は ST の一部を既定で外す。forbidigo は既定で呼び出しの字面だけを照合し、exhaustive は名前付きの型の列挙だけを見る。gopls の analyzer の一部は golangci-lint に入っていない
  • CI では --new=false --fix=false を明示する。設定を書いたら、違反を 1 つ置いて検出されることを確かめる

以下は golangci-lint 2.14.0 (go1.26.4, linux/amd64) と gopls v0.23.0 で実測した。設定は v2 の形式 (version: "2") で書く。

前提: golangci-lint は 3 つの段階で指摘を減らす

指摘は 読み込み・検査・後処理 の 3 段階で消え、どこで消えても出力は 0 issues になる

golangci-lint は 1 回の実行で、次の 3 つの段階を順に通してから報告する。

  1. 読み込み: パッケージのファイルを読み、型検査する
  2. 検査: 有効な linter に、読み込んだコードを検査させる
  3. 後処理: linter が出した指摘を、設定に従って絞り込む

前の段階で外れたものは後ろの段階に届かない。読み込まれなかったファイルは検査されず、検査で出なかった違反は後処理に来ない。

段階何で決まるかこの記事の節
読み込み型検査に載せるファイル。run.build-tags、run.testsbuild tag、staticcheck
検査有効な linter と、その設定 (チェックの一覧、パターン)staticcheck、forbidigo、exhaustive、gosec の #nosec
後処理issues.new (差分)、issues.fix (自動修正)、//nolintissues.new、issues.fix、//nolint

後処理の中の処理は golangci-lint run -v の最後に出る (抜粋)。

level=info msg="[runner] processing took 8.095041ms with stages: diff: 7.985846ms, source_code: 41.076µs, nolint_filter: 32.08µs, (中略) fixer: 411ns, (中略) exclusion_rules: 301ns, exclusion_paths: 292ns, severity-rules: 120ns"

どの段階で消えても、出力は同じ 0 issues. と exit 0 になる。何も見ていないときと、見て違反が無かったときの区別は出力に出ない。

issues.new: true は差分で追加された行の指摘だけを残す

issues.new は、作業ツリーの状態で比べる差分を変え、追加された行に付いた指摘だけを残す

golangci-lint run --help は --new (設定では issues.new: true) を次のとおり書いている。

-n, --new   Show only new issues: if there are unstaged changes or untracked files, only those changes are analyzed, else only changes in HEAD~ are analyzed.
            For CI setups, prefer --new-from-rev=HEAD~, as --new can skip linting the current patch if any scripts generate unstaged files before golangci-lint runs.

errcheck の違反を 1 つずつ持つ 2 つのコミットで試す。

# .golangci.yaml
version: "2"
linters:
  default: none
  enable: [errcheck, unused]
issues:
  new: true
// old.go (1 つ目のコミット)
package n

import "os"

func helper() {}

func Old() {
	helper()
	os.Remove("old.txt")
}
// new.go (2 つ目のコミット)
package n

import "os"

func New() {
	os.Remove("new.txt")
}
$ golangci-lint run
new.go:6:11: Error return value of `os.Remove` is not checked (errcheck)
1 issues:

$ golangci-lint run --new=false
new.go:6:11: Error return value of `os.Remove` is not checked (errcheck)
old.go:9:11: Error return value of `os.Remove` is not checked (errcheck)
2 issues:

比べる差分は作業ツリーの状態で変わる。

作業ツリー比べる差分出た指摘
未追跡のファイル wip.go がある未コミットの変更だけwip.go の 1 件。new.go の指摘も出ない
クリーンHEAD~ との差分new.go の 1 件
shallow clone (--depth 1)取れない全件の 2 件と警告

CI の checkout はクリーンなので、HEAD~ まで取得していれば、検査されるのは最後の 1 コミットの差分だけになる。既存のコードの違反は何件あっても報告されない。

指摘が変更していない行に付くと落ちる

残すのは、差分で追加された行に付いた指摘だけ。Old から helper() の呼び出しを消す。

 func helper() {}
 
 func Old() {
-	helper()
 	os.Remove("old.txt")
 }
$ golangci-lint run
0 issues.
(exit 0)

$ golangci-lint run --new=false
new.go:6:11: Error return value of `os.Remove` is not checked (errcheck)
old.go:8:11: Error return value of `os.Remove` is not checked (errcheck)
old.go:5:6: func helper is unused (unused)
3 issues:

helper を使われなくしたのは今回の変更だが、unused の指摘は変更していない宣言行 (5 行目) に付くので落ちる。 変更したファイルの全体を見るなら --whole-files を使う。--new-from-rev か --new-from-patch と組み合わせる。

$ golangci-lint run --new-from-rev=HEAD --whole-files
old.go:8:11: Error return value of `os.Remove` is not checked (errcheck)
old.go:5:6: func helper is unused (unused)
2 issues:

shallow clone では差分を取れず全件に戻る

HEAD~ が無いと差分を取れず、警告を出して全件を報告する。

$ git clone --depth 1 file:///path/to/repo shallow && cd shallow
$ golangci-lint run
level=warning msg="[runner] Can't process results by diff processor: can't prepare diff by revgrep: could not read git repo: error executing \"git diff --color=never --no-ext-diff --default-prefix --relative HEAD~ --\": exit status 128: fatal: bad revision 'HEAD~'\n"
new.go:6:11: Error return value of `os.Remove` is not checked (errcheck)
old.go:9:11: Error return value of `os.Remove` is not checked (errcheck)
2 issues:

actions/checkout の既定は fetch-depth: 1 なので、既定のまま checkout して golangci-lint を叩く CI は、この状態で全件を見ている。設定の意図ではなく clone の深さの副作用なので、fetch-depth を変えると、設定に触れずに最後の 1 コミットの差分だけになる。

差分だけを見るのは手元にとどめ、CI では差分のフィルタを外す。

lint:     ; golangci-lint run              # 手元: 差分のみ
lint-all: ; golangci-lint run --new=false  # CI: 全件
  • PITFALL: CI が make lint-all を経由せず golangci-lint を直接叩いていると、Makefile にターゲットを足しても CI は差分のまま。設定・Makefile・CI の 3 か所が繋がっているかを確かめる
  • PITFALL: lint の前にファイルを生成するステップがあると、--new は生成された未コミットのファイルだけを見る (--help の注記のとおり)

issues.fix: true は直した指摘を報告から外す

fix: true は、自動修正できる指摘をファイルへ書き込み、報告から外す

issues.fix: true は、自動修正を持つ linter の指摘を、実行のたびにファイルへ書き込んでから報告から外す。差分だけの実行でも、全件 (--new=false) の実行でも同じ。自動修正を持たない指摘は、そのまま報告される。

# .golangci.yaml
version: "2"
linters:
  default: none
  enable: [modernize]
issues:
  new: true
  fix: true
// main.go
package main

import "fmt"

func main() {
	n := 3
	for i := 0; i < n; i++ {
		fmt.Println(i)
	}
}
$ golangci-lint run --new=false --fix=false
main.go:7:6: rangeint: for loop can be modernized using range over int (modernize)
1 issues:
(exit 1)

$ golangci-lint run --new=false
0 issues.
(exit 0)

$ git diff
-	for i := 0; i < n; i++ {
+	for i := range n {

errcheck も有効にして os.Remove("x") を足すと、--new=false の実行で modernize の指摘は出ず、errcheck の 1 件だけが 1 issues: で残った。

--new=false は検査の範囲を広げるだけで、検出後に直すかどうかは fix が決める。範囲を全件にしても、設定の fix: true は効いたまま。 CI では、ランナーの上で直したファイルはジョブの終わりに捨てられる。指摘は誰にも届かず、ジョブは緑になる。

対象は golangci-lint help linters で [auto-fix] が付く linter (staticcheck、modernize、revive、godot など) の指摘。 CI では --fix=false を明示し、ソースが書き換わっていないことを後ろで確かめる。

golangci-lint run --new=false --fix=false
git diff --exit-code   # linter がソースを書き換えていたら exit 1
  • PITFALL: 作業ツリーが dirty になるので、クリーンな checkout を要求するリリースツール (goreleaser release --clean など) が後段で落ちる。落ちるのはリリースのときなので、原因が lint の設定だと気づくまでが遠い
  • PITFALL: 同じジョブの後段のテストは、自動修正された後のソースを検査することになる

gosec の抑止は #nosec か //nolint:gosec。抑止されるのは名指ししたものだけ

#nosec は gosec が読み、//nolint は golangci-lint が読む。# の無い //nosec は gosec も golangci-lint も読まない

exec.Command に環境変数の値を渡し、gosec の G204 を出す行に、抑止コメントを 1 つずつ付ける。

// main.go (抜粋。行番号付き)
15	_ = exec.Command(bin())                          // 1. 抑止なし
16	_ = exec.Command(bin())                          //nosec
17	_ = exec.Command(bin())                          // #nosec
18	_ = exec.Command(bin())                          // #nosec G204 -- 呼び出し元を信頼する
19	_ = exec.Command(bin())                          //nolint:gosec // 呼び出し元を信頼する
20	_ = exec.Command(bin())                          //nolint:errcheck
21	_ = exec.CommandContext(ctx, argv[0], argv[1:]...) // #nosec G204 -- 呼び出し元を信頼する
22	_ = exec.CommandContext(ctx, argv[0], argv[1:]...) // #nosec G204,G702 -- 呼び出し元を信頼する

bin() は os.Getenv("BIN") を返し、argv は os.Args[1:]。golangci-lint (gosec を有効化) と、gosec 単体で走らせる。

行コメントgolangci-lintgosec 単体
15なしG204G204
16//nosecG204G204
17// #nosec出ない出ない
18// #nosec G204出ない出ない
19//nolint:gosec出ないG204
20//nolint:errcheckG204G204
21// #nosec G204G702G702
22// #nosec G204,G702出ない出ない
  • gosec の注記は #nosec。# の無い //nosec は、gosec 単体でも効かない
  • #nosec は gosec 自身が読むので、golangci-lint 経由でも効く。//nolint:gosec は golangci-lint の後処理が読むので、gosec 単体では効かない。両方で走らせるなら #nosec が両方に効く
  • //nolint:errcheck は errcheck の指摘しか抑止しない。//nolint は linter 名、#nosec はルール ID の単位で効く

21 行目は、G204 を名指しした抑止が G204 だけを抑止し、同じ呼び出しを taint 解析で見る G702 が残る例。 gosec に G702 が入る前に #nosec G204 を書いた行は、gosec (golangci-lint に同梱の版を含む) を上げると、コードを変えていないのに G702 で落ちる。 22 行目のように ID を足すと G702 も出なくなる。

  • PITFALL: ID を足す前に、新しいルールが本当に同じ懸念を指しているかを確かめる。同じ行でも別の問題を指していることがある。抑止の理由をコメントに残していないと、次の人がこの判断をできない

build tag 付きのファイルは run.build-tags に入れないと読まれない

タグ付きのファイルは読み込みの段階で外れ、本体の改名で壊れても 0 issues になる

実環境に繋ぐテストを build tag で通常のテストから外している構成で試す。

// store.go
package b

func Save(id string) error { return nil }
// store_dev_test.go
//go:build devfirestore

package b

import "testing"

func TestSaveDev(t *testing.T) {
	if err := Save("x"); err != nil {
		t.Fatal(err)
	}
}

本体の Save を Put に改名する。タグ付きのファイルは古い名前のまま。

$ golangci-lint run
0 issues.
(exit 0)

$ go vet ./...
(exit 0)

$ golangci-lint run --build-tags=devfirestore
store.go:1: : # example.com/b [example.com/b.test]
./store_dev_test.go:8:12: undefined: Save (typecheck)
package b
1 issues:
* typecheck: 1
(exit 1)

タグを渡さないと、タグ付きのファイルは存在しないものとして扱われる。go vet も同じ。 設定に入れると、毎回の lint で型検査に載る。型検査に認証情報は要らないので、CI の lint に入れられる。

run:
  build-tags:
    - devfirestore

issues.new: true と併用し、差分を .golangci.yaml の変更だけにしても、store_dev_test.go の undefined: Save (typecheck) は報告され exit 1 になった。型検査の失敗は差分のフィルタで落ちない。

  • PITFALL: lint に載せても、タグ付きのテストが実行されるわけではない。実行する口 (make のターゲットとドキュメント) は別に要る

staticcheck は既定で ST の一部を外す

既定でも S・QF と ST1005・ST1023 は出るが、ST1000・ST1003・ST1016・ST1020 は checks: all を書くまで出ない

staticcheck だけを有効にし、checks を書かない既定と、checks: [all] を比べる。

// s.go
package s

import "errors"

var userId = 1

func Get() (int, error) {
	if userId == 0 {
		return 0, errors.New("Not found.")
	}
	return userId, nil
}
// s_test.go
package s

import "errors"

var testUserId = 2

func check() error { return errors.New("Bad input.") }
# 既定
s.go:9:13: ST1005: error strings should not end with punctuation or newlines (staticcheck)
1 issues:

# checks: [all]
s.go:1:1: ST1000: at least one file in a package should have a package comment (staticcheck)
s.go:5:5: ST1003: var userId should be userID (staticcheck)
s.go:9:13: ST1005: error strings should not end with punctuation or newlines (staticcheck)
s_test.go:5:5: ST1003: var testUserId should be testUserID (staticcheck)
4 issues:

別のファイルで、ST 以外と ST の残りも数えた。

チェック指摘の例既定checks: [all]
S1002should omit comparison to bool constant出る出る
QF1003could use tagged switch on x出る出る
ST1005error strings should not end with punctuation出る出る
ST1023should omit type int from declaration出る出る
ST1000at least one file in a package should have a package comment出ない出る
ST1003var userId should be userID出ない出る
ST1016methods on the same type should have the same receiver name出ない出る
ST1020comment on exported function Exported should be of the form “Exported …”出ない出る

エディタで素の staticcheck を走らせている場合と、CI で検知の範囲が食い違う。全部を有効にして、要らないものだけ外す。

linters:
  settings:
    staticcheck:
      checks: [all, -ST1000]
  • PITFALL: run.tests: false にすると _test.go を読み込まない。checks: [all] でも s_test.go の ST1003 は消えた。v2 の既定は tests: true

forbidigo は既定で呼び出しの字面だけを照合する

字面の照合は slog.Info しか捕まえず、analyze-types は 4 形を捕まえるが、パターンをインポートパスで書くと 0 件になる

slog の直呼びを禁止して共通のラッパーへ寄せる規約を、4 つの呼び方で試す。

// main.go
package main

import (
	"context"
	"log/slog"
)

type handler struct{ lg *slog.Logger }

func main() {
	ctx := context.Background()
	slog.Info("a")
	slog.Default().InfoContext(ctx, "b")
	lg := slog.Default()
	lg.InfoContext(ctx, "c")
	h := handler{lg: lg}
	h.lg.WarnContext(ctx, "d")
}
設定slog.Infoslog.Default().InfoContextlg.InfoContexth.lg.WarnContext
字面 ^slog\.(Debug|Info|Warn|Error)(Context)?$検出---
analyze-types: true + ^slog\.Logger\. と上の字面検出検出検出検出
analyze-types: true + ^log/slog\.Logger\.----

既定のパターンは、ソースに書かれた呼び出し式の綴りに当たる。lg.InfoContext はレシーバの型を追わないので素通りする。 analyze-types: true にすると、型を解決した slog.Logger.InfoContext にパターンを当てる。報告に出るのはソースの字面のまま。

linters:
  settings:
    forbidigo:
      analyze-types: true
      forbid:
        - pattern: ^slog\.Logger\.(Debug|Info|Warn|Error|Log)(Context|Attrs)?$
        - pattern: ^slog\.(Debug|Info|Warn|Error)(Context)?$
main.go:12:2: use of `slog.Info` forbidden by pattern `^slog\.(Debug|Info|Warn|Error)(Context)?$` (forbidigo)
main.go:13:2: use of `slog.Default().InfoContext` forbidden by pattern `^slog\.Logger\.(Debug|Info|Warn|Error|Log)(Context|Attrs)?$` (forbidigo)
main.go:15:2: use of `lg.InfoContext` forbidden by pattern `^slog\.Logger\.(Debug|Info|Warn|Error|Log)(Context|Attrs)?$` (forbidigo)
main.go:17:2: use of `h.lg.WarnContext` forbidden by pattern `^slog\.Logger\.(Debug|Info|Warn|Error|Log)(Context|Attrs)?$` (forbidigo)
4 issues:

パターンはパッケージ名 (slog) で書く。インポートパス (log/slog) で書くと、エラーにならず 0 件になる。 ラッパーの実装自身とテストは linters.exclusions.rules で外す。

  • PITFALL: 「1 件も出ないから元々違反が無い」と読むと、効いていない設定のまま移行を終える。設定を書いたら、違反を 1 つ置いて検出されることを確かめてから消す
  • PITFALL: *Context 版だけを禁止すると、ctx の無い版へ書き換えるのが抜け道になる。禁止は ctx 付きと無しの両方を並べる

exhaustive は名前付きの型の列挙だけを見る

名前付きの型の定数は switch と map の抜けを指摘し、型の無い定数は何も出ない

linters:
  settings:
    exhaustive:
      check: [switch, map]
      default-signifies-exhaustive: true   # default のある switch は網羅済み扱い
// typed.go
package e

type RequestStatus string

const (
	StatusActive   RequestStatus = "active"
	StatusCanceled RequestStatus = "canceled"
	StatusDone     RequestStatus = "done"
)

func Label(s RequestStatus) string {
	switch s {
	case StatusActive:
		return "進行中"
	case StatusCanceled:
		return "取消"
	}
	return ""
}

var labels = map[RequestStatus]string{
	StatusActive:   "進行中",
	StatusCanceled: "取消",
}

var allowedStatus = []RequestStatus{StatusActive, StatusCanceled}

func LabelWithDefault(s RequestStatus) string {
	switch s {
	case StatusActive:
		return "進行中"
	default:
		return ""
	}
}
// untyped.go
package e

const (
	PlanActive   = "active"
	PlanCanceled = "canceled"
	PlanDone     = "done"
)

func PlanLabel(s string) string {
	switch s {
	case PlanActive:
		return "進行中"
	case PlanCanceled:
		return "取消"
	}
	return ""
}

var planLabels = map[string]string{
	PlanActive:   "進行中",
	PlanCanceled: "取消",
}

var allowed = []string{PlanActive, PlanCanceled}
typed.go:12:2: missing cases in switch of type e.RequestStatus: e.StatusDone (exhaustive)
typed.go:21:14: missing keys in map of key type e.RequestStatus: e.StatusDone (exhaustive)
2 issues:

untyped.go の switch と map は、同じ抜けがあっても何も出ない。列挙を「名前付きの型と、その型の定数」で判別しているため。 default のある LabelWithDefault は、default-signifies-exhaustive: true で網羅済みとして扱われた。 型の無い定数同士は、別の意味の "active" を取り違えてもコンパイルが通る。状態値は型を付けて定義する。

  • PITFALL: 許可値を並べたスライス (allowedStatus) の追加漏れは、型を付けても拾えない
  • PITFALL: v1 の書き方 (トップレベルの linters-settings:) を version: "2" の設定に書くと、golangci-lint run はエラーにせず、その設定を読まない。map の抜けが出なくなり、default のある LabelWithDefault が missing cases in switch of type e.RequestStatus: e.StatusCanceled, e.StatusDone で指摘された。golangci-lint config verify は additional properties 'linters-settings' not allowed で exit 3 になる

gopls の診断の一部は golangci-lint に無い

エディタに出る writestring の診断は gopls にしか無く、gopls check は指摘があっても exit 0

// g.go
// Package g is a sample.
package g

import "strings"

// Join joins a and b with a colon.
func Join(a, b string) string {
	var sb strings.Builder
	sb.WriteString(a + ":" + b)
	return sb.String()
}
$ golangci-lint run      # linters.default: all。非推奨 linter の警告行は省略
g.go:10:2: return with no blank line before (nlreturn)
g.go:9:2: expressions should not be cuddled with declarations or returns (wsl)
2 issues:

$ gopls check g.go
/path/to/g.go:9:2-29: Inefficient string concatenation in call to WriteString
(exit 0)

エディタに出る Inefficient string concatenation in call to WriteString は、gopls に同梱された writestring analyzer の診断。golangci-lint は全 linter を有効にしても出さない。文字列がどちらのバイナリに入っているかで、出どころを確かめられる。

$ strings "$(command -v gopls)" | grep -c 'Inefficient string concatenation'
1
$ strings "$(command -v golangci-lint)" | grep -c 'Inefficient string concatenation'
0

gopls の analyzer のうち golang.org/x/tools/gopls/internal/analysis/ にあるものは、Go の internal パッケージの規則で外から import できない。公開パス (go/analysis/passes/modernize など) にあるものだけが linter 側に入る。golangci-lint を上げても、公開パスへ移されない限り入らない。

CI に入れるなら gopls check を使う。指摘があっても exit 0 なので、出力が空でないことを失敗にする。引数はファイル名で、./... は no such file or directory で exit 2 になる。

lint-gopls:
	@out=$$(gopls check $$(git ls-files --cached --others --exclude-standard '*.go')); \
	if [ -n "$$out" ]; then echo "$$out"; exit 1; fi
  • PITFALL: git ls-files '*.go' だけだと、未追跡の新しいファイルが外れる
  • PITFALL: リポジトリ全体を見るので、gopls を上げて analyzer が増えると、触っていないファイルで落ちる。差分だけを見る lint と同じ入口にぶら下げると、その分担が崩れる

まとめ

場面0 issues になる理由対策
issues.new: true追加行の指摘だけを残す。クリーンな checkout は HEAD~ との差分だけCI は --new=false。変更ファイル全体なら --new-from-rev + --whole-files
shallow clone差分を取れず全件に戻る (警告あり)全件を見たいなら --new=false で明示し、clone の深さに頼らない
issues.fix: true直した指摘を報告から外す。--new=false でも効くCI は --fix=false、後ろに git diff --exit-code
//nosecgosec の注記は #nosec。gosec 単体でも golangci-lint でも効かない// #nosec G204 -- 理由 か //nolint:gosec // 理由
#nosec G204名指しした ID だけを抑止する新しい ID は懸念が同じか確かめてから足す
build tag 付きのファイル読み込まれない。go vet も同じrun.build-tags に入れる。実行の口は別に作る
staticcheck の既定ST1000 / ST1003 / ST1016 / ST1020 などを外すchecks: [all, -<要らないもの>]
run.tests: false_test.go を読まないv2 の既定 true のままにする
forbidigo既定は字面の照合。インポートパスのパターンは 0 件analyze-types: true とパッケージ名のパターン。違反を 1 つ置いて確かめる
exhaustive型の無い定数とスライスは見ない状態値に型を付ける
v1 の設定キーrun はエラーにせず読まないgolangci-lint config verify
gopls の analyzergolangci-lint に入っていないgopls check の出力の有無で失敗にする

参考