1,400箇所の置換を1コミットで投げてきた朝のこと
ある朝、4本の個人開発アプリで使い回しているイベント計測の呼び出しを、合意ベースの計測に切り替えるためにラッパーへ寄せようとしました。古い Analytics.logEvent("name", props) を、同意状態を見てから送る track("name", props) に置き換える、という単純な機械作業です。
Antigravity に「全部 track に寄せて」と頼んだところ、230ファイル・1,400箇所を一度に書き換えた単一のコミットが返ってきました。diff は気が遠くなる長さで、私自身、どこが安全でどこが危ういのかを見分けられません。
機械置換そのものは正しく動いていました。問題は、正しさを人が確認できない形で渡されたことのほうにあります。レビューできない変更は、たとえ中身が正しくても本番には出せません。
ここで段取りを説明したくなりますが、まず私が差し戻した理由から順に置いていきます。最終的にたどり着いたのは、置換を「機械の仕事」と「人の確認」に割り直す小さな設計でした。
なぜ「全置換1コミット」がレビュー不能になるのか
巨大な機械置換の diff には、性質の違う変更が混ざります。
大半は完全に定型の置換です。引数の順番も意味も変わらず、目視する価値すらありません。ところがその中に、ごく一部だけ意味が変わる箇所が紛れます。たとえば計測の前に同意状態を確認するようになったことで、同意前に呼ばれていた数箇所の挙動が変わる、といった具合です。
1,400箇所の中に紛れた十数箇所の「意味が変わる置換」を、人間が目で拾うのは現実的ではありません。レビュアーは集中力が続かず、結局「たぶん大丈夫」で通してしまいます。これがいちばんの落とし穴でした。
機械が確実に直せる部分はレビュー不要にし、人が判断すべき部分だけを小さく切り出す。この分離ができていないと、量に飲まれて品質が下がります。
設計の核: codemod を二つの層に分ける
私が採用した分け方は、次の二層です。
第一層は、構文パターンで確実に一致する「安全な機械置換」です。引数をそのまま運ぶだけの変換は、ルールに書ければ人が見る必要はありません。ここはエージェントに任せ、人は結果のテストだけを見ます。
第二層は、機械では判断できない「意味が変わりうる箇所」です。同意前に呼ばれていた計測、ループ内での連続呼び出し、戻り値を使っている箇所などは、パターンでは安全側に倒せません。ここはエージェントに置換させず、候補一覧として人へ渡してもらいます。
この二層を、ast-grep のルールと、検証付きのバッチドライバで実装しました。順に書きます。
ast-grep でルールを書く
ast-grep は構文木に対してパターンを当てる検索・置換ツールです。正規表現と違い、引数の入れ子や改行に強く、コードの形そのものに一致します。
第一層の「安全な機械置換」は、メタ変数を使って引数を運びます。
# rules/migrate-logevent-safe.yml
id: migrate-logevent-safe
language: typescript
rule:
pattern: Analytics.logEvent(, )
# 同意前に呼ばれる初期化ファイルは除外(第二層に回す)
not:
inside:
kind: function_declaration
has:
pattern: onBeforeConsent
fix: track(, )
第二層は、置換せずに「人が見るべき候補」を洗い出すだけのルールにします。fix を書かず、検出に徹します。
# rules/flag-logevent-in-loop.yml
id: flag-logevent-in-loop
language: typescript
severity: warning
rule:
pattern: Analytics.logEvent(, )
inside:
any:
- kind: for_statement
- kind: while_statement
- kind: call_expression
has: { pattern: .forEach }
note: ループ内の計測呼び出し。連続送信や同意状態の扱いを人が確認すること
第二層を別ルールに切り出しておくと、ast-grep scan の警告として一覧で出せます。エージェントには「warning が出た箇所は触らず、そのまま報告して」と伝えます。
バッチドライバ: 1コミット=1ディレクトリ=検証付き
機械置換を安全に積むために、ディレクトリ単位で置換→検証→コミットを回す小さなドライバを用意しました。要点は、1コミットが必ず型チェックとテストを通っていることです。
#!/usr/bin/env bash
# codemod-batch.sh <対象ディレクトリ群>
set -euo pipefail
MAX_FILES_PER_BATCH=25 # 1バッチで触ってよいファイル数の上限
RULE="rules/migrate-logevent-safe.yml"
for dir in "$@"; do
echo "▶ batch: $dir"
# 1) このディレクトリで一致するファイル数を数える
hit=$(ast-grep scan -r "$RULE" "$dir" --json | jq '[.[].file] | unique | length')
if [ "$hit" -eq 0 ]; then echo " skip (0 hits)"; continue; fi
if [ "$hit" -gt "$MAX_FILES_PER_BATCH" ]; then
echo " ✋ $hit files > 上限 $MAX_FILES_PER_BATCH。さらに小さいパスに分けて再実行してください"
exit 2
fi
# 2) 機械置換を適用
ast-grep scan -r "$RULE" "$dir" --update-all
# 3) 検証ゲート(ここを通らない限りコミットしない)
npm run typecheck
npm run test -- --findRelatedTests "$dir" --passWithNoTests
# 4) このバッチだけをコミット
git add "$dir"
git commit -m "codemod: migrate logEvent→track in $dir ($hit files)"
done
# 5) 残件を可視化(第二層の警告も含む)
echo "── 残り(要人確認)──"
ast-grep scan -r rules/flag-logevent-in-loop.yml . --json | jq 'length'
このドライバは、検証が落ちたバッチを未コミットのまま止めます。つまり「壊れた状態でコミットが進む」ことが構造的に起きません。落ちたディレクトリだけを切り離して調べ、直してから再開できます。
検証を「関連テストだけ」に絞る理由
全テストを毎バッチ回すと、大規模置換では時間が現実的でなくなります。私は変更ファイルに関連するテストだけを走らせ、全体テストはバッチ群の最後に一度だけ回す運用にしています。本番前の最終確認は全体で取り、途中は速さを優先する切り分けです。
適用前にドライランで件数を見る
いきなり --update-all を回す前に、私は必ず ast-grep scan を --json だけで走らせ、ディレクトリごとの一致件数を眺めます。ここで想定よりはるかに多い数字が出るなら、ルールが広すぎて意図しない箇所まで拾っている合図です。たとえば 230ファイルの想定が 400ファイルに膨らんでいたとき、原因はパターンが別ライブラリの同名メソッドにも一致していたことでした。置換してから気づくと巻き戻しが面倒なので、件数の異常はドライランの段階で潰しておくと安全に回避できます。
エージェントに渡す制約: 巨大diffを積めないようにする
ここがいちばん効きました。エージェントへの指示に、次の三つを制約として明記します。
- 置換は
codemod-batch.sh をディレクトリ単位で呼ぶこと。リポジトリ全体を一度に --update-all しない
- 1コミットが
MAX_FILES_PER_BATCH を超えたら、より小さいパスに割って出し直すこと
- 第二層ルールの warning が出た箇所は置換せず、ファイルと行を一覧で報告すること
この制約があると、エージェントは「全部やりました」ではなく「このディレクトリ群を、各コミット検証つきで処理しました。warning は7件、一覧はこちらです」という返し方になります。レビューする側は、定型バッチは結果のテストだけ確認し、warning の7件に集中できます。
エージェントに自由を与えるほど巨大diffに戻りやすいので、私はこの三点を AGENTS.md にも書き込み、毎回読ませるようにしています。
全置換1コミットと検証付きバッチの違い
二つの進め方の差を、実際に困った観点で並べます。
| 観点 | 全置換1コミット | 検証付きバッチ |
| レビュー | 1,400箇所を目視(実質不能) | warning 数件に集中 |
| 壊れたときの切り分け | どのバッチが原因か不明 | 落ちたディレクトリだけ調査 |
| ロールバック | 全体を巻き戻すしかない | 該当コミットだけ revert |
| 意味が変わる箇所 | 定型置換に紛れて見落とす | 第二層で別管理 |
| 本番への出しやすさ | 怖くて出せない | 各コミットが検証済み |
私の手元では、差し戻し後にバッチ方式へ組み替えたことで、レビューに割く時間がおおよそ2.5倍短くなりました。数が減ったのではなく、人が見る対象が「定型1,380箇所」から「要確認の十数箇所」に絞れたためです。
検証マトリクス: 置換の正しさをどう担保するか
機械置換の安心は「直したこと」ではなく「壊していないこと」で測ります。私が各バッチで見ている確認軸です。
| 確認軸 | 方法 | 落ちたときの意味 |
| 型整合 | npm run typecheck | 引数の型がラッパーと不一致 |
| 関連テスト | --findRelatedTests | 挙動が変わった箇所がある |
| 冪等性 | 同ルールを再実行し0件か確認 | 取りこぼし・二重置換の疑い |
| 第二層残件 | flag ルールの json 件数 | 人の判断が未処理 |
冪等性の確認は地味ですが重要です。置換後に同じルールをもう一度走らせて0件なら、少なくとも「同じパターンの取りこぼし」と「二重に track(track(...)) のように包んでしまう事故」は起きていないと言えます。
この設計に落ち着くまでと、次の一歩
最初は私も「機械置換なんだから一気にやればいい」と思っていました。実際に巨大diffを前にして、正しさと確認可能性は別物だと痛感したのが出発点です。AdMob まわりの計測のように、同意状態で挙動が変わるコードを触るときほど、この差は効いてきます。
次に試すなら、まず手元の一番小さなディレクトリで codemod-batch.sh を1回だけ回してみてください。1コミットが型チェックとテストを通って積まれる感覚をつかめれば、あとは対象を広げるだけです。エージェントには「検証つきで、小さく、warning は触らず報告」とだけ伝えれば、巨大diffは返ってこなくなります。
同じように大量の機械置換を AI に任せている方の、安心して本番に出すための一助になればうれしいです。