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、typecheck 和 lint。下面把每一步展开讲,并给出 Cursor 2.5 和 Claude Code 里能让这个流程更快的具体设置。
在动手之前先做一件事:别再盲接受了。在 Claude Code 里按 Shift+Tab 把模式从 acceptEdits(自动接受)切回 default,或切到 Plan Mode,让 Claude 先给出一份计划、你批准后它才动文件(截至 2026 年 6 月,Shift+Tab 在 default → 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) 在 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”,红色(删除)那一栏从上到下扫一遍。在 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 名字(如 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
可能的话,用 git worktree 开两个 checkout(一个 HEAD、一个改后),跑同一个端到端场景对比输出。
| 风险类型 | 工具 | 通过判定 |
|---|---|---|
| 删除有用代码 | git grep 跨仓引用 | 0 处剩余引用 |
| 重命名越界 | git diff --name-only vs grep | 改动文件 = grep 命中文件 |
| 语义改变 | 单元测试 + 边界 case | 边界 case 测试已存在并通过 |
| 调用方未更新 | typecheck + lint | 严格模式无错误 |
怎么确认可以放心接受
只有以下四条全部成立,才算审完:
- 每个被删的符号,
git grep -n <符号>跨整个仓库(包括docs/、scripts/、tests/、CI 的 YAML)都返回 0 处命中。 - 含每处重命名的文件集合,等于你本意要改的文件集合——没有多出来的文件。
npm run typecheck && npm run lint干净退出(严格模式下),且改动相关的测试和整个 suite 都绿。- 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 revert 或 git reset --hard 回到那个点。如果没 commit,也许还能从编辑器的 local history 或 git reflog 里把改动前的文件捞回来。这正是每次 AI 改动前都要先 commit 的原因。