【LLM×Java】Maven/JUnitプロジェクトを自動修復するエンジンを作ったら「パス解決」と「プロンプト」の落とし穴にハマった話
1. はじめに
最近、LLM(Gemini API)を活用してコードのエラーを自動修復するツール(qa-sensei)を開発しています。
もともとは特定のコードや簡易的なテスト向けに個別に修復ロジックを書いていたのですが、「他の言語やプロジェクトでも使い回せるようにしたい」と考え、エンジンの構造をリファクタリングし、どんなプロジェクトでも組み込めるように抽象化・共通化を行いました。
今回は、この「新しく共通化したエンジン」を使い、実際の Java / Maven プロジェクト(JUnitテスト) のエラーを自動修復できるか検証を行いました。
今回試した処理の流れ
- ターゲット(Java)プロジェクトの Maven テストを実行
- JUnitのテストエラーログを検知
- Gemini API にログとコードを渡して修正案を生成
- 修正コードで実ファイルを上書きし、再テスト
結果として無事に success: true(自動修復&テスト通過) を達成できたのですが、個別のスクリプトから「共通化されたエンジン」へ進化させたことで、**外部プロジェクトを操作する際の「パス解決の沼」や「LLM特有の変数の罠」**という新たな壁にぶつかりました。
本記事では、エンジン共通化の先に待っていた泥臭いハマりポイントと、その解決策を共有します!
2. 動作確認環境
- Node.js: v24.18.1
- TypeScript: v6.0.3
-
LLM (Gemini):
gemini-3.5-flash-lite(@google/generative-ai) - Java: OpenJDK 17.0.19
- Build Tool: Apache Maven 3.8.7
3. ハマりポイント①:マルチプロジェクト構造における「パス解決」の罠
発生した問題
エンジンの実行元(qa-sensei)と、修復対象のプロジェクト(sample-java-app)は別ディレクトリにあります。
最初、ファイル操作を行う際に相対パスの扱いが曖昧になっており、ENOENT(ファイルが存在しない)エラーが発生したり、関係のないディレクトリにファイルが書き込まれそうになる問題が発生しました。
-
実行元(ツール側):
/path/to/qa-sensei -
対象(ターゲット):
/path/to/sample-java-app
解決策:役割に応じた「絶対パス」と「相対パス」の使い分け
ファイル操作周りのロジックを整理し、「ツール内部処理」と「AIへの指示文」でパスの渡し方を明確に分離しました。
1. Node.js (fs) 側の処理:すべて絶対パスで安全に操作
// targetDir (/path/to/sample-java-app) と 相対パス を結合して絶対パスを確定
const fullPath = path.resolve(targetDir, relativePath);
// ディレクトリを作成してファイルを安全に書き込み
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf-8');
2. LLM (Gemini) へのプロンプト:相対パスのみを渡す
AI に絶対パス(/Users/username/projects/...)を渡すと混乱の元になるため、プロジェクトルートからの相対パス(例: src/main/java/Calculator.java)だけを伝えます。
この分離を行うことで、どの階層からエンジンを実行しても確実にターゲットプロジェクトのファイルを操作できるようになりました。
4. ハマりポイント②:AIが "path": "undefined" に書き込もうとする怪奇現象
発生した問題
パスの処理を整えたはずなのに、ターミナルのログに以下のような謎の出力が残りました。
🔍 [DEBUG FileWriter] AIからの受信データ: {"path":"undefined","content":"..."}
[FileWriter] ✏️ 修正を反映しました: /path/to/sample-java-app/undefined
なんと、AIが文字通り "undefined" というファイル名・パスでレスポンスを返してきていたのです。
原因:TypeScript のテンプレート文字列の仕様
プロンプトを組み立てる関数内で、targetFilePath 変数が何らかの理由で未定義(undefined)のまま処理されていました。
JavaScript/TypeScript のテンプレート文字列(${targetFilePath})は、中身が undefined だと自動的に文字列の "undefined" に変換されてしまいます。
結果として、AIには以下のような指示文が届いていました。
【AIに届いていたプロンプトの例】
以下の形式の JSON で出力してください:
[ { "path": "undefined", "content": "..." } ]
AIは指示に超真面目に従い、「"path": "undefined" で返せばいいんだな!」と判断してそのまま返答していたわけです。
解決策(ビフォー/アフター)
❌ 修正前(NG)
// 未定義だと `${undefined}` が文字列の "undefined" に化ける
const prompt = `対象ファイル: ${params.targetFilePath}`;
⭕️ 修正後(OK)
// 1. プロンプト生成関数でのデフォルト値ガード
const filePath = params.targetFilePath || 'src/main/java/Calculator.java';
const prompt = `対象ファイル: ${filePath}`;
// 2. FileWriter側での柔軟なプロパティ取得(フォールバック)
const targetPath = item.path || item.filePath || item.filename || filePath;
5. 実際の修復成功ログ
以上の修正を反映してエンジンを実行した結果です!
🤖 [Gemini API Call] コード生成・修正リクエストを送信中...
[Loop 1/3] 💾 修正コードをファイル (src/main/java/Calculator.java) に反映中...
🔍 [DEBUG FileWriter] AIからの受信データ: {"path":"src/main/java/Calculator.java","content":"public class Calculator {\n public int add(int a, int b) {\n return a + b;\n }\n}"}
[FileWriter] ✏️ 修正を反映しました: /path/to/sample-java-app/src/main/java/Calculator.java
[Loop 2/3] 🧪 Maven テストを実行中...
[Loop 2/3] 🎉 すべてのテストを通過しました!
・ engine.execute の実行結果: {
success: true,
files: [ '/path/to/sample-java-app' ]
}
🎉 【完全成功】 リファクタリング後のエンジンで修復が成功しました!
JUnit テストの失敗ログを検知し、Gemini が適切な Java コード(足し算のロジック修正)を生成、ファイルが正常に書き換えられ、2回目のテスト実行で見事に success: true となりました。
6. まとめ&今後の展望
今回の開発を通して、**「LLMを使った自動化ツール開発」**における重要な教訓が得られました。
実行元と操作対象が異なるツールでは、絶対パス変換と相対パス指示の境界線を明確にする
プロンプト生成時の undefined 文字列化はAIの挙動を破壊するため、厳密にガードする
まずは基本となる「検知→ 修正→ テスト通過」のループが安全に回る堅牢な基盤が完成しました。
次回のテーマ(予定)
現在は正常系の挙動が固まった段階です。次回は、AIが不完全なJSONを返してきた場合の 「レスポンスのパース保護(エラーハンドリング強化)」 に取り組む予定です!
