AIでレガシーコード移行を自動化する:LLMベースのコードマイグレーション実践ガイド
CTOが会議で爆弾を落とした。「AngularJSの20万行、Reactに移行します。6ヶ月で。」チームの半分はAngularJSを触ったこともない。誰かが「AIでやれないの?」と言った瞬間、全員の視線が自分に集まる。
今、何千もの企業でこれが起きている。レガシーコードベース——AngularJS、jQuery、Java 8、Python 2、COBOL、古いPHP——をモダナイズする必要がある。従来のアプローチ(手動リライト)は何年もかかってチームの士気を潰す。新しいアプローチ(LLMに投げて祈る)は、AIがバグ1つ直すたびに新しいバグを3つ生み出すのを見るまでは良さそうに見える。
真実はその中間にある。LLMはコードマイグレーションを劇的に加速できる。ただし、正しいパイプラインを組んだ場合だけ。この記事では何がうまくいって、何がダメで、AIマイグレーションプロジェクトをレガシーコードより酷い災害にしてしまうミスをどう避けるかをまとめた。
コードマイグレーションがLLMに向いている理由(そして思ったより難しい理由)
コードマイグレーションにはLLMに特に適した特性がある:
- パターンの繰り返し: マイグレーションの大半は同じ変換パターンを何百ファイルに適用する作業。AngularJSのコントローラーにもテンプレートがあるし、Java POJOにもテンプレートがある。LLMはパターン認識と適用が得意。
- 入出力が明確: 「ビフォー」(旧フレームワーク)と「アフター」(新フレームワーク)がはっきりしている。変換ルールは既知。
- 検証可能: 創作文やサマリーと違って、コードマイグレーションにはハードな正解チェックがある。コンパイルできるか?テスト通るか?
でも「ChatGPTに投げたらいいんじゃね」アプローチが見落としている根本的な問題がある:
コンテキストウィンドウ問題
実際のAngularJSコントローラーは単独で存在しない。サービスをインポートし、そのサービスは別のサービスをインポートする。テンプレートはディレクティブを参照し、scopeの継承チェーンが複数ファイルにまたがっている。コントローラーファイルだけを見たLLMは、構文的には正しいが意味的に間違ったReactコードを生成する。
┌─────────────────────────────────────────────────────────────┐
│ マイグレーションの氷山 │
│ │
│ ┌──────────────┐ │
│ │コントローラー│ ← LLMが見るのはここだけ │
│ │ (1ファイル) │ │
│ ─────────┴──────────────┴───────── │
│ / \ │
│ / ┌──────────┐ ┌──────────┐ \ │
│ / │サービス │ │テンプレ │ \ │
│ / │ (12個) │ │ (HTML 3) │ \ │
│ / └──────────┘ └──────────┘ \ │
│ / ┌──────────┐ ┌──────────┐ ┌──────────┐ \ │
│ / │ Scope │ │ ルート │ │ グローバ │ \ │
│ / │ チェーン │ │ 設定 │ │ ル状態 │ \ │
│ / └──────────┘ └──────────┘ └──────────┘ \ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ← 正確なマイグレーションにはこれ全部が必要 │
└─────────────────────────────────────────────────────────────┘
セマンティクギャップ:構文は合っても意味がずれる
コードマイグレーションは単なる構文変換じゃない。パラダイムそのものの翻訳なんだ。AngularJSは$scopeで双方向データバインディングを使い、Reactはhooksで単方向データフローを使う。1:1のマッピングなんて存在しない。$scope.nameを機械的にuseState('name')に変換するLLMは、コンパイルは通るけどエッジケースで挙動が変わるコードを生成する。フォーム更新のレースコンディション、遅延ウォッチャー、ダイジェストサイクルのタイミング——こういうところで壊れる。
AIマイグレーションの80/20ルール
実際にはLLMはマイグレーション作業の約80%をうまく処理する。退屈で繰り返しの変換。残り20%(複雑なビジネスロジック、フレームワーク固有のエッジケース、横断的関心事)は人間の判断が必要。パイプラインはこの現実を踏まえて設計する必要がある。
アーキテクチャ:AST対応マイグレーションパイプライン
素朴なアプローチ——ファイルをChatGPTに貼り付けて結果をもらう——だとスケールしない。プロダクションマイグレーションで実際に回る構成はこれ:
┌─────────────────────────────────────────────────────────────┐
│ AIマイグレーションパイプライン │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 1. パース│───▶│ 2. チャン│───▶│ 3. コンテ│ │
│ │ (AST) │ │ キング │ │ キスト補強│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 6. テスト│◀───│ 5. 後処理│◀───│ 4. LLM │ │
│ │ (検証) │ │ │ │ 変換 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 7. 人間 │ 信頼度 < 90%? → 人間レビューにフラグ │
│ │ レビュー│ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
各ステップを見ていこう。
Step 1: ASTにパース
ソースコードを生のままLLMに渡さない。まずAbstract Syntax Tree(AST)にパースする。テキストでは見えない構造情報が得られる。
import { Project, SyntaxKind } from 'ts-morph'; interface MigrationUnit { filePath: string; type: 'component' | 'service' | 'directive' | 'filter' | 'config'; className: string; dependencies: string[]; templatePath?: string; sourceCode: string; ast: any; complexity: number; } function parseAngularModule(filePath: string): MigrationUnit[] { const project = new Project(); const sourceFile = project.addSourceFileAtPath(filePath); const units: MigrationUnit[] = []; sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).forEach(call => { const expr = call.getExpression().getText(); if (expr.includes('.component') || expr.includes('.controller')) { const args = call.getArguments(); const name = args[0]?.getText().replace(/['"]/g, ''); const deps = extractDependencies(call); const complexity = calculateComplexity(call); units.push({ filePath, type: expr.includes('.component') ? 'component' : 'service', className: name, dependencies: deps, sourceCode: call.getFullText(), ast: call.getStructure(), complexity, }); } }); return units; } function calculateComplexity(node: any): number { let complexity = 1; node.getDescendantsOfKind(SyntaxKind.IfStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.SwitchStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.ForStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.WhileStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.ConditionalExpression).forEach(() => complexity++); return complexity; }
なぜAST先行か:
- 優先順位付け: 複雑度の低いファイルから移行(LLM成功率が高い)
- 知的な分割: 任意の行数ではなく関数/クラスの境界で分割
- 依存関係追跡: どのファイルを一緒に移行すべきか把握
- 出力検証: 入力と出力のAST構造を比較して構造的リグレッションを検出
Step 2: マイグレーションユニット単位でチャンキング
ファイル全体を一度に移行しない。論理的な単位で移行する。コンポーネント1つ、サービス1つ、ユーティリティ関数1つずつ。LLM呼び出しごとにフォーカスが保たれ、コンテキストウィンドウに収まる。
interface MigrationBatch { primary: MigrationUnit; dependencies: MigrationUnit[]; templates: string[]; totalTokens: number; } function createBatches( units: MigrationUnit[], maxTokens: number = 12000 ): MigrationBatch[] { const sorted = units.sort((a, b) => a.complexity - b.complexity); return sorted.map(unit => { const deps = unit.dependencies .map(dep => units.find(u => u.className === dep)) .filter(Boolean) as MigrationUnit[]; const depContext = deps.map(d => extractTypeSignature(d)); const totalTokens = estimateTokens( unit.sourceCode + depContext.join('\n') ); return { primary: unit, dependencies: deps, templates: unit.templatePath ? [readFileSync(unit.templatePath, 'utf-8')] : [], totalTokens, }; }); }
Step 3: コンテキスト補強
ほとんどのAIマイグレーション試行がここで失敗する。LLMには移行対象ファイル以外のコンテキストが必要:
interface MigrationContext { batch: MigrationBatch; targetFramework: { version: string; stateManagement: string; styling: string; routing: string; }; conventions: { fileNaming: string; exportStyle: string; hookPrefix: string; testFramework: string; }; // 完了済みマイグレーション例(few-shot学習) examples: { before: string; after: string; explanation: string; }[]; edgeCases: string[]; }
examplesフィールドが肝心。代表的なコンポーネントを3〜5個手動で移行した後、すべてのLLM呼び出しにfew-shot例として含める。モデルがプロジェクト固有のコンベンションを学習するので、出力品質が格段に上がる。
Step 4: LLM変換
プロンプトの構造が結果を左右する。キーポイント:
- Rule 8が最重要: 「ビジネスロジックを一切変えるな」。LLMはマイグレーション中にコードを「改善」しようとする。それは要らない。マイグレーションとリファクタリングは別のPR。
- Few-shot例: プロジェクトの実際のマイグレーション2〜3件を含める。どんな長い指示文よりも効果的。
- 依存関係コンテキスト: 実装全体ではなくtype signatureだけ含めてトークン消費を抑える。
Step 5: 後処理
LLMの生の出力をそのまま信用しない。後処理する:
async function postProcess( llmOutput: string, context: MigrationContext ): Promise<{ code: string; confidence: number; issues: string[]; }> { const issues: string[] = []; let confidence = 100; try { const project = new Project({ useInMemoryFileSystem: true }); const sourceFile = project.createSourceFile('output.tsx', llmOutput); const diagnostics = sourceFile.getPreEmitDiagnostics(); if (diagnostics.length > 0) { confidence -= diagnostics.length * 10; issues.push( ...diagnostics.map(d => `TS Error: ${d.getMessageText()}`) ); } const imports = sourceFile.getImportDeclarations(); for (const imp of imports) { const moduleSpecifier = imp.getModuleSpecifierValue(); if (!isValidImport(moduleSpecifier, context)) { confidence -= 15; issues.push(`未解決のimport: ${moduleSpecifier}`); } } const text = sourceFile.getFullText(); if (text.includes('// TODO') || text.includes('// FIXME')) { confidence -= 5; issues.push('LLMがTODO/FIXMEコメントを追加'); } const hookViolations = checkHookRules(sourceFile); if (hookViolations.length > 0) { confidence -= hookViolations.length * 20; issues.push(...hookViolations); } const originalFunctions = extractFunctionNames( context.batch.primary.sourceCode ); const migratedFunctions = extractFunctionNames(llmOutput); const missing = originalFunctions.filter( f => !migratedFunctions.some(m => isSimilarName(f, m)) ); if (missing.length > 0) { confidence -= missing.length * 15; issues.push(`欠落関数: ${missing.join(', ')}`); } return { code: llmOutput, confidence, issues }; } catch (e) { return { code: llmOutput, confidence: 0, issues: [`パースエラー: ${e.message}`], }; } }
Step 6: 自動テスト
最も重要なステップ。自動検証なしでコミットしてはいけない:
async function verifyMigration( originalPath: string, migratedPath: string, testSuite: string ): Promise<MigrationVerification> { const results: MigrationVerification = { compiles: false, testsPass: false, renderMatches: false, accessibilityPass: false, performanceRegression: false, }; const compileResult = await exec(`npx tsc --noEmit ${migratedPath}`); results.compiles = compileResult.exitCode === 0; if (testSuite) { const testResult = await exec(`npx vitest run ${testSuite}`); results.testsPass = testResult.exitCode === 0; } results.renderMatches = await compareScreenshots( originalPath, migratedPath ); return results; }
Step 7: 信頼度ベースの人間レビュー
移行されたすべてのファイルに同じレベルの人間の注意が必要なわけじゃない:
function triageMigration( result: PostProcessResult, verification: MigrationVerification ): 'auto-merge' | 'quick-review' | 'deep-review' | 'manual-rewrite' { if (result.confidence >= 95 && verification.testsPass && verification.compiles) { return 'auto-merge'; } if (result.confidence >= 80 && verification.compiles) { return 'quick-review'; } if (result.confidence >= 50) { return 'deep-review'; } return 'manual-rewrite'; }
実際にfew-shot例がしっかり用意されたAngularJS→Reactマイグレーションの場合:
- 約60%のファイル: 自動マージまたはクイックレビュー
- 約25%のファイル: ディープレビュー(主に複雑なステート管理)
- 約15%のファイル: 手動リライト(複雑な$scope継承、ダイジェストサイクルのハック)
実践マイグレーションパターン
パターン1: AngularJS → React
今最も多いエンタープライズマイグレーション。
// ❌ BEFORE: AngularJSコントローラー angular.module('app').controller('UserListCtrl', ['$scope', '$http', 'UserService', 'NotificationService', function($scope, $http, UserService, NotificationService) { $scope.users = []; $scope.loading = true; $scope.searchTerm = ''; $scope.selectedRole = 'all'; $scope.loadUsers = function() { $scope.loading = true; UserService.getAll({ role: $scope.selectedRole }) .then(function(users) { $scope.users = users; $scope.loading = false; }) .catch(function(err) { NotificationService.error('Failed to load users'); $scope.loading = false; }); }; $scope.filteredUsers = function() { if (!$scope.searchTerm) return $scope.users; return $scope.users.filter(function(user) { return user.name.toLowerCase() .includes($scope.searchTerm.toLowerCase()); }); }; $scope.$watch('selectedRole', function(newVal, oldVal) { if (newVal !== oldVal) $scope.loadUsers(); }); $scope.loadUsers(); } ]);
// ✅ AFTER: React + TypeScript import { useState, useEffect, useMemo, useCallback } from 'react'; import { useUserService } from '@/hooks/useUserService'; import { useNotification } from '@/hooks/useNotification'; interface User { id: string; name: string; email: string; role: string; } type RoleFilter = 'all' | 'admin' | 'user' | 'moderator'; export function UserList() { const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [selectedRole, setSelectedRole] = useState<RoleFilter>('all'); const userService = useUserService(); const { showError } = useNotification(); const loadUsers = useCallback(async () => { setLoading(true); try { const data = await userService.getAll({ role: selectedRole }); setUsers(data); } catch { showError('Failed to load users'); } finally { setLoading(false); } }, [selectedRole, userService, showError]); useEffect(() => { loadUsers(); }, [loadUsers]); const filteredUsers = useMemo(() => { if (!searchTerm) return users; return users.filter(user => user.name.toLowerCase().includes(searchTerm.toLowerCase()) ); }, [users, searchTerm]); if (loading) return <LoadingSpinner />; return ( <div> <SearchInput value={searchTerm} onChange={setSearchTerm} /> <RoleFilter value={selectedRole} onChange={setSelectedRole} /> <UserTable users={filteredUsers} /> </div> ); }
LLMが得意な部分: ステートマッピング、基本的なhook変換、effectの依存関係。
LLMが間違える部分: useCallbackの依存配列(depsの漏れが多い)、useMemoの最適化境界、プロジェクト固有の通知システムに合ったエラーハンドリングパターン。
パターン2: Java 8 → Kotlin
// ❌ BEFORE: Java 8 public class OrderProcessor { private final OrderRepository orderRepo; private final PaymentService paymentService; private final NotificationService notificationService; public OrderProcessor(OrderRepository orderRepo, PaymentService paymentService, NotificationService notificationService) { this.orderRepo = orderRepo; this.paymentService = paymentService; this.notificationService = notificationService; } public OrderResult processOrder(OrderRequest request) { if (request == null || request.getItems() == null || request.getItems().isEmpty()) { throw new IllegalArgumentException("Invalid order"); } BigDecimal total = request.getItems().stream() .map(item -> item.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); if (total.compareTo(BigDecimal.valueOf(10000)) > 0) { request.setDiscount(total.multiply( BigDecimal.valueOf(0.1))); } PaymentResult payment = paymentService.charge( request.getCustomerId(), total); if (!payment.isSuccessful()) { return OrderResult.failed(payment.getErrorMessage()); } Order order = orderRepo.save( Order.from(request, payment.getTransactionId())); notificationService.sendConfirmation( request.getCustomerId(), order); return OrderResult.success(order); } }
// ✅ AFTER: Kotlin class OrderProcessor( private val orderRepo: OrderRepository, private val paymentService: PaymentService, private val notificationService: NotificationService, ) { fun processOrder(request: OrderRequest): OrderResult { require(request.items.isNotEmpty()) { "Invalid order" } val total = request.items.sumOf { item -> item.price * item.quantity.toBigDecimal() } if (total > 10_000.toBigDecimal()) { request.discount = total * 0.1.toBigDecimal() } val payment = paymentService.charge(request.customerId, total) if (!payment.isSuccessful) { return OrderResult.failed(payment.errorMessage) } val order = orderRepo.save( Order.from(request, payment.transactionId) ) notificationService.sendConfirmation(request.customerId, order) return OrderResult.success(order) } }
LLMが得意な部分: Null safety、requireへの変換、プロパティアクセス構文、トレイリングカンマ、式の単純化。
LLMが間違える部分: BigDecimalのカスタムoperator overloading、Kotlin固有のコレクション拡張(sumOf)、Resultやsealed classを使ったイディオマティックなエラーハンドリング。
パターン3: Python 2 → Python 3
# ❌ BEFORE: Python 2 class DataProcessor(object): def __init__(self, config): self.config = config self.logger = logging.getLogger(__name__) def process_batch(self, items): results = [] for item in items: try: processed = self._transform(item) results.append(processed) except Exception, e: self.logger.error( u"Failed to process item %s: %s" % (item.get('id', 'unknown'), unicode(e)) ) return results def _transform(self, item): if isinstance(item, basestring): item = {'value': item} keys = item.keys() keys.sort() output = {} for key in keys: value = item[key] if isinstance(value, unicode): output[key] = value.encode('utf-8') elif isinstance(value, (int, long)): output[key] = float(value) else: output[key] = value return output
# ✅ AFTER: Python 3 class DataProcessor: def __init__(self, config): self.config = config self.logger = logging.getLogger(__name__) def process_batch(self, items): results = [] for item in items: try: processed = self._transform(item) results.append(processed) except Exception as e: self.logger.error( f"Failed to process item {item.get('id', 'unknown')}: {e}" ) return results def _transform(self, item): if isinstance(item, str): item = {'value': item} output = {} for key in sorted(item.keys()): value = item[key] if isinstance(value, str): output[key] = value elif isinstance(value, int): output[key] = float(value) else: output[key] = value return output
LLMが得意な部分: except Exception as e構文、f-string、unicode/basestring/longの除去、(object)継承の除去。
LLMが間違える部分: 微妙な挙動変更。Python 2のdict.keys()はリスト(ミュータブル)を返すが、Python 3はビューを返す。LLMはsorted()でラップするのは正しいが、イテレーション中にキーリストを変更するケースを見落とす。
プロンプトエンジニアリングのプレイブック
何千回も変換を回してみて、安定して良い結果を出すパターンがわかった:
1. システムメッセージ:マイグレーションペルソナ
You are a staff-level software engineer performing a code migration.
Your output will be committed directly to a production repository.
CRITICAL RULES:
- Preserve ALL business logic exactly as-is. Do NOT refactor, optimize,
or "improve" the code during migration.
- If you are unsure about a conversion, mark it with
__MIGRATION_REVIEW__ in a comment.
- Do NOT add explanatory comments about the migration itself.
- Do NOT change variable names unless required by target language
conventions.
- Output ONLY the migrated code. No markdown fences, no explanations.
2. Few-shotは長い指示文に勝つ
AngularJSからReactへの変換ルールを50個書くより、実際のマイグレーション例を3つ含めた方が効果的。モデルは抽象的なルールよりも具体的な例からパターンを学ぶ。
3. 「保存、改善はしない」原則
最も重要なルール。LLMは以下をやろうとする:
- 元になかったエラーハンドリングの追加
- アルゴリズムの最適化
- 変数名の「もっと分かりやすい」ものへの変更
- 厳しすぎるまたは緩すぎるTypeScript型の追加
これらはすべてリスクを導入する。マイグレーションと改善は別のPRにすべき。
4. 信頼度マーカー
LLMに不確実性をマークさせる:
If any conversion is ambiguous (e.g., unclear scope inheritance,
non-obvious side effects), add this exact comment:
// __MIGRATION_REVIEW__: [reason for uncertainty]
This will be caught by our post-processing pipeline and flagged
for human review.
LLMが正解してくれることを祈るより、「ここ自信ない」とマークしてくれる方が圧倒的に使える。
本番環境のガードレール:何が壊れるか
1. ハルシネーションされたimport
LLMが存在しないパッケージのimportを捏造する。学習データでimport { useQueryClient } from '@tanstack/react-query'を見ているので使う。プロジェクトがSWRを使っていてもお構いなし。
対策: すべてのimportを実際のpackage.jsonとプロジェクトファイル構造で検証する。
2. サイレントな挙動変更
最も危険なバグ。コードはコンパイルされ、テストも通るが、挙動が微妙に変わっている。よくある例:
- イベントハンドラのタイミング(Angularのダイジェストサイクル vs Reactのステートバッチング)
- null/undefinedハンドリングの差異
- 非同期操作の実行順序
対策: マイグレーション開始前にインテグレーションテストに投資する。古いコードベースに対して書いてから、新しいコードで通ることを確認。
3. オーバーエンジニアリングされたコンポーネント
LLMがシンプルなAngularJSコントローラーをカスタムhook 4つ、context provider 3つ、reducer 1つのReactコンポーネントに変換する。技術的には正しいが、メンテ不能。
対策: プロンプトに追加:「シンプルさを優先。ステートロジックがuseReducerを必要とするほど複雑でない限りuseStateを使うこと。1つのコンポーネントでしか使わないロジックのカスタムhookは作らないこと。」
4. コピペの爆発
類似コンポーネントを移行する際、LLMが共有ロジックを抽出せずにほぼ同じコードを生成する。同じデータフェッチングパターンのコピーを持つコンポーネントが20個できる。
対策: 初回マイグレーションパスの後、共有パターンをhooksとutilitiesに抽出する2回目のパスを実行する。LLMが移行済みファイル全体にアクセスできるので、別ステップにした方が良い。
マイグレーション成功の測定
| メトリクス | 目標 | 測定方法 |
|---|---|---|
| 自動マージ率 | > 50% | すべての自動チェックをパスしたファイル |
| コンパイル成功率 | > 90% | 初回TypeScriptコンパイル |
| テスト合格率 | > 85% | 既存テストスイート vs 移行コード |
| ビジネスロジック保全 | 100% | インテグレーションテストスイート |
| 1日の移行行数 | 2,000+ | パイプラインチューニング後 |
| ファイル当たりレビュー時間 | < 15分 | 「クイックレビュー」ティアの平均 |
マイグレーションダッシュボード
モジュールごとの進捗を追跡するシンプルなダッシュボードを構築する:
interface MigrationStatus { module: string; totalFiles: number; migrated: number; autoMerged: number; inReview: number; manualRewrite: number; avgConfidence: number; blockers: string[]; }
この可視化はステークホルダーへの報告に不可欠。「200ファイル中147を92%の自動マージ率で移行済み」は「順調です」よりはるかに説得力がある。
どのLLMを使うか
2026年4月時点、マイグレーションテスト結果:
| モデル | 得意分野 | 制限 |
|---|---|---|
| Claude 4 Sonnet | 複雑なロジック保全、TypeScript精度、ビジネスロジックの忠実な再現 | 200Kコンテキストウィンドウが大規模マルチファイルバッチには制限的 |
| GPT-5 | 幅広い言語サポート、一貫したフォーマット、強い指示遵守 | マイグレーション中の過剰リファクタリング傾向、トークン単価が高い |
| Gemini 2.5 Pro | ロングコンテキスト(1Mトークン)、マルチファイル理解、大規模展開時のコスト効率 | 存在しないAPIを捏造することがある |
| DeepSeek V3 | 単純変換のコストパフォーマンス、Python/Javaパターンに強い | 複雑なビジネスロジックやクロスファイル依存関係の精度が低い |
おすすめ: 初回マイグレーション(複雑なロジック保全)はClaude 4 SonnetかGPT-5で、単純ファイルのクリーンアップパスはGemini 2.5 ProかDeepSeekで。コスト差は10〜50倍で、単純ファイルに高いモデルは不要。
厳しい現実:AIには移行できないもの
正直なところ、限界はある:
- アーキテクチャ決定: モノリシックAngularJSアプリをマイクロフロントエンドにすべきか?AIには答えられない。
- ステート管理設計: Zustand、Redux、Context?LLMは言われた通りにするが、アーキテクチャ決定はできない。
- パフォーマンス最適化: LLMはトラフィックパターンもボトルネックも知らない。
- ビジネスルール検証: 元のコードに「想定通り動いている」バグ(別の場所で補償されている)があれば、LLMはそのバグを忠実に再現する。
- 横断的関心事: ロギング、モニタリング、エラートラッキング、フィーチャーフラグ。新アーキテクチャで人間が設計する必要がある。
タイムライン:現実的なマイグレーション計画
200K LOCのAngularJS→Reactマイグレーションの場合:
| フェーズ | 期間 | 内容 |
|---|---|---|
| 1. セットアップ | 2週間 | パイプライン構築、LLM設定、手動マイグレーション例5〜10個作成 |
| 2. パイロット | 2週間 | モジュール1つ(20〜30ファイル)をエンドツーエンドで移行、プロンプトチューニング |
| 3. スケール | 8〜10週間 | パイプラインが残りのモジュールを依存関係順に処理 |
| 4. ポリッシュ | 4週間 | エッジケース修正、共有パターン抽出、パフォーマンスチューニング |
| 5. 検証 | 2週間 | 全体リグレッションテスト、ステークホルダー承認 |
合計:約4〜5ヶ月 vs 従来の手動マイグレーション12〜18ヶ月。
核心は、フェーズ1と2が最も重要ということ。パイロットモジュールで80%以上の自動マージ率を達成できれば、残りは実行あるのみ。パイロットが失敗したら、スケールする前にアプローチを再考すべき。
マイグレーションチェックリスト
準備
- 既存テストスイートがクリティカルパスの70%以上カバー
- ターゲットフレームワークのコンベンション文書化
- 手動リファレンスマイグレーション5〜10件完了
- ソース言語用ASTパーサー設定
- CIパイプラインにコンパイル/テスト検証を含む
パイプライン
- プロンプトテンプレートを20以上の代表ファイルでテスト
- 後処理が無効なimportを検出
- 信頼度スコアリング調整済み(自動マージ閾値設定)
- ファイルタイプごとのfew-shot例を含む
- 依存関係解決順序を算出
実行
- 依存関係順にマイグレーション(リーフノードから)
- 各バッチを次に進む前に検証
- ダッシュボードで進捗と信頼度スコアを追跡
- モジュールごとに人間レビュアーを割り当て
- モジュールごとのロールバック戦略を定義
検証
- 移行コードに対してインテグレーションテスト合格
- ビジュアルリグレッションテスト完了
- パフォーマンスベンチマークが維持または改善
- 認証/データ処理パスのセキュリティレビュー
- プロダクトチームによる移行機能の承認
AIコードマイグレーションは魔法じゃない。エンジニアリングそのもの。LLMが得意なこと(パターン変換)を最大限活かしつつ、苦手なこと(正確性の保証)にはガードレールをしっかり置くパイプラインを作る。ちゃんと作れば、1年かかるマイグレーションを1四半期にできる。間違えると、その四半期を「AIがなぜ認証ロジックを書き換えたのか」のデバッグに費やすことになる。パイプラインを作ろう。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう