Claude Codeでgolangci-lintを回す — 設定とhookで指摘をその場で直す
Claude Codeの編集直後にgolangci-lintを走らせるPostToolUse hookと、.golangci.ymlのリンタ選別、--new-from-revで指摘を新規コードに絞る設定をまとめます。
golangci-lintをhookに載せると何が変わるか
golangci-lintは、Goのリンタを束ねて1つのコマンドで走らせる実行器です。golangci-lint runだけでerrcheckやstaticcheckなどが動き、設定は.golangci.ymlに集まります。
これをClaude CodeのPostToolUse hookに載せると、Claudeがファイルを編集するたびに該当パッケージが検査されます。直せる指摘は--fixがその場で書き換え、残った指摘はstderrからClaudeに返ります。テストを回す前に、go buildでは通る誤りを拾えます。
ただし、既存のGoプロジェクトにそのまま入れると、触っていない箇所の指摘まで大量に返ってきます。そこで本記事では次の3点に絞ります。
- どのリンタを有効にするか(
.golangci.yml) - どの範囲の指摘を返すか(
--new-from-rev) - どのタイミングで走らせるか(PostToolUseとStop)
Go開発の全体像はClaude CodeでGoアプリを開発する手順に、言語ごとの自動修正コマンドの比較はPostToolUse hookのLint自動修正の早見表にあります。ここではgolangci-lintの設定とhook連携だけを掘ります。
v2を固定して入れる
ドキュメントのインストール例は、バイナリをGOPATHのbinに置く形です。バージョンを必ず指定します。
curl -sSfL https://golangci-lint.run/install.sh \
| sh -s -- -b $(go env GOPATH)/bin v2.14.0
golangci-lint --versionmacOSならHomebrewでも入ります。ただしドキュメントには、Homebrewが想定外のGoバージョンでバイナリをビルドすることがあり、公式バイナリか使うGoのバージョンの確認を勧める注記があります。go installやtoolディレクティブでの導入も「動く保証がない」とされ、バイナリ導入が推奨です。
バージョン固定は、hookの文脈でも効きます。CI向けの節には、linters.default: allを使うと新しいリンタが追加された時点で、あるいは上流のリンタが更新された時点で、全ビルドが一斉に落ちうるという説明があります。Claudeが直し続ける対象がある日突然増えないよう、手元もCIも同じ版に揃えます。
設定ファイルの書式はversion: "2"の形式です。設定ファイルとして認識されるのは、次のファイル名です。
.golangci.yml.golangci.yaml.golangci.toml.golangci.json
どれが読まれているかはgolangci-lint run -vで確認できます。分析対象の先頭パスから親ディレクトリをたどって探すため、サブディレクトリで実行しても、リポジトリ直下の設定が拾われます。
.golangci.ymlでリンタを選ぶ
設定なしで動かしたときに有効なのは、次の5つです。
| リンタ | ドキュメントの説明 |
|---|---|
errcheck | ドキュメントの説明確認されていないエラーを検出する |
govet | ドキュメントの説明go vetとほぼ同じ。怪しい構文を報告する |
ineffassign | ドキュメントの説明使われない代入を検出する |
staticcheck | ドキュメントの説明staticcheckのルール群 |
unused | ドキュメントの説明使われない定数・変数・関数・型を検出する |
linters.defaultの既定値はstandardで、この5つを指します。Claudeに回させる場合は、standardを土台にして、実行時に効く指摘を足す構成を第一候補にします。
hookで返す価値がある指摘とは
本記事では、次の基準で足すリンタを選びます。
- ビルドは通るが、実行時に問題を起こす指摘であること
- Claudeが自力で直し方を判断できる局所的な指摘であること
- 自動修正がないものは、直す手間が小さいこと
指摘の数が多くて文脈を食う種類は、hookには向きません。以下は、この基準で候補にしたリンタの一覧です。説明はドキュメントの記述を要約しています。
| リンタ | 検出するもの | 自動修正 |
|---|---|---|
bodyclose | 検出するものHTTPレスポンスボディを閉じ忘れている | 自動修正なし |
sqlclosecheck | 検出するものsql.Rowsやsql.Stmtなどを閉じ忘れている | 自動修正なし |
rowserrcheck | 検出するものRows.Errを確認していない | 自動修正なし |
noctx | 検出するものcontext.Contextを使っていない関数・メソッド | 自動修正なし |
contextcheck | 検出するもの親から引き継いでいないcontextを使っている | 自動修正なし |
nilerr | 検出するものエラーを確認したのにnilを返している | 自動修正なし |
gosec | 検出するものセキュリティ上の問題 | 自動修正なし |
errorlint | 検出するものGo 1.13のエラーラップで問題になる書き方 | 自動修正あり |
自動修正の有無は、ドキュメントの一覧にAutofixの表示があるかどうかで確認できます。自動修正があるリンタは、--fixが機械的に直すのでClaudeのトークンを使いません。自動修正がないリンタは、指摘がClaudeの文脈に入ります。数を増やすほど、その分のやり取りが増えます。
逆に、modernizeやintrangeのようにコードの書き方を新しい形へ寄せる系統は、自動修正が付く反面、既存コードの広い範囲に当たります。--fixと組み合わせると差分が膨らむため、まず入れず、必要になった時点で--new-from-revと併用して広げる運用を想定しています。関数の長さや複雑度に上限を置くリンタも、既存コードに当たりやすい点は同じです。
設定ファイルの例
version: "2"
linters:
default: standard
enable:
- bodyclose
- contextcheck
- errorlint
- gosec
- nilerr
- noctx
- nolintlint
- rowserrcheck
- sqlclosecheck
settings:
nolintlint:
require-explanation: true
require-specific: true
exclusions:
generated: strict
rules:
- path: _test\.go
linters:
- gosec
formatters:
enable:
- gofmt
- goimports
issues:
max-same-issues: 0これはドキュメントの設定リファレンスの書式に沿った、本記事の例示です。各キーの意味は次のとおりです。
linters.enableはdefaultに足すリンタですexclusions.generatedの既定はstrictで、// Code generated ... DO NOT EDIT.の行を持つファイルを除外しますexclusions.rulesのpathとlintersは、テストファイルからgosecの指摘を外す書き方です。ドキュメントの例と同じ型ですformattersはlintersとは別の節です。フォーマッタはlintersの節や-Eで有効にできませんmax-same-issuesの既定は3で、同じ文面の指摘は3件までしか出ません。0にすると全件が出ます
nolintlintはnolintの書き方そのものを検査するリンタです。require-explanationは理由の記載を、require-specificは抑制するリンタ名の明記を要求します。後述するとおり、Claudeに指摘を黙らせる近道を使わせないための歯止めになります。
--new-from-revで見る範囲を決める
既存のコードベースに入れる場合、全指摘を一度に直すのは現実的ではありません。ドキュメントも「新しいコードに指摘を出さないほうが、既存の指摘を全部直すより実用的」という立場で、新規分に絞るオプションを複数用意しています。
| オプション | 見る範囲 |
|---|---|
--new / -n | 見る範囲未ステージの変更や未追跡ファイルがあればそれだけ。なければHEAD~との差分 |
--new-from-rev REV | 見る範囲指定したGitのリビジョン以降に作られた指摘 |
--new-from-merge-base | 見る範囲HEADとのmerge-base以降に作られた指摘 |
--new-from-patch PATH | 見る範囲指定したパッチファイルの差分に含まれる指摘 |
--whole-files | 見る範囲変更されたファイルは全体を対象にする(new-from-revかnew-from-patchが前提) |
設定ファイルではissues.new、issues.new-from-rev、issues.new-from-merge-base、issues.whole-filesが同じ役割です。
使い分けは次のとおりです。
- PostToolUse hookは
--new-from-rev=HEADにします。コミット済みの内容との差分、つまりその時点の未コミットの変更だけが対象です - Stop hookは
--new-from-merge-base=mainにします。ブランチ全体で足した分がすべて対象です(ブランチ名は実際の主ブランチに置き換えます) - CIは
--new-from-rev=HEAD~か--new-from-merge-baseにします
CIで--newを避ける理由は、CLIのヘルプに書かれています。--newは、golangci-lintの実行前にスクリプトが未ステージのファイルを生成すると、現在のパッチの検査を飛ばすことがあるためです。CIには--new-from-rev=HEAD~が勧められています。
--whole-filesは、変更した行ではなくファイル全体の指摘を返します。Claudeに「触ったファイルは全部きれいにして」と任せたいときは有効ですが、古いファイルを1行直しただけで多数の指摘が返ることがあるので、最初は付けないほうが扱いやすくなります。
PostToolUse hookで編集直後に検査する
hookの設定は.claude/settings.jsonに書きます。Edit|Writeにマッチさせ、コマンドはスクリプトに逃がします。パスは${CLAUDE_PROJECT_DIR}で指し、argsを付けて、シェルを介さずに起動します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/golangci-lint.sh",
"args": [],
"timeout": 120
}
]
}
]
}
}PostToolUseの入力はJSONで、編集したファイルの絶対パスがtool_input.file_pathに入っています。スクリプトはこれを読み、Goファイルでなければすぐ終わります。
mkdir -p .claude/hooks
cat > .claude/hooks/golangci-lint.sh <<'SH'
#!/bin/bash
file_path=$(jq -r '.tool_input.file_path // empty')
case "$file_path" in
*.go) ;;
*) exit 0 ;;
esac
if ! command -v golangci-lint >/dev/null; then
echo "golangci-lint が見つかりません" >&2
exit 1
fi
cd "$CLAUDE_PROJECT_DIR" || exit 1
dir=$(dirname "$file_path")
rel=".${dir#"$CLAUDE_PROJECT_DIR"}"
output=$(golangci-lint run --fix --new-from-rev=HEAD "$rel" 2>&1)
if [ $? -ne 0 ]; then
echo "$output" >&2
exit 2
fi
exit 0
SH
chmod +x .claude/hooks/golangci-lint.sh要点は3つあります。
1つ目は、ファイルでなくディレクトリを渡すことです。golangci-lint runはファイルも受け取れますが、ファイルとパッケージの混在はできず、ファイルは同じパッケージのものに限られます。Goのリンタは型情報を使うため、パッケージ単位で渡すほうが素直です。ディレクトリは再帰的に分析されず、そのパッケージだけが対象です。relの行は、絶対パスのディレクトリを./internal/fooのような相対パスに直しています。プロジェクト直下のファイルなら.になります。
2つ目は、--fixでその場で直すことです。golangci-lint run --fixは、リンタの自動修正と、formattersに設定したフォーマッタの修正の両方を適用します。golangci-lint run単体はフォーマッタを実行するだけでコードは整形しません。hookで整形まで済ませたいなら--fixが必要です。整形だけを別に走らせたいときはgolangci-lint fmtを使います。
3つ目は、終了コード2で残りを返すことです。PostToolUseでは、終了コード2でもツールは実行済みなので取り消せませんが、stderrの内容がClaudeに届きます。終了コード0のまま返すと、stderrはClaudeに見えません。golangci-lintは指摘があると既定で終了コード1を返し、--issues-exit-codeで変えられます。スクリプトは非ゼロをすべて2に変換しています。この変換により、設定ファイルの誤りのような実行自体の失敗も、Claudeに伝わります。PostToolUse hookの仕組み全般はPostToolUse hookでツール実行後の後処理を自動化するにあります。
BiomeでJavaScript側に同じ型を組んだ例はClaude CodeでBiomeを使うにあります。違いは、Biomeが1ファイルを直接渡せるのに対し、golangci-lintはパッケージ単位で渡す点です。
Stop hookで最終ゲートを置く
PostToolUseは編集の直後に走りますが、--new-from-rev=HEADは、Claudeが途中でコミットすると、そこまでの変更が差分から消えます。ブランチ全体の整合を取る最終ゲートは、Stop hookに分けます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/golangci-lint-stop.sh",
"args": [],
"timeout": 300
}
]
}
]
}
}cat > .claude/hooks/golangci-lint-stop.sh <<'SH'
#!/bin/bash
input=$(cat)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 1
output=$(golangci-lint run --new-from-merge-base=main ./... 2>&1)
if [ $? -ne 0 ]; then
echo "$output" >&2
exit 2
fi
exit 0
SH
chmod +x .claude/hooks/golangci-lint-stop.shStop hookの終了コード2は、Claudeが止まるのを防ぎ、会話を続けさせます。stop_hook_activeは、すでにStop hookの結果で続行している最中にtrueになります。この確認を入れないと、直らない指摘があるたびにループします。さらに、Claude Codeは連続8回ブロックした時点でhookを上書きして止める安全弁も持っています。上限を変えたい場合はCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで調整します。
編集のたびに全体を検査するのは重いので、PostToolUseは変更パッケージ、Stopはブランチ全体と、検査の粒度を分けています。
指摘を黙らせる近道を塞ぐ
リンタを回すと、Claudeが指摘を直さずに消してしまうことがあります。典型は、//nolintの追加と、.golangci.ymlの書き換えです。
nolintの書き方を決める
ドキュメントによると、nolintは通常のコメントではなく「ディレクティブ」で、//とnolint、nolintと:、:とリンタ名の間にスペースを入れられません。// nolintや//nolint: xxxは無効な書式で、期待どおりに指摘が消えません。正しい形は//nolint:xxxです。理由は同じ行に// 理由の形で添えられます。
//nolint:gocyclo // 移行期間中の旧関数。分割は別PRで行う
func legacyHandler() error {
// ...
}この書式をCLAUDE.mdに書いておき、nolintlintで機械的に検査します。require-explanationとrequire-specificを有効にした設定なら、理由もリンタ名もないnolintが、それ自体が指摘になります。
## Goのlint
- 編集後にgolangci-lintのhookが走る。返ってきた指摘は`//nolint`で消さず、コードを直す
- どうしても抑制が必要なときは`//nolint:<リンタ名> // <理由>`の形で書く
- `.golangci.yml`は編集しない。設定を変えたいときは、理由を説明して確認を取るCLAUDE.mdはClaudeが文脈として読む指示で、強制ではありません。守らせたい部分は、設定側の仕組みで縛ります。
設定ファイルへの編集を拒否する
.golangci.ymlの書き換えは、権限のdenyルールで止められます。Edit(/path)のパスは、プロジェクトの設定ファイルに書いた場合、プライマリ作業ディレクトリからの相対位置を指します。
{
"permissions": {
"deny": [
"Edit(/.golangci.yml)"
]
}
}denyルールは、allowより先に評価され、先に一致した結果が優先されます。ルールの書式はClaude Codeの/permissionsコマンドにも整理があります。
つまずきやすい点
--fast-onlyは期待ほど絞れない
hookを速くしようと--fast-onlyを足したくなりますが、ドキュメントのヘルプ出力例で[fast]と表示されている既定リンタはineffassignだけです。errcheckやstaticcheckなどにはその表示がありません。fastなリンタだけにすると、実行時に効く指摘が検査から外れる可能性があります。fastに分類されるリンタは、golangci-lint help linters --jsonのfastで確認できます。
指摘の件数に上限がある
max-issues-per-linterの既定は50、max-same-issuesの既定は3です。同じ文面の指摘は3件で打ち切られるため、直すたびに続きが出る動きになります。Claudeが何往復もするより、max-same-issues: 0にして一度に見せたほうが速い場合があります。逆に、文脈を節約したいなら既定のままにします。
初期コミット前のリポジトリ
--new-from-rev=HEADはHEADを基準にします。コミットが1つもないリポジトリでは参照先がなく、失敗する可能性があります。失敗はスクリプトを経由してClaudeに届くので、最初のコミットを作ってから有効にすると切り分けやすくなります。
go.modが複数あるリポジトリ
Goのパッケージ解決はモジュール単位なので、モジュールをまたぐ構成ではhook側でgo.modのあるディレクトリに移動してから実行する必要があります。ここは構成ごとにスクリプトの調整が要る部分です。
生成コードの扱い
生成ファイルはexclusions.generatedの既定値strictで外れます。strictが見るのはGoの慣習に沿った// Code generated ... DO NOT EDIT.の行だけです。それ以外の表記の生成ファイルも外したいときはlaxにします。disableにすると、生成ファイルも検査されます。
導入の順序
最初から全部を入れると、何が原因でhookが赤くなったのか追えません。次の順に足すと切り分けやすくなります。
golangci-lint runを手元で実行して、standardの5つが通る状態にする.golangci.ymlにnolintlintと、実行時に効くリンタを足す- PostToolUse hookを入れ、
--new-from-rev=HEADで運用する - Stop hookでブランチ全体を検査する
settings.jsonのdenyルールとCLAUDE.mdの規約を入れる
CI側にも同じ設定を使えば、手元で通ったものがCIで落ちる、という食い違いを減らせます。CIではバージョンを固定し、--new-from-rev=HEAD~か--new-from-merge-baseを使います。
まとめ
golangci-lintをClaude Codeに回させる設定は、リンタの選別、範囲の指定、タイミングの3層に分かれます。リンタはstandardを土台に、実行時に効く指摘を足し、nolintlintで抑制の書き方を縛ります。範囲は--new-from-rev=HEADで未コミットの変更に絞り、ブランチ全体はStop hookと--new-from-merge-baseで見ます。
PostToolUse hookは、パッケージ単位で--fixを付けて走らせ、残った指摘を終了コード2でClaudeに返します。既存コードに大量の指摘がある状態でも、新しく書いた部分から品質を揃えていけます。
同じ型は、Pythonならruffとmypyの検証ループ、JavaScriptならBiomeで組めます。リンタの違いは、検査の単位と自動修正の範囲に表れます。