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) 在 user 为 undefined 时行为反了。array.find(x => x.id === id) → array.filter(x => x.id === id)[0] 在重复 id 场景下不同。Promise.all → Promise.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 名字(如 user、data、config),要求 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