Claude Code に「このモジュールを新しい構造に寄せて」と頼み、返ってきた差分をレビューして、テストも緑で、安心して本番に出したのに、数日後に運用側から「なんか挙動が変わっていませんか」と静かに言われる。この、単体テストの網をすり抜けてくる違和感こそ、大規模リファクタで一番怖いものだと感じています。
私自身、個人開発と複数の技術ブログの自動投稿基盤を長く回してきたのですが、後者のパイプラインをまるごと書き直したときに、まさにこれを踏みました。テストは全部通っているのに、公開処理の一部だけがサイレントに空振りし、数日気付かなかったのです。原因は「例外を握りつぶして既定値を返す」前提に周辺コードが依存していたのに、リファクタで素直に throw するようになっていたことでした。テストはその契約を検証していなかったので、緑のまま壊れていました。
この記事は、そういう「動くのに壊れている」を、テストとは別のレイヤーで捕まえるための道具立てです。中心になるのは、コードの観測可能な振る舞いを署名として録り、リファクタ前後で差分を取る「契約スナップショット」という考え方と、それをコミット単位で回すハーネスの実装です。
なぜ大きな diff は「動くのに壊れている」を隠すのか
Claude Code は賢いので、たいていのリファクタは動くコードを返してきます。問題は、差分が大きくなるほど「動いている」と「以前と同じ契約で動いている」の距離が開くことです。
ここで言う契約とは、型シグネチャのような明示的なものだけではありません。むしろ厄介なのは暗黙の契約のほうです。たとえば次のようなものが、コードのどこにも書かれないまま前提として機能しています。
暗黙の契約 壊れたときに起きること
例外を投げるか、既定値を返すか 呼び出し側の握りつぶし前提が崩れ、バッチが途中停止する
ログ行の構造(キーの並び・区切り) 監視の正規表現が静かに外れ、アラートが鳴らなくなる
初期化・接続確立の順序 アイドル時だけコネクションが枯渇するなど、負荷条件依存の障害になる
null と空値の区別、丸めの向き 集計値がわずかにずれ、下流のしきい値判定が変わる
単体テストは戻り値の等価性を確かめるのは得意ですが、これらの「振る舞いの署名」までは普通カバーしていません。だからこそ、リファクタの前後で署名そのものを録って比較する層を、テストとは別に一枚差し込みます。これが契約スナップショットの発想です。
契約スナップショット — 何を署名にするか
契約スナップショットは、対象のコードパスを一連の代表シナリオ(プローブ)で叩き、その実行から観測できる振る舞いを構造化レコードに畳み込みます。戻り値だけでなく、「どの経路で結果が返ったか」を含めるのが肝です。
一つのプローブから録るレコードには、最低限このあたりを入れます。
結果の値(正規化して比較可能にしたもの)
結果が返ったチャネル(正常 return か、例外か。例外なら型名まで)
そのプローブ実行中に出たログ行の「形」(値ではなく、キー集合とレベルの並び)
副作用の順序(DB 接続確立、外部呼び出しなどの発生順のラベル列)
値そのものではなく形を録るのがポイントです。たとえばログ本文のタイムスタンプや ID は毎回変わるので、そのまま比較すると差分だらけになります。キー集合とレベルの並びという「骨格」に落として署名化することで、監視が依存している構造が変わったときだけ差分が立つようにします。
ベースラインを録るプローブ設計
まず、リファクタに着手する前のコミットで、対象コードパスのベースライン署名を録ります。プローブは網羅ではなく、契約が集中する境界を狙って十数個を選びます。私は「正常系1つに対して、境界と失敗系を2〜3つ」の比率を目安にしています。失敗系こそ暗黙の契約が濃く出るためです。
プローブは、こういう素朴なデータ構造で十分です。
# probes.py — 契約が集中する代表シナリオを列挙する
from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class Probe :
name: str
run: Callable[[], Any] # 対象コードパスを1回叩く。戻り値は正規化前でよい
def build_probes (order_service) -> list[Probe]:
return [
Probe( "normal_single_item" ,
lambda : order_service.total( items = [{ "sku" : "A" , "qty" : 1 , "price" : 100 }])),
# 境界: 数量ゼロ。丸めと空集合の扱いが契約
Probe( "boundary_zero_qty" ,
lambda : order_service.total( items = [{ "sku" : "A" , "qty" : 0 , "price" : 100 }])),
# 失敗系: 在庫欠品。例外を投げるか既定値かが契約の核心
Probe( "missing_inventory" ,
lambda : order_service.total( items = [{ "sku" : "GHOST" , "qty" : 1 , "price" : 100 }])),
# 失敗系: 不正入力。呼び出し側の握りつぶし前提を検証する
Probe( "malformed_input" ,
lambda : order_service.total( items = [{ "sku" : "A" }])),
]
大事なのは、これらのプローブを Claude Code に書かせず、自分で書くことです。リファクタ対象の暗黙契約を一番わかっているのは、その領域を運用してきた人間だからです。Claude Code にはこの後の構造変更を任せますが、「何を壊してはいけないか」の定義はこちらが握ります。
契約ドリフトを検出するハーネス
プローブを一度実行し、各レコードを正規化して署名にまとめ、ベースラインとして JSON に保存します。リファクタの各コミット後に同じことをして、署名同士を突き合わせます。以下がそのままコミット単位で回せる最小ハーネスです。
# contract_harness.py — 契約スナップショットの採取と差分
import io
import json
import logging
import sys
from contextlib import contextmanager
def _normalize_value (v):
"""比較可能な正規形へ。丸めや順序を意図的に固定する。"""
if isinstance (v, float ):
# 丸めの向きは契約なので、あえて固定桁で署名する
return round (v, 4 )
if isinstance (v, dict ):
return {k: _normalize_value(v[k]) for k in sorted (v)}
if isinstance (v, ( list , tuple )):
return [_normalize_value(x) for x in v]
return v
@contextmanager
def _capture_logs ():
"""発生したログ行の『形』(logger名・レベル・キー集合)だけを集める。"""
buf = []
handler = logging.Handler()
handler.emit = lambda r: buf.append(
(r.name, r.levelname, tuple ( sorted ((r. __dict__ .get( "extra_keys" ) or []))))
)
root = logging.getLogger()
root.addHandler(handler)
try :
yield buf
finally :
root.removeHandler(handler)
def take_record (probe):
"""1プローブを実行し、値・チャネル・ログ形状を署名レコードにする。"""
with _capture_logs() as logs:
try :
value = probe.run()
channel = "return"
exc_type = None
except Exception as e: # noqa: BLE001 — チャネル差分の検出が目的
value = None
channel = "raise"
exc_type = type (e). __name__
return {
"probe" : probe.name,
"channel" : channel, # return / raise — これ自体が契約
"exc_type" : exc_type, # 例外型が変わるのも契約ドリフト
"value" : _normalize_value(value),
"log_shape" : sorted ( set (logs)), # 値ではなく骨格だけを署名化
}
def take_snapshot (probes):
return {r[ "probe" ]: r for r in (take_record(p) for p in probes)}
def diff_snapshots (baseline: dict , current: dict ) -> list[ dict ]:
drifts = []
for name, base in baseline.items():
cur = current.get(name)
if cur is None :
drifts.append({ "probe" : name, "kind" : "probe_missing" })
continue
if base[ "channel" ] != cur[ "channel" ]:
drifts.append({ "probe" : name, "kind" : "channel_drift" ,
"from" : base[ "channel" ], "to" : cur[ "channel" ]})
if base[ "exc_type" ] != cur[ "exc_type" ]:
drifts.append({ "probe" : name, "kind" : "exception_drift" ,
"from" : base[ "exc_type" ], "to" : cur[ "exc_type" ]})
if base[ "value" ] != cur[ "value" ]:
drifts.append({ "probe" : name, "kind" : "value_drift" ,
"from" : base[ "value" ], "to" : cur[ "value" ]})
if base[ "log_shape" ] != cur[ "log_shape" ]:
drifts.append({ "probe" : name, "kind" : "log_shape_drift" })
return drifts
def main ():
from probes import build_probes
from app import build_order_service # 対象アプリの組み立て口
probes = build_probes(build_order_service())
snapshot = take_snapshot(probes)
mode = sys.argv[ 1 ] if len (sys.argv) > 1 else "check"
path = ".contract/baseline.json"
if mode == "record" :
with open (path, "w" ) as f:
json.dump(snapshot, f, ensure_ascii = False , indent = 2 , default = str )
print ( f "baseline recorded: { len (snapshot) } probes" )
return 0
with open (path) as f:
baseline = json.load(f)
drifts = diff_snapshots(baseline, snapshot)
if drifts:
print ( "契約ドリフトを検出しました:" )
print (json.dumps(drifts, ensure_ascii = False , indent = 2 , default = str ))
return 1
print ( "契約ドリフトなし" )
return 0
if __name__ == "__main__" :
raise SystemExit (main())
このハーネスがテストと決定的に違うのは、値の正誤を判定しないことです。判定するのは「以前と署名が変わったか」だけです。だから、意図した変更でもドリフトとして出ます。それでいいのです。意図の有無を判断するのは人間の仕事で、ハーネスの役割は「変わった事実」を漏れなく机の上に載せることです。
コミット単位で回す — フックと CI ゲート
契約スナップショットは、コミットが大きくなってから回しても効きが弱くなります。1コミット=1可逆な変更という粒度を守り、各コミット直後に差分を取るのが最も効率的です。
リファクタに着手する前に、ベースラインを一度だけ録って、リポジトリに含めておきます。
# リファクタ開始前の、確実に動いていたコミットで一度だけ実行する
mkdir -p .contract
python contract_harness.py record
git add .contract/baseline.json
git commit -m "chore: 契約スナップショットのベースラインを固定"
あとは push 前に必ず差分を取ります。pre-push フックにしておくと、ドリフトを机に載せないまま共有ブランチへ出てしまう事故が減ります。
# .git/hooks/pre-push — 契約ドリフトが出たら push を止める
#!/usr/bin/env bash
set -euo pipefail
if ! python contract_harness.py check ; then
echo ""
echo "契約ドリフトが検出されました。意図した変更ならベースラインを更新してください:"
echo " python contract_harness.py record && git add .contract/baseline.json"
exit 1
fi
CI では、これをリファクタ用ブランチの必須チェックにします。ドリフトが出たジョブは、差分の JSON をそのままアーティファクトに残しておくと、レビュー時に「どの契約が、どちらの向きに動いたか」がひと目で追えます。ここで Claude Code に「この channel_drift は意図的か、事故か」を、差分 JSON を渡して一次仕分けさせるのは有効です。ただし最終判断はこちらでやります。
ログ形状と初期化順序のドリフトをどう署名するか
値のドリフトは比較的わかりやすいのですが、運用を静かに壊すのはたいてい非機能側です。ここは署名の取り方に少しだけ工夫が要ります。
ログ形状は、本文をそのまま録らず、構造化ログの「キー集合とレベル」に落とします。上のハーネスでは extra_keys を拾う簡易実装にしていますが、実際には構造化ロガー(たとえば JSON ロガー)のレコードからキー名の集合を取り出すのが確実です。監視の正規表現やダッシュボードのクエリは、この骨格に依存しているので、骨格が変わったときだけ差分を立てれば、ノイズなく本質を捕まえられます。
初期化順序は、副作用にラベルを付けた列として署名します。テスト用の薄いフックで「db_connect」「cache_warm」「external_ping」などの発生順ラベルを記録し、その並びを比較します。順序が変わっても正常系の値は同じことが多いので、値のドリフトには現れません。だからこそ、順序を独立した署名として持っておく価値があります。私が過去にアイドル時のコネクション枯渇を踏んだのは、まさにこの初期化順序が1手ずれていたケースでした。順序署名があれば、その1コミットで気付けたはずです。
ドリフトが出たとき — 戻すか、契約を更新するか
ハーネスがドリフトを机に載せたら、判断は二択に収束します。契約を守るべきだったなら戻す、契約を変えるのが正しいなら明示的に更新する、のどちらかです。曖昧なまま先に進めないことが、この道具の一番の効用です。
判断の目安を挙げておきます。
channel_drift(return↔raise)や exception_drift は、原則いったん戻します。呼び出し側の握りつぶし前提を壊す可能性が高く、影響範囲が読みにくいためです。契約を変えるなら、呼び出し側の全経路を同じコミットで直してから、ベースラインを更新します。
value_drift は、意図の有無を系列の形で確かめます。ステージングで主要導線を1度通し、オブザーバビリティ上でレスポンス分布やエラーカテゴリの分布が変わっていないかを見ます。形が変わっていなければ意図的な値変更として受け入れ、ベースラインを更新します。
log_shape_drift は、監視側との契約なので、更新する場合は監視の正規表現・クエリを同じ変更で追随させます。片方だけ更新して緑にするのが、一番静かに壊れる道です。
ベースラインの更新は、必ず独立したコミットにして、コミットメッセージに「なぜ契約を変えたか」を残します。こうしておくと、未来の自分や別のレビュアーが「この署名変更は事故ではなく意図だった」と一発で追えます。ドリフトを消すために黙ってベースラインを録り直すのだけは避けます。それは安全弁を自分で外す行為だからです。
Claude Code への依頼にハーネスを組み込む
最後に、この道具を Claude Code のワークフローへ差し込む形にまとめます。私は大規模リファクタを頼むとき、プロンプトの冒頭にこの制約を必ず入れています。
このリポジトリで大規模リファクタを行います。次の制約を必ず守ってください。
1. 変更は1コミット=1可逆な単位に分割し、各コミット単体でビルド・起動できること。
2. 各コミットの後、私が python contract_harness.py check を回します。
channel / exception / value / log_shape のいずれかにドリフトが出たら、
そのコミット内で解消するか、解消できない理由を明記して止まってください。
3. .contract/baseline.json と probes.py は変更しないでください。
契約の定義はこちら側が管理します。
Claude Code の生成速度は本物です。ただ、その速度を本番で安心して使うには、速く生成することと、以前と同じ契約で動いていることを、別々に保証する仕組みが要ります。契約スナップショットは、その後者だけを引き受ける薄い層です。テストが「正しさ」を、契約ドリフト検出が「以前との同一性」を見る、という二枚構えにしておくと、書き直しの規模が大きくても、机の上に載らないまま本番へ滑り込む変更がぐっと減ります。
次に大きなリファクタを Claude Code に頼むときは、コードを一行も書く前に、失敗系のプローブを3つだけ書いてベースラインを録るところから始めてみてください。その3つが、数日後の静かな障害から自分を守ってくれるはずです。