🔮

codemod実装のベストプラクティス

2022/12/20に公開約16,100字

codemod を作成・運用しリファクタリングしてきた中で分かった、実装のベストプラクティスについて。

jscodeshift

はじめに codemod の実装に使う jscodeshift について説明する。
既に知っている人は読み飛ばしてOK。

jscodeshift を簡単に表すと、JS 向け codemod 作成用のツールキットライブラリ。
コードの変換を担う transformer を実装するための API と、それをディレクトリやファイルに適用するための CLI をまとめてツールキットとして提供している。
このライブラリは React[1]StorybookNext.js などで codemod の実装に使用されおり、それぞれ独自の記法を持つライブラリのソースコードでも、AST(Abstract Syntax Tree, 抽象構文木)レベルに落とし込んで変換できる。

ライブラリの内部では、Recast という JavaScript AST パーサーを利用して

  1. ソースコードを AST に変換
  2. AST を探索・置換
  3. AST をソースコードに変換

以上のステップでソースコードを書き換える。

また、JS の AST 周りに触れたことのある方は既に知っているかもしれないが、AST は変換に用いるパーサーによって若干形式が異なる。
有名所でいくと AcornEsprima がある。以下はそれぞれで alert を AST に変換した例。

acorn_out.json
{
  "type": "Program",
  "start": 0,
  "end": 5,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 5,
      "expression": {
        "type": "Identifier",
        "start": 0,
        "end": 5,
        "name": "alert"
      }
    }
  ],
  "sourceType": "module"
}
esprima_out.json
{
  "type": "Program",
  "body": [
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "Identifier",
        "name": "alert",
        "range": [
          0,
          5
        ]
      },
      "range": [
        0,
        5
      ]
    }
  ],
  "sourceType": "module",
  "range": [
    0,
    5
  ]
}

双方の変換結果にある程度互換性はあるものの、似て非なるものが吐き出される。
なお、この2つ以外の JS パーサーでも元を辿っていくと大抵どちらか(Recast は Esprima)に行き着くので、パーサーごとの変換結果に劇的な差は見られない。

以上のパーサーやそれが吐き出す AST について、すべて理解しないと codemod を作れない訳ではないので、とりあえず

  • jscodeshift には Recast というパーサーが使われている
  • Recast は Esprima をラップしている

を頭の片隅においておくと実装が楽になるはず。

ベストプラクティスについて

ここからは codemod 実装のベストプラクティスについて、簡単な例を交えつつ紹介する。
jscodeshift の example や、前の項で紹介した React や Next.js の codemod の実装を参考にしているので、物足りない場合は適宜そちらも参考に。

transformerの書き方のコツ

jscodeshift の docs(README) では、transformer の書き方や API の使い方を解説している部分が少ないので、導入も兼ねてちょっとした書き方のコツを紹介する。

基本的な書き方は、jscodeshift( j )のインスタンス( $j )に対して find を呼び出すと AST を探索できる。find の呼び出しはチェーン状に書ける。

$j.find(j.MemberExpression).find(j.CallExpression)...

探索できたら replaceWith を呼び出して AST を置換する。これも $j に対してチェーン状に書ける。
replaceWith の引数には j に生えている AST を作成する関数(か、オブジェクト)を渡す。

$j.find(/* ~~~ */).replaceWith(j.identifier('foo'))
// 別にこう書いてもいい
$j.find(/* ~~~ */).replaceWith({ type: 'Identifier', name: 'foo' }))

これだけだと、README の内容を紹介しただけなので、実際にコードの変換を通して API の実用的な使い方を紹介する。

変換したいコード(input.js)と、変換後のコード(output.js)は以下のとおり。
foo/Foo を見つけたとき、fuga/Fuga に変換する。

input.js
const { Bar } = require('./Bar');

function fooFunc(args) {
  return class Foo extends Bar {
    constructor(params) {
      this.name = args.name;
      this.parentName = params.parentName;
    }

    logFoo() {
      console.log(name + ': ' + this.name, parentName + ': ' + this.parentName);
    }
  };
}

function main() {
  const Foo = fooFunc({ name: 'Foo' });
  const foo = new Foo({ parentName: 'Bar' });
  foo.log();
}
output.js
const { Bar } = require('./Bar');

function fugaFunc(args) {
  return class Fuga extends Bar {
    constructor(params) {
      this.name = args.name;
      this.parentName = params.parentName;
    }

    logFuga() {
      console.log(name + ': ' + this.name, parentName + ': ' + this.parentName);
    }
  };
}

function main() {
  const Fuga = fugaFunc({ name: "Fuga" });
  const fuga = new Fuga({ parentName: 'Bar' });
  fuga.log();
}

transformer 実装のコツとして、AST の探索範囲が「広い→狭い」ように書くと意図した変換がしやすい。
例えば「mainという関数の中の、fooFuncという関数を呼び出した戻り値を格納した、変数のFoo」と、広い範囲から順に絞っていく。

const $mainFn = $j.find(j.FunctionDeclaration, {
  id: { type: 'Identifier', name: 'main' },
});
// `const Foo = fooFunc()` -> `const Fuga = fooFunc()`
const $execFn = $mainFn.find(j.VariableDeclarator, {
  id: { type: 'Identifier', name: 'Foo' },
  init: {
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'fooFunc' },
  },
});
const $return = $execFn.find(j.Identifier, { name: 'Foo' });
$return.replaceWith(j.identifier('Fuga'));

すべての「変数のFoo」を探索・置換したい場合は愚直に $j.find(j.Identifier, { name: 'Foo' }) と書けば良いが、これだと影響範囲が広く他の変換と競合しやすいので、なるべく狭い範囲を置き換えるように書く方が良い。

input.js から他にもfoo/Fooも探索してfuga/Fugaに置換する transformer を書いてみる。

以上の書き方を雰囲気だけでもいいのでおさえておけば、最低限の transformer は書けるようになるはず。

Tips: jscodeshiftのインスタンスを代入した変数にはわかりやすい命名をする($をつける)

transformer の例のソースコード中に、$ から始まる変数がちらほら存在する。
これは @next/codemodnew-link の書き方を取り入れたもの。

以下のように jscodeshift のインスタンスを代入した変数は $ から始まる名前にする。
また、インスタンスから get API などで取得した値は $ をつけない変数名にする。

$j.forEach((path) => {
  // jscodeshift インスタンス
  const $method = j(path)
    .find(j.ClassBody)
    .find(j.MethodDefinition)
    .find(j.Identifier, { type: 'Identifier', name: 'logFoo' });
  if (!($method.length > 0)) {
    return;
  }
  // jscodeshift インスタンスから取得した値
  const newMethodName = $method.get('name').value.replace(/Foo/g, 'Fuga');
  $method.replaceWith(j.identifier(newMethodName));
});

jscodeshift のインスタンスが格納された変数かどうかを見た目で判断でき、置換間違いを防ぐことができたので筆者的にはよかった。

ちなみに jscodeshift の API は jQuery ライクに書けることを推しているので、その流れで $ が登場したのかもしれない、と勝手に推測している。

jscodeshift is a reference to the wrapper around recast and provides a jQuery-like API to navigate and transform the AST. Here is a quick example, a more detailed description can be found below.

transformerからfsなどでファイルの読み書きをしない

言い換えると jscodeshift の API 以外でファイルの読み書きをしない。
これをやると実行した際に意図しない変換結果になる場合がある。

例えば以下のように、jscodeshift の API 経由で読み込んだファイルのソースコードを、別ファイルのソースコードの AST から取得した値で書き換えるとする。

sample.js
// メインの変換対象
const $j = j(file.source);

// 他のファイルを読み込む
const file = fs.readFileSync(/* path */).toString();
const $f = j(file);

// newName で書き換える
const newName = $f.find(/* ~~~ */).get('name').value;
$j.find(/* ~~~ */).replaceWith(j.identifier(newName));

return $j.toSource();

このコードだと変換に失敗するときがある。順に再現すると、

  1. jscodeshift を実行
  2. 対象ファイルの内容が jscodeshift の worker に格納される
    1. に対して sample.js を非同期[2]に実行
  3. sample.jsnewName を含む AST が、どこかの worker で書き換わる
  4. 書き換わったファイルを read する
  5. 💥

以上のステップにおいて問題になるのは 3 と 4。
3では worker に格納されたそれぞれのファイルの内容に対して、非同期に codemod の transformer が実行されている。
これの実行が完了するタイミングを transformer が知らないために、4 で他の worker が書き換えたかもしれないファイルを read する羽目になる。

「何が問題?」と思うかもしれないが、入力ファイルの内容(AST)がタイミングによって違うと newName が想定外の値になったり、そもそも探索できなくて値が得られなくなったりすることが問題。

筆者が transformer を実装した際は、テスト段階で transformer から read するファイルをモックしたがためにチェックをすり抜け、本番で動かしたときにこれが発覚した。

sample-for-testing.js
// メインの変換対象
const $j = j(file.source);

// 他のファイルを読み込む
let $f;
if (process.env.NODE_ENV !== 'test') {
  const file = fs.readFileSync(/* path */).toString();
  $f = j(file);
} else {
  // テストでは fs を使わないようにモックする。
  $f = j(MOCK_FILE);
}

// newName で書き換える
const newName = $f.find(/* ~~~ */).get('name').value;
$j.find(/* ~~~ */).replaceWith(j.identifier(newName));

write でも同様に、出力先のファイルが codemod で書き換わっているかもしれない & transformer はそれを知らないため、read と同じく意図しない変換になる場合がある。

この問題への対処法は、そもそも transformer 実行中に他のファイルを読み書きしない。
「1つの transformer で読み書きするファイルは1つ」とまで割り切ってもいい。

もしくは、テストでファイルの読み書きをモックしない。
だがこの場合、ファイルが書き換わる前後すべてを考慮してテスト用のファイルを用意する必要があり、かなり手間がかかるのでおすすめしない(実際に何回か codemod を動かして、変換ミスしたケースを集めるようなことになると思う)。

他に、目的に合っているかはわからないが CLI ではなく promise を返す jscodeshift の API を await して、すべての codemod の実行が終わってから fs などを使って読み書きするようにすればいい。

(async () => {
  // ~~~
  await jscodeshift(transformPath, paths, options);
  fs.readFileSync(/* path */);
  fs.writeFileSync(/* path */);
})();

ただ、codemod の実行中にI/Oを発生させるのは前述の通り厳しい。
本当にやりたいのであれば jscodeshift の worker にパッチを当てて、同期っぽく振る舞うようにすればいいが若干手間がかかる。

筆者の場合はファイルの読み書きと codemod の実行を plopjs の Action としてそれぞれ作成し、ファイル読み書きAction → codemod 実行 Action の順で実行して対処した。
こういったツールで npm の script にしてみると、codemod を使う側はコマンド1つですべての変換が適用でき、親切な設計にもなるので良さげ。

少し言葉による説明が多かったが、transformer で複数のファイルを操作しないことに併せて、jscodeshift は非同期に codemod を実行することさえ覚えておけば大丈夫。

AST Explorerでtransformerのデバッグや検証をする

ソースコードの AST の構造を知りたいときや、transformer のデバッグをしたいときに、AST Explorer というツールが役立つ。
AST を console.log で出力してターミナルで確認するのは、かなり視認性が低くてつらいので、こういった AST インスペクター付きのツールを活用する方が良い。

AST Explorer

基本的な使い方としては、左側のエディター部分にソースコードを貼り付けると、右側のインスペクターで出力された AST を確認できる。
右側のインスペクターは Chrome などの DevTools みたく、ポチポチ押して AST を展開して構造を調べられる。

赤線で囲われた部分を押すとパーサーを選択できる。今回は recast を選んでいる。

AST Explorerの使い方(1)

様々なパーサーによる変換結果を確認する他に、codemod の実装においては recast で変換できない構文(TypeScript や ESM の構文など)を他のパーサーで検証するために使用する。
どのパーサーも変換結果にそこまで大きな差異は無いので、適宜選んで試してみると実装のヒントが得られると思う。

ちなみに筆者は recast で変換できない構文があるとき、typescript か espree(recast と同じ esprima の派生。ESLint で使用されているもの) を選んでいた。

また、青線で囲われた部分を押すと jscodeshift や ESLint、Prettier などを使用して変換を行える。

AST Explorerの使い方2)

左下のエディターにソースコードを貼り付けるだけで変換結果を確認できるため、テストや dry-run なしで簡単に transformer を検証できる。

early returnはforEachのコールバックか別の関数のブロック内でする

JS じゃなくてもなんでもそうだが I/O の回数はなるべく減らしたい。
codemod を書いていると、1度読み込んだファイルの AST に対して、できる限りの変換をしてから書き込みを行いたくなる。そこで発生するのが early return で処理を中断してしまう問題。

例えば、以下のように jscodeshift インスタンスに対して get でプロパティを取得するには、インスタンスの length が 0 より大きい必要がある。

// ~~~
const $j = j(file.source).find(/* ~~~ */);
if ($j.length > 0) {
  const foo = $j.get('name').value;
}

get を使うたびに if でネストしていくのは、かなりコードの見通しが悪くなるので early return でぱぱっと済ませたくなる。
だが、$j の length が 0 より大きくない場合、当然 return 以降の処理は実行されない。

// ~~~
const $j = j(file.source).find(/* ~~~ */);
if (!($j.length > 0)) {
  return;
}
const foo = $j.get('name').value;

// 他の変換処理など
// ~~~

「early return する処理より前に他の処理書けばいいじゃん?」となるかもしれないが、early return が複数ある場合、それらを優先度分けするか if の条件文をひたすら増やして対応することになる。
優先度分けする場合は、いくつかの処理は未完になることを許容しなければならないし、if の条件文はなるべく少なく書けるほうが(筆者的には)コードの見通しが良い。

そこで、early return しても後続の処理を続けたい場合、jscodeshift のインスタンスに forEach メソッドが生えているので以下のようにすると対応できる。

j(file.source)
  .find(/* ~~~ */)
  .forEach((path) => {
    const $p = j(path);
    if (!($p.length > 0)) {
      return;
    }
    const foo = $j.get('name').value;
    // foo を用いる処理
    // ~~~
  });

または、即時関数で囲ってしまって forEach みたくローカルスコープを持ってしまってもいい。

const $j = j(file.source).find(/* ~~~ */);

(() => {
  const $p = j(path);
  if (!($p.length > 0)) {
    return;
  }
  const foo = $j.get('name').value;
  // foo を用いる処理
  // ~~~
})();

もちろん forEach や即時関数のブロックからは return すると抜けてしまうので、このブロック内の return 以降には get で得た値を用いる処理以外を書かない方がいい。

jscodeshiftのtestUtilsでテストを書く

codemod はなるべくテストケースを充実させて、様々な入力に対応できるようにしておきたい。

Jest などで普通にテストを書く場合、test.each で Table Driven な感じにすると思う。

describe('sample transformer', () => {
  test.each([
    { input: 'A', output: 'A' },
    { input: 'B', output: 'B' },
    { input: 'C', output: 'C' },
    // ~~~
  ])('can transform $input -> $output', ({ input, output }) => {
    expect(transformer(input).trim()).toBe(output);
  });
});

ただ、テストケースを追加するにつれて Table 部分が雪だるま式に増えていく。
ソースコードを文字列として貼り付けるため、ソースコードを編集しづらいかつ、かなりの行数になるので管理しづらくなる。
こういったテストは fixture として入出力データをファイルごとに切り出し、テストケースを増やす際は fixture ファイルを追加・編集するだけにしたい。

jscodeshift には testUtils というテスト向けモジュールが付属しているので、これを使うと自前で組むことなく上記の fixture の切り出しができる。

簡単に testUtils の使い方[3]を紹介する。
jscodeshift 以外に必要なパッケージは Jest だけ。TS で書く場合は他に @types/jest など諸々追加する。

まず、以下のようにディレクトリとファイルを作成する。

transforms
├── __test__
│   └── sample.spec.js
├── __testfixtures__
│   ├── sample-01.input.ts
│   ├── sample-01.output.ts
│   ├── sample-02.input.ts
│   └── sample-02.output.ts
└── sample.js

sample.js ファイルの transformer に対してテストを行う想定で進める。
テストの入出力に使うファイルは __testfixtures__ 以下に置く。
このとき、入力ファイルには input を、出力ファイルには output を拡張子に含めるようにする。

あとは以下のように sample.spec.js を書く。

sample.spec.ts
const defineTest = require('jscodeshift/dist/testUtils').defineTest;
jest.autoMockOff();

const transformName = 'sample';
const fixtures = ['sample-01', 'sample-02'];
const options = {
  dry: false,
  force: false,
  print: false,
};
fixtures.forEach((fixture) => {
  defineTest(__dirname, transformName, options, `${fixture}`, { parser: 'ts' });
});

入出力ファイルの拡張子に合ったパーサーを defineTest に渡さないと正常に動かないので注意。

Tips: testUtilsのdefineInlineTestはパーサーが指定できないので、applyTransformをラップして使う

testUtils にはdefineTest 以外にも、ソースコードを文字列として渡してテストできる defineInlineTest が含まれている。

describe('sample', () => {
  defineInlineTest(
    transform,
    {},
    'const foo = "foo"',
    'const bar = "foo"',
    'Replace foo with bar'
  );
});

この defineInlineTestdefineTest と異なり、パーサーの指定ができない。
仕様なのかもしれないがどこに明言されているかまでは追っていないので、とりあえずパーサーを指定できるようにする方法だけ紹介する。

const applyTransform = require('jscodeshift/dist/testUtils').applyTransform;

function customDefineInlineTest(
  module: any,
  options: Record<string, any>,
  input: string,
  expectedOutput: string,
  testOptions: Record<string, any>
) {
  const { testName, ...otherTestOptions } = testOptions;
  it(testOptions.testName || 'transform correctly', () => {
    const output = applyTransform(
      module,
      options,
      { source: input },
      otherTestOptions
    );
    expect(output).toEqual(expectedOutput.trim());
  });
}

以上のコードのように testUtils の applyTransform をラップすれば、パーサーを指定してインラインテストを回せる。
(気づいた筆者が PR 投げて直してよって話だが、手元で直せてしまったので放置している。コントリビュートチャンスだとは思うので興味ある方はぜひどうぞ。)

TSのASTを変換するときはreplaceWithで型注釈を残すように書く

少し限定的なシチュエーションにはなるが、jscodeshift の仕様だったので紹介する。

以下のような transformer で TS のソースコードを変換すると、型注釈(type annotation)が抜け落ちる。

codemod.ts
const j = jscodeshift.api;
const $j = j(source);
$j
  .find(j.Identifier, { name: 'foo' })
  .replaceWith(j.identifier('bar'));
// 変換前
const foo: Foo = 'foo';

// 変換後(codemod.ts適用後)
const bar = 'foo';

これは仕様と明言されていて、同時に対処法も提示されている。
これを参考に、型注釈を残すように replaceWith のコールバックを書けば大丈夫。

codemod.ts
const j = jscodeshift.api;
const $j = j(source);
$j
  .find(j.Identifier, { name: 'foo' })
  .replaceWith((p) => {
    return j.identifier.from({
      ...p.value,
      name: 'bar',
    })
  });

型注釈を落とす理由は Issue のコメントを参照してほしいが、簡単に説明すると「type annotationがつくかつかないかはパーサーによってバラバラだから、変換結果には含まないべき」とのこと。
あくまで互換性を重視した結果らしい。

おわりに

codemod を実装・運用してきた中で得た知見をベストプラクティスとして紹介した。

実際に codemod を導入した所感として、大量のファイルを変更する単純作業における人為的なミスは減ったように思う。
特に PR の diff において、codemod で書き換えた箇所はそこまで神経質に確認しなくてよくなったため、他のロジックなどのレビューに集中できるようになった。

一方、transformer で想定できていない記法が結構出てきて、うまく変換できていない部分の範囲が狭ければ手作業で直すこともあったし、広すぎる場合は codemod の適用を見送ったこともあった。
最初から codemod の変換結果が100%想定通りになることを期待して実装するよりも、ある程度実装したら動かし、結果を見てから codemod を改善して100%にするか、人の手で直して100%にするか、そもそも codemod を使わないか判断するのが良いと思う。

この改善も愚直にやると大変なので、適宜開発チームでコード規約を整えて書き方のばらつきを抑えると transformer が書きやすくなり、手作業で直す手間は減らせるとは思う(開発の初期段階じゃないと簡単にはできないが)。

他に、変換後のデグレや UI 崩れの有無を手作業で確認するのはかなり辛いので、E2E と合わせて Snapshot や Visual Regression によるテストで検知できるようにしておくと安定した運用ができるはず。

何かの役に立てば幸いです。ご精読ありがとうございました。

脚注
  1. jscodeshift は facebook 製なのでそれはそうという感じはする ↩︎

  2. https://github.com/facebook/jscodeshift/blob/d8fb5ecc2b31658429cf47f9dc14147a342ebe93/src/Worker.js を読めば、非同期であることがわかる ↩︎

  3. 参考: https://github.com/facebook/jscodeshift/blob/352e7b677abe11f8600283f3ae97cebaef526842/sample ↩︎

Discussion

ログインするとコメントできます