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

AI 给的 200 行 diff,编译过 ≠ 安全。这是阅读顺序。

Claude Code 或 Cursor 给你一个 200 行的 diff,编译通过、测试也绿,于是你 Cmd+Enter “Accept All”。两小时后线上出现一个诡异的 bug——AI 在删除”看似不用的代码”时顺手干掉了一个边界判断;或者把 if (!user) 换成 if (user === null) 但前面是用 undefined 调用的;或者改名时把同名变量误改了一个无关上下文。这些都是”diff 编译过 ≠ 行为不变”的典型例子。这篇给出审 AI diff 的固定顺序——先看删除、再看重命名、最后看新增——以及每一类该怎么快速判定危险性。

常见原因

按风险从高到低排序。

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”,红色那一栏从上到下扫一遍。

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

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

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

预防建议

  • > 200 行的 AI diff 必须真审核,不接受 “Accept All”;超过 500 行直接退回让 agent 拆分多次 commit
  • 在 CLAUDE.md / .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
  • 关键模块(auth、payment、permission)独立目录 + CODEOWNERS 强制人工 review,不让 AI 直接 commit

相关阅读

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