Claude Media
Claude Codeでcodemodを書かせる — jscodeshiftとts-morphの使い分け

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を扱いますが、強みが違います。

観点jscodeshiftts-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. 1

    変換前後の例を人が確定する

    置換したいパターンを5〜10個、変換前と変換後のペアで書き出させ、人が読んで確定します。ここで「変換しない例」(引数が2つの呼び出し、別オブジェクトの同名メソッド)を必ず入れます。

  2. 2

    fixtureを先に作る

    確定した例を __testfixtures__/ の入力ファイルと出力ファイルにします。変換スクリプトより先に置くと、Claudeが答えに合わせてテストを書き換える余地が減ります。

  3. 3

    変換スクリプトを書かせて、テストを通す

    テストが落ちたら、差分を読ませて変換側を直させます。fixtureは直させません。

  4. 4

    --dry で本物のコードに当てる

    ファイルを書かずに結果だけ見ます。見る対象は、変換された件数と、スキップされた件数です。

  5. 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が通ったあとも、本物のコードで想定外が出ます。確認の順序を決めておくと、取りこぼしを減らせます。

  1. 対象外を除く: node_modules や生成物にcodemodを当てると壊れます。--gitignore を付けると .gitignore の記述が除外に使われ、--ignore-pattern で個別の除外も足せます
  2. 拡張子を指定する: jscodeshiftの既定の対象拡張子は js です。.ts / .tsx は --extensions=ts,tsx を付けないと素通りします
  3. スキップ件数を見る: --dry 中は、変換スクリプトの中で api.stats('名前') を呼ぶと、その回数が最後に集計されます。「想定外の形の呼び出し」を数える用途に使えます
  4. 失敗を終了コードにする: --fail-on-error を付けると、変換中のエラーがあったときに終了コード1になり、スクリプトから止められます
  5. 整形ツールを最後に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で拾う順序で足ります。

参照した一次ソース

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

  1. code.claude.com/docs/en/headless
  2. github.com/facebook/jscodeshift
  3. ts-morph.com/
この記事を共有:XはてブLinkedIn