Claude Media
PostToolUse hookのLint自動修正 — 言語別コマンド12種の早見表

PostToolUse hookのLint自動修正 — 言語別コマンド12種の早見表

PostToolUse hookでLintとFormatを自動修正するコマンドを、ESLintからSwiftLintまで12種の早見表でまとめます。修正できない指摘が残ったときの伝え方も扱います。

PostToolUse hookでLintを自動修正する仕組み

Claude CodeがファイルをEdit・Writeしたあとに、ESLintやPrettierなどのLint/Formatツールを自動で走らせて直す。これがPostToolUse hookでの自動修正の基本形です。matcherとhandlerの基本構文はPostToolUse hookでツール実行後の後処理を自動化するに譲り、本記事ではLint/Formatに特化した言語別のコマンドと、直せない指摘が残ったときの扱いに絞って扱います。Hooks全体の33種類のイベント一覧はClaude Code Hooks完全ガイドにまとめています。

PostToolUse hookには1つ制約があります。ツールの実行結果をブロックできません。ファイルの書き込みはすでに完了しているため、フックにできるのは後始末とClaudeへのフィードバックだけです。この制約は、Lint自動修正フックの設計に直接影響します。フォーマットのように必ず成功する処理は問題になりませんが、Lintのように「直せる指摘」と「直せない指摘」が混在するツールでは、直せなかった分をどうClaudeに伝えるかが本題になります。

設定の最小形 — 対象拡張子はifかスクリプト側で絞る

最小構成は、Edit|Writeにマッチさせたハンドラーに自動修正コマンドを1本書くだけです。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-autofix.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

言語ごとにコマンドを切り替えたい場合、ハンドラーのifフィールドで拡張子ごとに分ける方法と、1本のスクリプトの中でtool_input.file_pathの拡張子を見て分岐する方法の2つがあります。if: "Edit(*.py)"のように書けば、Pythonファイルのときだけ動くハンドラーを別に定義できます。ツールが多い場合はスクリプト側で分岐したほうが設定は短くなります。後半の実装例はこの方式で書いています。

言語別Lint/Format自動修正コマンド早見表

自動修正コマンドの効き方はツールによって性格が違います。フォーマッタは基本的に全件を直しますが、Lintツールは「安全に直せる指摘」だけを直し、残りは検出のみで終わります。型検査ツールにはそもそも自動修正コマンドがありません。Svelteのsv checkを編集直後に走らせる例はClaude CodeでSvelteKitを開発するにあります。

対象ツール自動修正コマンド自動修正の範囲
JS/TS(コード品質)ツールESLint自動修正コマンドeslint --fix <file>自動修正の範囲ルール違反のうち安全な修正のみ。--fix-typeで対象を絞れる
JS/TS/CSS/JSON等(整形)ツールPrettier自動修正コマンドprettier --write <file>自動修正の範囲整形のみ。コード品質のルールは見ない
JS/TS/CSS/JSON等(一括)ツールBiome自動修正コマンドbiome check --write <file>自動修正の範囲Lint・Format・import整理を1コマンドで適用
CSS/SCSSツールstylelint自動修正コマンドstylelint --fix <file>自動修正の範囲修正可能なCSSルール違反のみ
Python(Lint/Format)ツールruff自動修正コマンドruff check --fix <file> / ruff format <file>自動修正の範囲checkは既定で安全な修正のみ(unsafeな修正は--unsafe-fixesが別途必要)。formatは整形専用
Python(型検査)ツールmypy自動修正コマンドmypy <file>自動修正の範囲自動修正コマンドは無い。型エラーの報告のみ
Python(型検査)ツールpyright自動修正コマンドpyright <file>自動修正の範囲自動修正コマンドは無い。型エラーの報告のみ
Goツールgolangci-lint自動修正コマンドgolangci-lint run --fix自動修正の範囲リンター・フォーマッターが検出した指摘のみ。対応の有無はリンターごとに異なる
RustツールClippy自動修正コマンドcargo clippy --fix自動修正の範囲一部のlintのみ自動修正。未コミットの変更が残っているとfixが拒否される場合がある
Kotlin(整形)ツールktlint自動修正コマンドktlint -F自動修正の範囲カレントディレクトリ以下の.kt/.ktsを一括走査。単一ファイルはパスを引数で渡す
Kotlin(静的解析)ツールdetekt自動修正コマンドdetekt --auto-correct自動修正の範囲既定のルールセットは自動修正非対応。--pluginsでktlintのルールセットを追加しないと何も直らない
SwiftツールSwiftLint自動修正コマンドswiftlint --fix自動修正の範囲修正可能なルール違反のみ。--strictと組み合わせることもできる
くらべる

ツールの3つの性格と、フックに要る後処理

後処理は不要

フォーマッタ

Prettier・ktlint・SwiftLintの整形部分が該当します。入力を1つの形に正規化するだけなので、「直らなかった指摘」という状態がほぼ生まれません。

再チェックが要る

Lint

ESLint・stylelint・ruffのcheck・golangci-lint・Clippyは、安全な指摘だけ直して判断が要る指摘を残します。「直った」「直らなかった」を後段で判定する必要があります。ESLint 9のflat configへの移行でこの判定を繰り返す手順は、Claude CodeでESLintのflat config移行を進めるにあります。

検出だけで終わる

型検査

mypy・pyrightは書き換え機能がなく、検出しかしません。報告された型エラーは、そのまま終了コード2でClaudeに渡す形になります。

BiomeのようにLintとFormatを1コマンドにまとめたツールを使うか、ESLintとPrettierのように役割を分けたツールを組み合わせるかは、プロジェクトの既存構成に依存します。両方を同じディレクトリに残したまま二重に走らせると、同じ種類の指摘が重複して報告されることがあり、Claudeが読む指摘も重複します。フックを新設するタイミングで、どちらか一方の設定ファイルを無効化しておくと、この重複を避けられます。Biomeのhook設定はClaude CodeでBiomeを使うにまとめています。

自動修正後も直らない指摘をどうClaudeに伝えるか

Lintツールの--fixは魔法ではありません。ESLintの--fixが直せるのは、修正可能(fixable)と定められたルールの違反だけです。フックの設計で肝になるのは、この「直らなかった分」をClaudeにどう見せるかです。

PostToolUseはツールの実行結果を差し戻せませんが、終了コード2を返すとClaudeに標準エラー出力(stderr)が見える形で表示されます。これを使い、Lintの再チェック結果が残っている場合だけ終了コード2にする設計が基本形になります。

状態終了コードClaudeに伝わる内容
自動修正で全て解消終了コード0Claudeに伝わる内容デバッグログのみ。会話には何も表示されない
自動修正後も指摘が残る終了コード2Claudeに伝わる内容stderrの内容がツール結果の横に表示される(実行済みのツールをブロックすることはできない)
型検査ツールでエラーを検出終了コード2Claudeに伝わる内容同上。修正コマンドが無いツールでは、検出したらそのまま終了コード2にする設計になる
リンター自体が異常終了終了コード2以外の非0Claudeに伝わる内容標準出力がJSONでなければ非ブロッキングのエラー扱い。<hook name> hook errorの通知にstderrの1行目が付く

この仕組みが繰り返されると、Claudeが次のターンでstderrの指摘を読み、該当箇所を編集し、フックがまた自動修正とチェックを走らせる、という運用上のループになります。ただしこれはClaude Codeが保証する自動リトライではなく、Claudeがstderrを読んで自発的に動くかどうかに依存します。フックが返せるのは「直せなかった事実をClaudeの目の前に置く」ところまでです。

終了コード2を使わず、JSON出力のdecision: "block"とreasonで伝える設計もあります。PostToolUseはdecisionとreasonを最上位のフィールドとして扱う側のイベントなので、標準出力に{"decision": "block", "reason": "..."}を出し、終了コードは0のままにする形です。この場合、フックスクリプト自体は正常終了として扱われつつ、指摘の文面だけがツール結果の横に添えられます。フックの実行が失敗したのかLintの指摘が残っているだけなのかを区別してログに残したいときは、stderr+終了コード2よりもこちらのほうが扱いやすくなります。

複数言語をディスパッチする自動修正フックの実装例

拡張子で分岐し、自動修正後に再チェックして残りがあれば終了コード2にするスクリプトの例です。

mkdir -p .claude/hooks
cat > .claude/hooks/lint-autofix.sh <<'SH'
#!/bin/bash
input=$(cat)
file_path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
[ -z "$file_path" ] && exit 0
 
case "$file_path" in
  *.ts|*.tsx|*.js|*.jsx)
    npx eslint --fix "$file_path" >/dev/null 2>&1
    remaining=$(npx eslint "$file_path" 2>&1)
    ;;
  *.py)
    ruff check --fix "$file_path" >/dev/null 2>&1
    remaining=$(ruff check "$file_path" 2>&1)
    ;;
  *.go)
    golangci-lint run --fix "$file_path" >/dev/null 2>&1
    remaining=$(golangci-lint run "$file_path" 2>&1)
    ;;
  *)
    exit 0
    ;;
esac
 
if [ -n "$remaining" ]; then
  echo "$remaining" >&2
  exit 2
fi
exit 0
SH
chmod +x .claude/hooks/lint-autofix.sh
手順

スクリプトが1回動くときの流れ

  1. 1

    stdinのJSONからfile_pathを取り出す

    jq -r '.tool_input.file_path // empty'で絶対パスを読みます。空ならexit 0で何もしません。

  2. 2

    拡張子で分岐して自動修正を走らせる

    --fixの出力は/dev/nullに捨てます。この時点の終了コードは見ません。

  3. 3

    同じコマンドを修正なしで再実行して残りを集める

    出力をremainingに入れます。--fixの出力ではなく、直したあとのファイルに対する検査結果を見るためです。

  4. 4

    残りがあればstderrに書いて終了コード2

    remainingが空でなければecho "$remaining" >&2のあとexit 2にします。Claudeに見える文面をここで自分で組み立てます。

Claude Codeを起動せずにスクリプトの分岐を試す

フックの入力はstdinに渡されるJSONなので、printfで同じ形のJSONを流し込めば、分岐と終了コードをClaude Codeなしで確かめられます。次の例は自動修正ツールの代わりに、Python標準のpython3 -m py_compileを再チェック役に置いた縮小版です(Claude Code v2.1.291の環境で実行)。構文エラーのあるbad.pyは終了コード2とstderrの本文が返ります。正常なok.pyと対象外のnote.mdは終了コード0で素通りしました。

cat > check.sh <<'SH'
#!/bin/bash
file_path=$(jq -r '.tool_input.file_path // empty')
case "$file_path" in
  *.py) remaining=$(python3 -m py_compile "$file_path" 2>&1) ;;
  *) exit 0 ;;
esac
[ -n "$remaining" ] && { echo "$remaining" >&2; exit 2; }
exit 0
SH
chmod +x check.sh
printf 'def f(:\n    pass\n' > bad.py
printf '{"tool_name":"Edit","tool_input":{"file_path":"%s/bad.py"}}' "$PWD" | ./check.sh; echo "exit=$?"
  File "<dir>/bad.py", line 1
    def f(:
          ^
SyntaxError: invalid syntax
exit=2

ここで返った文字列が、実運用のフックでClaudeの目に届くstderrになります。file_pathを持たない入力({})や.mdのような対象外の拡張子も終了コード0で終わるので、想定外のツールが混ざっても誤って止まりません。

Bashで書き換えたファイルはPostToolUseに届かない

Edit|WriteにマッチさせたPostToolUseフックが動くのは、EditツールとWriteツールが書いたときだけです。Claudeがsed -iやコード生成コマンドをBashで走らせてファイルを書き換えても、Edit|Writeのフックは動きません。Claude Codeの外のプロセスが書いた場合も同じです。

代替

Bash経由の書き換えを拾う3つの道

  • matcherを外す

    matcherを省くか"*"にすると、成功した全ツール呼び出しの後にフックが動きます。フック側でgit status --porcelainを走らせれば、未追跡ファイルを含む変更ファイルを自分で拾えます。

  • FileChangedイベント

    ファイル監視でツールを問わず発火します。ただしmatcherはワーキングディレクトリ直下のリテラルなファイル名(.envrc|.envのように|区切り)で、*.pyのような任意のglobでは監視できません。設定ファイルのような固定名のファイル向けです。

  • bashEditDiff

    Bashコマンドで変わったファイルがPostToolUseのtool_response.bashEditDiffに入ります。v2.1.269以降で、bashEditDiffEnabled設定を有効にしたとき、またはオートモードとbypassPermissionsモードのうち、Claude CodeがBash経由のファイル編集をClaudeに指示した場合に限って記録されます。gitリポジトリ内で、gitが無視するファイルとサブモジュールは対象外です。

並列のツール呼び出しにも注意が要ります。PostToolUseはツール1回ごとに発火するため、Claudeが複数のファイルを並列に編集すると、フックも同時に複数走ります。バッチ全体で1回だけ動かしたい検査は、全ツールの完了後に1度だけ発火するPostToolBatchが受け皿になります。

ただしPostToolBatchでは、終了コード2やdecision: "block"の意味が変わります。次のモデル呼び出しの前にエージェントのループそのものを止める動作になるためです。Lintの指摘をClaudeに渡すだけなら、hookSpecificOutput.additionalContextにJSONで書く形が向いています。

実行時間を抑える工夫 — 単一ファイルの範囲に留める

上のスクリプトはtool_input.file_pathで渡された1ファイルだけを対象にしています。eslint --fix .のようにプロジェクト全体を毎回スキャンする書き方もできますが、Editのたびに全体のLintが走り、その間Claudeは次の動作を待たされます。コマンドhookの既定タイムアウトは600秒で、timeoutフィールドで変えられます。単一ファイルに絞れば、待ち時間はほぼファイル1つの解析コストだけで済みます。

golangci-lintやClippyのようにビルドを伴うツールでは、単一ファイル指定でもコンパイル自体の時間がかかります。どうしても重くなる場合は、ハンドラーにasync: trueを付けてバックグラウンド実行にする選択肢があります。ただし通常のasyncではdecisionなどの応答フィールドが効かず、結果として次のターンに届くのはJSONのadditionalContextとsystemMessageだけです。stderrと終了コード2では指摘を見せられません。失敗したときだけClaudeを起こしたいなら、asyncRewake: trueを使います。バックグラウンドの終了コードが2のときにstderr(空ならstdout)がシステムリマインダーとしてClaudeに渡るため、重いチェックを裏で走らせつつ、問題があったときだけ通知する構成にできます。

よくあるつまずき

detektに--auto-correctを渡しても何も直らない

detektの既定のルールセットは自動修正に対応していません。フォーマット関連の修正を効かせたい場合は、--pluginsでktlintのルールセットを追加する必要があります。追加なしで--auto-correctだけ渡すフックは、指摘は出るが1文字も直らない状態になります。

.cmd・.batのシムをexec形式で直接呼んで失敗する

Windowsでnode_modules/.binにインストールされるESLintやPrettierの.cmdシムは実行ファイルではないため、argsを指定したexec形式では起動できません。commandをnodeにして本体スクリプトのパスをargsに渡すか、argsを省いたシェル形式で呼び出します。この制約自体はPostToolUse hookの記事で扱っている一般的な落とし穴です。

golangci-lintの--fixが一部の指摘を直さない

--fixは「リンターとフォーマッターが検出した修正のうち、そのリンターが対応しているものだけ」を適用します。対応していないリンターの指摘は、--fixを付けても残り続けます。整形だけを目的にするならgolangci-lint fmtという専用サブコマンドもあり、run単体はチェックのみでファイルを書き換えません。再チェックで検出した残りをそのままClaudeに渡す設計であれば、この差は特に問題になりません。

Clippyの--fixが未コミットの変更で拒否される

Clippyの--fixはcargo fixの仕組みを使って動くため、作業ディレクトリに未コミットの変更が残っていると拒否されることがあります。Claudeが直前に編集したファイルもまだコミットされていないため、この拒否に当たりやすくなります。--allow-dirtyを明示するかどうかは、フックの中で変更を巻き込んでよいかの判断になります。

mypy・pyrightに--fix相当のフラグを探してしまう

型検査ツールは指摘を報告するだけで、コードを書き換える機能を持ちません。同じフックの中でESLintやruffと並べて「自動修正」を期待すると、想定と違う挙動に見えます。型検査の結果は、検出した時点でそのまま終了コード2にしてClaudeに渡す設計になります。

まとめ

自動修正コマンドの選定よりも、直せなかった指摘を再チェックで拾って終了コード2でClaudeに見せる後半部分が、このフックの要です。フォーマッタだけを走らせる構成なら再チェックは省け、Lintや型検査を含める構成なら残りを渡す仕組みが要ります。matcherやifの基本設定はPostToolUse hookでツール実行後の後処理を自動化する、失敗時だけ動く別系統のフックはPostToolUseFailure hookでツール失敗時だけ動くリカバリを書くを参照してください。hookを使わずCLAUDE.mdとpermissionsでruffとmypyの実行順を決める方法は、Claude Codeでruffとmypyの検証ループを組むにあります。

この記事を共有:XはてブLinkedIn