コードレビュー187件を分類したら、バグ指摘は5件に1件だった
コードレビュー187件を分類したら、バグ指摘は5件に1件だった
コードレビューは本当にバグを捕まえる工程なのか、それとも我々がバグ検出だと錯覚してきた別の何かなのか。
導入
2026年8月26日の夜、Qiitaに「ベテランエンジニアのPRレビュー187件を分類してみたら、バグは5件に1件しか指摘されていなかった」という記事が投稿された。LGTM98、ストック73。この反応量は「思い当たる節があった人が多かった」ということである。
話題性より興味深いのは、この記事の作られ方だ。著者 ktdatascience は、自分のレビューがなぜ薄いのか分からなかったと書く。同じPRを見ているのに自分は「LGTM」で終わり、隣の人は的確な指摘を10件出してくる。何を見ればそうなるのかと聞いても「気になった箇所を見てるだけ」という答えしか返ってこない。それなら数えるしかない、とGitHub REST APIでその人のインラインコメントをリポジトリ単位で全部引っ張った。3リポジトリ・44本のPRから212件。スレッド内の返信21件と、同一レビューが複数回サブミットされて本文が完全一致していた4件を除いた187件が分析対象である。それを全部読み、「どんな主題について語っているか」という軸で分類した。
結果は著者の予想を裏切った。最大勢力はバグ指摘ではなかった。指摘した時点では何も壊れていない領域が全体の3分の1を占め、著者自身がまったく手を出せていなかったのが、まさにそこだった。
先にこのデータの性格を確認しておく。記事の末尾が自ら断っているとおり「件数・分類は筆者が手作業で分類した私的データセット」である。レビュアー1人、リポジトリ3つ、分類者1人。統計的標本ではないし、他組織にそのまま持ち込める定数でもない。それでも読む価値があるのは、我々が滅多に観測しないもの——コードレビューが実際に何を生産しているのか——を、数字の形にしたからである。レビューの効果に関する組織の信念は、ほとんどの場合、測定されたことがない。
187件の地図が示していたもの
著者は187件を3つの軸で分けている。型(何を指摘したか。条件の誤り、デッドコード、非対称など)、動作(見つけるために何を開いたか。既存の別ファイル、呼び先、公式ドキュメントなど)、そして領域(そもそも何に注意を向けているか)である。記事が扱うのは3番目だ。レビューで手が止まる原因はたいてい「探し方を知らない」ではなく「そこに注意が向いていない」からだ、という判断による。
187件は11の大領域(さらに37の中領域)に分かれた。件数順に並べる。
| 順位 | 領域 | この領域が立てている問い | 件数 |
|---|---|---|---|
| 1 | 隣のコードと揃っているか | 同じことをしている場所と食い違ってないか | 31 |
| 2 | 契約と意図は保存されているか | 半年後の人が同じ判断に辿り着けるか | 30 |
| 3 | 値そのものは正しいか | この数字・この判定は合っているか | 25 |
| 4 | 失敗に気づけるか | 壊れたとき人間は知れるか | 17 |
| 5 | 使う人から見てどうか | 運用担当とユーザーの手元で何が起きるか | 16 |
| 6 | 外部との境界 | 自分が書いていないものは本当にそう動くか | 15 |
| 7 | データが壊れないか | 落ちた・並んだ・消えたとき何が残るか | 13 |
| 7 | 設計の見通し | 次に触る人が読めるか | 13 |
| 9 | 速度とコスト | 何秒・何円かかるか | 12 |
| 10 | 変更はどこまで効くか | 書いた人が見ている画面の外にも届くか | 11 |
| 11 | 検証できるか | 効いたことをあとから確かめられるか | 4 |
上位2つで61件、全体の33%。そしていわゆるバグ相当——3位の値の正しさ25件と7位のデータ破壊13件——は合わせて38件、20%である。5件に1件。これが表題になった数字だ。
ここで一点はっきりさせておきたい。この20%は「レビューが存在するバグの20%を捕まえた」という意味ではない。分母はバグではなくレビューコメントである。レビューをすり抜けたバグが何件あったかは、このデータセットに入っていないし、入れる方法もない。このデータが言えるのはただ一つ、優れたレビュアー1人が、自分のレビュー時間の大半をバグ以外に使っていたという事実である。それだけでも十分に重い。レビューをバグゲートと呼ぶとき、我々はそのゲートが実際に何を通し何を止めているかを見たことがないのだから。
レイヤーを分けると絵がまた変わる。同じ人のレビューでも、対象がバックエンドかモバイルかWebフロントかで厚い領域がはっきり違った。バックエンドは「値の正しさ」が20%で突出する(金額・時刻・DB制約)。モバイルは「データが壊れないか」「変更の波及」「外部SDK」に寄る。Webフロントは「一貫性」と「意図の保存」だけで半分を占め、「外部との境界」と「検証できるか」はゼロ。11領域を毎回すべて見る必要はなく、差分の属するレイヤーで絞れるということである。
分類のあと、著者は同じPRに自分が出したレビューを同じ11領域に振り直してみた。3位(値)と5位(使う人)には入っていたが、1位・2位・10位には1件もない。見ていた領域が3つしかなかった。実力差の正体が「センス」ではなく注意を向ける先の個数に還元された瞬間である。この還元こそが、この記事が広まった理由だろう。センスは学べないが、チェックリストは学べる。
diffは局所的で、バグは大域的である
なぜバグ指摘が5件に1件に留まるのか。記事の2番目の軸が答えの半分をくれる。「見つけるために何を開いたか」で数えると、187件のうち129件(69%)が差分の外を見た結果だった。差分だけを上から下に読んで出せるのは58件(31%)しかない。1位の領域はさらに極端で、31件のうち27件(87%)が「リポジトリ内の別の場所を開いた」動作から生まれている。
この数字は、コードレビューという工程の構造的限界を正確に指している。我々がレビューと呼んでいるものの表現形式はdiffである。diffは変更された行を見せるよう最適化された表現であり、変更されていない行との相互作用は原理的に見せない。ところがバグの多くは、変更された行そのものではなく、変更された行と変更されていない行の関係から生まれる。diffは局所的で、バグは大域的だ。レビュアーが怠けているのではなく、道具が映す画面が最初からバグの住処を切り落としている。
記事の事例がこの診断と重なる。合計と明細でロジックが食い違っていた件がある。合計は Math.abs(quantity) と異常値フォールバック 0 で集計するのに、各行の表示は Math.abs なし・フォールバック 1。通常データなら合うが、返品などで数量がマイナスになると各行を足しても合計にならない。この指摘は差分の中では出せない。「合計」と「明細」は必ずペアだから両方開く、という動作が先に来る。
さらに冷ややかなのがリファクタの事例だ。共通処理を別モジュールへ切り出す際に元クラスへ委譲用の別名を5つ残したが、移動先が内部でモジュールグローバルを直接参照していたため、その別名は本番コードからの参照が0。結果としてテストで @patch しても実物が呼ばれ、SQL定数を書き換えても実行されるSQLは変わらない。著者の言葉どおり「挙動不変のリファクタなのに、テストの縫い目だけが静かに偽物になっていた」。差分の中では完璧に通る変更である。
ではバグ検出という仕事は、何が代わりにやるべきなのか。候補は5つある。それぞれレビューより得意なことと不得意なことが分かれる。
テストは回帰に強く常時走るが、実装と同じ前提の上で書かれる。記事にある単価と合計の取り違え——一覧の金額表示が price * quantity から price 単体に変わり、数量分の乗算が消えた件——は、実機で同じ取引が一覧550円・詳細230円と食い違うところまで確認したうえでの指摘だった。著者はこの類型について「テストは通る。テストも同じ思い込みで書かれるから」と釘を刺している。テストは自分が想像できた失敗しか検出しない。
型システムは常時検査という点でレビューより強いが、宣言された分しか検査しない。TanStack Query の事例がそれだ。クエリの meta に独自キーを渡しながら Register インターフェースに queryMeta を宣言していないため meta の型が Record<string, unknown> のままで、どんなキー名でも通りタイポをコンパイルが拾えない。指摘の書き方が肝で、「型が緩い」ではなく「タイポが通る」と言えば直す理由がはっきりする。人が as string キャストで型検査を自分から切っている場合も同様で、それを見つけるのは結局レビューである。
静的解析とリンターは、このデータで最大の自動化余地を露わにする。著者が優先的に習慣化すると決めた6つの中領域のうち、二重定義(9件)、デッドコード(8件)、変更の波及範囲(7件)は、かなりの部分がgrep相当の機械作業だ。レビュアーがやっていた仕事の一部は、道具にできるのに誰も有効にしていないため人間が手でやっていたにすぎない。逆に「合計と明細は同じ規則を使うべきだ」はドメイン知識であり、リンターはそのペアを知らない。
契約と不変条件はレビューより根本的に強い。レビューは1回きりで、スキーマ制約は常時だからである。7位の事例がまさにその不在を語る。GROUP BY user_id して MIN(company_id) を採る実装に対し、DB制約が1ユーザー×1会社を強制していない(片方は会社単位ユニーク、もう片方は施設単位ユニーク)ため、複数所属の行が作れた瞬間に画面では片方しか見えなくなる、という指摘だ。正しい解決はレビューを頑張ることではなく、制約を張ることである。
カナリアリリースと可観測性が残りを受ける。ただしここには循環がある。4位「失敗に気づけるか」の17件は機能としては全部正しく動き、著者の言うとおり機能テストでは1件も落ちない。HTTPクライアント aspida が throwHttpErrors: false(既定値)で初期化されていたため、await した post が400や500を受けても例外を投げず、try/catch で囲んでいるのに catch 節に入らない。APIが拒否しても「変更しました」の成功バナーが出る。設定ファイル1行が「try/catch があるから安全」という直感をひっくり返している。可観測性はリリース後に働くが、観測コードが正しく仕込まれているかはコードの上でしか見えない。だからこの領域は最後までレビューに残る。
記事で最も精緻な指摘もこの系列だ。有効期限の判定に、タイムゾーンを持たない expires_at(中身はUTCのつもりの日時)とタイムゾーン付きの NOW() を比較するSQLがあった。型が違うのでPostgreSQLは片方を変換してから比べるが、このときタイムゾーンなしの値はセッションの TimeZone 設定のローカル時刻として解釈される。設定が Asia/Tokyo なら、UTCのつもりで入れた値がJSTとして読まれ、実際より9時間早く「期限切れ」と判定される。金銭やポイントの有効期限でこれが起きれば、まだ生きているはずのものが消える。指摘は「今は正しく動く」ことをまず認め、なぜ今は動くのかまで書いている。DBコンテナの TimeZone が未指定でたまたまUTCだから、というだけの理由だ。つまりDBの起動オプションが1つ変わるだけで壊れる。
-- 両辺ともタイムゾーンなしのUTCになるので、セッション設定に左右されない
SELECT * FROM items WHERE expires_at < NOW() AT TIME ZONE 'utc';
この指摘をテストが出せるか。テスト環境のDBも同じ理由でUTCなので出せない。型検査器が出せるか。SQL文字列の中なので出せない。リンターはパターンを登録すれば出せるが、そのパターンを登録するには誰かが先にこの事故を理解している必要がある。残るのは人間である。
レビューの目標を書き直すと何が変わるか
ここまでの再解釈を実務の言葉に置き直す。コードレビューが生む価値はバグ検出ひとつではなく複数あり、バグ検出はそのうち持ち分5分の1の項目にすぎない。残りは設計の整合(隣のコードとの一貫性)、意図と根拠の保存、命名と可読性、規約の揃え、そしてコード所有権の分散である。1位と2位が共有する性質を著者は正確に突いている。**指摘した時点では何も壊れていない。**壊れるのは選択肢を1つ足したとき、共通スタイルの値を変えたとき、片方だけ直したとき。要するに未来の改修だ。このレビュアーの作業は、PRを通すための検査ではなく、コードベースの劣化速度を落とす作業として設計されていた。
ではレビューを「バグを捕まえる工程」として宣伝すると、どんな歪みが生じるか。観測可能なものが3つある。第一に、ゲートへの信頼の過大評価。レビューを通ったからバグがない、という推論はこのデータのどこにも根拠がないのに、組織はレビュー承認を安全シグナルとして読む。第二に、投資圧力の移動。レビューがバグを捕まえてくれると信じれば、テスト・スキーマ制約・可観測性に向かうはずの予算が「レビューをもっと丁寧に」という無形の要求に置き換わる。前節で見たとおり、その相当部分は人間より機械のほうが安く常時できる仕事だ。第三が最も有害で、事故が起きたときに原因が「レビュアーが見落とした」という個人帰責に収束する。diffという表現形式が構造的に隠すものを人間の注意力不足として記録した瞬間、その組織は同じ事故を繰り返すしかない。
代わりの宣伝文句はこう書ける。レビューの目標は、この変更が半年後の改修コストをどれだけ押し上げるかを下げることである。こう書けば、1位・2位の領域に時間を使うことが職務怠慢ではなく職務そのものになる。
AIレビュアーはこの絵を変えるのか。条件付きで論じるべきだ。機械に有利な側から見ると、187件の69%が「差分の外を見た」という作業量の問題だったという事実は、自動化余地が大きいという信号である。ファイルをもう1枚開く、定義した定数をgrepする、対になるファイル(一覧と詳細、合計と明細、iOSとAndroid)を並べる——専門知識をほとんど必要としない、と著者自身が書いている。疲れない機械が人間より有利な地点だ。
不利な側も3つある。ひとつ、道具に実際に与えられる入力が依然としてdiffだけなら、機械は同じ限界をより速く反復するだけである。リポジトリ全体を入力に取る構成と差分だけを取る構成は、名前が同じでも別物だ。ふたつ、1位・2位の領域は「このリポジトリの慣例は何か」を知って初めて成立するが、慣例はたいてい文書化されておらずコードに散っている。検索で回収はできても、複数の先例のどれが正本かは判断であり、判断を誤れば機械は誤った慣例を強制する側に回る。みっつ、指摘のコスト構造が違う。人間のレビュアーは指摘数を自分で抑えるが、機械には抑える理由がなく、S/N比が悪化すればチームはレビューコメント全体を無視するようになる。AIレビュアーがこの絵を実際に変える条件は3つが重なるときだ。入力がdiffではなくリポジトリであること、出力が優先順位付けされていること、そしてどの領域を見てどの領域を見なかったかを自己申告すること。最後の条件が特に重い。人間のレビュアーの死角が3つしかなかったことを、著者は数えてみて初めて知ったのだから。
すぐ実行できる処方も記事の中にある。著者が費用対効果の高い中領域として選んだのは6つ。根拠の保存(数値リテラルに「この根拠はどこに書いてあるか」と聞く)20件、対の非対称(対になる名前を探して並べる)10件、二重定義(新しく定義した定数の値をgrepする)9件、デッドコード(参照をgrepで数える)8件、silent failure(HTTPクライアントの設定を開き、catch の到達条件を全部言う)8件、変更の波及範囲(変更した部品の呼び出し元をgrepで数える)7件。合計62件、全体の33%である。しかも1位の根拠の保存は20件中14件が差分を読むだけで出せた。ファイルを1枚も開かずに始められるものが、いちばん件数が多い。
結論
冒頭の問いに戻る。コードレビューはバグを捕まえる工程なのか。少なくともこのデータセットではそうではない。優れたレビュアーの時間の80%はバグ以外に使われていて、その80%が消えたら残るのは「今のところ何の問題もない」コードベースと、半年後の改修コストである。だからといってレビューが無用だという結論にはならない。間違っているのはレビューではなく、レビューに貼ったラベルのほうだ。
ラベルを貼り替えれば、付いてくる調整がある。バグ検出の相当部分はテスト、型、リンター、スキーマ制約、可観測性へ移すべきである。それらは常時走り、人間の注意力に依存しない。レビューに残すべきなのは機械が表現できないもの——チームの慣例、判断の根拠、対の対称性、そしてこのコードを知る人間をもう1人つくること——だ。この配分を明示しないまま「レビューをもっと丁寧に」と言うのは、何も指示していないのと同じである。
最後は、この記事自身が提案する実験である。著者は半日もかからないと書く。チームの直近1四半期のレビューコメントをAPIで全部引っ張り、同じ軸で数えてみればいい。11領域のうちいくつが埋まっているか。そして埋まっていない領域は、本当に別の工程が覆っているから空いているのか、それとも単に誰も見ていないから空いているのか。両者は数字の上では同じ0に見えるが、一方は設計であり、もう一方は事故である。
出典: