結論
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 つの段階で指摘を減らす

golangci-lint は 1 回の実行で、次の 3 つの段階を順に通してから報告する。
- 読み込み: パッケージのファイルを読み、型検査する
- 検査: 有効な linter に、読み込んだコードを検査させる
- 後処理: linter が出した指摘を、設定に従って絞り込む
前の段階で外れたものは後ろの段階に届かない。読み込まれなかったファイルは検査されず、検査で出なかった違反は後処理に来ない。
| 段階 | 何で決まるか | この記事の節 |
|---|---|---|
| 読み込み | 型検査に載せるファイル。run.build-tags、run.tests | build tag、staticcheck |
| 検査 | 有効な linter と、その設定 (チェックの一覧、パターン) | staticcheck、forbidigo、exhaustive、gosec の #nosec |
| 後処理 | issues.new (差分)、issues.fix (自動修正)、//nolint | issues.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 は差分で追加された行の指摘だけを残す

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 は直した指摘を報告から外す

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。抑止されるのは名指ししたものだけ

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-lint | gosec 単体 |
|---|---|---|---|
| 15 | なし | G204 | G204 |
| 16 | //nosec | G204 | G204 |
| 17 | // #nosec | 出ない | 出ない |
| 18 | // #nosec G204 | 出ない | 出ない |
| 19 | //nolint:gosec | 出ない | G204 |
| 20 | //nolint:errcheck | G204 | G204 |
| 21 | // #nosec G204 | G702 | G702 |
| 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 に入れないと読まれない

実環境に繋ぐテストを 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:
- devfirestoreissues.new: true と併用し、差分を .golangci.yaml の変更だけにしても、store_dev_test.go の undefined: Save (typecheck) は報告され exit 1 になった。型検査の失敗は差分のフィルタで落ちない。
- PITFALL: lint に載せても、タグ付きのテストが実行されるわけではない。実行する口 (
makeのターゲットとドキュメント) は別に要る
staticcheck は既定で ST の一部を外す

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] |
|---|---|---|---|
| S1002 | should omit comparison to bool constant | 出る | 出る |
| QF1003 | could use tagged switch on x | 出る | 出る |
| ST1005 | error strings should not end with punctuation | 出る | 出る |
| ST1023 | should omit type int from declaration | 出る | 出る |
| ST1000 | at least one file in a package should have a package comment | 出ない | 出る |
| ST1003 | var userId should be userID | 出ない | 出る |
| ST1016 | methods on the same type should have the same receiver name | 出ない | 出る |
| ST1020 | comment 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 の直呼びを禁止して共通のラッパーへ寄せる規約を、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.Info | slog.Default().InfoContext | lg.InfoContext | h.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 は名前付きの型の列挙だけを見る

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 に無い

// 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'
0gopls の 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 |
//nosec | gosec の注記は #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 の analyzer | golangci-lint に入っていない | gopls check の出力の有無で失敗にする |