AIレビューで大きいPRを分割する単位をトークン量ではなく依存から決める

1回に渡しきれない差分を分けるとき、ファイル数で等分するのが最も手早い。30ファイルの差分を10ファイルずつ3回に渡した場合、ファイル間の直接の依存33本のうち17本が塊の境界で切れた。切れた側に、1件の指摘が成立するのに必要な2ファイルの組が2件含まれている。分割の線は量ではなく、1つの指摘が根拠にするファイルの集合で引く。

この記事の提案を使うのは、差分が1回に渡しきれないか、渡せても指摘が薄くなるときである。

10ファイルずつ渡すと落ちるもの

題材にした差分には、単位の変更が3件含まれている。そのうち1件を引く。金額の内部表現を、円の整数から通貨ごとの最小単位へ変える変更である。

billing/money.py(差分の並びで5番目)
-def to_minor_units(amount: int) -> int:
-    """表示用の金額を内部表現へ変換する。JPY は等倍である。"""
-    return amount
+def to_minor_units(amount: int, currency: str = CURRENCY) -> int:
+    """表示用の金額を内部表現へ変換する。通貨の補助単位の倍率を掛ける。"""
+    return amount * factor(currency)

この関数を呼ぶ側のうち1箇所が、同じ差分の中でこうなっている。

reporting/export.py(差分の並びで19番目)
-def rows() -> list[str]:
+def rows(currency: str = CURRENCY) -> list[str]:
     """外部システムへ渡す行。外部は cent 単位で受け取る(JPY は等倍なので 100 倍する)。"""
     out = []
     for item in CATALOG:
-        cents = to_minor_units(item["price"]) * 100
+        cents = to_minor_units(item["price"], currency) * 100

to_minor_units が倍率を掛けるようになったので、* 100 は二重の変換になった。JPY の倍率は1なので既存のテストは通り、この PR が足した USD の経路でだけ値が100倍に膨らむ。実際に呼ぶと次の値が返る(Python 3.12.3、2026-10-11 に実行)。

変更後の題材で rows() を呼んだ結果
rows('JPY') = ['W-1,80000', 'G-2,250000']     ← 既存のテストが見ている値
rows('USD') = ['W-1,8000000', 'G-2,25000000']

この指摘は、どちらか一方のファイルだけでは書けない。billing/money.py だけを見れば倍率を掛ける実装は正しく、reporting/export.py だけを見れば * 100 には「外部は cent 単位で受け取る」という理由が書かれている。2つを並べたときに初めて、理由が成立しなくなったことが分かる。

30ファイルをファイル名の順に10ファイルずつ3つに分けると、billing/money.py は1つ目の塊に入り、reporting/export.py は2つ目の塊に入る。どちらの塊にも、この指摘を書くための材料がそろっていない。

題材の差分(30ファイル・203行)

注文・請求・配送を扱う Python のリポジトリに、「通貨を JPY 固定から通貨ごとの最小単位へ変える」PR を当てた。変更前は32ファイル、差分は30ファイル・203行(書き換え24・新規6)である。差分の中のファイル同士の直接の import は33本ある。

仕込んだ弱点は3件で、どれもテストでは出ない。変更前は9件、変更後は17件のテストがすべて成功する(python3 -m unittest discover、2026-10-11)。読んで切る話を測る題材なので、実行すれば分かる弱点は置いていない。

弱点 中身 必要なファイル(差分の並び順)
1 単位の二重変換。倍率が1の通貨では既存のテストが通る billing/money.py(5)と reporting/export.py(19)
2 項目の鍵 title を name へ変えたのに、配送ラベルが旧名で読む catalog/schema.py(8)と shipping/label.py(21)
3 配送料の区分で <= を < に変え、コメントの「上限を含む」と合わなくなった shipping/rates.py(22)と tests/test_rates.py(28)

弱点2は、鍵の定義と読み出しが別のパッケージにある。

catalog/schema.py(8番目)
 """カタログの項目の鍵の定義。ここが唯一の定義である(約束2)。"""
 
-FIELDS = ("sku", "title", "price", "weight_g")
+FIELDS = ("sku", "name", "price", "weight_g")
shipping/label.py(21番目)
-def label_for(sku: str, qty: int) -> str:
+def label_for(sku: str, qty: int, postal: str = "130000") -> str:
     item = find(sku)
     fee = fee_for(item["weight_g"] * qty)
-    return f"{item['title']} x{qty} / {fee}"
+    return f"{item['title']} x{qty} / {fee} / {zone_of(postal)}"

label_for にはテストが無いので、この取り残しは実行では出ない。呼べば KeyError('title') になる。

弱点3だけは性質が違う。実装とテストが並んで差分に入っている。

shipping/rates.py(22番目)と tests/test_rates.py(28番目)
-        if weight_g <= limit:  # 上限を含む
+        if weight_g < limit:  # 上限を含む
---
-        # 1000g は軽量の区分に含む(約束3)
-        self.assertEqual(fee_for(1000), 300)
+        # 1000g は中量の区分になった
+        self.assertEqual(fee_for(1000), 600)

実装の変更に合わせてテストの期待値を書き換えているので、テストは通る。この2ファイルは差分の並びが近く、10ファイルずつに切っても同じ塊に残る。等分でも見つかる弱点の対照として置いた。

等分した3通りで切れた依存の数

分割の良し悪しを、指摘の件数ではなく切れた依存の本数で測る。差分の中のファイル同士の直接の import を数え、塊の境界をまたいだものを切断面と呼ぶ。切断面の向こう側にあるファイルは、そのパスでは渡っていない。

ファイル名の順に等分した3通りを数えた。

切り方 塊の数 最大の塊 切断面 弱点1 弱点2 弱点3
15ファイルずつ2分割 2 15 ファイル 13/33 分断 分断 同居
10ファイルずつ3分割 3 10 ファイル 17/33 分断 分断 同居
6ファイルずつ5分割 5 6 ファイル 20/33 分断 分断 分断

分割数を増やすと切断面は 13 → 17 → 20 と増える。そして弱点1と弱点2は、どの分割数でも分断された。等分の線はファイル名の並びで決まるのに対して、指摘に必要な2ファイルは別のパッケージにあるので、並びの上では離れている。パッケージごとにディレクトリを分ける慣習があるかぎり、名前の順で切る線は依存の構造と無関係になる。

1ファイルあたりの差分の行数が均されていないことも表に出ている。10ファイルずつの3分割では、塊ごとの差分は42行・79行・82行になった。ファイル数で等分しても行数では等分にならないので、量を均すのが目的なら、量そのもので切る必要がある。量で切っても切断面は減らない。

依存で切ると何が同じ集合に残るか

切る手順を変える。差分の中のファイルだけを頂点にして、import の関係で辺を張り、辺の多いファイルを種にして集合を育てる。種に、いまの集合といちばん多く辺を持つファイルを順に足し、上限に達したら次の種へ移る。

この形で上限を8ファイルにして切ると、7集合になり、切断面は33本のうち13本に減った。ただし弱点2は分断されたままである。

原因は依存の段数にある。shipping/label.py は catalog/items.py を import していて、catalog/items.py が catalog/schema.py を import している。鍵の定義までは2段ある。直接の import だけを辺にすると、label.py と schema.py のあいだに辺が無いので、同じ集合へ入る理由が立たない。

辺を張る段数を2段に広げると、3件すべてが同じ集合に入った。

切り方 集合の数 最大の集合 切断面 弱点1 弱点2 弱点3
依存・1段・上限8 7 10 ファイル 13/33 同居 分断 同居
依存・1段・上限12 5 14 ファイル 12/33 同居 分断 分断
依存・2段・上限8 7 10 ファイル 12/33 同居 同居 同居
依存・2段・上限10 8 12 ファイル 8/33 同居 分断 同居

2段・上限8のときの集合のうち、弱点に関わる2つを示す。

依存・2段・上限8 で作った集合(7集合のうち2つ)
[2]  9 ファイル / 差分 91 行
     billing/currency.py, billing/money.py, catalog/items.py, orders/cart.py,
     orders/checkout.py, orders/validate.py, reporting/export.py, …
[4] 10 ファイル / 差分 59 行
     catalog/schema.py, catalog/search.py, catalog/stock.py, reporting/summary.py,
     shipping/label.py, shipping/rates.py, shipping/zones.py, …

集合2が弱点1の2ファイルを持ち、集合4が弱点2の2ファイルと弱点3の2ファイルを持つ。

切断面はゼロにならない。 30ファイルのうち28ファイルが1つのかたまりにつながっているので、1集合を8から12ファイルに抑えるかぎり、どこかの依存は切れる。上限を広げれば切断面は減るが、1回に渡す量が増えて、分割した理由が消える。

表の4行目には、上限を8から10へ広げたのに弱点2が分断された行がある。集合の育て方が貪欲なので、上限を広げると種の選ばれ方が変わり、同居していた組が別の集合に落ちることがある。上限を上げれば安全になるという読み方はできない。切る条件を変えたら、根拠がそろっていてほしいファイルの組が同じ集合に入っているかを、その条件で確かめる必要がある。

切る手順

3手順になる。1と2は機械で、3は指示文に書く。

手順1 抽出。 差分に出てくるファイルを読み、import を取る。リポジトリ内のモジュールを指すものだけを辺にし、標準ライブラリと外部パッケージは無視する。段数は2段から始める。鍵や単位の定義を1段奥に持つ指摘は、1段だけでは両端が別の集合に落ちる。

手順2 集合の作成。 辺の多いファイルを種にして、いまの集合といちばん多く辺を持つファイルを上限まで足す。つながるファイルが無くなったら次の種へ移る。上限に達したあとで隣が全部埋まったファイルは、隣がいちばん多い集合へ寄せる。1ファイルだけのパスを増やすより、1つの集合を数ファイル大きくするほうが切断面も実行回数も減る。

手順1と2を実行する形にすると、集合の一覧と切断面の一覧が出る。核になるのは育てる部分である。

集合を育てる部分(adjacency は差分の中のファイル同士の辺)
while unassigned:
    seed = max(sorted(unassigned), key=lambda n: len(adjacency[n] & unassigned))
    group = {seed}
    unassigned.discard(seed)
    while len(group) < max_files and unassigned:
        best = max(sorted(unassigned),
                   key=lambda n: (len(adjacency[n] & group),
                                  len(adjacency[n] & unassigned)))
        if not adjacency[best] & group:
            break           # いまの集合につながるファイルが残っていない
        group.add(best)
        unassigned.discard(best)
    groups.append(sorted(group))

手順3 宣言。 切断面が残るので、集合の外に根拠があるときの扱いを指示文に書く。渡す範囲を宣言する欄と、指摘ごとに根拠の所在を書かせる欄を足す。

1集合ぶんの指示文に足す3つの欄
## 対象の範囲
次に貼る差分だけをレビューする。これはより大きい変更の一部で、ここに含まれないファイルは渡していない。

## 出力項目
指摘1件ごとに次の3行を書く。1件も無ければ「指摘なし」と書く。
- 場所: <ファイル:行>
- 主張: <何が問題か。一文>
- 根拠: 集合内 / 要追加(<渡されていないファイルのパス>)

## 対象外の宣言
渡されていないファイルの中身を推測して書かない。推測が必要なら「要追加」にしてパスを挙げる。

「根拠: 要追加」が付いた指摘は、そのパスでは判定できない。パスを全部終えたあとで、挙がったパスを足して1回だけ追加で渡す。この欄が無いと、集合の外にあるファイルの中身を埋めた指摘が混ざり、どれが集合内で確かめられた指摘なのかを後から区別できない。

集合を増やすと何を使うか

払うのはパスの数である。30ファイルの差分は、2段・上限8で7集合になった。1回で渡していたものが7回になり、実行時間と課金はおよそ7倍になる。切断面が12本残るので、「要追加」の指摘を回収する追加のパスが最大で1回増える。等分の3分割(3回)と比べると、回数は2倍以上になる。

採否の判定にかかる人の時間も、集合の数だけ分かれる。1集合あたりの出力を読むのに3分かかるとすると、3分割なら9分、7集合なら21分である。切断面が減った分は、同じ指摘が2つのパスから別の形で出てくるのを防ぐので、読み直しは減る。それでも合計の時間は増える。

この提案を落としていい条件は、差分の中のファイル同士に依存が無いときである。 設定ファイルだけの差分、翻訳ファイルの一括更新、生成物の差し替えのように、ファイル同士が import で結ばれていない差分では、辺が1本も立たないので集合は作れない。その場合はファイル数で等分してよい。切る前に辺の本数を数えて、0本なら等分に戻す。

指示文の点検

差分を分けるときに確かめる項目
  • 分割の線を、ファイル数やトークン量ではなく依存で引いたか
  • 差分の中のファイル同士の辺を、何段まで辿って作ったか決めたか
  • 鍵や単位の定義を1段奥に持つファイルが、定義と同じ集合に入っているか
  • 根拠がそろっていてほしいファイルの組を挙げ、その条件で同居を確かめたか
  • 上限を変えたあとに、同居の確認をやり直したか
  • 1集合ぶんの指示文に「ここに含まれないファイルは渡していない」と書いたか
  • 指摘1件ごとに根拠の所在(集合内か要追加か)を書かせているか
  • 「要追加」で挙がったパスを足して、追加のパスを1回走らせる段取りがあるか
  • 渡されていないファイルの中身を推測して書かないと宣言したか
  • 集合の数だけ実行時間と課金が増えることを見積もったか
  • 辺が1本も立たない差分で、依存による分割を使っていないか

おわりに

30ファイル・203行の差分では、ファイル名の順に等分すると直接の依存33本のうち13本から20本が切れ、1件の指摘に必要な2ファイルの組は分割数を変えても分断された。等分の線はファイル名の並びで決まり、指摘に必要なファイルは別のパッケージにあるからである。

import の関係で集合を育てる形に変えると、2段まで辿って1集合8ファイルに抑えたとき、3件の弱点の組がすべて同じ集合に入り、切断面は12本になった。切断面はゼロにならないので、集合の外に根拠があるときの扱いを指示文に書く段が残る。

次に取る行動を1つ挙げる。手元の大きい差分について、同じ集合に入っていてほしいファイルの組を2つか3つ書き出す。単位を変えた定義とその呼び出し元、鍵の定義とその読み出しが候補になる。その組が同じ塊に入るかどうかで、いまの分割の線を点検できる。