polyscan
← All posts
ai-agentscode-reviewcode-qualitystatic-analysis

コードレビューがボトルネックになった

コーディングエージェントを使うと、パッチを書く時間は短くなりますが、レビューする時間は短くなりません。レビューのうち「コードベース全体への影響を見る」部分は機械に任せないと、レビューが回らなくなります。

English version

コーディングエージェントを使い始めてから、プルリクエストの数は増えましたが、レビューにかけられる時間は増えていません。以前はパッチを書くのに半日かかり、レビューは30分で済んでいました。今はパッチが数分で届き、レビューには相変わらず30分かかります。いちばん時間のかかる工程が、書く側からレビューに移ったわけです。

問題は、レビューが追いついていないことが外から見えにくい点です。レビューが遅れているだけならキューが詰まって気づきますが、疲れたレビュアーが流し読みで承認していると、キューは順調に流れているように見えます。

私自身の失敗を書きます。以前、エージェントが同じ形の関数を4つ書いていた話を記事にしました。あの4つ目の関数を含むプルリクエストを承認したのは私です。差分だけ見れば問題のない関数だったので、そのまま通しました。この記事では、エージェントがなぜ4つ目を書いたかではなく、レビュアーがなぜそれを見逃すのか、これからどうすればいいのかを書きます。

コードレビューには2種類の仕事がある

1つ目は、変更が正しいかどうかの確認です。Issueで頼まれた通りに動くか、既存の機能を壊していないか。この確認の大部分は、すでに人間の手を離れています。テストがあればレビュアーはテストの中身が妥当かを見るだけで済みますし、型の不整合はmypyが、書式はフォーマッタが先に指摘します。mypyが入っているプロジェクトで「ここに None が来る可能性があります」というレビューコメントを書く人はいません。人が見る前に機械が指摘し、直っているからです。

2つ目は、その変更がコードベース全体にどう影響するかの確認です。実際のレビューコメントで言えば、次のようなものです。

utils/dates.pyparse_iso_date があるので、そちらを使ってください

この関数が長くなってきたので、バリデーション部分を別の関数に分けませんか

domain/ から api/ をimportするのは層の方向が逆です

_legacy_discount はもうどこからも呼ばれていないので、消していいと思います

これらのコメントに共通するのは、diffを読むだけでは書けないことです。「すでに関数がある」と言うには他のファイルを知っている必要があり、「長くなってきた」と言うには変更前の長さを知っている必要があり、「層の方向が逆」と言うにはプロジェクトの構成ルールを知っている必要があります。

この2つ目の確認は、これまで自動化されてきませんでした。必要がなかったからです。パッチを書く人はそのコードベースを何か月も触っている人で、parse_iso_date があることも層のルールも知っていました。つまり2つ目の確認の大部分は書く人が書きながら済ませていて、レビュアーは書く人が見落とした分だけを拾えばよかったのです。

エージェントは2つ目の確認をしない

エージェントはコードベースの一部だけを見て書きます。コンテキストに入る範囲がすべてで、3つ離れたモジュールに同じ関数があることは知りようがありません。「既存のロジックを重複させないでください」と指示しても、重複の相手をエージェントが読んでいなければ、その指示は実行できません。この点は前の記事で詳しく書きました。

結果として、これまで書く人が書きながら済ませていた確認が、まるごとレビュアーに回ってきます。

これは推測ではなく、数字があります。SWE-benchで14モデルのパッチを、同じIssueを直した人間のコミットと比べたところ、14モデル中13モデルが、人間より高い割合で関数の複雑度を上げるかデッドコードを残していました。人間より良い成績のモデルはありませんでした。どのパッチもIssueを解決してテストを通していますが、コードベースに残す負債は人間より多い、ということです。

もう1つ、小さいチームや個人開発では、書く人とレビューする人が同じ人になっているという事情があります。以前は書く人とレビュアーの2人がいて、書く人が見落とせばレビュアーが拾っていました。今は指示を出す人がいて、エージェントが書いて、指示を出した本人がレビューする構成になり、拾う役目の2人目がいません。

人が全部読むのも、LLMに読ませるのも足りない

対策の1つ目は、それでも全部を丁寧に読むことです。数本ならできますが、週に40本になると続きません。実際に何が起きるかというと、説明文を読み、テストファイルを眺め、CIが緑なのを確認し、diffが短いのを見て承認する、という流れになります。私もそうなりました。この読み方では2つ目の確認はほぼ機能しません。同じ形の関数の4つ目は、それ単体では普通の関数に見えるからです。

2つ目は、別のLLMにレビューさせることです。正しさの確認には効きます。境界条件のミスや空リストの処理漏れは拾ってくれます。しかしLLMレビュアーが読むのもdiffで、書いたエージェントと同じ範囲しか見えていません。リポジトリ全体を渡そうとしてもコンテキストには入りきらず、結局どのファイルを見せるかを選ぶ問題になります。これは書く側が抱えていたのと同じ問題です。加えて、ベンチマークで測った14モデルは、レビュアーとして使う候補も同じ14モデルです。人間より片づいたコードを書けなかったモデルに「このパッチは片づいていますか」と聞いても、書いたときと同じ判断が返ってきます。

3つ目は、確認を分けることです。コードベース全体を見ないと分からない部分はコードベース全体を測れるツールに任せ、人はツールに判断できない部分だけを見ます。私が有効だと考えているのはこれです。ただし「ツールに任せる」を「リンターを追加する」と解釈すると失敗します。理由は次の例で書きます。

例:9行の追加で複雑度が7から10になる

次の関数があるとします。

def apply_discount(order, code):
    if code is None:
        return order.total
    if code.expired:
        raise ValueError("expired")
    if code.kind == "percent":
        return order.total * (1 - code.value / 100)
    if code.kind == "fixed":
        return max(order.total - code.value, 0)
    raise ValueError(f"unknown kind {code.kind}")

エージェントに「割合割引に上限額をつけて、送料無料クーポンの種別も追加して」と頼むと、次のようなdiffが返ってきます。

     if code.kind == "percent":
+        if code.max_off is not None and order.total * code.value / 100 > code.max_off:
+            return order.total - code.max_off
         return order.total * (1 - code.value / 100)
     if code.kind == "fixed":
         return max(order.total - code.value, 0)
+    if code.kind == "shipping":
+        if order.shipping and not order.shipping.free:
+            return order.total - order.shipping.cost
+        return order.total
     raise ValueError(f"unknown kind {code.kind}")

追加された行はどれも正しく、テストも通ります。ruff も何も言いません。差分は9行で、チケット通りの変更です。レビュアーは承認するでしょうし、私も承認します。

同じファイルを変更前と変更後でpyscnにかけると、apply_discount の循環的複雑度は7から10に上がり、pyscnのリスク判定は「低」から「中」に変わります。

この数字が意味するのは「パッチが間違っている」ではありません。「この関数は分岐が増えて読みにくくなった。次にこの関数を触る人は複雑度10から始めることになる」ということです。レビューコメントで言えば「この関数が長くなってきたので分けませんか」に相当します。人が気づく前に、機械が数字つきで同じ指摘をしたと考えてください。こういう変更が4〜5回、それぞれ個別に承認されて積み重なると、誰も触りたくない関数になります。

リンターがこの指摘をしないのは、リンターが「このファイル単体でルールに違反しているか」しか見ないからです。複雑度10はルール違反ではありません。ここで見たいのは「変更の前後で何が変わったか」であって、「現在の状態がルール内か」ではないのです。

ツールに任せるときの条件

2つ目の確認をツールに任せるなら、測るものは決まっています。パッチが触った関数の複雑度、到達不能なコードの追加、既存コードと構造が同じ複製の追加、モジュール間の依存の変化です。そのうえで、運用には3つの条件があります。

現在値ではなく差分で判定する。 10年もののコードベースには、どんな閾値を設定しても超えている関数が何百もあります。それを理由に全部のプルリクエストにバツがつくと、誰も見なくなります。見るべきは「この変更で悪くなったか」で、それは変更前後の比較です。ベンチマークでこの比較をすると、人間のパッチも24%が引っかかりました。つまり「少しでも悪化したらブロック」というルールにすると、人間のプルリクエストの4分の1も止まります。現実的な運用は、悪化があればコメントとして表示し、ブロックするのはリスク判定が変わったときとデッドコードを足したときに限る、あたりだと思います。判断は人がします。ツールの役目は数字を出すことです。

部分解析で狂う指標と狂わない指標を区別する。 関数の複雑度や到達不能コードは、その関数を含むファイルだけを見れば正確に出ます。一方、複製の検出やモジュール間の依存は、リポジトリの一部だけを見ると見落としが出ます。複製の相手が、変更していないファイルにあるかもしれないからです。前者は変更ファイルだけに、後者はリポジトリ全体にかける必要があります。3ファイルだけに複製検出をかけて「重複なし」と表示するツールは、誤った安心を与えるぶん、ないほうがましです。

決定的で速い。 同じ入力に対して毎回同じ結果を返し、1秒以内に終わること。LLMに複雑度を見積もらせることもできますが、実行のたびに答えが変わるツールは、その答え自体をレビューする必要が出てきて本末転倒です。

人に残る確認

ツールが「複雑度が上がった」「デッドコードが増えた」「複製ができた」を先に指摘してくれると、レビュアーはそれ以外の確認に時間を使えます。たとえば、そもそもこの変更は必要か。送料の計算を apply_discount の中に書くのが正しいか、別の関数にすべきか。max_off という名前は業務で使っている言葉と合っているか。これらはツールには判断できませんし、以前からレビューでいちばん価値のある部分でした。ただ、これまでは機械的な確認に時間を取られて、ここまで手が回らないことが多かったのです。

「レビューを減らす」のではなく、「人にしかできない確認だけを人がやる」という整理です。

CIではなくエージェントの手元で走らせる

ここまでの話はCIでも実現できますが、CIの結果を読むのが人である限り、人の時間は減りません。

効くのは、パッチを書いたエージェント自身に変更前後の数字を見せることです。プルリクエストを開く前に複雑度が7から10になったと分かれば、エージェントは自分で関数を分けてから出せます。レビュアーは悪化した版を見ずに済みます。CIは、エージェントが見落としたときの二重チェックとして置きます。

そのためにツール側に必要なのは、変更ファイルに対して1秒以内で終わること、結果をJSONなどプログラムが読める形で返すこと、エージェントがツールとして直接呼び出せることの3つです。pyscn(Python)と jscan(JavaScript/TypeScript)をCLIだけでなくMCPサーバーとAgent Skillsとしても配布しているのは、この用途のためです。

この考え方の限界

構造の指標で分かるのは構造の問題だけです。設計が適切か、名前が正しいか、その変更が必要かは分かりません。だから人のレビューは残ります。

指標は回避もできます。複雑度18の関数を、順番に呼び合う複雑度6の関数3つに割れば数字は下がりますが、読みやすさは変わりません。差分で判定するほうが現在値で判定するより回避されにくいものの、完全ではありません。エージェントが「数字を下げること」を目的にし始めたら、それはまた人がレビューで見つけるしかなくなります。

また、この記事の根拠にしている数字は、Pythonのみ、指標は複雑度とデッドコードの2つのみ、比較対象は同じバグを直した人間1人のパッチのみです。「エージェントは2つ目の確認の負担を増やす」ことは言えますが、「このゲートを入れればレビュー時間がどれだけ減るか」は測っていません。次に測りたいのはそこです。

現状

pyscnとjscanはMITライセンスで、polyscan に含まれます。CLI(pyscn check --max-complexity 15 .)、MCPサーバー、Agent Skillsの3つの形で使えます。polyscan Bot はこれらをプルリクエストに対して実行し、変更前後の差分をコメントします。現在ベータです。

Issueや意見は github.com/ludo-technologies/polyscan へ。