Claude CodeでLighthouse CIを自動実行 — Hooksでパフォーマンス回帰を防ぐ
Claude CodeのHooksでLighthouse CIを自動実行し、性能回帰があるセッションの終了をブロックする設計をStop/PostToolUse/FileChangedの使い分けで解説します。
Lighthouse CIは、GoogleChrome組織が公開しているパフォーマンス回帰検知用のOSSツール群です。Claude CodeのHooksと組み合わせると、AIがコードを変えた直後にLighthouseの計測を挟み、スコアが悪化したままのセッション終了を止められます。この記事ではStop・PostToolUse・FileChangedの3イベントを比べ、動くsettings.jsonとスクリプトを示します。
Lighthouse CIは何をして、いつ失敗するのか
Lighthouse CIは、Lighthouseの計測結果を継続的に実行・保存・アサートするためのツール群で、ライセンスはApache-2.0です。READMEのQuick Startは、GitHub Actions上で次のコマンドを実行する例です。
npm install && npm install -g @lhci/cli@0.15.x
npm run build
lhci autorunlhci autorunはcollect(計測)・assert(判定)・upload(保存)を順に走らせるエントリーポイントです。フックに載せる前に押さえておきたいのは、次の3点です。
- 設定ファイルは作業ディレクトリの
lighthouserc.jsonや.lighthouserc.jsなどから自動で読まれる - 計測するHTMLの置き場所は
dist・build・out・publicの順で探す。outはnext exportで生成されたものだけが対象 collect.numberOfRunsの既定値は3で、1回のautorunでLighthouseが3回走る
見落としやすいのが、assertの設定がなければ、性能が悪化してもコマンドは失敗しない点です。assertionsでerrorレベルを指定した項目だけが、非ゼロの終了コードの原因になります。warnは結果を表示するだけで、終了コードは変わりません。性能予算をゲートにするなら、まずlighthouserc.jsonに予算を書きます。
{
"ci": {
"collect": {
"staticDistDir": "./out",
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }]
}
}
}
}categories:performanceはカテゴリ全体のスコアに対する判定です。個別の監査項目は、監査IDをキーにmaxNumericValueやmaxLengthで閾値を指定します。
どのイベントで走らせるか
最初の判断は、どのイベントで発火させるかです。候補はPostToolUse・Stop・FileChangedの3つで、得意な場面が違います。
| イベント | 発火タイミング | 計測結果で止められるか | 向いている使い方 |
|---|---|---|---|
PostToolUse | 発火タイミングツール呼び出し直後 | 計測結果で止められるか止められない(ツールは実行済み) | 向いている使い方特定ファイル編集への即時フィードバック |
Stop | 発火タイミングメインエージェントの応答完了時 | 計測結果で止められるか止められる(decision: "block"で継続を強制) | 向いている使い方終了前に予算超過を検知してゲートする |
FileChanged | 発火タイミング監視対象ファイルがディスク上で変わったとき | 計測結果で止められるか止められない | 向いている使い方npm run buildが書き換える成果物の検知 |
「セッションを終える前に予算を満たしているか確かめる」ゲートにはStopが向いています。decision: "block"とreasonを返すと、Claudeはreasonを読んで作業を続けます。PostToolUseもdecision: "block"を受け付けますが、効果はreasonをツール結果の隣に添えることだけで、ツール呼び出しは取り消せません。
Stopフックで性能予算をゲートにする
Stopフックが動く流れは、次の図のとおりです。
Stopゲートが働く1周分の流れ
- 1
Claudeが応答を終える
メインエージェントの応答完了でStopが発火し、フックのスクリプトがJSONを標準入力で受け取ります。
- 2
スクリプトが計測の要否を決める
stop_hook_activeがtrueなら、すでにこのフックの継続中なので何もせず終わります。作業ツリーに変更がなければ計測を省きます。 - 3
lhci autorunを実行する
assertのerrorに当たると非ゼロで終わります。スクリプトは出力の末尾をreasonに詰めてdecision: "block"を返します。 - 4
Claudeが続きを直す
reasonを読んで修正し、もう一度応答を終えます。stop_hook_activeを見ないフックが8回連続でブロックすると、Claude Codeが次のブロックを無視してターンを終えます。下のスクリプトはstop_hook_activeを見るので、ゲートは1回限りです。
フックスクリプトは.claude/hooks/lighthouse-stop-check.shに置きます。
#!/bin/bash
input=$(cat)
# 既にこのフックの継続でループ中なら、これ以上ブロックしない
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
# 作業ツリーに変更がなければ計測しない
if [ -z "$(git status --porcelain)" ]; then
exit 0
fi
output=$(npx --yes @lhci/cli@0.15.x autorun 2>&1)
if [ $? -ne 0 ]; then
jq -nc --arg reason "Lighthouse CIの予算を満たしていません。$(echo "$output" | tail -n 20)" \
'{decision: "block", reason: $reason}'
fi
exit 0Stopは応答のたびに発火します。変更のないターンまでnumberOfRunsの3回分を回すと待ち時間が積み上がるため、git status --porcelainで変更の有無を先に見ています。回数を絞って待ちを減らすなら、lhci autorun --collect.numberOfRuns=1のように=でつなぎます。--collect.numberOfRuns 1と空白で区切る書き方は使えません。
スクリプトに実行権限を付け、.claude/settings.jsonに登録します。
chmod +x .claude/hooks/lighthouse-stop-check.sh{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lighthouse-stop-check.sh",
"args": []
}
]
}
]
}
}args: []を付けると、commandはシェルを介さずに実行ファイルとして直接起動されます。${CLAUDE_PROJECT_DIR}を使うパスは、この形なら引用符の扱いに悩みません。command・http・mcp_toolのtimeoutは既定で600秒です。ビルドを含む計測がそれを超えそうなら、timeoutに秒数を書いて延ばします。
lhciを差し替えて動作を試す
claudeを起動しなくても、フックはただのスクリプトなので標準入力にJSONを流して試せます。lhciの代わりに失敗を返すだけのスタブnpxをPATHの先頭に置き、一時ディレクトリで上のスクリプトを動かすと、次の出力になります。Claude Code v2.1.289・jq 1.7.1の環境での出力です。
$ echo '{"stop_hook_active": false}' | .claude/hooks/lighthouse-stop-check.sh
{"decision":"block","reason":"Lighthouse CIの予算を満たしていません。✘ categories:performance failure for minScore assertion\n expected: >=0.9\n found: 0.82"}
$ echo '{"stop_hook_active": true}' | .claude/hooks/lighthouse-stop-check.sh
$ echo $?
0stop_hook_activeがtrueなら出力はなく、終了コードは0です。ここまでがスクリプト側の責務で、Claudeにこのreasonが渡るかどうかはClaude Code側の挙動です。公式のHooksガイドも、この値を読んでtrueなら早めに終える形を勧めています。
もうひとつ、スタブを外して@lhci/cli@0.15.xで解決される0.15.1を、設定ファイルもビルド成果物もない一時ディレクトリで走らせると、次のエラーで終わります。
ERROR:
Unable to automatically determine the location of static site files.
Use the CLI flag --collect.staticDistDir or a lighthouserc config file to tell Lighthouse CI where your HTML files are located.このスクリプトはそれを失敗として拾い、同じ文面をreasonに詰めてblockを返します。成果物の置き場所が決まっていないリポジトリにこのフックを入れると、Claudeは直せない指摘を1回受け取ります。lighthouserc.jsonのstaticDistDir、またはserve:lhciというnpm scriptを先に用意しておくのが前提です。
PostToolUseとFileChangedで代替する
編集のたびに素早い反応がほしい場合は、matcher: "Edit|Write"を付けたPostToolUseが候補です。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lighthouse-quick-check.sh",
"args": [],
"async": true
}
]
}
]
}
}PostToolUseが拾うのはClaudeのEdit・Writeツールの呼び出しだけです。Bashコマンドや外部プロセスが同じファイルを書き換えても発火しません。
npm run buildの出力先のように、書き込み元を問わず変化を拾いたいときはFileChangedを使います。Claude Codeはファイルシステムの監視で変化を検出するため、Editツールでも、Bashの中のスクリプトでも、Claude Code外のプロセスでも発火します。matcherは他のイベントと違い、|で区切ったリテラルなファイル名の並びとして監視対象になります。^\.envのような正規表現は、その名前のファイルを探すだけです。FileChangedには判定の仕組みがなく、結果でセッションを止められません。
同期と非同期で何が変わるか
Lighthouse CIは複数回走るので、待ち時間をClaudeに負わせるかどうかがasyncを付ける分かれ目です。
同期実行と非同期実行(async: true)
同期(asyncなし)
計測が終わるまでClaudeは待ち、結果で継続を指示できます。Stopに付ける形で、予算を満たさなければセッションを続けさせられます。
非同期(async: true)
Claudeは待たずに作業を続けます。decision・permissionDecision・continueは無効で、結果は次のターンにadditionalContextとして届きます。
非同期で動き始めたフックにはtimeoutが強制されないので、lhci autorunが長引いても打ち切られません。asyncはtype: "command"のフックだけで使えます。
claude -pで動かすセッションでは、終了時点でまだ動いている非同期フックは強制終了され、結果はcancelledになります。計測を-pの終了後まで生かしたいときは、フックから完全に切り離したプロセスを起動する必要があります。
非同期のまま失敗をClaudeに伝えたい場合は、asyncRewake: trueという選択肢もあります。バックグラウンドで動き、終了コード2で終わったときにClaudeを呼び起こし、stderrをシステムリマインダーとして見せます。ただしこちらはtimeoutが強制されます。
claude -pでCIに載せるときの落とし穴
対話セッションでは、フォルダの信頼ダイアログを承認するまで、~/.claude/settings.jsonを含むすべての設定ファイルのHooksが保留されます。-pでは状況が逆です。claude --helpには次の注記があります。
-p, --print Print response and exit (useful for pipes). Note: The workspace trust
dialog is skipped when Claude is run in non-interactive mode (via -p,
or when stdout is not a TTY, e.g. piped or redirected output). Only
use this in directories you trust.-pを付けたときだけでなく、標準出力がパイプやリダイレクトに繋がっているときも、信頼ダイアログは出ません。外部から取り込んだコードの.claude/settings.jsonにHooksがあれば、そのまま動くということです。
選択肢は3つあります。
信頼できないリポジトリで-pを走らせる前の選択肢
設定を先に読む
.claude/配下の設定ファイルとフックのスクリプトを、実行前にレビューします。自分のLighthouseフックを残したまま走らせたいときは、この道になります。--bareで起動する
claude --helpによれば、--bareはsettingsとインストール済みプラグインで定義されたフックをスキップします。自分のLighthouseフックも動かなくなります。disableAllHooksで無効化する
--settings '{"disableAllHooks": true}'でその実行だけフックを切ります。こちらも自分のフックは止まります。
--bareとdisableAllHooksは、リポジトリ側のフックを止める道具であって、Lighthouseのゲートだけを残す道具ではありません。CIでゲートが必要なら、フックに頼らずlhci autorunをワークフローのステップとして直接呼ぶ構成も選べます。
よくあるつまずき
exit code 1では止まらない。Stopをはじめ多くのイベントで、コードだけでブロックできるexit codeは2です。JSON出力なしの1は非ブロッキングのエラーとして扱われ、そのまま進みます。decision: "block"のJSONを返すか、exit 2を明示します。
8回で打ち切られる。stop_hook_activeを確認しない場合、Stopフックは8回連続のブロックで打ち切られます。この上限はCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変更でき、0にすると上限自体がなくなります。修正に8回以上の往復が要る前提なら、引き上げるという選択肢があります。
Ctrl-Cで中断するとゲートを通らない。Stopは、ユーザーの割り込みで止まったときには発火しません。急いで打ち切ると、予算チェックを経ないコード変更だけが残ります。APIエラーで止まった場合はStopFailureが発火しますが、StopFailureには判定の仕組みがなく、出力と終了コードは無視されます。ログやアラートなど、副作用の用途に使います。
コマンドフックはフルユーザー権限で動く。lhci autorunはビルド成果物を読むだけでなく、Node.jsのプロセスを起動します。Hooks全般の注意点として、次の5つが挙げられています。
- 入力データを検証・サニタイズする
- シェル変数は
"$VAR"のように引用符で囲む - ファイルパスの
..を確認し、パストラバーサルを防ぐ - スクリプトは絶対パスで参照する(exec formでは
${CLAUDE_PROJECT_DIR}をそのまま、shell formでは二重引用符で囲む) .envや.git/、鍵ファイルなど機微なファイルに触れない
shell formでは引用符が要る。argsを省略するとシェル経由になるため、${CLAUDE_PROJECT_DIR}は二重引用符で囲みます。パイプや&&を使いたいときは、あえてargsを省くほうが向いています。
まとめ
assertにerrorレベルの項目を書かない限り、どのイベントに載せてもLighthouseは何も止めません。信頼できないリポジトリで-pを回すなら、--bareやdisableAllHooksは自分のゲートも切るため、lhci autorunをワークフローで直接呼ぶ構成が残ります。レイアウトのずれを画面で確かめたいときは、Claude CodeでCLSの原因を探して直す手順が使えます。
Hooksの全イベントと設定の基本はClaude Code Hooks完全ガイド、他の実用レシピはClaude Code Hooks実例カタログにまとめています。完了条件を強制する設計は、Agent Teamsでの品質ゲートを扱ったAgent Teams Hooksで強制する品質ゲートの仕組みも参考になります。計測結果を外部ダッシュボードへ転送したい場合は、コマンドフックの代わりにClaude CodeのHooksをHTTPエンドポイントで受ける設計も選べます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。