ASTとLLMによるJavaScriptからTypeScriptへの移行自動化:大規模コードベース向け安全なパイプライン

AI tutorial - IT technology blog
AI tutorial - IT technology blog

実践の現場で生じた課題:25万行の移行におけるボトルネック

半年前、私たちのチームは大きな壁にぶつかりました。約1,200ファイル、25万行のJavaScriptで構成されるレガシーなNode.js/Expressバックエンドを、厳格なTypeScript(strictモード)へ移行する必要があったのです。5年間にわたる急速な機能開発の結果、型定義のないORMモデル、暗黙のグローバル変数、アドホックなオブジェクトのミューテーションが至る所に残されていました。すべてを手作業で移行しようとすれば、6名のエンジニアが約9ヶ月間拘束される計算になります。私たちにそんな余裕はありませんでした。

まず、ts-migrate や airbnb-ts-migrate といった既存のコードモッドツールを試しました。これらはファイル名のリネームや基本的な定型コードの変換を素早く処理してくれました。しかし一方で、14,000行以上ものコードに any アノテーションや // @ts-expect-error によるエラー抑制をばらまく結果となったのです。コードベースのコンパイルは通るようになったものの、コンパイル時の安全性はほとんど得られませんでした。

次に、ソースファイル全体をそのままLLMに渡すアプローチを試みました。生成されたコードは一見綺麗に見えましたが、微細なリグレッション(機能先祖返り・不具合)がすぐにステージング環境を壊しました。LLMはインストールされていないnpmパッケージをハルシネーション(幻覚)で呼び出したり、内部ユーティリティを勝手に重複定義したり、同期ループを非同期パターンに置き換えて実行順序を暗黙のうちに狂わせたりしたのです。2週間にわたって不安定なテストスイートのデバッグに追われた後、私たちはファイル全体を直接変換する手法を断念しました。

根本原因の分析:構文の厳密性とコンテキストによるハルシネーションの対立

構造変換とセマンティックな型推論を同じ単一のタスクとして扱ってしまうと、自動移行は失敗します。これらはまったく異なるツールセットを必要とする課題です。

  • ASTコードモッドは決定論的な精度を提供します。パーサーがコードを抽象構文木(AST)に変換するため、ランタイムロジックに一切触れることなく、宣言のリネームやモジュール構文の更新、ノードの挿入を安全に行えます。しかし、ASTは開発者の意図までは推論できません。ASTパーサーは型注釈のない req.body を見て、それがオプションの billingAddress を持つ CreateUserDTO として型付けされるべきであることを推論できないのです。
  • LLMはコンテキスト推論に長けています。隣接するファイル群を横断してデータベースクエリ、バリデーションスキーマ、呼び出し元コードを解析し、正確なドメインモデルを生成できます。しかし、LLMは確率的なエンジンです。モデルに500行のファイルを丸ごと渡してしまうと、コメントの欠落やステートメントの並び替え、ロジックの意図しない変更といったミスが発生する余地が大きくなりすぎます。

ASTは意味的理解を欠いた構造的安全性を担保し、LLMは構造的保証を欠いた意味的理解を提供します。この両者を組み合わせることこそが、双方の課題を解決するアプローチです。

手法の比較:移行戦略のベンチマーク

リポジトリ内の代表的な1,200ファイルのサンプルを対象に、4つの異なる手法をベンチマーク測定しました。

戦略 スループット 厳格な型カバレッジ ランタイム安全性 手動作業コスト
1. 手動移行 約3ファイル / エンジニア / 日 高 (約95%) 高 極めて高い (6名で9ヶ月)
2. 純粋なAST / コードモッド 瞬時 (合計2分未満) 極めて低い (any を除くと15%未満) 高 (構文を完全維持) 高 (広範な手動型定義が必要)
3. ファイル全体のLLMプロンプト 約20ファイル / 時間 中程度 (約70%) 低 (ロジックの逸脱が頻発) 高 (綿密な1行ずつのQAが必要)
4. AST + LLM ハイブリッドパイプライン 約120ファイル / 時間 高 (約94%) 最高 (コンパイラ検証済み) 最小限 (PRのレビューのみ)

解決策:3段階のデュアルエンジンパイプライン

私たちのプロダクションパイプラインは、構造的操作と型合成を3つの明確なフェーズに分離しています:AST抽出 → 対象を絞ったLLM型合成 → AST注入とコンパイラ検証。

フェーズ1:決定論的なAST抽出

まず、ASTスクリプトが各JavaScriptファイルを解析し、CommonJSの require 呼び出しをESモジュールの import 文に変換した上で、型定義のないすべての関数宣言をマッピングします。他のコードには一切触れず、関数シグネチャとその直下の本体コードのみを生のコードスニペットとして抽出します。

以下は、@babel/parser と @babel/traverse を使用して型定義が必要な対象関数を抽出するNode.jsスクリプトです:

const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;

function extractUntypedSignatures(filePath) {
  const code = fs.readFileSync(filePath, 'utf-8');
  const ast = parser.parse(code, {
    sourceType: 'module',
    plugins: ['jsx']
  });

  const targets = [];

  traverse(ast, {
    FunctionDeclaration(path) {
      // パラメータに型注釈が不足しているかチェック
      const hasUntypedParams = path.node.params.some(p => !p.typeAnnotation);
      if (hasUntypedParams) {
        targets.push({
          functionName: path.node.id ? path.node.id.name : 'anonymous',
          start: path.node.start,
          end: path.node.end,
          params: path.node.params.map(p => p.name || 'destructured'),
          rawSnippet: code.slice(path.node.start, path.node.end)
        });
      }
    }
  });

  return targets;
}

const targets = extractUntypedSignatures('./src/services/userService.js');
console.log(`型定義が必要な関数が ${targets.length} 件見つかりました。`);

フェーズ2:対象を絞ったLLM型合成

500行の混在したコードを丸ごとLLMに渡す代わりに、抽出した関数のスニペットとスキーマコンテキスト(データベース定義やJSDocコメントなど)のみを送信します。そして、厳格に型付けされたインターフェース定義とシグネチャの注釈をJSON形式で返すようLLMに強制します。

LLMの出力を型定義のみに限定することで、モデルが実行時ロジックを破壊するリスクを排除できます。

import json
import requests

def synthesize_types(function_snippet: str, schema_context: str) -> dict:
    prompt = f"""
    このJavaScript関数を解析し、TypeScriptの型を返してください。
    関数のロジックを書き換えたり変更したりしないでください。
    
    スキーマコンテキスト:
    {schema_context}
    
    関数のスニペット:
    {function_snippet}
    
    以下のキーを持つ厳密なJSONオブジェクトを返してください:
    1. "interfaces": TypeScriptのinterface/type文字列の配列。
    2. "params": "name" と "type" の文字列を持つオブジェクトの配列。
    3. "returnType": 戻り値の型を表す文字列。
    """
    
    payload = {
        "model": "gemini-2.5-flash",
        "messages": [{"role": "user", "content": prompt}],
        "response_format": {"type": "json_object"}
    }
    
    response = requests.post(
        "https://api.example.com/v1/chat/completions",
        headers={"Authorization": "Bearer YOUR_API_KEY"},
        json=payload,
        timeout=30
    )
    return json.loads(response.json()["choices"][0]["message"]["content"])

フェーズ3:AST注入とコンパイラ検証ループ

LLMから型宣言が返されると、ASTエンジンが新しいインターフェースをファイルの先頭に挿入し、推論された型アノテーションを関数のパラメータに付与します。元の関数本体はそのまま保持されます。

続いて、TypeScriptコンパイラを実行し、プロジェクトの設定に従って変換後のファイルを検証します:

# 出力ファイルを生成せずにリポジトリ全体の型を検証
npx tsc --noEmit --project tsconfig.json

tsc がエラー(オプショナルプロパティフラグの欠落など)を検知した場合、パイプラインはコンパイラの診断メッセージをキャプチャし、自己修正パスのためにLLMへ再フィードバックします。私たちの移行実績では、初期型エラーの82%が初回の再試行で自動解決されました。

成果:25万行を18日間で移行完了

このハイブリッドパイプラインをコアリポジトリ全体に適用したことで、極めて明確な成果が得られました:

  • ランタイムリグレッションゼロ:ASTのセーフガードによってロジックの逸脱が防止されたため、既存の単体テストおよび結合テストが初回の実行ですべてパスしました。
  • 94%の厳格な型カバレッジを達成:手動での介入が必要だったのは、動的なEventEmitterペイロードを中心とするごく一部(6%)のエッジケースのみでした。
  • 開発期間を10分の1に短縮:当初9ヶ月を見込んでいたプロジェクトが、PRレビューを担当するエンジニアわずか2名体制で、実働18日で完了しました。

大規模なコードベースを移行する際は、壊れやすい正規表現スクリプトや制約のないLLMコード書き換えだけに依存するのは避けるべきです。ASTに構造を制御させ、LLMに型のインテリジェンスを提供させるのが最善のアプローチです。

Share: