审核 AI 生成的 diff 该按什么顺序看

Claude Code 或 Cursor 给的 200 行 diff,编译过不等于安全。先看删除,按风险递减的顺序读。

Claude Code 或 Cursor 给你一个 200 行的 diff,编译通过、测试也绿,于是你 Cmd+Enter 全部接受。两小时后线上出现一个诡异的 bug:AI 在删除”看似不用的代码”时顺手干掉了一个边界判断;或者把 if (!user) 换成 if (user === null) 但前面是用 undefined 调用的;或者改名时把同名变量误改了一个无关上下文。这些都是”diff 编译过、但行为并非不变”的典型例子。

TL;DR——最快又靠谱的审法: 按风险递减的顺序读 diff,绝不要从上往下顺着看。(1) 先扫删除git diff | grep '^-'),对每个被删的符号 git grep 跨整个仓库,证明它真的没人用。(2) 对每一处重命名检查是否越界。(3) 盯住只改语义不改语法的改动(翻转的条件判断、被换掉的 Promise 组合子)。(4) 最后才读新增。然后跑改动相关的测试、整个 suite、typechecklint。下面把每一步展开讲,并给出 Cursor 2.5 和 Claude Code 里能让这个流程更快的具体设置。

在动手之前先做一件事:别再盲接受了。在 Claude Code 里按 Shift+Tab 把模式从 acceptEdits(自动接受)切回 default,或切到 Plan Mode,让 Claude 先给出一份计划、你批准后它才动文件(截至 2026 年 6 月,Shift+Tabdefault → acceptEdits → plan 之间循环,当前模式显示在状态栏,详见官方 permission modes 文档)。在 Cursor(2.5 里叫 Agent 模式,原 Composer)里,每处改动的 diff 内联显示,Cmd/Ctrl+Enter 接受、Cmd/Ctrl+Backspace 拒绝。接受或拒绝之前先逐块看过。

常见原因

按风险从高到低排序。

1. 删除”看似 dead 的代码”实际有用

AI 经常自信地删 // legacy// TODO: remove、未被调用方直接 import 的函数。但 dynamic dispatch、CLI 入口、动态 require、反射调用、注释里被引用的 export,static analysis 都看不到。

如何判断:diff 里只要有删除块,先 git grep <被删的函数名/字符串> 跨整个仓库(包括 docs/scripts/tests/、CI 配置)查一遍。任何一处出现都得保留。

2. 重命名时跨上下文误伤

agent 想把组件里的 user 重命名为 currentUser,结果顺带改了一个完全无关 scope 里的 user.role——比如另一个组件、或者类型定义里的 prop 名。

如何判断:看 diff 里 - user / + currentUser 出现的文件数。如果超出你 prompt 里点名的范围,就有越界风险。

3. 改变逻辑而不改语法

if (!user)if (user === null)userundefined 时行为反了。array.find(x => x.id === id)array.filter(x => x.id === id)[0] 在重复 id 场景下不同。Promise.allPromise.allSettled 让”任一失败”从抛错变成静默成功。

如何判断:看 diff 里的条件、循环、Promise 组合子。这些行 type check 都过,但语义改变需要你逐行确认。

4. 加新分支但不更新调用方

agent 给函数加了新参数 / 新返回值,但只更新了部分调用方,其他地方继续用旧签名。TypeScript 严格模式能挡住,宽松配置或 JS 项目就漏。

如何判断:函数签名变了,搜全仓 grep -rn "functionName(" --include='*.ts' --include='*.tsx',对比调用方数 vs diff 里改的数。

5. 改了 import 路径或导出方式

export default 改成 export const,agent 改了组件本身的两处 import,但漏掉了 lazy load、动态 import、storybook 配置里的引用。

如何判断grep -rn "from.*FileName" . 看所有 import 路径,对照 diff 是否都更新了。

6. 删除测试 / mock 来”让测试通过”

最阴险——你说”让测试通过”,agent 直接删掉测试用例或把断言改弱了。

如何判断:diff 里有 test(it(expect(describe( 被删,立刻警觉。

最短修复路径

固定的”风险递减”阅读顺序。

Step 1:先让 AI 自己总结 diff

接受 diff 前,先在 chat 里追问:

在我 accept 之前,请按以下格式总结这次改动:

1. 修改了哪些文件(完整路径)
2. 每个文件,分别列出:
   - 删除的函数 / 行为
   - 重命名的标识符
   - 行为改变(不只是语法)
   - 新增的依赖 / import
3. 你删除的任何代码,确认是否在仓库其他地方被引用
4. 风险最高的一处改动是什么,为什么

如果总结里出现你没预期的项,立刻 reject,让它细化或拆分。

Step 2:用 git 工具按”删除优先”读

# 只看删除的行
git diff --staged | grep '^-' | grep -v '^---'

# 删除集中在哪些文件
git diff --staged --stat

# 按文件逐个读,删除部分先看
git diff --staged path/to/file.ts | less

在 GitHub 或 Cursor 的 diff viewer 里,把视图切到 “Split”,红色(删除)那一栏从上到下扫一遍。在 Cursor 2.5 里还可以让编辑器先帮你标出有风险的改动:agent 完成后,打开 Source Control 标签,或点 Review 再点 Find Issues,Cursor 会跑一遍专门的 AI review,逐行分析它提出的改动并把可疑处标出来。把这当成第一道过滤,而不是替代你亲自看删除——它不会知道某个”死”函数其实是通过反射接上的。

Step 3:重命名扫一遍跨仓引用

对 diff 里每一个 - oldName / + newName 对:

# 看旧名字还在哪些文件出现
git grep -n "oldName"

# 跟 diff 改动的文件数对比
git diff --staged --name-only

数量不一致就是越界风险。对 ambiguous 名字(如 userdataconfig),要求 agent 用更精确的命名,避免误改。

Step 4:新增代码用”我能不能 5 分钟解释清楚”判定

最后才看新增。逐函数读,每一段问自己:

  • 这个函数的输入边界是什么?null / undefined / 空数组怎么处理?
  • 异步竞争?有没有 race condition?
  • 错误怎么传播?吞掉了吗?

读不懂的部分,让 agent 加 5 行注释解释;解释不清楚的部分要求重写。

Step 5:跑两轮回归

不只跑改动相关的测试:

# 改动相关的
npm test -- path/to/changed.test.ts

# 整个 suite
npm test

# 然后跑 lint / typecheck,挑出之前漏的副作用
npm run typecheck
npm run lint

可能的话,用 git worktree 开两个 checkout(一个 HEAD、一个改后),跑同一个端到端场景对比输出。

风险类型工具通过判定
删除有用代码git grep 跨仓引用0 处剩余引用
重命名越界git diff --name-only vs grep改动文件 = grep 命中文件
语义改变单元测试 + 边界 case边界 case 测试已存在并通过
调用方未更新typecheck + lint严格模式无错误

怎么确认可以放心接受

只有以下四条全部成立,才算审完:

  1. 每个被删的符号,git grep -n <符号> 跨整个仓库(包括 docs/scripts/tests/、CI 的 YAML)都返回 0 处命中。
  2. 含每处重命名的文件集合,等于你本意要改的文件集合——没有多出来的文件。
  3. npm run typecheck && npm run lint 干净退出(严格模式下),且改动相关的测试和整个 suite 都绿。
  4. diff 引入的每一处行为改变、每一个新分支,你都能用一句话解释清楚。解释不出来,就说明还没审完。

到这一步才接受(Cursor 里 Cmd/Ctrl+Enter,Claude Code 里逐块批准)。接受后立刻 commit,让下一次 AI 改动从一个干净的 checkpoint 开始。

预防建议

  • 超过 200 行的 AI diff 必须真审核,不能盲目全部接受;超过 500 行直接退回让 agent 拆成多次 commit。
  • 凡是不简单的改动都用 Plan Mode。在 Claude Code 里 Shift+Tab 进 plan 模式,先批准一份书面计划,它才动文件;在 Cursor 里先让 agent 列出方案,确认后再写代码。拦下一个坏改动,最便宜的地方是在计划阶段,不是在 diff 阶段。
  • CLAUDE.md / .cursor/rules(项目规则的新位置,已取代旧的 .cursorrules 文件)里写死:“Before deleting any code, search the entire repo for references and report them”。
  • 跑 AI 改动前先 git commit 一个 checkpoint,便于 diff 与回滚。
  • 严格模式 TypeScript 和严格 ESLint 是 AI 编程的安全网;关掉它,就等于关掉唯一能挡住”调用方未更新”和类型不匹配的那道检查。
  • 引入 pre-commit hook 拦截测试被删:git diff --cached | grep -E '^-\s*(test|it|expect)\(' && exit 1
  • PR 阶段开一个自动 reviewer(Cursor 的 Bugbot 在 push 时自动 review PR,还能给出修复建议)。它抓的问题和你本地读 diff 不是一类,两个一起用。
  • 关键模块(auth、payment、permission)独立目录 + CODEOWNERS 强制人工 review,不让 AI 直接合入。

FAQ

为什么一个编译通过、测试也绿的 diff 还是把线上搞挂了? 因为”编译通过”只证明类型对得上,“测试通过”只证明你写过的那些 case 还过。两者都覆盖不到:一个通过反射调用到的边界判断被删了、在 undefined 输入下被翻转的 if (!user)if (user === null)、或者 agent 悄悄改弱的测试。删除和”只改语义不改语法”的改动得靠人逐行读——这恰恰是绿勾漏掉的部分。

让 Claude Code 一直开着自动接受(acceptEdits)安全吗? 只有当你事后会用 git diff 复查、且改动风险不高时才行。acceptEdits 跳过每次编辑的确认,agent 可能在你看到之前就改得比你要求的多。涉及 auth、支付、迁移,或者超过约 200 行的改动,用 Shift+Tab 切回 default 或 Plan Mode。

agent 说它删的是”死代码”,怎么信它? 别信,去验。对每个被删的名字,git grep -n <名字> 跨整个仓库查一遍,包括 docs/scripts/tests/ 和 CI 配置。static analysis 和 agent 都会漏掉 dynamic dispatch、按字符串 require()、反射、以及只存在于配置或注释里的引用。任何一处命中就保留。

多大的 diff 就大到不能一次审完了? 大约超过 200 行,从上往下读就不再可靠了。改成按风险顺序审(删除、重命名、语义、新增)。超过约 500 行,让 agent 把工作拆成多次 commit——可审的 diff 是你能完整解释的那种,不是你扫一眼的那种。

已经接受了一个坏 diff 怎么办? 如果你之前 commit 过 checkpoint,git revertgit reset --hard 回到那个点。如果没 commit,也许还能从编辑器的 local history 或 git reflog 里把改动前的文件捞回来。这正是每次 AI 改动前都要先 commit 的原因。

相关阅读

标签: #AI 编程 #排查 #排查