バイブコーディングの限界と言語移行(6)|TDDが「移植」では機能しなかった――実装先行の堂々巡りと、Claudeが「移植」を苦手とする構造

こんにちは、パレイド思想部です。

前回、TypeScript + Electron への移植が当初の想定の4倍——1週間のはずが約1ヶ月になった——という報告と、その2つの主因(TDDの空回り/Claudeの性能低下)の見取り図を書きました。

今回は、その主因(1)である「TDDが『移植』では機能しなかった」を正面から掘り下げます。性能低下の話には深入りしません。それは次回(第7回)の領分です。ここでは性能が完全に出ていたとしても残るはずの、作業形態としての「移植」が AI 協働に持ち込んだ構造的な難しさについて書きます。

本記事はローカル LLM による自動執筆パイプラインで生成されました。現段階ではクラウド AI(Claude 等)の補助や人間の編集が介在していますが、pareido.jp では最終的に AI が自律的にコンテンツを制作できる仕組みの構築を目指しています。

TDDは「意図 → テスト → 実装」を前提にしている

まず、新規開発でのTDDが何を前提にしていたかを言葉にしておきます。

新規開発において、TDDのサイクルは次のように進みます。

  1. 意図 ——「何を作りたいか」が頭の中にある
  2. テスト ——その意図を検証可能な形に翻訳する
  3. 実装 ——テストが通る最小限のコードを書く
  4. リファクタリング ——テストの保護下で形を整える

この順序の本質は、意図が先にあり、テストはその意図の証文(しょうもん)として書かれるということです。テストは「これが実装されれば意図が満たされた、と判定する基準」であり、まだ存在しないものを記述する文書として機能します。実装はそれに従う形で生まれてくる。

第4回で「型は AI のための地図」と書きました。TDDのテストもまた、もう一つの地図です。型が「今、構造的にどこにいるか」を示すなら、テストは「これから、何を満たしたら正解か」を示す。両方の地図が揃った状態で、AI は安心してコードを書ける——というのが新規開発における協働の前提でした。

「移植」では時間軸が逆転する

ところが、移植というタスクでは、この時間軸が音もなく逆転します。

移植では、すでに動いている実装が最初から存在しています。つまり、こうなる。

  1. 既存実装 ——Python版のコードがすでに動いている
  2. 推測された意図 ——AIがそのコードを読んで、何を意図していたかを推測する
  3. テスト ——推測した意図を入出力ケースとしてテストに落とす
  4. 新実装 ——TypeScript版でテストを通すコードを書く

経路が、新規開発の3段階から4段階に伸びました。たった1段階の差ですが、間に挟まる「推測された意図」がすべてを変えます。

新規開発では、「意図」は人間の頭の中にありました。AIは人間と対話しながらそれをテストに落とし込めばよかった。意図の所在が明確で、揺れたら人間に聞けばよかったのです。

移植では、「意図」は Pythonのコードという外部 に分散して埋まっています。AIは人間に聞くのではなく、コードに聞くことを要求されます。しかしコードは、なぜそう書かれているかを答えてくれません。書かれた結果だけがそこにあって、書いた人の判断や試行錯誤や妥協は、コミット履歴の隙間と作者の記憶の中に沈んでいます。

見方を変えると、TDDが暗黙に依存していた「意図の所在」が、移植では宙に浮くのです。地図はあっても、目的地そのものを誤読する余地がそこにある。

実装が先にあると、堂々巡りに入る

具体的に、何が起きるか。

私たちが何度も観察した症状は、こういうものでした。AIに「Pythonのこの関数を移植してテストを書いて」と頼みます。AIはPythonのコードを読み、TypeScript版のテストを書き、実装を書く。テストはグリーンになる。報告が返ってくる——「移植完了しました」。

しかし、レビューしてみると、テストがPythonの実装の影として書かれていることに気づきます。

つまり、AIは Python の関数を読み、その入出力をなぞるテストケースを書き、そのテストを通すために TypeScript の実装を書いていました。一見正しい順序です。ところが、テストの中身を精査すると、Pythonの偶発的な振る舞いまでがテストとして固定されている。例えば、Python版で例外的にNoneを返していたエッジケース——本来はバグだった挙動——が、TypeScript版でも「Noneを返すべき入力」としてテストに刻まれている。

逆転させると、こうも言えます。実装が先にあると、テストは実装を説明する道具として書かれてしまう。「これから何を満たしたら正解か」を示す地図ではなく、「すでに何が起きているかを観察した記録」になる。テストは未来を指す矢印だったはずなのに、移植では過去をなぞる証言になってしまうのです。

そして、ここから先がやっかいでした。観察記録としてのテストを通すために、AIは TypeScript 版の実装を書く。書いてみるとどこかが微妙に合わない。テストを少し変える。実装を直す。また別のテストを足す。実装が動かない。テストの方を緩める——。

これが堂々巡りです。意図という錨が外側のコードに分散しているため、AIは自分が今どこを目指しているのか分からないまま、テストと実装を往復します。テストは実装の影、実装はテストを通すためのコード、テストは実装に合わせて書き換えられる。意図がどこにも存在しないループが静かに回り始めます。

軽い具体例:if/else の片側が落ちる

長くしたくないので、一例だけ書いておきます。

Python 版に、こういう関数があったとします(実際の関数を抽象化したものです)。

def resolve_asset_role(file_path: str, manifest: dict) -> str:
    if file_path.endswith('.png'):
        if 'preview' in manifest.get('hints', []):
            return 'preview'
        return 'image'
    if file_path.endswith('.json'):
        return 'metadata'
    if file_path.endswith('.layer'):
        return 'layer'
    raise ValueError(f"Unknown role for {file_path}")

これをAIに「TypeScript に移植してテスト付きで」と頼むと、こういうテストが返ってきます。

describe('resolveAssetRole', () => {
  it('returns image for .png files', () => {
    expect(resolveAssetRole('a.png', { hints: [] })).toBe('image');
  });
  it('returns metadata for .json files', () => {
    expect(resolveAssetRole('a.json', {})).toBe('metadata');
  });
  it('returns layer for .layer files', () => {
    expect(resolveAssetRole('a.layer', {})).toBe('layer');
  });
});

一見、網羅できているように見えます。テストはグリーンになり、実装は通ります。

しかし、Python版の if 'preview' in manifest.get('hints', []) という分岐——hints に preview が含まれていれば preview を返す——が、テストにも実装にも落ちていません。AIが読み取ったとき、この分岐は「主要なケースではない」と判断され、こぼれ落ちました。Python版を実際に使うと preview が返るケースがあるのに、TypeScript 版では永遠に image しか返ってこない。

しかもテストはグリーンです。AIにとって、テストグリーン=完了です。報告も「移植完了」と返ってきます。ブランチの片側が音もなく消えたことは、Python版と TypeScript版を実データで突き合わせるまで気づけません。

Claude が「移植」を苦手とする構造

なぜこういうことが起きるのか。整理すると、Claude(あるいは現世代のAIコーディングエージェント全般)が「移植」というタスク形態の前で踏み外しやすいポイントは、いくつかの層に分けられます。

1. 「なぜそう書かれているか」が復元できない

コードには、書いた人にしか分からない判断が無数に埋め込まれています。「ここで Noneを返すのは、上位の呼び出し側がそれを期待しているから」「この順序で if を並べたのは、過去にこの順序でしかパスしないテストケースがあったから」「この変数名が長いのは、別ファイルの同名変数と衝突しないため」——こうした 書かれた背景 は、コードの表面には現れません。

AIは表面しか読めない。読めたものを「これが意図だ」と推測する以外に方法がない。結果として、偶発的な振る舞いまで仕様として固定する現象が起きます。それがバグだったのか仕様だったのかを判定する材料が、コード単体には存在しないからです。

2. 完了バイアス

第5回でも軽く触れましたが、ここでもう少し掘ります。

AIにとって「テストがグリーンになった」は強烈な完了シグナルです。新規開発では、このシグナルは概ね正しく機能していました。なぜなら、テストが先に書かれており、テストの妥当性は人間が確認していたからです。テストが正しい意図を表現しているなら、グリーン=意図充足、で問題なかった。

移植では、このシグナルが空回りします。テストはAIが書いている。テストの妥当性を保証する意図は、Pythonのコードに分散していて、AIの読解の範囲でしか抽出されていない。にもかかわらず、AIはグリーンになった瞬間に「移植完了」と報告する。

つまり、完了の判定基準そのものが、AI自身の読解範囲を超えていないのです。AI が「分かった」と思った範囲だけが「全体」と扱われる。これが移植において完了バイアスを致命的にする構造です。

3. コンテキスト分散

新規開発では、意図は人間の頭の中にありました。AIのコンテキストウィンドウに収まらない意図は、対話を通じて徐々にAIに渡されます。コンテキストの容量制約はあれど、意図の所在は明確でした。

移植では、意図がPythonのコード——ときに数千行に及ぶ——に分散しています。AIは一度にすべてを読めません。読めた範囲で推測し、その推測に基づいてテストと実装を書く。ここで起きるのは、AIのコンテキストに収まる範囲だけが「全体」として扱われる現象です。コンテキストの外にある分岐や、別ファイルから呼ばれる前提条件は、推測の対象にすら入りません。

新規開発のコンテキスト制約は「対話の長さの制約」でした。移植のコンテキスト制約は「仕様抽出の網の細かさの制約」です。同じトークン数の制約でも、性質がまったく違います。

4. ブランチ網羅の困難

これは(1)〜(3)の帰結です。

AIが書くテストは、AIが読み取れた範囲の仕様でしかありません。Python版に20本の if/else 分岐があったとして、AIが読み取れたのが12本だったなら、テストは12本分しかカバーしません。残りの8本は、テストが存在しないままTypeScript版から消えるか、あるいは TypeScript版で別の挙動として再実装されます。

そして、残り8本があったかどうかを判定する仕組みが、AI協働の中には標準で存在しない。Python版の網羅率を測るカバレッジ計測ツールはあっても、それを「TypeScript版の移植網羅率」に翻訳する手順が、現状の AI ワークフローには組み込まれていないのです。

新規開発と移植の比較

ここまで書いてきたことを、表として整理しておきます。

観点 新規開発 移植
意図の所在 人間の頭の中 既存コードという外部に分散
TDDの順序 意図 → テスト → 実装 既存実装 → 推測された意図 → テスト → 新実装
テストが指すもの これから満たすべき未来 すでに観察された過去
完了の判定基準 人間が書いた意図に対するグリーン AIが読み取れた範囲のグリーン
AIの強み発揮 高い(生成・補完・対話) 低い(推測・復元・網羅)
主な失敗モード 過剰実装・脱線 分岐の脱落・偶発挙動の固定
コンテキスト制約の性質 対話の長さの制約 仕様抽出の網の細かさの制約

こうして並べてみると、新規開発と移植は、表面的にはどちらも「コードを書く作業」ですが、AIに対してはまったく別種の負荷を要求していることが見えてきます。

「AIが読みやすいコード」と「AIが再現しやすいコード」は別物

ここから少しだけ思想部らしい一段に入ります。

第4回で「型はAIのための地図」と書きました。型を入れることで、AIはコードを読みやすくなる。これは確かに事実でした。TypeScript への移植が落ち着いた範囲では、その効果は今も体感できています。

しかし、今回見えてきたのは、「AIが読みやすいコード」と「AIが再現しやすいコード」は別物だ、という非対称性です。

  • AIが読みやすいコード ——構造が明示的で、AIが「これは何をしている」を素早く把握できる。型・命名・コメント・モジュール分割が効く。
  • AIが再現しやすいコード ——別の言語・別のフレームワークに、意図を保ったまま書き直せる。これには「読みやすさ」だけでは足りない。書かれた背景・偶発的でない判断・分岐の網羅性まで含む情報が要る。

第4回のテーゼ——型はAIのための地図——は揺らぎません。地図は確かに役に立ちました。しかし今回追加で見えてきたのは、地図があっても、目的地そのものを誤読するという気づきです。地図は「今どこにいるか」を教えてくれますが、「目的地が本当にそこなのか」は教えてくれない。Python版という目的地を AI が正しく読み取れているかどうかは、TypeScript側の地図では検証できない層の問題でした。

逆方向から見ると、こうも言えます。新規生成は得意で、既存コードの忠実な移植は苦手——この非対称性は、現世代の AI コーディングエージェントに通底する構造的な特性なのかもしれません。新規生成では、AIは「自分が分かる範囲のコード」を生み出せばよい。出力範囲がAIの理解範囲と一致するから、出力に矛盾は生じにくい。

移植では、入力範囲がAIの理解範囲を超えていることが前提にあります。入力には、書いた人にしか分からない判断や、複数ファイルにまたがる前提や、過去のバグ修正の痕跡が含まれています。それらをすべて理解しないままAIは出力に取りかかる。理解範囲と出力要求のあいだに開いたギャップが、堂々巡りや分岐の脱落として顕在化します。

対処として試したこと(軽く)

実用層でも何を試したかを記録しておきます。深入りはしません。第8回の暫定総括で改めて整理する予定です。

(a) Python版をAIに読ませる前に、人間が「意図のサマリ」を先に書く運用

AIにいきなり Python 関数を渡すのではなく、人間が先に「この関数は何のためにあって、どの分岐が本質で、どの分岐は偶発的なバグの吸収か」を 5〜10 行のメモにまとめます。そのメモをAIに渡してから、コードと突き合わせさせる。意図の所在を、コードの外側に取り戻す作業です。コストはかかりますが、堂々巡りに入る確率は明らかに下がりました。

(b) 「テスト先・実装は最後」の順序を強制する

移植であっても、テストを書く段階では実装を見せない、という運用を試しました。Pythonのコードからまず仕様サマリだけを作り、それに基づいてテストを書き、最後にPython実装と突き合わせる。テストが実装の影として書かれることを、順序の制約で防ぐアプローチです。完全には機能しませんでしたが、テストが「過去の観察記録」化する傾向は弱まりました。

(c) ブランチ単位での移植

関数まるごとを一気に移植するのではなく、Python版の if/else を一本ずつTypeScript に移していく。テストも同じ粒度で、分岐ごとに一本ずつ書く。粒度を細かくすると、AIが読み取り損ねる分岐が減り、完了報告の単位も小さくなって、見落としが見つけやすくなります。

ただし、これらはすべて作業設計の工夫であって、根本解決ではありません。「移植というタスク形態が AI 協働にとって構造的に難しい」という問題そのものは残ります。

それでも、これだけでは1ヶ月にはならなかった

ここまで、移植 × TDD の難しさを掘ってきました。堂々巡り、完了バイアス、コンテキスト分散、ブランチ網羅の困難——どれも実在し、どれも私たちを足止めしました。

ただ、正直に言うと、これだけでは1ヶ月にはならなかったと思っています。

移植というタスク形態の難しさは、想定の2倍——つまり2週間程度——までは説明できる範囲だった気がします。1週間が2週間になるのは、「やってみたら難しかった」で済む範囲です。前回も書いたとおり、それを4倍の1ヶ月にまで押し広げたのは、もう一つの主因——Claude Code の性能低下が、ちょうどこの移植期間と重なった——でした。

性能低下期にあっては、上で書いた構造的な難しさが、いつもなら踏みとどまれる境界線で踏みとどまれませんでした。普段ならレビューで捕捉できる完了バイアスが、postmortem 期には捕捉できない密度で発生していました。「TDD が機能しなかった」という構造と、「Claudeが本来の精度を出せていなかった」という偶発的事象が、互いを増幅し合った——これが私たちの観測です。

次回は、Claude の性能低下と私たちが観測した症状の話を書きます。

コメント

タイトルとURLをコピーしました