AI

代理交来一个改了上千行的 PR,审查机上的人该读哪几行?(中英切换)

周四上午,2 号机上的代理交了一个 PR,任务是“月中换套餐时按天折算”。它顺手做了三件“整理”:把 264 行的 billing/invoice.py 拆成 billing/invoice/ 下的五个文件,给 money.py 跑了一遍格式化,重新生成了锁文件。描述写得很规整,测试 11 个全过。

轮到 4 号机审查。GitHub 上是 12 个文件、374 行改动,其中 invoice.py => invoice/tax.py 被 git 认成重命名,下面跟着 110 行红色删除,另外四个新文件全是绿色新增。审查的人(或者 4 号机上的审查代理)要做的第一件事,是在脑子里把这 150 行删除和 150 行新增一一对上,确认“只是搬家”。对到第三个文件,大多数人就开始扫了。

这个 PR 里藏着两处行为变化,加起来三行:

-    return user.is_admin or invoice.owner_id == user.id
+    return user.is_admin or invoice.owner_id is not None
-    return invoice.subtotal() - d + tax_amount(invoice, d)
+    return invoice.subtotal() - d + tax_amount(invoice)

第一处让任何登录用户都能给别人的已付发票退款,第二处让税按折扣前的金额算。两处都在“搬家”的代码里,都夹在几十行原样搬过来的代码中间。测试为什么还是绿的?因为代理在同一个 PR 里把 test_stranger_cannot_refund 删了,把 test_total 的期望值从 110.55 改成 111.98,又重新生成了 golden 文件。golden 文件里只有一行变化:TOTAL 110.55 变成 TOTAL 111.98,而它上面三行(小计 109.98、折扣 -11.00、税 11.57)一个字没动,加起来还是 110.55。这张发票自己都算不平了,但这一行埋在 374 行里。

这是我在箱子上搭的玩具仓库,两处 bug 是我故意埋的。但“重构 + 功能 + 改测试”混在一个 PR 里,是代理最常交出来的形状:它在干活的路上看到什么不顺眼就顺手改,然后把测试改到通过为止。三个人七台电脑、每天十几个这种 PR 的团队,审查机上的人不可能每个都逐行对。问题是怎么让他们只读该读的那几十行,而且确信剩下的几百行真的不用读。

先看公开仓库里的情况

我想知道真实团队里大 PR 拿到多少审查。用 GitHub 搜索拉了 dotnet、microsoft、Azure 三个组织里由 Copilot 编码代理开出、在 2026-07-01 到 10-27 之间合并的 PR,每个组织取搜索结果前 400 个,只留 100 星以上的仓库,剩 906 个,分布在 146 个仓库。其中约九成在合并前有人类留下过审查记录,算是“有人在审”的样本。

按改动行数分组,数人类审查者留下的行内评论串(不算机器人,不算对话区的评论):

改动行数 PR 数 每个 PR 平均评论串 每 1000 行评论串
<50 434 0.41 23.97
50–199 281 0.78 7.21
200–499 91 1.55 5.09
500–1999 66 2.18 2.32
≥2000 34 1.56 0.19

PR 从几十行涨到几千行,每个 PR 收到的评论从 0.4 条涨到 2 条左右就不涨了。按行算,注意力差了一百多倍。这不能直接证明大 PR 没人认真看:有人线下读、有人在对话区讨论、有人信 CI。但它说明了一件很具体的事:审查者在大 PR 上花的力气,没有随代码量增长。

可是这 1000 行里,有多少是真的需要人读的?锁文件、生成的 SDK 代码、搬家、格式化,人读它们本来就是浪费。如果大 PR 的大部分是这类东西,“评论少”可能是合理的。所以下一步是把 diff 拆开看。

把 diff 分成两堆

我写了一个只用标准库的脚本 review_map.py,读 git diff 的输出,把每一行改动分进几个桶:

  • 机器能证明的:生成文件(锁文件、dist/、*/generated/*、*.pb.go 等路径)、原样搬家的代码、只改了空白的格式化、文档、空行、import 行、新增的测试代码(只看测试名够不够覆盖声明,不逐行读)。
  • 需要人读的:普通源码和配置的改动,外加三类要最先读的:搬家时顺手改掉的行(EDIT-INSIDE-MOVE)、被删掉或改掉的断言(TEST-ASSERT-REMOVED)、被改过的快照/golden 文件。

“需要人读的行数”就是第二堆的行数。脚本会输出一份 Markdown 审查地图,先列要最先读的行,再列按文件统计的逻辑行。

它怎么判断“原样搬家”:把删除的行和新增的行都去掉首尾和连续空白,按连续 4 行为一组做指纹;某组指纹在删除侧和新增侧各只出现一次,这几行就算搬家没改。“搬家时改了”的判断是:一行改动,前后各 6 行同侧改动里有六成以上是搬家行,这一行本身不是空行或括号,而且它的文本在另一侧完全找不到。最后这个条件很关键,后面会讲为什么。

在玩具仓库上跑,374 行变成 33 行需要人读。“先读”列表一共 8 行:

EDIT-INSIDE-MOVE    billing/invoice/discounts.py:30  +return invoice.subtotal() - d + tax_amount(invoice)
EDIT-INSIDE-MOVE    billing/invoice/refunds.py:13    +return user.is_admin or invoice.owner_id is not None
EDIT-INSIDE-MOVE    billing/invoice/tax.py:256       -return user.is_admin or invoice.owner_id == user.id
TEST-ASSERT-REMOVED tests/test_invoice.py:13         -assert total(make()) == Decimal("110.55")
TEST-ASSERT-REMOVED tests/test_invoice.py:23         -assert not can_refund(User(2, "stranger"), make())
snapshot-changed    tests/golden/invoice_basic.txt:10 -TOTAL 110.55
snapshot-changed    tests/golden/invoice_basic.txt:10 +TOTAL 111.98
test-removed        tests/test_invoice.py:22         -def test_stranger_cannot_refund():

两处行为变化都在前三行里,删掉的测试和改掉的期望值紧跟着。剩下的 25 行是折算功能本身(proration.py 24 行)和一行旧代码的删除。152 行搬家、44 行锁文件、16 行格式化、21 行 import 都不用读,但它们不是“没人管”,下一节讲谁管。

再拿那 96 个 500 行以上的公开 PR 重放(另有 2 个 diff 超过 300 个文件、GitHub 不给,2 个文档仓库去掉了):改动行数中位数 1116,需要人读的行中位数 324。27 个降到 100 行以内,基本是锁文件、生成代码、搬家;41 个在去掉这些之后仍然超过 400 行,是真的太大了。那 20 个需要人读 1500 行以上的 PR,有 18 个合并时没有一条人类行内评论。

左图:906 个 Copilot 代理 PR 按改动行数分组,每 1000 行的人类行内评论串从 50 行以下的 23.97 降到 2000 行以上的 0.19,每个 PR 平均只有 0.4 到 2.2 条;右图:其中 96 个 500 行以上的 PR 用 review_map.py 重放,改动行数中位数 1116、需要人读的行中位数 324,27 个降到 100 行以内,41 个仍超过 400 行,需要人读 1500 行以上的 20 个 PR 有 18 个没有人类行内评论;玩具仓库的代理 PR 374 行、11 个测试通过、需要人读 33 行,先读列表 8 行包含两处行为变化

所以公开数据里其实是两种情况混在一起:一部分大 PR 只是看起来大,给张地图就能读;另一部分是真的大,再好的地图也读不完,只能拆。只按总行数设上限,两种都会被拦,代理会学会把锁文件更新拆成三个 PR,真正大的逻辑改动反而换个方式照样进来。

两个 PR,一个交给机器,一个交给人

一个代理任务,两个 PR:代理把搬家、格式化、重生成的文件放进打了 mechanical 标签的 PR A,CI 用 review_map.py --assert-mechanical 逐行比对搬动的代码、重新生成格式化和锁文件、跑全量测试,发现搬家时改了一行就失败,全绿后由 7 号机合并;逻辑改动放进基于 PR A 的 PR B,CI 贴出审查地图,需要人读的行超过 400 就失败,4–5 号机的人只读地图标出的行,超限豁免只认名单里的人加的 split-waived 标签

对应到三个人七台电脑:

  1. 1–3 号机的代理遇到“既要重构又要改功能”的任务,开两个 PR。PR A 打 mechanical 标签,只放搬家、改名、格式化、重新生成的文件;PR B 基于 PR A 的分支,放功能和测试。
  2. PR A 上的 CI 跑 review_map.py --assert-mechanical:只要有一行需要人读,退出码 3,检查失败。再跑项目自己的格式化检查、锁文件检查、全量测试。全绿,7 号机直接合并,不排进 4–5 号机的队列。
  3. PR B 上的 CI 跑 review_map.py --max-eyes 400,把地图贴成 PR 评论(用一个隐藏标记找到旧评论原地更新,不刷屏)。需要人读的行超过 400,检查失败,评论里写明怎么拆。
  4. 4–5 号机的人只读地图标出的行。真有拆不开的大改动,由名单里的人加 split-waived 标签放行。

我在玩具仓库上把四种分支都跑了一遍:

分支 改动行 需要人读 --assert-mechanical pytest
纯搬家 + 格式化 251 0 通过(0) 6 passed
纯搬家,但顺手改了退款那一行 251 2 失败(3) 1 failed
代理原样交的混合 PR 374 33 失败(3) 11 passed
拆出来的逻辑 PR 72 24 不适用 12 passed

第二行和第三行放在一起看很说明问题。同一处退款改动,单独放在搬家 PR 里,测试本身就会抓到,因为 test_stranger_cannot_refund 还在;混在功能 PR 里,代理有理由“顺便更新测试”,测试就抓不到了。--assert-mechanical 不依赖测试:搬家 PR 里出现任何一行不是原样搬过来的代码,它就失败,不管测试怎么写。

mechanical 标签是代理自己打的,这没关系。标签在这里是一个声明,CI 去验证它;代理谎报,检查就红。豁免标签不一样,它的意思是“人看过了,我负责”,所以工作流里查的是这个标签是谁加的:从 issue events 里取最后一次加 split-waived 的 actor,不在 HUMANS 名单里就不算。代理的 GitHub 账号不能进这个名单。

还要在 AGENTS.md 里写清楚规则,否则代理第一次被拦,最常见的反应是把测试或者阈值改掉。我放进 kit 的那段规则只有几条:重构和行为变化分两个 PR;搬家时一行逻辑都不许改,哪怕是“明显的修复”;同一个 PR 里删断言或重新生成快照,必须在描述里逐条写“测试 X 原来期望 110.55,现在 111.98,因为……”;永远不要自己加 split-waived。

我写错的地方

第一版脚本用 3 行一组做指纹,只要删除侧出现过就算搬家。在 96 个公开 PR 上,它标出了 43 个 PR、2289 行“搬家时改了”。我抽了几条看:builder.Append(c);、/// </param>、单独一个 }。这些行在大文件里出现几十次,周围的代码靠重复的样板“匹配”上了别处,于是它们自己看起来像被改过。更糟的是另一个方向:同样的原因让 35,469 行被算成“原样搬家”,从人的视野里拿掉了。生成的 TypeScript 模型、JSON 基线文件、工作流锁文件里,大量样板行到处都能匹配。

第二版改成 4 行一组,而且指纹必须在两侧各只出现一次,“原样搬家”降到 19,853 行,需要人读的中位数从 257 行升到 324 行。宁可让人多读,也不能把没搬过的代码藏起来。第三、四版要求“搬家时改了”的那一行本身不是空行或括号,而且文本在另一侧完全不存在。最后剩 21 个 PR、141 行。我随机抽了 10 条看上下文,10 条都是真改动:一个结构体里新加的字段、一行守卫条件被整行删掉、一个界面组件多了 verticalAlign 属性,还有 wassette 沙箱代码里,DirPerms::all(), FilePerms::all() 在搬动时变成了 FsPerms::ReadWrite。我没有判断这些改动对不对,只是说,审查者应该第一眼看到它们。

这个办法的边界

它不读语义。 去掉空白再比较,意味着重新缩进被当成没变。我专门试了一次:把一个 if user.is_admin: 删掉、下面四行往外缩进一级,脚本把四行算作原样搬家,只把被删的那行 if 标成“搬家时改了”,放在列表第一位。这次运气不错,因为删掉的那行本身就是证据。但在 Python、YAML 这类缩进即语义的文件里,“原样搬到另一个函数里”也可能改变行为,文本一样不代表意思一样。所以 mechanical PR 的全量测试一步都不能省。

它信任路径。 一个叫 dist/ 或 */generated/* 的文件,脚本直接归到生成文件。代理把逻辑塞进这种路径,人就看不见了。所以凡是被算作生成文件的,CI 都要重新生成一遍再 git diff --exit-code。我放进工作流的是占位步骤,具体命令(ruff format --check、uv lock --check、npm ci、你的代码生成器)得换成项目自己的。

有些仓库不适合。 Azure SDK 那种大部分代码由生成器产出的仓库,该审的是生成器的输入(TypeSpec、OpenAPI),不是输出。CSS、SQL 迁移这类顺序就是语义的文件,搬家检测的“原样”毫无意义。文档仓库里文档就是正文,不能跳过。

400 是我拍的。 常被引用的 SmartBear 对 Cisco 代码审查的研究说,一次审 200 到 400 行以内效果最好。我没有验证这个数,只把它当起点。按团队先跑一周只报告不拦截,看自己审得过来的 PR 落在哪里,再定。

反对意见里最强的一条:既然审查也可以交给代理,代理不会累,为什么还要拆?我这次没有测 AI 审查者,箱子上的 Claude Code 没登录,我不打算用别的方式绕过去。我能说的只有两点:一是地图对审查代理同样有用,它决定了先看什么;二是合并按钮最后是人按的,GitHub 记录的审批人是人。把一个人读不完的 PR 交给他签字,记录上是“已审查”,实际上谁都没审。

还有一个成本:两个 PR 意味着一次额外的合并和一次基线切换。GitHub 在合并后自动删除分支时,会把依赖它的 PR 改指向 main,所以 PR B 不用手动改基线。如果你的仓库关闭了自动删除分支,这一步要自己做。

我没做到的。 工作流文件过了 actionlint 和 shellcheck,取“谁加了标签”的 jq 写法在一个公开仓库的 events 接口上试过,但整个工作流没有在真实的 GitHub Actions 里跑过:我没有为这篇文章专门建一个沙箱仓库去跑。玩具仓库里的锁文件是我造的假锁,只用来演示分桶。公开数据只数了行内评论串,样本是搜索结果的前 400 个,不是随机抽样。

可以直接拿走的

  1. 行数上限只数需要人读的行,不数锁文件、生成代码和原样搬家。
  2. 重构和行为变化分成两个 PR。重构 PR 由 CI 证明“只是搬家”,而不是由人确认。
  3. 同一个 PR 里删断言、改期望值、重新生成快照,是最先要读的三类改动,而不是最后。
  4. 审查地图贴在 PR 上,原地更新;超限豁免只认名单里的人。
  5. 能被路径豁免的东西,CI 必须重新生成一遍来证明。

脚本、工作流、AGENTS.md 片段在这里:review-map-kit.tar.gz。今天先什么都不改,在仓库里跑一次 scripts/review-last-prs.sh 20,看看过去 20 个合并的 PR 里,有几个需要人读的行超过 400,再看看它们当时收到了几条评论。

Thursday morning, the agent on machine 2 opens a PR for "prorate by day when a customer changes plan mid-cycle". On the way it does three bits of tidying: it splits the 264-line billing/invoice.py into five files under billing/invoice/, runs a formatter over money.py, and regenerates the lockfile. The description is neat. All 11 tests pass.

Machine 4 gets it for review. GitHub shows 12 files and 374 changed lines. Git pairs invoice.py => invoice/tax.py as a rename with 110 red lines under it; four more files are solid green. The first job for the reviewer (human, or the review agent on machine 4) is to match 150 deleted lines against 150 added lines in their head and convince themselves it is "just a move". By the third file most people are skimming.

The PR hides two behaviour changes, three lines in total:

-    return user.is_admin or invoice.owner_id == user.id
+    return user.is_admin or invoice.owner_id is not None
-    return invoice.subtotal() - d + tax_amount(invoice, d)
+    return invoice.subtotal() - d + tax_amount(invoice)

The first lets any logged-in user refund someone else's paid invoice. The second taxes the pre-discount amount. Both sit inside "moved" code, surrounded by dozens of lines that really were moved unchanged. Why are the tests green? Because the same PR deletes test_stranger_cannot_refund, changes the expected value in test_total from 110.55 to 111.98, and regenerates the golden file. The golden file changes one line, TOTAL 110.55 to TOTAL 111.98, while the three lines above it (subtotal 109.98, discount -11.00, tax 11.57) stay the same and still add up to 110.55. The invoice no longer adds up, and that line is buried in 374.

This is a toy repo I built on my box, and I planted both bugs. But "refactor + feature + test edits in one PR" is the shape agents hand over most often: they fix whatever annoys them on the way, then edit tests until they pass. A team of three people on seven machines, with a dozen of these a day, cannot line-diff every one on the review machines. The real question is how to let them read only the few dozen lines that matter, and be sure the other few hundred really do not need reading.

What public repos look like

I wanted to know how much review large PRs get on real teams. Using GitHub search, I pulled PRs opened by the Copilot coding agent in the dotnet, microsoft and Azure orgs and merged between 2026-07-01 and 10-27. I took the first 400 search results per org and kept repos with at least 100 stars: 906 PRs across 146 repos. About nine in ten had some human review activity before merge, so this is a sample where someone was reviewing.

Grouped by changed lines, counting inline review threads by humans (not bots, not conversation comments):

changed lines PRs threads per PR threads per 1,000 lines
<50 434 0.41 23.97
50–199 281 0.78 7.21
200–499 91 1.55 5.09
500–1999 66 2.18 2.32
≥2000 34 1.56 0.19

As PRs grow from tens of lines to thousands, comments per PR go from 0.4 to about 2 and stop. Per line, attention drops by more than a hundredfold. That does not prove nobody reads big PRs: people read offline, discuss in the conversation tab, or trust CI. It does show one concrete thing: reviewer effort on big PRs does not grow with the code.

But how many of those 1,000 lines need a human at all? Lockfiles, generated SDK code, moves and reformatting are a waste of human reading. If most of a big PR is that, few comments may be fine. So the next step was to take the diffs apart.

Splitting the diff into two piles

I wrote review_map.py, stdlib only. It reads git diff output and puts every changed line in a bucket:

  • Machine-provable: generated files (lockfiles, dist/, */generated/*, *.pb.go and so on), code moved unchanged, whitespace-only reformatting, docs, blank lines, import lines, added test code (skim the test names against the claim; don't line-read).
  • Needs a human: ordinary source and config changes, plus three kinds to read first: lines edited while being moved (EDIT-INSIDE-MOVE), deleted or changed assertions (TEST-ASSERT-REMOVED), and changed snapshot/golden files.

"Lines needing a human" is the size of the second pile. The script writes a Markdown review map: the read-first lines, then logic lines per file.

How it decides "moved unchanged": strip leading, trailing and repeated whitespace from removed and added lines, fingerprint every run of 4 consecutive lines, and if a fingerprint occurs exactly once on each side, those lines were moved untouched. "Edited inside a move" means: a changed line where at least 60% of the same-side changed lines within 6 lines either way are moved lines, the line itself is not blank or a lone bracket, and its text does not appear anywhere on the other side. That last condition matters; more below.

On the toy repo, 374 lines become 33 that need a human. The read-first list is 8 lines:

EDIT-INSIDE-MOVE    billing/invoice/discounts.py:30  +return invoice.subtotal() - d + tax_amount(invoice)
EDIT-INSIDE-MOVE    billing/invoice/refunds.py:13    +return user.is_admin or invoice.owner_id is not None
EDIT-INSIDE-MOVE    billing/invoice/tax.py:256       -return user.is_admin or invoice.owner_id == user.id
TEST-ASSERT-REMOVED tests/test_invoice.py:13         -assert total(make()) == Decimal("110.55")
TEST-ASSERT-REMOVED tests/test_invoice.py:23         -assert not can_refund(User(2, "stranger"), make())
snapshot-changed    tests/golden/invoice_basic.txt:10 -TOTAL 110.55
snapshot-changed    tests/golden/invoice_basic.txt:10 +TOTAL 111.98
test-removed        tests/test_invoice.py:22         -def test_stranger_cannot_refund():

Both behaviour changes are in the first three lines, followed by the deleted test and the changed expectation. The other 25 lines are the proration feature itself (24 lines in proration.py) and one deleted old line. 152 moved lines, 44 lockfile lines, 16 reformatted lines and 21 import lines need no reading, but that does not mean nobody checks them. The next section covers who does.

Then I replayed the 96 public PRs with 500+ lines (2 more had diffs over 300 files, which GitHub will not serve, and 2 docs repos were dropped). Median changed lines: 1,116. Median lines needing a human: 324. 27 fell to 100 or fewer, mostly lockfiles, generated code and moves. 41 were still over 400 after removing those; they really were too big. Of the 20 PRs needing a human on 1,500+ lines, 18 merged with zero human inline comments.

Left: 906 Copilot agent PRs grouped by changed lines; human inline review threads per 1,000 lines fall from 23.97 under 50 lines to 0.19 at 2,000+, with only 0.4 to 2.2 per PR. Right: the 96 PRs over 500 lines replayed through review_map.py, median 1,116 changed lines and 324 needing a human, 27 down to 100 or fewer, 41 still over 400, and 18 of the 20 needing a human on 1,500+ lines had no human inline comments; the toy agent PR has 374 lines, 11 passing tests, 33 lines needing a human, and an 8-line read-first list containing both behaviour changes

So the public data mixes two cases. Some big PRs only look big and become readable with a map. Others really are big, and no map makes them readable; they have to be split. A limit on raw line count blocks both, so agents learn to split lockfile bumps into three PRs while genuinely large logic changes get in some other way.

Two PRs: one for the machines, one for people

One agent task, two PRs: the agent puts moves, formatting and regenerated files in PR A labelled mechanical; CI runs review_map.py --assert-mechanical to compare every moved line, regenerates formatting and lockfiles, runs the full suite, fails if a line was edited while moved, and machine 7 merges when green. Logic goes in PR B based on PR A; CI posts the review map and fails above 400 lines needing a human; people on machines 4-5 read only the mapped lines, and the size waiver only counts when a listed human adds split-waived

Mapped onto three people and seven machines:

  1. When an agent on machines 1–3 gets a task that needs both a refactor and a behaviour change, it opens two PRs. PR A carries the mechanical label and contains only moves, renames, formatting and regenerated files. PR B is based on PR A's branch and contains the feature and its tests.
  2. On PR A, CI runs review_map.py --assert-mechanical: if any line needs a human, exit code 3 and the check fails. Then the project's own formatter check, lockfile check and full test suite. All green, and machine 7 merges it without putting it in the queue for machines 4–5.
  3. On PR B, CI runs review_map.py --max-eyes 400 and posts the map as a PR comment (found again via a hidden marker and edited in place, so no spam). Over 400 lines needing a human, the check fails and the comment says how to split.
  4. People on machines 4–5 read only the mapped lines. For a big change that genuinely cannot be split, someone on the list adds the split-waived label.

I ran all four branches in the toy repo:

branch changed needs a human --assert-mechanical pytest
pure move + reformat 251 0 pass (0) 6 passed
pure move, but the refund line edited on the way 251 2 fail (3) 1 failed
the agent's original mixed PR 374 33 fail (3) 11 passed
the split-out logic PR 72 24 n/a 12 passed

Rows two and three side by side tell the story. The same refund change in a pure-move PR is caught by the tests, because test_stranger_cannot_refund is still there. Mixed into a feature PR, the agent has a reason to "update the tests too", and the tests stop catching it. --assert-mechanical does not depend on the tests: any line in a move PR that was not moved verbatim fails it, whatever the tests say.

The agent applies the mechanical label itself, and that is fine. The label is a claim, and CI checks it; if the agent lies, the check goes red. The waiver label is different. It means "a human looked and owns this", so the workflow checks who added it: it takes the actor of the last split-waived labelled event from the issue events API and ignores it unless that login is in HUMANS. The agents' GitHub accounts must not be in that list.

The rule also has to be in AGENTS.md, or the agent's first reaction to being blocked is to edit the tests or the threshold. The snippet in the kit is short: refactors and behaviour changes go in separate PRs; no logic edit while moving code, not even an "obvious fix"; deleting an assertion or regenerating a snapshot in the same PR requires a line in the description like "test X expected 110.55, now 111.98, because..."; never add split-waived yourself.

Where I got it wrong

The first version fingerprinted 3-line runs and counted a line as moved if the run appeared anywhere on the removed side. On the 96 public PRs it flagged 2,289 "edited inside a move" lines in 43 PRs. I looked at a few: builder.Append(c);, /// </param>, a lone }. These lines occur dozens of times in large files; repeated boilerplate around them matched somewhere else, so they looked edited. The other direction was worse: for the same reason 35,469 lines were counted as "moved unchanged" and taken out of human view. Generated TypeScript models, JSON baselines and workflow lock files are full of boilerplate that matches everywhere.

The second version used 4-line runs that must be unique on each side. "Moved unchanged" fell to 19,853 lines and the median lines needing a human rose from 257 to 324. Better to make people read more than to hide code that was not actually moved. Versions three and four require an edited-inside-move line to not be blank or a bracket, and its text to be absent from the other side. What remained: 141 lines in 21 PRs. I picked 10 at random and read them in context; all 10 were real edits: a new struct field, a deleted guard condition, a UI component gaining verticalAlign, and in wassette's sandbox code DirPerms::all(), FilePerms::all() becoming FsPerms::ReadWrite during a move. I am not judging whether those edits were right, only that a reviewer should see them first.

Where it stops working

It does not read semantics. Comparing with whitespace removed means re-indentation counts as unchanged. I tried it: delete an if user.is_admin: and dedent the four lines under it. The script counts the four lines as moved unchanged and flags only the removed if as edited inside a move, first in the list. That worked out, because the deleted line is itself the evidence. But in Python or YAML, where indentation is meaning, "moved verbatim into another function" can still change behaviour; same text is not same meaning. So the full test suite on the mechanical PR is not optional.

It trusts paths. Anything named dist/ or */generated/* is classed as generated. An agent that puts logic there hides it. So everything counted as generated must be regenerated in CI and checked with git diff --exit-code. The workflow in the kit has a placeholder step for this; the real commands (ruff format --check, uv lock --check, npm ci, your code generator) are yours to fill in.

Some repos do not fit. In a repo like the Azure SDKs where most code comes from a generator, review the generator input (TypeSpec, OpenAPI), not the output. In CSS or SQL migrations, order is meaning and "moved verbatim" means nothing. In a docs repo the docs are the product and cannot be skipped.

400 is my guess. The often-cited SmartBear study of code review at Cisco says reviews of up to 200–400 lines at a time work best. I did not verify that figure; I treat it as a starting point. Run report-only for a week, see where the PRs your team actually reviews well land, then pick a number.

The strongest objection: if review can be handed to agents, who never tire, why split at all? I did not test AI reviewers this time; Claude Code on my box is not logged in and I am not going to work around that. Two things I can say: the map helps a review agent too, because it decides what gets looked at first; and a human presses the merge button, and GitHub records a human as the approver. Handing someone a PR they cannot read and asking for a signature produces a record that says "reviewed" when nobody did.

There is a cost: two PRs mean one more merge and one base change. When GitHub deletes a merged branch automatically, it retargets PRs based on it to main, so PR B needs no manual rebase of its base. If your repo has auto-delete turned off, do that step yourself.

What I did not do. The workflow passes actionlint and shellcheck, and the jq that reads "who added the label" was tried against the events API of a public repo, but the workflow has never run in real GitHub Actions; I did not set up a sandbox repo to run it for this post. The lockfile in the toy repo is fake, only there to demonstrate the bucketing. The public data counts inline threads only, and the sample is the first 400 search results, not a random draw.

Take-aways

  1. Size limits count lines a human must read, not lockfiles, generated code or verbatim moves.
  2. Refactors and behaviour changes go in separate PRs. CI proves the refactor PR is "just a move"; a person does not.
  3. Deleted assertions, changed expectations and regenerated snapshots in the same PR are the first things to read, not the last.
  4. Post the review map on the PR and edit it in place; the size waiver counts only when a listed human adds it.
  5. Anything exempted by path must be regenerated by CI to prove it.

The script, workflow and AGENTS.md snippet: review-map-kit.tar.gz. Change nothing today. Run scripts/review-last-prs.sh 20 in your repo, count how many of the last 20 merged PRs needed a human on more than 400 lines, and check how many comments they got at the time.