Claude Codeでast-grepの一括置換を書かせる — 差分と件数を見てから適用
ast-grepの置換ルールをClaude Codeに書かせ、書き換える前に差分と件数を確かめる手順です。sedで文字列リテラルまで変わる例と、ast-grep 0.51.0で試した出力を載せています。
関数の呼び出し方を全ファイルで変えたいとき、正規表現の置換は文字列リテラルやコメントまで巻き込みます。ast-grepは構文木で一致を取るので、コードの呼び出しだけを書き換えられます。Claude Codeに書かせるのは置換ルール1つで、人間が見るのは差分と件数です。
ここでは小さなTypeScriptのリポジトリで試した出力を使って、ルールの書かせ方、適用前の確認、CLAUDE.mdとスキルへの組み込みまでを順に説明します。
sedの置換が巻き込むものと、ast-grepが外すもの
ast-grepは、コードを構文木に直してからパターンを照合する検索・置換ツールです。公式は「構文を理解するgrep/sed」と説明しています。パターンはメタ変数($URLのような$始まりの名前)を含められ、置換側で同じ名前を再利用できます。
違いは、検証用のリポジトリで見るのが早いです。legacyFetch(url, opts)をapiClient.get(url, opts)に変える場面を用意しました。src/user.tsとsrc/misc.tsの2ファイルで、legacyFetchを含む行は9行あります。
| 行の種類 | 行数 | 書き換えたいか |
|---|---|---|
| import文 | 行数2 | 書き換えたいか別途対応(後述) |
| コメント | 行数1 | 書き換えたいかいいえ |
| 文字列リテラル内の語 | 行数1 | 書き換えたいかいいえ |
変数名の一部(legacyFetchCount) | 行数1 | 書き換えたいかいいえ |
| 実際の呼び出し | 行数4 | 書き換えたいかはい |
user.tsにsed 's/legacyFetch(/apiClient.get(/g'を当てたときの差分が次です。
5,6c5,6
< const res = await legacyFetch(`/users/${id}`, { retry: 2 });
< console.log("legacyFetch(url) finished");
---
> const res = await apiClient.get(`/users/${id}`, { retry: 2 });
> console.log("apiClient.get(url) finished");
11c11
< return legacyFetch(
---
> return apiClient.get(ログ出力の文字列まで変わっています。引数が複数行に割れた呼び出しは、先頭行だけが変わるので、行単位の置換でも壊れてはいません。ただし文字列の巻き込みは、置換後に気づきにくい種類の誤りです。
ast-grepで試した差分と件数
同じ置換をast-grep runで実行します。-Uを付けなければファイルは書き換わらず、差分だけが出ます。
ast-grep run -p 'legacyFetch($URL, $OPTS)' \
-r 'apiClient.get($URL, $OPTS)' -l ts src0.51.0で実行した結果の抜粋です。
src/misc.ts
4 │-export const ping = () => legacyFetch("/ping", {});
4│+export const ping = () => apiClient.get("/ping", {});
src/user.ts
5 │- const res = await legacyFetch(`/users/${id}`, { retry: 2 });
5 │+ const res = await apiClient.get(`/users/${id}`, { retry: 2 });
11 │- return legacyFetch(
12 │- `/orders?user=${id}`,
13 │- { retry: 3, timeout: 5000 },
14 │- );
11│+ return apiClient.get(`/orders?user=${id}`, { retry: 3, timeout: 5000 });コメント、ログの文字列、legacyFetchCountは対象外です。複数行の呼び出しは1行にまとまりました。このあとgit status --shortは0行を返し、作業ツリーは未変更のままでした。
件数は--json=compactで取れます。公式はJSON出力を「マッチを構造化して出す」ものと説明しています。
ast-grep run -p 'legacyFetch($URL, $OPTS)' -l ts \
--json=compact src | python3 -c \
"import json,sys;d=json.load(sys.stdin);print(len(d),len({m['file'] for m in d}))"出力は3 2で、3箇所・2ファイルです。ここで1つ数が合いません。呼び出しは4箇所あるのに、パターンが$URL, $OPTSの2引数なので、引数1つのlegacyFetch("/tag")が漏れています。引数の数を問わない$$$ARGSで数え直すと、4件になりました。
適用前に件数を見る価値はここにあります。想定した呼び出し数と差分の件数が合わなければ、ルールを直してから適用できます。
Claude Codeに頼む手順
ルールを書かせて、確かめてから適用する
- 1
ast-grepを入れる
公式はnpm・Homebrew・cargo・pipなどの配布を挙げています。
- 2
対象と移行先を伝える
関数名、引数の数が変わるか、対象言語と対象ディレクトリを渡します。
- 3
件数を先に報告させる
適用の前に、
--json=compactでのマッチ数と、想定した呼び出し数の突き合わせを求めます。 - 4
差分を読む
-Uなしの出力で、文字列やコメントが混ざっていないかを見ます。 - 5
適用して、型チェックとテストを回す
-Uで適用し、git diff --statとプロジェクトの検証コマンドで確かめます。
インストールは次のとおりです。
npm i @ast-grep/cli -g
# または
brew install ast-grep依頼文は、ルールの置き場と確認の順を指定すると安定します。
legacyFetch(url, opts) を apiClient.get(url, opts) に置き換えたい。
対象は src/ の TypeScript。
1. ast-grep のルールを rules/legacy-fetch.yml に書く
2. まだ -U を付けずに、マッチ件数(--json=compact)と差分を見せる
3. grep で数えた呼び出し数と件数が合うか報告する
4. 私が承認したら -U で適用し、tsc とテストを実行するClaudeが書くルールは、ワンライナーの-p/-rでも、YAMLでも構いません。YAMLはfixキーに置換後のコードを書けて、複数のルールを---区切りで1ファイルに並べられます。
id: legacy-fetch-to-api-client
language: TypeScript
rule:
pattern: legacyFetch($$$ARGS)
fix: apiClient.get($$$ARGS)実行はast-grep scan -r rules/legacy-fetch.yml srcです。-Uを付けるまでは、runと同じく差分の表示だけです。
引数の取り方で変わる結果
$$$ARGSは、ゼロ個以上のノードにマッチするメタ変数です。引数の数が揺れる呼び出しを一括で拾えます。反面、検証では複数行の呼び出しの整形が崩れました。
- return legacyFetch(
- `/orders?user=${id}`,
- { retry: 3, timeout: 5000 },
- );
+ return apiClient.get(`/orders?user=${id}`,
+ { retry: 3, timeout: 5000 },);構文としては成立していますが、フォーマッタを通さないと読みにくい形です。ast-grepの置換は、プロジェクトのフォーマッタの代わりにならないので、適用後にprettierなどを走らせる指示を依頼文に入れておくと安全です。
引数が2つに決まっているなら$URL, $OPTSのように名前を付けたほうが、整形の崩れは出ませんでした。逆に、引数が1つの呼び出しを同時に直すなら、別のルールか$$$ARGSを使います。
もう1つの注意はimport文です。検証では、-Uの後もimport { legacyFetch } from "./http"が残りました。ルールが書き換えるのは一致したノードだけなので、import側は別ルールで直すか、Claudeに手で直させます。
落とし穴
-Uの後に戻す手段がない
-Uは確認なしでファイルを書き換えます。適用前に作業ツリーをgit commitかgit stashで退避しておけば、git diffで変更を見直せます。インタラクティブに選びたいときは-iで、ファイルごとに可否を選べます。
適用範囲を絞り、適用後に残りを数える
テストコードや生成ファイルは別の方針で移行したいことがあります。--globsは、含めるパスや除外するパスをgitignoreと同じ書式で指定します。!を前に付けると除外です。検証では、src/__tests__/a.test.tsにlegacyFetch("/t", {});を1行置いたうえで、除外あり・なしを比べました。
ast-grep run -p 'legacyFetch($$$ARGS)' -l ts \
--globs '!**/__tests__/**' --json=compact src除外ありの出力は空配列[]で、除外なしは1件でした。指定が効いているかも件数で確かめられます。
適用後は、同じパターンをもう一度流します。ast-grep runは、マッチが1行でもあれば終了コード0、見つからなければ1を返すので、検証の判定にそのまま使えます。-Uを付けた後にlegacyFetch($$$ARGS)を流すと終了コードは1で、置換後のapiClient.get($$$ARGS)は4件でした。置換前の4件と一致します。
Claudeに頼むときは、この「適用前の件数」「適用後の旧パターン0件」「適用後の新パターン件数」の3つを報告させると、文章の「直しました」よりはっきりした証拠になります。ファイルを1つずつ開いて編集させる進め方だと、全箇所を直せたかは最後にgrepで数え直すしかありません。ルールを書かせる進め方なら、同じ判定が適用前に1コマンドで済みます。
--inspect summaryを付けると、走査したファイル数も出ます。検証ではscannedFileCount=2,skippedFileCount=0でした。想定したディレクトリが読まれているかを見るのに使えます。
CLAUDE.mdとスキルに型として置く
同じ種類の置換を繰り返すなら、手順をリポジトリに残します。CLAUDE.mdには、順序だけを書きます。テストコマンドの書き方はVitestのCLAUDE.mdの記事と同じ考え方です。
## コードの一括置換
- 関数名・呼び出し形式の一括変更は ast-grep を使う(sed は使わない)
- 適用前に必ず -U なしで差分と件数を出し、私が承認してから -U を付ける
- 適用後は tsc とテストを実行するast-grep側は、Claude Code向けスキルを公式ドキュメントで案内しています。ルールの書き方をClaudeに教えるもので、~/.claude/skillsに置くと自動で検出されます。AGENTS.mdに「構文を伴う検索はast-grep --lang [language] -p '<pattern>'を既定にする」と書く方法や、MCPサーバーを使う方法も紹介されています。
自作するなら、手動でだけ起動するスキルにできます。disable-model-invocation: trueを付けると、Claudeが勝手に読み込まず、/名前で呼んだときだけ動きます。
---
description: ast-grepで一括置換するときの手順
disable-model-invocation: true
---
1. -U なしで差分とマッチ件数を出す
2. 想定件数と合うか報告し、承認を待つ
3. 承認後に -U で適用する
4. フォーマッタとテストを実行する権限は、ast-grepを許可リストに入れないのが単純です。許可リストにBash(ast-grep *)を置くと-U付きの実行も通ります。許可を絞りたいときの書き方は権限ルールの書き方の記事にまとめてあります。権限ルールはdeny、ask、allowの順に評価され、askに一致すれば、より具体的なallowがあっても確認が出ます。
ルールが当たらないとき、Claudeに任せる順番
件数が0件、または想定より少ないときは、ルールを足す前に構文木を見ます。--debug-queryは、パターンがどう解釈されたかを出すオプションで、-lで言語を明示する必要があります。
ast-grep run -p 'legacyFetch($URL, $OPTS)' -l ts --debug-query astast-grepのAI向けガイドは、ルール作成を試行錯誤で進めるやり方を紹介しています。要点は、問い合わせを小さな条件に分け、サブルールを組み合わせて1つのルールにし、当たらなければサブルールを減らして原因を探す流れです。依頼文に「一度に完成形を書かず、最小のパターンから広げて、毎回件数を報告して」と書けば、この流れを指定できます。
つまずきやすい点
Linuxではsgが別コマンド(setgroups)と衝突します。公式のガイドは、ast-grepのフルネームを使うか、alias sg=ast-grepで別名を張るよう案内しています。Claudeに書かせるコマンドもast-grepに統一しておくと困りません。
パターンが部分的なコードだと、構文として解釈できず一致しないことがあります。FAQは、--debug-queryで構文木を出して見比べる方法を挙げています。
メタ変数に接尾辞は付けられません。置換文の$VARNameは$VARN+ameと読まれるため、大文字で始まる文字列をつなぎたいときはreplace変換を使います。また、一致しなかったメタ変数は空文字になります。
ast-grepは構文の水準で動きます。比較ページには、型情報や制御フロー解析には対応していないとあります。同名の別関数を型で区別したい置換では、差分で目視して確かめます。
まとめ
置換の安全性は、ルールの賢さよりも、適用前に件数と差分を並べて見る習慣で決まります。呼び出し数をgrepで数え、--json=compactの件数と照らす、という一手間だけで、2引数のパターンが1引数の呼び出しを取りこぼす穴を、適用前に見つけられました。
機械的な書き換えを仕組みにしたい場合は、探索の進め方を扱った大規模コードベースの探索とあわせて、CLAUDE.mdに手順を残しておくと、次の移行にそのまま使えます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。