Claude Codeでcodemodを書かせる — jscodeshiftとts-morphの使い分け
数百ファイルのAPI置換を、Claude Codeに変換スクリプトとして書かせる手順です。jscodeshiftとts-morphの選び方、fixtureでの検証、--dryでの確認までを手元の出力つきでまとめます。
数百ファイルに同じAPI置換をかけるとき、Claude Codeにファイルを1つずつ編集させる方法は、件数が増えるほど遅くなり、結果もぶれます。代わりに、変換そのものを行うスクリプト(codemod)をClaudeに書かせ、スクリプトを機械的に全ファイルへ当てる方法があります。変換の正しさはfixtureの入出力で先に固めるので、全体へ当てる前に誤りを見つけられます。
この記事では、api.fetchUser(id) を api.users.get({ id }) に書き換える題材で、jscodeshift版とts-morph版の両方を動かします。出力はすべて手元で実行したものです(jscodeshift 17.4.0、ts-morph 28.0.0、Node 20.18.0)。
codemodとは — ファイルでなく「変換」を作らせる
codemodは、ソースコードを構文木(AST)として読み、条件に合う箇所だけを機械的に書き換えるスクリプトです。jscodeshiftは、複数のJavaScript/TypeScriptファイルにcodemodを走らせるツールキットとして公式に説明されています。各ファイルに変換を適用するランナーと、recastをラップしたAPIの2つで構成されます。recastは元のコードの書式をできるだけ保ったまま書き換える構文木変換ツールです。
Claudeにファイルを直接編集させる場合との違いは、再現性です。
- 直接編集は、ファイルごとにClaudeが判断するので、同じパターンでも書き換え方が揺れる
- codemodは、同じ入力に必ず同じ出力を返す。揺れたら変換スクリプトのバグとして1か所を直せば済む
- codemodは、未コミットの作業ツリーに対して何度でもやり直せる。
git restoreで戻して、スクリプトを直して、もう一度流す
一方で、codemodが向くのは機械的に言い切れる変換です。「この呼び出しを、この形に」と規則で書ける置換なら、Claudeは変換規則とテストを一緒に書けます。文脈を読まないと書き換え方が決まらない箇所は、codemodで拾える分だけ拾い、残りをClaudeの個別編集に回す二段構成が現実的です。
jscodeshiftとts-morphはどう使い分けるか
どちらもASTを扱いますが、強みが違います。
| 観点 | jscodeshift | ts-morph |
|---|---|---|
| 得意なこと | jscodeshift書式を保った一括置換 | ts-morph型情報を使った絞り込み |
| 実行の単位 | jscodeshiftファイルごとに変換関数を呼ぶ | ts-morphプロジェクト全体を1つのProjectで読む |
| 並列実行 | jscodeshiftランナーが子プロセスで分担する | ts-morph組み込みの分担はなく、1つのProjectで読む |
| テスト補助 | jscodeshiftjscodeshift/dist/testUtils にfixture用の関数がある | ts-morph自分でテストを書く |
| 向く変換 | jscodeshift名前の置換、引数の組み替え、import整理 | ts-morph「このクラスのメソッドだけ」のような型で決まる置換 |
jscodeshiftの変換関数は、fileInfo(パスとソース)、api(jscodeshiftのライブラリ本体と補助関数)、options を受け取り、変更後のソースを文字列で返します。ts-morphは、TypeScriptコンパイラAPIのラッパーで、ASTの探索と書き換えを簡単にするためのライブラリです。
選ぶ基準は1つです。書き換え対象を名前だけで決められるか、型を見ないと決められないかです。api.fetchUser のように変数名が一意なら、jscodeshiftで足ります。fetchUser というメソッドが複数のクラスにあり、片方だけを置換したいなら、ts-morphで型を見ます。両方が混在するなら、jscodeshiftで大半を直し、残りをts-morphで拾います。
手順: fixtureを先に作り、変換を書き、dry-runで確かめる
Claudeに書かせる順序を決めておくと、手戻りが減ります。
codemodを全体へ当てるまでの順序
- 1
変換前後の例を人が確定する
置換したいパターンを5〜10個、変換前と変換後のペアで書き出させ、人が読んで確定します。ここで「変換しない例」(引数が2つの呼び出し、別オブジェクトの同名メソッド)を必ず入れます。
- 2
fixtureを先に作る
確定した例を
__testfixtures__/の入力ファイルと出力ファイルにします。変換スクリプトより先に置くと、Claudeが答えに合わせてテストを書き換える余地が減ります。 - 3
変換スクリプトを書かせて、テストを通す
テストが落ちたら、差分を読ませて変換側を直させます。fixtureは直させません。
- 4
--dry で本物のコードに当てる
ファイルを書かずに結果だけ見ます。見る対象は、変換された件数と、スキップされた件数です。
- 5
本適用して、型検査とテストで確かめる
tsc --noEmitと既存のテストスイートが通ることを確認してから、差分をコミットします。
jscodeshiftで書く — 引数を組み替える変換
題材は api.fetchUser(id) を api.users.get({ id }) に直す変換です。引数が1つの呼び出しだけを対象にし、それ以外はスキップします。
// transform.js
module.exports = function transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
let changed = false;
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'api' },
property: { type: 'Identifier', name: 'fetchUser' },
},
})
.forEach((path) => {
const args = path.node.arguments;
if (args.length !== 1) {
if (api.report) api.report(`${file.path}: 引数が1つでない呼び出しをスキップ`);
return;
}
path.node.callee = j.memberExpression(
j.memberExpression(j.identifier('api'), j.identifier('users')),
j.identifier('get')
);
path.node.arguments = [
j.objectExpression([
j.property.from({
kind: 'init',
key: j.identifier('id'),
value: args[0],
shorthand: args[0].type === 'Identifier' && args[0].name === 'id',
}),
]),
];
changed = true;
});
return changed ? root.toSource() : undefined;
};
module.exports.parser = 'tsx';戻り値の扱いは公式の仕様どおりです。変更後の文字列が元と違えば成功、同じなら失敗、何も返さなければ「変換対象ではない」として扱われます。上のコードは、変更がなければ undefined を返すので、無関係なファイルが「失敗」に数えられません。
module.exports.parser = 'tsx' は、パーサーを指定する書き方です。選べるのは babel / babylon / flow / ts / tsx で、この記事の例では、TypeScriptの .ts ファイルにも tsx を指定しています。
dry-runの出力を読む
本物のコードには、まず --dry と --print を付けて当てます。
npx jscodeshift --dry --print -t transform.js \
--extensions=ts --parser=tsx src手元の出力は次のとおりでした(抜粋)。
Processing 2 files...
Spawning 2 workers...
Running in dry mode, no files will be written!
REP src/a.ts src/a.ts: 引数が1つでない呼び出しをスキップ
...
export async function load(id: string) {
const user = await api.users.get({
id
}); // 取得
...
export function keep() {
return db.fetchUser(1);
}
export function skip() {
return api.fetchUser(1, { cache: false });
}
...
Results:
0 errors
0 unmodified
0 skipped
2 ok読みどころは3つあります。
db.fetchUser(1)は変わらず、引数が2つの呼び出しはREPの行でスキップが報告されている。意図した除外が効いている{ id }が複数行に展開されている。recastは変更した部分だけを組み直すため、元の書式と揃わない箇所が出る。最後に整形ツールを1回かける前提にしておく- 「2 ok」は2ファイルが処理された数で、
src/a.tsだけではない。fixtureをsrcの下に置いたことで、fixtureまで変換対象になっていた
fixtureでテストする
jscodeshiftには、Jest/Jasmine向けのテスト補助が入っています。決まった配置にすると、数行でテストを定義できます。
transform.js
__tests__/transform-test.js
__testfixtures__/basic.input.tsx
__testfixtures__/basic.output.tsx// __tests__/transform-test.js
jest.autoMockOff();
const { defineTest } = require('jscodeshift/dist/testUtils');
defineTest(__dirname, 'transform', null, 'basic', { parser: 'tsx' });defineTest は、__testfixtures__/basic.input.tsx を変換にかけ、結果を basic.output.tsx と比べます。第4引数でfixtureの名前を変えられるので、パターンごとに FirstFixture / SecondFixture のように分ける運用ができます。
手元で npx jest を流すと、通ります。次に、変換側の 'get' を 'find' に壊してみました。
FAIL __tests__/transform-test.js
● transform › transforms correctly using "basic" data
expect(received).toEqual(expected) // deep equality
- Expected - 2
+ Received + 2期待と実際の差分が行単位で出るので、Claudeにこの出力を読ませて変換側を直させる反復が成立します。
ts-morphで書く — 型で対象を絞る変換
同じ fetchUser というメソッドが、LegacyClient と Cache の両方にあるとします。名前だけでは区別できません。ts-morphなら、呼び出しの受け手の型を見て絞れます。
// codemod.ts
import { Project, SyntaxKind } from "ts-morph";
const dry = process.argv.includes("--dry");
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
let count = 0;
for (const sf of project.getSourceFiles("src/**/*.ts")) {
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const callee = call.getExpressionIfKind(SyntaxKind.PropertyAccessExpression);
if (!callee || callee.getName() !== "fetchUser") continue;
// 型で絞る: LegacyClient のメソッド呼び出しだけが対象
const owner = callee.getExpression().getType().getSymbol()?.getName();
if (owner !== "LegacyClient") continue;
const [arg] = call.getArguments();
call.replaceWithText(
`${callee.getExpression().getText()}.users.get({ id: ${arg.getText()} })`
);
count++;
}
}
console.log(`${dry ? "dry-run: " : ""}${count} 件を書き換え`);
if (!dry) await project.save();ポイントは2つです。tsConfigFilePath を渡すと、tsconfigに紐づくソースファイルが自動で追加されます。また、ファイルへの書き込みは project.save() を呼ぶまで行われません。途中でエラーになってもファイルが中途半端な状態にならないので、この形が推奨されています。
--dry で流した手元の結果です。
dry-run: 2 件を書き換え
export const a = legacy.users.get({ id: "1" });
export const b = cache.fetchUser("2");
export function f(c: LegacyClient) {
return c.users.get({ id: "3" });
}cache.fetchUser("2") は書き換わらず、legacy と引数 c の2か所だけが変わりました。jscodeshiftでは作れない絞り込みです。
本適用の後に npx tsc --noEmit -p . を流すと、型検査が新しい呼び出しを検証します。
src/use.ts(3,25): error TS2339: Property 'users' does not exist on type 'LegacyClient'.
src/use.ts(6,12): error TS2339: Property 'users' does not exist on type 'LegacyClient'.この例では移行先のAPI(users)を型定義側に足していないので、型検査が落ちました。実際の移行では、新しいAPIの定義を先に入れてから流します。置換と型定義の更新を同じcodemodに詰めず、順序を分けておくと原因を追いやすくなります。
落とし穴
replaceWithText のあと、元のノードは使えない
ts-morphでは、ノードを replaceWithText で置き換えると、置き換え前のノードは破棄され、使おうとするとエラーになります。置換後に続けて操作したいときは、メソッドが返す新しいノードを使います。insertText / replaceText / removeText も、それまでに辿った子孫ノードを無効にします。入れ子になった呼び出しを1つのループで書き換えるときは、内側から処理するか、置換後に探索をやり直します。
Claude Codeに書かせる
ここまでの手順を、Claudeへの指示に落とします。まず、リポジトリの CLAUDE.md にcodemod用の約束事を置きます。
## codemodの作り方
- 変換スクリプトは tools/codemods/<名前>/ に置く
- fixtureは先に作り、テストが落ちたら変換側を直す。fixtureは書き換えない
- 「変換しない例」のfixtureを必ず1つ以上入れる
- 本物のコードへ当てるのは --dry の結果を見せてから
- 完了条件: jest が通り、tsc --noEmit が通り、git diff の変更ファイル数を報告する対話セッションなら、これを前提に「この置換のcodemodを書いて」と頼めば足ります。繰り返し流したい、CIや夜間に回したい場合は、非対話モードの claude -p が使えます。
claude -p "tools/codemods/fetch-user に transform.js を書いて、fixtureのテストを通してください。本物のコードには当てないでください" \
--allowedTools "Read,Edit,Write,Bash(npx jest *),Bash(npx jscodeshift --dry *)"--allowedTools は、確認なしで使えるツールを指定するフラグです。Bash(npx jest *) のように末尾の * を付けると前方一致になり、npx jest で始まるコマンドを許可します。* の前の空白は意味があって、空白がないと別のコマンドまで一致します。
許可を --dry つきの jscodeshift に絞っているのは、意図があります。本適用を許可しないでおけば、Claudeが勝手にファイルを書き換える経路を閉じられます。--dry の結果を人が読んでから、本適用だけを自分の手で流せます。
Manualモードの claude -p では、承認が要る呼び出しは、hookなどが承認しない限り拒否されます。許可モードの詳しい違いはclaude -pの記事にまとめています。スクリプトやCIでは --bare が推奨されています。hooksやCLAUDE.mdを読み込まず、どの環境でも同じ結果になるからです。ただしbareモードはCLAUDE.mdも読まないので、上の約束事は --append-system-prompt-file などで渡す必要があります。
全体に当てる前の確認
fixtureが通ったあとも、本物のコードで想定外が出ます。確認の順序を決めておくと、取りこぼしを減らせます。
- 対象外を除く:
node_modulesや生成物にcodemodを当てると壊れます。--gitignoreを付けると.gitignoreの記述が除外に使われ、--ignore-patternで個別の除外も足せます - 拡張子を指定する: jscodeshiftの既定の対象拡張子は
jsです。.ts/.tsxは--extensions=ts,tsxを付けないと素通りします - スキップ件数を見る:
--dry中は、変換スクリプトの中でapi.stats('名前')を呼ぶと、その回数が最後に集計されます。「想定外の形の呼び出し」を数える用途に使えます - 失敗を終了コードにする:
--fail-on-errorを付けると、変換中のエラーがあったときに終了コード1になり、スクリプトから止められます - 整形ツールを最後に1回かける: recastの出力は、元の書式と揃わない箇所が出ます。PrettierやBiomeを通して差分のノイズを消します。編集のたびに整えたい場合は、Biomeをhookで走らせる方法も使えます
件数が数百を超える場合は、全体を一度に当てる前に、ディレクトリを区切って当てると差分のレビューが現実的な大きさになります。ディレクトリごとにコミットを分けておけば、問題があったときも戻す範囲が小さく済みます。
落とし穴
--print を付けても、--dry がなければファイルは書き換わる
jscodeshiftの -p / --print は、変換後のソースを標準出力に出す指定です。ファイルへの書き込みを止めるのは -d / --dry です。手元で、fixtureの入力ファイルに --print だけを付けて流したところ、入力ファイルが変換後の内容で上書きされ、次のテストが「入力が既に変換済み」の状態で落ちました。確認のためだけに流すときは、必ず --dry を付けます。
手元で踏んだ4つのつまずき
fixtureを使うテストと、ランナーとで動きが違う点が、いくつかあります。
codemodで詰まりやすい4か所
fixtureの拡張子はパーサーで決まる
parser: 'tsx'を指定すると、期待されるfixtureはbasic.input.tsx/basic.output.tsxです。.tsで置くと、読み込みに失敗してENOENTになります。テストのapiには report がない
defineTestが渡すapiはjscodeshift/j/statsだけで、reportを含みません。変換側でapi.report(...)を無条件に呼ぶと、テストでapi.report is not a functionになります。上のコードのようにif (api.report)で囲みます。fixtureが変換対象に入る
fixtureを
srcの下に置くと、ランナーが変換対象として拾います。fixtureはルート直下の__testfixtures__に置くか、--ignore-pattern="**/__testfixtures__/**"で除外します。reportは標準出力に混ざる
api.reportは標準出力に書くので、--printの出力にスキップ報告の行が混ざります。出力をそのままfixtureの期待値に保存しないでください。
公式codemodと自作codemodの境界
React 19などのように、公式がcodemodを配布している移行は、まず公式のcodemodを当てるのが近道です。その後に残る個別の書き換えを、この記事の方法で自作します。数百ファイルに同じ変換をかけるときの一般的な進め方、つまり対象一覧の作成、2〜3件での試走、worktreeでの並列化は、Claude Codeでレガシーコードを改修する手順にあります。COBOLのような基幹システム全体の移行は、コードモダナイゼーションの記事が扱っています。
設定ファイルや依存関係のアップグレードで公式の移行ツールがあるものは、ツールを当てたあとの後始末をClaudeに任せる形が合います。Tailwind v4への移行やESLintのflat config移行がその例です。自作codemodが活きるのは、社内ライブラリのAPI変更のように、公式のツールが存在しない移行です。
まとめ
自作codemodの価値は、変換を「ファイルごとの判断」から「1つのスクリプトとテスト」に変える点にあります。Claudeには変換とテストを書かせ、fixtureの確定と本適用は人が持つ分担にすると、数百ファイルの置換でも結果を追跡できます。
名前で決まる置換はjscodeshift、型で決まる置換はts-morphです。迷ったら、まずjscodeshiftで書き、型が要る箇所だけts-morphで拾う順序で足ります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。