Claude Media
Claude Codeでast-grepの一括置換を書かせる — 差分と件数を見てから適用

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 src

0.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. 1

    ast-grepを入れる

    公式はnpm・Homebrew・cargo・pipなどの配布を挙げています。

  2. 2

    対象と移行先を伝える

    関数名、引数の数が変わるか、対象言語と対象ディレクトリを渡します。

  3. 3

    件数を先に報告させる

    適用の前に、--json=compactでのマッチ数と、想定した呼び出し数の突き合わせを求めます。

  4. 4

    差分を読む

    -Uなしの出力で、文字列やコメントが混ざっていないかを見ます。

  5. 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 ast

ast-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に手順を残しておくと、次の移行にそのまま使えます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. ast-grep.github.io/guide/rewrite-code.html
  2. ast-grep.github.io/reference/cli/run.html
  3. ast-grep.github.io/advanced/prompting.html
  4. code.claude.com/docs/en/skills
  5. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn