tsma — レガシーコードの回帰防御線

テストのないコードをどうリファクタリングするか?

10万行のレガシーコードを引き継いだ。テストがない。リファクタリングしたいが、触れば何が壊れるかわからない。テストを書くにはコードを理解する必要があり、コードを理解するにはドキュメントが必要だが、ドキュメントもない。

誰も触らない。さらに腐っていく。

世界中のすべてのレガシーコードがこのデッドロックに陥っている。Fortune 500企業のIT予算の60〜80%がレガシーの維持に費やされている。開発者の時間の42%が技術的負債の処理に消えている。

LLMがテストを代わりに書いてくれるとしたら?


LLMにテストを任せると生じる問題

LLMに「この関数のテストを書いて」と言えば、何かは出てくる。問題は3つある。

第一に、どこから始めるべきかわからない。 関数が527個あるとき、1番から順番に? 最も重要なものから? 基準がない。

第二に、テストの質を検証できない。 LLMが書いたテストがpassした。しかしそのテストは本当に関数の動作を検証しているのか、それとも呼び出すだけでassertのない空っぽなのか? 人が一つずつ読まなければわからない。

第三に、フィードバックがなければLLMのテストは60〜70%で止まる。 「この関数をテストして」だけでは分岐coverage 100%に到達できない。どの分岐が抜けているか伝えて初めて、残りを埋められる。

LLMがテストを書けないのではない。LLMに何を書くべきか、どれだけうまく書けたかを伝える構造がないことが問題なのだ。


tsma:コマンド一つで回る テストレール

tsmaは、プロジェクトのすべての関数をインデックスし、テストの有無を検知し、coverageを計測し、LLMエージェントに正確なフィードバックを返すCLIツールだ。

エージェントが知るべきコマンドは一つだけ。

$ tsma next

このコマンド一つがループ全体を駆動する:

$ tsma next          # テストのない次の関数を表示する
  → テストを書く
$ tsma next          # 新しいテストを検知し、実行し、coverageを計測する
  → 100%? PASS、次の関数へ
  → <100%? 未カバー分岐をライン番号とともに表示する
$ tsma next          # 修正されたテストを再計測する
  → 改善してもしなくても、DONEとして次へ

「All functions complete!」が出るまで繰り返す。


527個の関数で検証した

実際のGoプロジェクト(527個の関数)にtsmaを適用した。

結果件数割合
PASS(100%分岐coverage)24646.7%
DONE(best-effort)28153.3%
TODO(未処理)00%

246個の関数が分岐coverage 100%に到達した。残りの281個は100%に届かなかったが、可能な範囲までテストが書かれた。

なぜ100%に到達できない関数があるのか?


100%に到達する関数と到達しない関数

関数が100%分岐coverageに到達できるかは、依存性をどう受け取るかにかかっている。

インターフェース(mockable)— 100%達成可能:

type Handler struct {
    svc AuthSvc              // interface — mockに差し替え可能
}

テストでmockを注入すれば、すべてのパスを制御できる:

svc := mocks.NewMockAuthSvc(ctrl)
svc.EXPECT().Login(...).Return(result, nil)   // 成功パス
svc.EXPECT().Login(...).Return(nil, err)      // 失敗パス

具象型(not mockable)— 100%不可能:

type Handler struct {
    svc *service.SMSImportService    // structポインタ — 差し替え不可
}

実際の実装がDB、外部APIなどの内部依存性を持って動く。特定のエラーを発生させたり、特定の結果を返させたりできない。その結果に依存する分岐はユニットテストでは到達できない。

tsmaの対応: 未カバー分岐のフィードバック後にもう一度試みる。それでも到達できなければDONEとして受容する。これはツールの限界ではなく、コードのテスト可能性を反映している。インターフェースを導入すれば(DI)100%が可能になるが、それは元のコードを修正する作業だ。


フィードバックがLLMのテストを劇的に変える

tsmaの核心的価値は、インデックスでもcoverage計測でもない。未カバー分岐をライン番号で正確に伝えることだ。

フィードバックなし:

"ListContracts関数のテストを書いて"
→ LLMがhappy pathだけテスト
→ coverage 60〜70%

フィードバックあり:

"ListContracts関数のテストを書いて"
→ coverage 65%(11/17)
→ UNCOVERED:
    line 41 — if params.Status != nil
    line 44 — if params.BuildingId != nil
    line 70 — if err != nil (CountSummary)
→ LLMがまさにその分岐をカバーするテストを追加
→ coverage 100%

同じLLMだ。違いはフィードバックの有無だけ。ライン番号3行が60%と100%を分ける。


エージェントが落ちても進捗は保存される

LLMエージェントは落ちる。トークン上限、ネットワークエラー、セッション切断。527個の関数を1セッションで全部処理することはできない。

tsmaは進捗を.tsma/session.jsonに永続保存する。

$ tsma status

527 functions
PASS:  246 (46.7%)
DONE:  281 (53.3%)
TODO:    0 (0.0%)

エージェントが200番目の関数で落ちたら? 新しいエージェントがtsma nextを実行すれば201番目から再開する。session.jsonがチェックポイントだ。

複数のエージェントが交代で作業しても衝突はない。関数単位でアトミックだ。


セッションはキャッシュ、ソースファイルが真実

tsmaの設計原則の一つ:sessionはキャッシュであり、ソースファイルがsource of truthだ。

テストファイルを削除すれば、session.jsonにPASSと記録されていても、その関数はTODOに戻る。セッションが現実と乖離しない。

原則:
  session.jsonが"PASS"と言っても
  テストファイルがなければ → TODO
  ソースファイルが変わっていれば → 再計測対象

LLMエージェントへの指示

エージェントに必要な指示は6行だ:

1. tsma nextを実行
2. TODOなら — 関数を読んでテスト作成
3. テスト失敗なら — エラーを読んでテスト修正
4. 未カバー分岐が表示されたら — その分岐をカバーするテスト追加
5. PASS/DONEなら — 次の関数が自動で表示される
6. "All functions complete!"が出るまで繰り返す

エージェントが知るべきコマンドはtsma next一つだけ。残りはCLIが制約する。


列車と線路

バイブコーディングは列車だ。速い。しかし線路がなければ脱線する。

AIコーディングツールはすべて列車をもっと速くすることに集中している。より大きなモデル、よりスマートなエージェント、より良いプロンプト。しかし列車が速くなるほど、脱線の被害も大きくなる。

tsmaは線路だ。LLMがテストを生成し(Neural)、CLIが「ここまで」を定義する(Symbolic Constraint)。LLMの創造性はそのままに、結果の質は機械が強制する。

従来tsma
テスト作成人(遅い)またはLLM(無秩序)LLMが作成、CLIが検証
どこから?人が判断CLIが順序決定
品質確認人がレビューCLIがcoverage計測
フィードバックなし未カバー分岐のライン番号
進捗追跡なしsession.json自動

LLMは自由に生成する。しかしtsma nextという線路の上でだけ走る。


言語サポート

言語インデクサテストランナーCoverage
Gogo/astgo testgo test -coverprofile
TypeScriptregexnpx vitest / npx jestc8 / istanbul
Pythonregexpytestcoverage.py

GoはASTパーサーで正確な関数抽出。TypeScriptとPythonは正規表現ベース。

生成ファイル(*_gen.go*.pb.go)、テストファイル、vendor/node_modulesはインデックスから自動除外される。


インストールと実行

make install
cd your-legacy-project
tsma next

それだけだ。

MIT License. github.com/park-jun-woo/tsma