Claude Codeでprotobufを変更する — buf breakingで後方互換を検証
.protoの変更をClaude Codeに任せるとき、buf lintとbuf breakingを完了条件にする設定と、生成コードを編集させない権限ルールを、Stop hookとCLAUDE.mdの書き方までまとめます。
protobufのスキーマ変更をClaude Codeに任せるなら、buf breakingが通るまで終わらせない構成にします。.protoは、フィールドを1つ消す、型を1つ変えるだけで、すでに動いているクライアントや保存済みデータが読めなくなる種類のファイルです。コードとして読んで正しそうに見える変更でも、互換性は目では判定しにくいので、機械に判定させます。
この記事では、buf lintとbuf breakingを何に対して走らせるか、生成コードをClaude Codeに直接編集させない権限ルール、Stop hookで完了条件にする書き方、CLAUDE.mdに置く規約までを手順にします。gRPCサービスをMCPツールとして呼ばせる話はgRPCサービスをMCP化してClaudeに叩かせる2つの経路にあり、ここでは.protoそのものの変更を扱います。
buf breakingは「どの層で壊れるか」を選んで判定する
buf breakingは、現在のスキーマを過去のバージョンと比べ、クライアントやサーバー、生成コードを壊す変更を報告するコマンドです。比較先には、Gitのブランチやタグ、BSR(Buf Schema Registry)のモジュール、tarball、Bufイメージを指定できます。
たとえばフィールドの型をint32からstringに変えると、次のように報告されます。以下はBufのドキュメントに載っている出力例です。
user.proto:2:3:Field "1" on message "User" changed type from "int32" to "string".タグ番号1が別のワイヤ型を持つことになるので、既存のクライアントも、すでに保存されたメッセージも読めなくなります。この種の変更をマージ前に止めるのが目的です。
壊れ方は層ごとに違います。フィールドの追加はどの層でも安全で、リネームは生成コードを壊しますがワイヤ形式は壊しません。型の変更はすべてを壊します。buf breakingのルールは、この違いに沿って4つのカテゴリに分かれています。
| カテゴリ | 検出する壊れ方 | 向いている利用側 |
|---|---|---|
| FILE | 検出する壊れ方ファイル単位の生成コードの破壊(既定) | 向いている利用側PythonやC++など、生成物がファイル単位のimportを持つ言語 |
| PACKAGE | 検出する壊れ方パッケージ単位の生成コードの破壊 | 向いている利用側同一パッケージ内でファイルを移すことがある構成 |
| WIRE_JSON | 検出する壊れ方バイナリのワイヤ形式とJSONエンコーディングの破壊 | 向いている利用側ワイヤ越しにだけ使われ、JSONも流れる構成 |
| WIRE | 検出する壊れ方バイナリのワイヤ形式の破壊 | 向いている利用側ワイヤ越しだけで、JSONを使わない構成 |
上から下へ緩くなり、厳しいカテゴリを通ればそれより緩いカテゴリも通ります。WIRE_JSONは、JSONが多くの転送経路で使われるため、最低ラインとして勧められているカテゴリです。迷ったら既定のFILEから始め、あとで緩められます。
スキーマ変更をClaude Codeに任せる場面では、まず「このリポジトリの.protoを誰がどう使うか」を決めてカテゴリを選びます。生成コードを別チームが依存している場合は、WIREに緩めると生成コードの破壊を見逃します。
buf.yamlで検査の基準を固定する
buf lintとbuf breakingの基準は、ワークスペース直下のbuf.yamlに置きます。設定が無いときのbuf lintはSTANDARDを、buf breakingはFILEを適用します。明示すると次の形です。
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILElintのSTANDARDは、MINIMAL、BASICの上に、パッケージのバージョン接尾辞、サービス名の接尾辞、RPCのリクエスト・レスポンス名の規約、列挙型のゼロ値の命名などを足したカテゴリです。ドキュメントの例では、package weather;に対して「weather.v1のようにバージョン接尾辞を付けること」という指摘が出ます。
lintとbreakingは役割が別です。lintは「今のスキーマが規約に合っているか」を見ます。breakingは「前の版から何が壊れたか」を見ます。Claude Codeに.protoを書き足させるときは、lintで書き方を揃え、breakingで互換性を守る、と二重にかけるのが基本です。
比較先を決める — Claude Codeに渡す--against
buf breakingは比較先が必須です。--againstに渡す形を、使う場面ごとに表にします。
| 場面 | 指定 | 補足 |
|---|---|---|
| ローカルでmainと比べる | 指定--against '.git#branch=main' | 補足作業ツリーの未コミット変更も対象 |
| タグと比べる | 指定--against '.git#tag=v1.0.0' | 補足リリース済みの版を基準にする |
buf.yamlがサブディレクトリにある | 指定--against '.git#tag=v1.0.0,subdir=proto' | 補足subdir=で入力内の位置を指す |
| CI(浅いクローン) | 指定--against 'https://github.com/acme/petapis.git' | 補足必要な1コミットだけを取得する |
| BSRのモジュール | 指定--against buf.build/acme/petapis | 補足最新の公開版と比べる |
| 名前付きモジュール全体 | 指定--against-registry | 補足名前の無いモジュールがあるとエラー |
GitHub ActionsなどのCIは浅いクローンで動くことが多く、.git#branch=mainの比較先ブランチが手元に無い場合があります。その場合はリモートURLを--againstに渡すと、Bufが必要な1コミットだけを取得します。
Claude Codeのセッションでは、ローカルのmainが比較先です。ブランチを切って作業しているなら、次のコマンドが基本形になります。
buf lint
buf breaking --against '.git#branch=main'出力は既定で1違反1行です。hookやスクリプトで加工したいときは--error-format=jsonにすると、path、start_line、type、messageを持つJSONになります。typeにはルール名(例ではFIELD_SAME_TYPE)が入ります。--pathで対象ファイルを絞るオプションもありますが、これはエディタやBazelの連携向けの機能です。FILEカテゴリでは、Bufに.protoを自分で見つけさせたほうが結果が正しくなります。
生成コードをClaude Codeに直接編集させない
スキーマの変更を.protoに入れたら、生成コードは生成コマンドで作り直します。生成物を手で書き換えると、次の再生成で変更が消えるうえ、.protoと食い違います。この例では生成先をgen/とし、Claude CodeのEditツールでは触れないようにします。
{
"permissions": {
"deny": [
"Edit(/gen/**)"
],
"ask": [
"Edit(/buf.yaml)"
]
}
}これを.claude/settings.jsonに置きます。ポイントは3つあります。
- ファイルのパス規則には
Edit(...)を使います。Write(...)のようにツール名だけ変えた規則は受理されても参照されず、起動時に警告が出ます。 - 先頭の
/は、プロジェクト設定では作業ディレクトリ直下を指します。Edit(/gen/**)は<作業ディレクトリ>/gen/**にだけ当たります。 - 規則はdeny、ask、allowの順に評価され、最初に当たった規則で決まります。askにした
buf.yamlは、編集のたびに確認が出ます。
denyのEdit規則は、Claude Codeの組み込みのファイルツールと、cat、sed、teeなど認識できるファイルコマンドの書き込み先、リダイレクト先に効きます。一方で、ファイル名を渡さずに読み書きするコマンドや、スクリプトが内部でファイルを開く場合は対象外です。そのため、Claude Codeがbuf generateのような生成コマンドをBashで実行してgen/を更新するのは、この規則では止まりません。手で書き換えるのだけを止める、という効き方になります。
編集のたびにlintを返す — PostToolUse hook
.protoを編集した直後にbuf lintを走らせ、指摘をClaude Codeに返します。PostToolUseはツールがすでに実行された後のイベントですが、終了コード2で返したstderrはClaude Codeに表示されます。終了コードの意味やイベントごとの違いはClaude Code Hooks完全ガイドにまとまっています。
.claude/settings.jsonにhookを足します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/buf-lint.sh"
}
]
}
]
}
}スクリプトは、標準入力のJSONから編集されたファイルのパスを取り、.protoのときだけlintします。jqが必要です。
#!/bin/bash
# .claude/hooks/buf-lint.sh
FILE=$(jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.proto) ;;
*) exit 0 ;;
esac
OUT=$(buf lint 2>&1) || {
echo "$OUT" >&2
exit 2
}tool_input.file_pathはWrite、Edit、Readでは常に絶対パスです。Windowsではバックスラッシュ区切りで届くため、パスの比較を書くときは区切りを正規化してから照合します。この例は拡張子だけを見ているので影響を受けません。
lintの失敗は編集の誤りというより規約違反なので、PostToolUseで即座に返すのが向いています。OpenAPI定義をSpectralでlintする構成も、PostToolUse hookとCLAUDE.mdを組み合わせる同じ型です。互換性の判定は、編集の途中ではなく、作業が一区切りしたときに一度だけかけます。途中の編集では、フィールドを消してからreservedを足す、といった複数手順の途中で一時的に破壊と判定されるためです。
buf breakingを完了条件にする — Stop hook
Stop hookは、Claude Codeが応答を終えようとしたときに走り、終了コード2で返すと「止まらずに作業を続けさせる」ことができます。Stopで終了コード2を返すと停止が防がれ、stderrのメッセージが続行の理由としてClaude Codeに渡ります。これを使って、buf breakingが通るまで終わらせない構成にします。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/buf-breaking.sh"
}
]
}
]
}
}スクリプトは、.protoに変更があるときだけ検査します。
#!/bin/bash
# .claude/hooks/buf-breaking.sh
cd "$CLAUDE_PROJECT_DIR" || exit 0
# .proto に差分が無ければ何もしない(未追跡の新規ファイルも対象にする)
[ -z "$(git status --porcelain -- '*.proto')" ] && git diff --quiet main -- '*.proto' && exit 0
{
buf lint &&
buf breaking --against '.git#branch=main'
} > /tmp/buf-check.log 2>&1 || {
cat /tmp/buf-check.log >&2
echo "buf lint / buf breaking が通るまで完了にしないでください。" >&2
exit 2
}Stop hookの入力にはstop_hook_activeが含まれ、Stop hookの結果としてすでに続行中のときにtrueになります。これは「解決しない条件でブロックし続けない」ために見る値です。このスクリプトはtrueでも検査を続けます。buf breakingの失敗はClaude Codeが直せる種類の失敗だからです。止まらなくなる事態は、Claude Code側の上限が防ぎます。Stop hookが連続で8回続行させると、次のブロックは無効になってターンが終わります。続行の回数は、Claude Codeがツールを呼ぶたびに数え直されます。上限は環境変数CLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられます。Stopで完了条件を縛る別の例はagent hookで「テストが通るまで止めない」を検証させるにあります。
意図した破壊的変更の場合は、このhookが止め続けるぶん人が判断する必要があります。8回の続行で打ち切られたら、Claude Codeが何を壊そうとしているのかを、出力ログから人が確認します。
CLAUDE.mdに「壊さない書き方」を置く
hookは事後の検査です。Claude Codeが最初から壊さない書き方をするように、CLAUDE.mdにスキーマ変更の規約を置きます。内容は、buf breakingのルール説明から導けるものに絞ります。
## protobuf のスキーマ変更
- 変更後は必ず `buf lint` と `buf breaking --against '.git#branch=main'` を実行する
- フィールドの型は変えない。型を変えたいときは新しい番号で新しいフィールドを追加する
- フィールドや列挙値を削除しない。不要なら `[deprecated = true]` を付ける
- どうしても削除するときは、番号と名前を `reserved` に残す
- `gen/` 配下は編集しない。生成コマンドで作り直す
- `buf.yaml` の lint / breaking の基準は変更しない各項目の根拠は、ルールの説明にあります。
- 型の変更:
FIELD_SAME_TYPEは、生成コード、ワイヤ互換、JSON互換のすべてに影響するとして、「フィールドの型は変えないこと」を勧めています。 - 削除:
FIELD_NO_DELETEは、削除すると生成コードから値やフィールドが消えて参照が壊れるため、「削除せず非推奨にする」ことを勧めています。 reserved: 削除したフィールドの番号を予約しないと、将来同じ番号が別の型で再利用されて、ワイヤ互換が壊れる可能性があります。名前も予約するのはJSON互換のためです。ただし、ルールの説明は「あらかじめ予約するより、非推奨にするほうが多くの場合よい」とも述べています。
指摘の読み方と直し方
Claude Codeに返る指摘は、ルール名とメッセージで読めます。ドキュメントの例から、よくある3つを挙げます。
| 変更 | 指摘される層 | 直し方 |
|---|---|---|
フィールドの型を変えた(FIELD_SAME_TYPE) | 指摘される層生成コード、ワイヤ、JSON | 直し方元に戻し、新しい番号でフィールドを追加する |
列挙値の名前を変えた(例: PET_TYPE_HAMSTERをPET_TYPE_RODENTに) | 指摘される層生成コード | 直し方元の名前に戻す。新しい値は新しい番号で追加する |
| 列挙型を別ファイルに移した | 指摘される層FILEでは破壊、PACKAGEでは破壊ではない | 直し方同じファイルに戻す、またはPACKAGEを選ぶ判断を人がする |
列挙値の名前変更は、クイックスタートの例です。proto/pet/v1/pet.protoでPET_TYPE_HAMSTERをPET_TYPE_RODENTに変えると、「Enum value "4" on enum "PetType" changed name from "PET_TYPE_HAMSTER" to "PET_TYPE_RODENT".」と報告されます。ワイヤ上の値は同じ4なので通信は壊れませんが、生成コードの識別子が変わります。一方、新しい列挙値を末尾に追加する変更は破壊ではなく、コマンドは何も出さずに終わります。
ファイル移動は、カテゴリ選びの具体例です。列挙型をpet.protoからpet_type.protoに移し、パッケージをpet.v1のままにすると、FILEでは破壊と判定され、PACKAGEでは判定されません。PythonのようにファイルごとのimportがあるのでFILEが妥当ですが、Goのように同一パッケージ内でファイルを移しても問題が出ない言語だけが利用側なら、PACKAGEを選べます。
つまずきやすい点
- 比較先のブランチが無い: CIの浅いクローンでは
mainが取得されていないことがあります。リモートURLを--againstに渡します。 - 名前の無いモジュールで
--against-registryを使う: 全モジュールに名前が必要で、1つでも無いとエラーになります。 - カテゴリを混ぜる: カテゴリは互換性の保証としてまとまるように選ばれているため、ルールを個別に外すのはまれな場合に限ります。
- Stop hookが延々と続く: 8回の続行で打ち切られます。そこまで来たら、Claude Codeが意図した破壊的変更をしようとしていないかを人が確認します。
まとめ
.protoの変更をClaude Codeに任せる設計は、書かせる前、書いている最中、終える前の3段に分けると収まりがよくなります。書かせる前はCLAUDE.mdに「型を変えない・削除しない」を置き、gen/をdenyしてbuf.yamlをaskにします。書いている最中はPostToolUseでbuf lintを返し、終える前にStop hookでbuf breakingを通します。
最後の判断は人に残ります。互換性を意図して崩すとき、カテゴリをFILEからPACKAGEやWIRE_JSONへ緩めるときは、buf breakingの指摘を消す作業ではなく、利用側が何に依存しているかの判断です。