Uncle Bob は「AIのコードを読まない」らしい
どうすれば人間がテストコードを読まずに実装が済むかを考えていたときに、目にしたのが名著クリーンアーキテクチャーを書いた、Uncle Bob(Robert C. Martin)のXへの投稿でした。
I'm significantly older than you. I started coding in the late 60s. My current strategy is to not read any of the code written by my agents. That's the only way I can take advantage of their productivity. What I do instead is to surround the agents with extreme constraints. Unit tests, gherkin tests, QA procedures, quality metrics, mutation testing, test coverage, and a plethora of others. In the end, I have very high confidence in the code they produce because they've had to run the gauntlet of all of my constraints and tests.
私はあなたよりずっと年上で、60年代の終わりからコードを書いている。今の私のやり方は、エージェントが書いたコードを一切読まないことだ。そうしなければ、エージェントの生産性を活かせない。その代わりに、エージェントを極端な制約で囲む。ユニットテスト、Gherkin のテスト、QA の手順、品質の指標、ミューテーションテスト、テストカバレッジ、ほかにもたくさんある。エージェントはこれらの制約とテストをすべてくぐり抜けなければならないので、最終的に出てくるコードには非常に高い確信を持てる。
https://x.com/unclebobmartin/status/2080257779395154409
同じ時期の別のポストでは、ユニットテストはエージェントに書かせて自分では見ない一方、Gherkin で書いた受け入れテストと QA の手順は自分で確認している(重要度に応じて、全部読むこともあれば抜き取りで見ることもある)とも書いていました。
ここから
- 人間の確認をコードを読んでレビューすることから制約を設計することへ移せば、AIの速さを損なわずに品質を保てる
- 人間がコードを読む代わりはテストに受け持たせて、人間は受け入れ条件のみを見る
ということだろうか?と考え、似たことを自分のプロジェクトでもできないか試してみることにしました。
Uncle Bobのconstraintsという表現から名前を取り、私はこの進め方を「AI制約駆動開発」と呼んでいます。
実装の前に守るべき既存の動作と満たすべき条件と、その確かめ方を決めた受け入れ条件をmarkdownに残して、AIはそれをもとにテストを書きながら実装を行い、制約のテストを満たしたら実装が完了する、という流れで開発を進めます。
AI制約駆動開発の流れ
この全工程は、まずは/ai_dev + チケット番号やプロンプト で動かせるようにSkillを作りました。
/ai_dev 自体は、テストの書き方やレビューの中身を持っていません。/ai_devには、あらかじめ作っておいた設計やテストのやり方、レビューなどを入れた複数のSkillを順に呼び、各工程の成果物を確かめてから次へ進めることだけを担当します。
計画や受け入れ条件、レビュー結果などは全てMarkdownで残すようにしています。これは以前紹介した通り、Obsidianでai_wikiというフォルダを管理して使っているので、そこにプロジェクトごとのフォルダをつくらせてMarkdownを追加していくようにしています。
AI制約駆動開発/ai_devの全工程
工程0 判定 `/ai_dev`の引数の依頼を読み、3つの質問で作業の複雑さのレベルを決める
工程1 調査と仕様確認 既存機能を調べ、曖昧な点があれば1通にまとめて人間に聞く
工程2 計画 受け入れ条件と検証方法の対応表、作業マイルストーンを書く
工程3 計画レビュー 別の AI(Codexなど)に計画を独立してレビューさせて修正、の繰り返しを行う
工程4 実装 基本的にTDDで、まずテストを書いて処理を固定してから実装して、品質チェックのテストを流す
工程5 最終確認 残りの検証とセルフレビューを行い、出せる状態かを判定する
工程6 PR pushしてPRを作る
/ai_devが呼ぶ既存のSkill紹介
どれも単体で使っていたもので、/ai_dev はこれらの成果物を確認する役割に徹するように作っています。
| 工程 | 呼ぶ Skill | 役割 |
|---|---|---|
| 3 | 計画レビュー | Codex に計画をレビューさせ、指摘を実コードで裏取りして計画を直す |
| 4 | テスト先行実装 | 1マイルストーン分のテストを書いて固定し、実装し、品質チェックを流す |
| 5 | セルフレビュー | 過去のレビュー指摘の傾向をもとに、差分を出す前に点検する |
| 6 | PR 説明文 | テンプレートに沿って説明文を書き、ラベルと担当者を付ける |
| 6 | レビュー対応 | AI レビューの指摘を裏取りして、直すか返信するかを判定する |
中身を/ai_devに写さないことで、バラバラでもClaudeなどAIが必要に応じて使うことができます。使ってみてわかったのですが、/ai_devを通さない使い方をしたい場面もあるため、この運用にして良かったと思っています。
工程0:3つの質問で、必要な制約の量を決める
軽い修正にも重い手順を課すと、完了までに時間がかかりすぎます。そのため、作業の1番最初に渡された作業内容のプロンプトに合わせて3つの質問に答え、タスクの複雑さを3段階に分けます。
| 質問 | 「はい」の例 |
|---|---|
| Q1 既存の入力に対する結果(計算、判定、状態遷移、保存される値)が変わるか | 丸めのルールを変える |
| Q2 変更する処理を通る入口が2つ以上あるか | 画面と CSV 取り込みとバッチから同じ処理を呼ぶ |
| Q3 保存済みのデータを書き換えるか、変更する処理の結果を別の機能が読んでいるか | 既存の行の移行。発行した日付を請求処理が読む |
| レベル | 条件 | 主に増える制約 |
|---|---|---|
| L1 | すべて「いいえ」 | 受け入れ条件の対応表だけ。マイルストーンは1つ |
| L2 | Q1 か Q2 の片方だけ「はい」 | 影響する既存の動作の表と、人間による計画の確認 |
| L3 | Q3 が「はい」、または「はい」が2つ以上 | 現行の結果を先にテストで記録する工程と、変更前後の出力の比較 |
こちらのAIの回答は、回答して終わりではなく、判断の答え合わせのためにその証拠を探させています。
証拠が揃わない質問は「はい」として扱い、レベルを下げられるのは人間が指示を出した時のみにしています。これはAIが憶測から判断して手順を省くのを防ぐためと、自らの誤りを補正させるためにこうしてます。
工程1〜2:受け入れ条件と検証方法の対応表を作る
既存機能の調査では、変更するメソッドの呼び出し元に加え、同じテーブルを読み書きする処理、モデルのイベント、非同期のジョブ、共通の絞り込み条件まで調べます。
さらに実装計画は以下のような作る機能の受け入れ条件の対応表をMarkdownに出力してもらっています。
| ID | 条件 | 必須か任意か | 検証方法と確かめる範囲 | 実施する工程 |
|---|---|---|---|---|
| A1 | 上限を超える数値を送ると入力エラーになり、保存されない | 必須 | サーバーへリクエストを送るテスト。入力チェックと保存まで | M1 |
| A2 | 画面で上限を超える数値を入れると、送信前にエラーが表示される | 必須 | 画面での確認。実際のブラウザで入力して送信する | M1 の直後 |
| A3 | 既存の正しい入力は今までどおり保存される | 必須 | サーバーへリクエストを送るテスト | M1 |
ここでは、条件を結果が1つの結論に確定する様な文章で書くこと、そして検証方法にどこまで保証するかを書いています。
調査で見つかった曖昧な点や、チケットと現行コードの食い違いは、1通にまとめて人間に質問させます。全部に答えが出るまで計画には進みません。都度聞いてこられて煩わされないようにすることと、AIの長考を防ぐためにこうしています。
工程3:別の AI に計画をレビューさせる
Claudeなどが立てた計画をMarkdownに残し、それをCodexに読み取り専用でレビューさせます。計画を書いたAIとは別のAIに読ませることで、コンテキストによる思い込みを持ってレビューをすること防ぎます。ここは同じClaudeで別セッションを立ち上げる方法でも良いかもしれません。
またレビューの回数には上限を設けています。L1 と L2 は1ラウンド、L3 は2ラウンドです。
回数制限を設けているのは、無制限に回すと細かい指摘の応酬が続き、時間ばかりが溶けてなかなか次に進まないことが多かったからです。
もし残った論点がある場合は未解決事項として人間に渡して確認して解決します。
工程4:テストだけを先にコミットして固定する
ここが仕組みの中心です。作業マイルストーンごとに、次の順で進めます。
1. 受け入れ条件の対応表の各行について、その条件が破られたら落ちる最小のテストを書く
2. Codex にテストと対応表を照合させ、守れていない条件が無いか確認
3. テストを実行し、実装前なので失敗することを確かめる
4. テストのファイルとテスト用データの定義だけを1つのコミットにする
5. 実装し、テストと品質チェックを流す
TDDのアプローチで4のコミットであらかじめ処理の固定をした後は、テストの削除や制約の緩和(assert を減らす、期待値を緩める、スキップにする)を禁止します。新しいリスクが発覚した場合のテストの追加は許可し、期待値そのものを変える場合は計画の変更として扱って再レビューを通します。
固定したコミットはハッシュ値で記録し、そのコミットの中身がテストのディレクトリだけであることも機械的に確かめます。AIが実装に詰まったときにテストのほうを合わせて書き換える、という逃げ道をこれで塞いでいます。
品質チェックは保証したいことから選ぶ
作業工程(マイルストーン)ごとに実装後に流す品質チェックのテストは、計画の段階であらかじめ選んでおきます。全部を毎回流すのではなく、その作業工程(マイルストーン)で何を壊す恐れがあるか?から選びます。
以下は私が品質チェックで使うテストツールをまとめた表です。このようなテストツールを駆使して、ガチガチにAIを制約で縛っています。
| 役割 | Laravel | Rails | Go |
|---|---|---|---|
| ユニットテスト | PHPUnit | RSpec | go test |
| 型や静的な誤りの検出 | Larastan(PHPStan) | Sorbet | go vet |
| ミューテーションテスト(コードをわざと壊してテストが落ちるかを見るツール) | Infection | mutant | gremlins |
| 層の依存関係の検査 | PHPat | Packwerk | depguard、arch-go |
| 複数リクエストにまたがる API のシナリオ | runn | runn | runn |
このテストツールは、作業工程(マイルストーン)が終わるごとにかけると時間がかかりすぎるため、変更したファイルだけを実装の前後で解析し、警告が増えていないかを差分で見るようにしています。テストの検出力の確認も、計算や判定を変えた作業工程(マイルストーン)のときだけ流します。
結果は「通過」「失敗」「未実施」の3つでMarkdownに記録します。「未実施」には理由を書き、通過の行には実行したコマンドと終了コードを残します。
なお、品質チェックのテストツールは機能のブランチに混ぜないようにしています。テストツールの設定と依存関係はリポジトリの外に置き、作業用の worktree のコードをコンテナで動かして検査してます。チームのリポジトリにテストツールを入れる合意が取れない環境でも、個人の環境から小さく始められます。
画面での確認は、最後にまとめず各工程の実装直後に行う
ブラウザでしか確かめられない条件も、全て人間任せにすると問題発見 → 修正 → ブラウザテスト、だと手間なので、その動作を変えた工程の直後にAI がPlaywrightでブラウザを操作して確認するようにしています。確認結果は日時、環境、操作、結果とスクリーンショットを記録します。
また、ローカルで再現できない条件(外部サービスからの通知など)は「未検証」と理由を書いて残して最後に報告できるようにして、検証済みとは記録しないようにしています。
状態を分けて、何が終わったかを正確に言う
完了の状態を4つに分けています。
| 状態 | 意味 |
|---|---|
| Milestone GREEN | そのマイルストーンの必須の条件がすべて検証済み |
| Integration GREEN | それまでのマイルストーンのテストをまとめて流しても通る |
| Release Ready | 必須の条件がすべて検証済みで、セルフレビュー済み、未コミットの変更なし、判断待ちなし |
| QA Incomplete | 必須の条件に未検証か失敗が残っている |
QA Incomplete でも Draft PR は作れるようにしています。人間が確認しやすくするためです。この場合は未検証の点を人間がやる手順として出力させます。テストは通ったので完成という報告を、AIが出さないようにしています。
進捗はMarkdownで記録して、いつでも中断できるようにする
計画や受け入れ条件と同様に、各工程の結果は Markdown の進捗の文書に残させています。もしコンテキストがいっぱいになって/clearしなければいけない状態になっても、記録と実際の状態を照合してから再開できるようにしてます。
照合するのは、作業ブランチと最新のコミット、未コミットの変更が無いこと、固定したテストのファイルのハッシュ値、PR の有無などです。1つでも食い違えば、続きを始めずに人間に報告します。別のセッションが同じタスクを並行して触っている可能性を見逃さないためです。
使ってみた感想
実装の前に、チケットの前提の誤りが見つかった
既存機能の調査で、チケットが追加すると書いていた点の一部が、既に存在して使われていることが分かりました。重複した実装を行なってしまうところでした。
既存テストの穴が実装前に見つかった
Codex にテストと対応表を照合させたところ、そのチケットの変更で触れるのに既存のテストでは守れていない動作が見つかりました。受け入れ条件の対応表に行を足して、テストを追加して処理を固定する制約をいれることができました。
テストであらかじめ処理を固定すると、実装のやり直しは0回
固定した後にテストを変えた回数も、実装をやり直した回数も0回です。
条件が先に決まっているため、AI が途中で方針を変える余地が少なくなったと感じています。
PRレビューも、Fable5が来てから細かすぎるところまで指摘されていたのが、かなり指摘が少なくなったと感じました。
他の言語やフレームワークで取り入れるときのポイント
この仕組みの大半は、言語に依存しない文書の決まりと、Git の操作でできています。取り入れるときは、次の順で小さく始めるのがよいと思います。
- 受け入れ条件と検証方法の対応表を、計画の文書に必ず書く
- テストだけのコミットを先に作り、そのハッシュ値を記録する
- 固定した後のテストの削除と弱体化を禁止するルールを、AI への指示に書く
- 3つの質問でタスクの複雑さを分け、軽いタスクに重い手順を課さない
- 計画を計画作成のAIとは別のAIか人間に1回だけレビューしてもらう
まとめ
AIに実装を任せるとき、モデルの能力を上げるだけでは既存機能のデグレを防げません。重要なのは、何を守るべきか、どう検証するかを実装前に決め、AIがその制約を満たさなければ先に進めない仕組みを作ることだと考えました。
Uncle Bob の「コードを読まず、制約で囲む」という考え方に、守るべき既存の動作を先に決める工程とテストの固定を足したのが /ai_dev です。
/ai_dev は、受け入れ条件と検証方法の対応を先に決め、テストを固定してから実装し、未検証を未検証のまま報告する工程を、1つの入口で順に進める仕組みです。人間はテストコードを読まずに、対応表と仕様の判断に集中できます。
参考
- Uncle Bob(Robert C. Martin)の X への投稿: https://x.com/unclebobmartin/status/2080257779395154409