第 09 章 遗留代码重构与 Bug 修复
版本基准:2026-07-08 核对,Claude Code v2.1.202+,模型 Opus 4.8 / Fable 5 / Sonnet 5 / Haiku 4.5。
9.1 结论先行:第一步是建立事实模型,不是动手改
在陌生代码上修 Bug,90% 的翻车都来自同一个错误——没有建立事实模型就动手改。Agent 拿到一句"线上内存泄漏,你看看"就钻进第一个可疑文件,围绕它展开一轮过度重构,改完发现根因在别处,于是回退、再猜、再改。这条路走到底,你会得到一个比原来更烂的代码库,以及一份看起来很忙的提交历史。
正确的路径是四阶段闭环:
只读考古 → 复现为失败测试 → 最小修复 → 分层验证
↑ │
└──── 验证不通过或发现新事实则回退 ←──────────────┘这条闭环的核心纪律是:在任何编辑动作之前,先让 Claude 输出"证据文件 + 风险假设",人类确认方向后再授权。测试是精确契约,消除"我觉得修好了"的歧义;信息密度是修复效率的上限,一次性喂足比挤牙膏强十倍;根因溯源是不可让渡的工程纪律,禁止用注释代码或 @ts-ignore 绕过报错。
本章按这四阶段展开,所有命令、终端转录、diff 均为模拟示例(基于一个 Node.js 轮询服务 legacy-report-service),不暗示来自真实仓库。
9.2 第一阶段:陌生代码考古(只读)
9.2.1 用 Explore 子代理做代码考古
陌生代码库的第一步是派一个只读的 Explore 子代理做扇出搜索。Explore 子代理的关键特性:它没有 Edit/Write 权限,不会偷偷改你的代码;它在独立上下文里跑,不污染主对话;它返回的是结论与文件路径,不是文件内容堆砌。
用 Explore 子代理搜索 `ReportPoller` 的定义、实例化位置、
生命周期调用(start/stop)和事件消费者(reports/error/stopped)。
只读,不要修改任何文件。
搜索广度:very thorough。
输出:
- 调用图(谁创建、谁启动、谁停止、谁监听)
- 资源生命周期(timer/listener 的创建与释放点)
- 错误传播路径(fetch 失败如何流转)
- 相关测试覆盖了什么、漏了什么
- 高风险假设清单预期输出(模拟示例):
● Explore 子代理轨迹:
Grep("ReportPoller", path="src") → 4 命中
Read(src/report-poller.js) → 53 行
Read(src/app.js) → 看到 createApp()
Grep("\\.start\\(\\)", path="src") → 2 命中(app.js + reload 路径)
Grep("\"reports\"|\"error\"|\"stopped\"") → 3 命中
Read(test/report-poller.test.js) → 仅覆盖 happy path
Call path:
src/app.js:createApp()
└─ new ReportPoller(source)
├─ poller.on("reports", cache.replace)
├─ poller.on("error", logger.error)
├─ poller.start()
└─ shutdown → poller.stop()
Risk assumptions (需人类确认):
1. start() 非幂等:reload 路径会二次调用,创建第二个 timer
2. stop() 未 clearInterval:进程停止后轮询继续
3. fetchReports() reject 会产生 unhandled rejection,绕过 error listener
4. tick listener 匿名且每次 start 重新注册,无法精确移除9.2.2 用 grep/glob 跨文件搜索符号
Explore 子代理之外,主上下文里也常用 Grep/Glob 做精确符号定位。这两类工具是大仓库里快速定位的唯一通道——Agent 不"知道"你的仓库状态,它只能通过工具返回看到事实。
| 工具 | 适用场景 | 典型写法 |
|---|---|---|
Grep | 正则搜索符号、调用点、字符串字面量 | Grep("ReportPoller", path="src", output_mode="content", -n=true) |
Glob | 按文件名模式找文件 | Glob("**/report-poller*.js") |
# 找符号定义
Grep("class ReportPoller", path="src", output_mode="content", -n=true)
# 找所有调用点
Grep("\\.start\\(\\)", path="src", output_mode="content", -n=true)
# 找事件消费者
Grep("\\.(on|once)\\(\"(reports|error|stopped)\"", path="src")
# 找测试文件
Glob("**/*report-poller*test*")验证:考古阶段产出必须可校验——调用图里每条边都对应一个 Grep 命中或 Read 行号。让 Claude 把每条假设标注来源文件与行号,你随手抽两条核对。如果它说"reload 路径会调用 start()",你就在 src/app.js 里搜 start( 确认。
失败边界:
- 在错误理解上改:考古没做完就动手,是最常见的翻车。强制规则——Explore 子代理没返回风险假设清单之前,不授权任何 Edit。
- 用子代理做需精确引用的修改:Explore 是只读的,适合定位;但精确到行号的修改不要委派给子代理,子代理上下文隔离,容易丢上下文。修改放回主上下文做。
- 把
/bug当修 Bug 命令:/bug是/feedback的别名,向 Anthropic 报告问题,不是修 Bug(见第 06 章 6.4.2 节命令真实性表)。修 Bug 用自然语言或/fix-bug自定义 skill。
9.2.3 让 Claude 把"看到的代码"变成"可验证假设"
考古的产出不是文件 dump,是状态机 + 资源生命周期 + 失败假设。再让 Claude 把代码翻译成可验证假设:
解释 @src/report-poller.js。
不要逐行翻译;请给出:
1. 状态机(IDLE/RUNNING/STOPPED 的迁移条件)
2. 资源的创建与释放点(timer、listener)
3. 异常如何传播(同步抛出?异步 reject?)
4. 可以用测试证明的失败假设(每条对应一个可写的失败测试)预期产出(模拟示例):
状态机:
IDLE --start()--> RUNNING --stop()--> IDLE
\ |
\--start()-------+ 当前代码错误地创建第二个 timer
RUNNING --fetch success--> reports 事件
RUNNING --fetch reject----> unhandled rejection(绕过 error listener)
资源生命周期:
timer: start() 创建,stop() 未释放 → 泄漏
listener: 每次 start() 注册新的匿名 tick listener,无法精确移除 → 累积
可验证失败假设:
H1: 重复 start() 后,tick 触发 fetchReports 两次(非一次)
H2: stop() 后,timer 仍触发 fetchReports
H3: fetchReports reject 时,error listener 不被调用,进程收到 unhandledRejection这三条假设就是下一阶段要写的三个失败测试。考古阶段的产出直接喂给 TDD 阶段,闭环就闭合了。
9.3 第二阶段:TDD 驱动修复——测试即精确契约
9.3.1 为什么测试是契约
"内存泄漏"是一句模糊描述。Agent 听到这句话,可能去改数据结构、可能去加缓存上限、可能去换 EventEmitter 库——三个方向都"像在修内存泄漏",但只有一个是真的。测试把模糊描述变成机器可检查的事实:
"内存泄漏" → "重复 start() 后 fetchReports 被调用两次,预期一次"
"服务停止后还在轮询" → "stop() 后推进 3 秒,fetchReports 调用次数不增加"
"偶发 unhandled rejection" → "fetchReports reject 时 error 事件必须被触发"一旦变成断言,Agent 就没有"我觉得修好了"的空间——测试要么过要么不过。这是 TDD 驱动修复的本质:用测试消除歧义,而不是用 prompt 描述消除歧义。
9.3.2 先写失败测试,不要先改生产代码
任务指令(模拟示例):
为 `ReportPoller` 添加最小回归测试,先不要修改生产代码。
必须证明:
1. 重复调用 start() 不会创建第二个轮询器(H1)
2. stop() 后不会继续 fetch(H2)
3. fetchReports() reject 时触发 error 事件,不产生 unhandled rejection(H3)
使用现有 Vitest 风格,fake timers。
完成后运行:`npm test -- report-poller.test.js`
把失败原因按根因(H1/H2/H3)分组汇报。新增测试(模拟示例):
// test/report-poller.test.js
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ReportPoller } from "../src/report-poller.js";
describe("ReportPoller", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("H1: starts only one polling interval", async () => {
const source = { fetchReports: vi.fn().mockResolvedValue([]) };
const poller = new ReportPoller(source, 1000);
poller.start();
poller.start();
await vi.advanceTimersByTimeAsync(1000);
expect(source.fetchReports).toHaveBeenCalledTimes(1);
poller.stop();
});
it("H2: stops polling and releases the timer", async () => {
const source = { fetchReports: vi.fn().mockResolvedValue([]) };
const poller = new ReportPoller(source, 1000);
poller.start();
await vi.advanceTimersByTimeAsync(1000);
poller.stop();
await vi.advanceTimersByTimeAsync(3000);
expect(source.fetchReports).toHaveBeenCalledTimes(1);
});
it("H3: emits an error when the source rejects", async () => {
const failure = new Error("upstream timeout");
const source = { fetchReports: vi.fn().mockRejectedValue(failure) };
const onError = vi.fn();
const poller = new ReportPoller(source, 1000);
poller.on("error", onError);
poller.start();
await vi.advanceTimersByTimeAsync(1000);
expect(onError).toHaveBeenCalledWith(failure);
poller.stop();
});
});模拟失败输出:
$ npm test -- report-poller.test.js
FAIL test/report-poller.test.js
ReportPoller
× H1: starts only one polling interval
expected "spy" to be called 1 times, but got 2 times
× H2: stops polling and releases the timer
expected "spy" to be called 1 times, but got 4 times
× H3: emits an error when the source rejects
Error: upstream timeout (unhandled rejection)
Test Files 1 failed (1)
Tests 3 failed (3)验证:三条失败对应三条根因假设 H1/H2/H3。如果某条测试意外通过,说明假设错了——这时回退到考古阶段重新理解,而不是硬改。
失败边界:
- 改了没跑验证:写完测试不跑,等于没写。强制规则——测试文件保存后必须立即跑一次,看到失败,再进入修复。
- 为了通过测试删除失败断言:这是最隐蔽的作弊。Agent 偶尔会"删掉不过的测试"。
git diff审查时,任何测试断言的删除都要追问。
9.4 第三阶段:修复闭环——信息密度为王
9.4.1 一次性喂足,不要挤牙膏
修复阶段的效率上限由信息密度决定。挤牙膏式的对话——"看一下报错"→"再看一下文件"→"再看一下 schema"——每一轮都让 Agent 在不完整信息下猜测,而猜测会写进代码。
正确的喂法是一次性把四类信息打包:
| 信息类型 | 作用 | 喂法 |
|---|---|---|
| 错误日志 / 失败测试输出 | 锁定现象 | 粘贴 stderr 或测试失败摘要 |
| 相关文件 | 锁定上下文 | @src/report-poller.js @src/app.js |
| 数据库 schema(若涉及) | 锁定数据契约 | @db/schema.sql 或 Prisma schema |
| 最近变更 | 锁定可能引入 Bug 的 commit | git log --oneline -10 或 git diff HEAD~3 |
完整任务指令(模拟示例):
/fix-bug 修复 test/report-poller.test.js 的三个失败(H1/H2/H3),
只做生命周期相关的最小修改,不要替换架构。
上下文:
- 失败测试输出:<粘贴上面的 FAIL 摘要>
- 生产代码:@src/report-poller.js
- 调用方:@src/app.js
- 最近变更:git log --oneline -5 显示上周改过 reload 路径
约束:
- 保留 EventEmitter 公共接口,不要换成 RxJS
- start()/stop() 必须幂等
- 异步错误在 poll() 边界统一转为 error 事件
- 单文件执行时间保持在 1 秒内
验证:
- npm test -- report-poller.test.js
- npm test
- npm run lint
- npm run typecheck
完成输出:diff 摘要、每条验证命令的 exit code、仍未覆盖的运行态风险。9.4.2 修复闭环:读失败 → 定位 → 改 → 验证 → 自我修正
/fix-bug skill(自定义,非内置)要求 Claude 执行固定循环:
读取失败 → 定位根因 → 最小修改 → 跑目标测试
→ 若失败则读取新错误并继续 → 跑全量验证 → 输出 diff 与风险模拟执行轨迹:
● Read(test/report-poller.test.js)
● Read(src/report-poller.js)
● Edit(src/report-poller.js)
● Bash(npm test -- report-poller.test.js)
FAIL: H3 emits an error when the source rejects
expected spy to be called once, received zero
● Read(src/report-poller.js) ← 重新读,定位异步边界
● Edit(src/report-poller.js)
● Bash(npm test -- report-poller.test.js)
PASS: 3 tests
● Bash(npm test) ← 全量回归
PASS: 47 tests
● Bash(npm run lint)
PASS
● Bash(npm run typecheck)
PASS9.4.3 异步错误处理的典型陷阱
第一次修改经常在异步边界翻车。Claude 加了 try/catch,却忘记 await:
// 错误:同步 try/catch 捕不到异步 reject
try {
this.source.fetchReports(); // ← 没 await,reject 直接逃逸
} catch (error) {
this.emit("error", error);
}测试 H3 会立即报错——onError 调用次数为 0。这个失败反馈迫使 Claude 改成真正的异步边界:
// 正确:await 后 reject 进入 catch
try {
const reports = await this.source.fetchReports();
this.emit("reports", reports);
} catch (error) {
this.emit("error", error);
}这就是 Agent 闭环的价值:不是"一次生成正确代码",而是通过可执行事实不断排除错误实现。第一次改错不可怕,测试会逼它改对;可怕的是没有测试,错误实现直接合进主干。
9.5 真实 diff 示例(模拟)
修复前后 diff(模拟示例,标注为示例,不来自真实仓库):
diff --git a/src/report-poller.js b/src/report-poller.js
index 12d55e1..eb73070 100644
--- a/src/report-poller.js
+++ b/src/report-poller.js
@@ -5,22 +5,40 @@ export class ReportPoller extends EventEmitter {
super();
this.source = source;
this.intervalMs = intervalMs;
+ this.timer = null;
+ this.poll = this.poll.bind(this);
}
- start() {
- this.on("tick", async () => {
+ async poll() {
+ try {
const reports = await this.source.fetchReports();
this.emit("reports", reports);
- });
+ } catch (error) {
+ this.emit("error", error);
+ }
+ }
+
+ start() {
+ if (this.timer !== null) {
+ return; // 幂等:已启动则不再创建 timer
+ }
this.timer = setInterval(() => {
- this.emit("tick");
+ void this.poll(); // 异步错误在 poll 边界统一捕获
}, this.intervalMs);
}
stop() {
+ if (this.timer === null) {
+ return; // 幂等:未启动则 no-op
+ }
+
+ clearInterval(this.timer); // 释放 timer 句柄
+ this.timer = null;
this.emit("stopped");
}
}为什么这是比"移除 listener"更好的修复:
- 删除了不必要的内部
tick事件层——少一层间接,少一份累积; - timer 句柄成为显式状态——
null检查让幂等性可读; start()/stop()都幂等——H1/H2 直接解决;- 异步错误在
poll()边界统一转为error事件——H3 解决,reject 不再逃逸; - 变更只触及一个生产文件——回归面最小化。
9.6 根因溯源纪律:禁止绕过报错
9.6.1 红线:禁止注释代码或加 @ts-ignore 消除报错
这是工程纪律的硬约束,直接引用用户 CLAUDE.md 第 4 节「工程与执行纪律」:
根因溯源:禁止为了消除报错而简单注释代码或添加绕过标记(如
@ts-ignore),必须解决根本原因。
Agent 在修复闭环里偶尔会走捷径——TypeScript 报类型错误,它加 // @ts-ignore;某行抛异常,它注释掉;某个测试不过,它 it.skip。这些动作"让报错消失",但根因还在,而且现在被埋了三尺深。生产环境一旦触发,排查成本是当初的十倍。
9.6.2 反例 vs 正例
反例(禁止):
// @ts-ignore ← 类型系统在告诉你 user 可能是 null,你把它嘴堵上了
const name = user.name.toUpperCase();// try { ... } catch (e) { /* ignore */ } ← 错误被吞,生产环境查无此 bug// it.skip("H3: emits an error when the source rejects", ...) ← 测试不过就跳过正例(要求):
// 根因:user 类型声明为 User,但实际来自 API 可能是 null
// 修复:收紧类型签名 + 在边界处显式处理
function getUserName(user: User | null): string {
if (user === null) {
return "anonymous";
}
return user.name.toUpperCase();
}// 根因:fetchReports reject 未被捕获
// 修复:在 poll() 边界加 try/catch,转为 error 事件(见 9.5 diff)9.6.3 审查 diff 时如何识别绕过
git diff 审查时,以下模式出现就必须追问:
| 模式 | 真相 | 追问 |
|---|---|---|
@ts-ignore / @eslint-disable | 类型/lint 在报错,被嘴堵 | 根因是什么?为什么不修? |
// TODO: fix later | 知道有问题,延后 | 什么时候修?现在为什么不修? |
try { } catch (e) { } 空 catch | 错误被吞 | 错误应该流向哪里? |
it.skip / describe.skip | 测试不过就跳过 | 这个测试为什么不过? |
| 注释掉的生产代码 | 代码不再执行但还在仓库 | 是要删还是要修? |
process.on("unhandledRejection") 兜底 | 局部错误被全局掩盖 | 局部错误为什么没在局部处理? |
最后一条尤其危险——用全局兜底掩盖局部 Bug,会让所有同类 Bug 都变成"日志里没有"的幽灵。第 06 章 6.4.2 节"常见陷阱"里 /bug 不是修 Bug 命令的提醒,在这里同样适用:不要用全局兜底替代局部修复。
9.7 典型模式库
9.7.1 内存泄漏定位模式
Node.js/前端内存泄漏的典型信号:listener 数量增长、timer 不释放、闭包持有大对象、缓存无上限。定位模式:
1. 用 Explore 子代理搜:
- setInterval/setTimeout 的创建点与对应 clear
- addEventListener/on 的注册点与对应 remove
- 全局 Map/Set/WeakMap 的写入点
2. 写生命周期测试:fake timers + 多次 start/stop + 断言调用次数
3. 跑 `node --expose-gc --inspect` 用 Chrome DevTools heap snapshot 对比常见根因:
EventEmitter.on注册匿名函数,无法off→ 改为具名引用setInterval没有保存返回值,无法clearInterval→ 保存到this.timersetInterval回调里emit("tick"),listener 在 tick 上累积 → 删除中间事件层- 闭包捕获
this→ 用bind或箭头函数显式绑定
9.7.2 异常处理修复模式
异步错误处理的四条规则:
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
async 函数内调用 | 同步 try/catch 不 await | await 后 try/catch |
EventEmitter 异步 listener | 直接 emit 触发 async listener | listener 内 try/catch,失败时 emit("error") |
Promise 链 | .then() 不 .catch() | .catch() 或 await + try/catch |
| 全局兜底 | process.on("unhandledRejection") 掩盖 | 局部边界处理,全局只做日志 |
关键原则:错误要在离发生点最近的边界处理。全局兜底只用于"局部确实无法处理时记日志 + 优雅降级",不是修复手段。
9.7.3 通用修复 checklist
修任何 Bug 前,Claude 必须能回答:
[ ] 根因是什么?(一句话,指向具体文件行号)
[ ] 现象与根因的因果链是什么?
[ ] 哪个测试复现了根因?
[ ] 修复是否引入新的生命周期依赖?
[ ] 修复是否幂等?(重复调用安全)
[ ] 异常路径是否处理?(reject/throw/timeout)
[ ] 变更面是否最小?(只触及必要文件)
[ ] 验证命令是否全跑过?(exit code)
[ ] 仍未覆盖的运行态风险是什么?9.8 失败边界
9.8.1 在错误理解上改
考古没做完就动手。Agent 拿到"内存泄漏"就钻进第一个 Map 看到它无限增长,改成 LRU——但根因是 timer 没释放,Map 只是受害者。改完测试不过,再回头考古,浪费一轮。
拦截规则:Explore 子代理未返回风险假设清单 + 人类未确认方向之前,不授权 Edit。Plan Mode(第 07 章)是这一阶段的正式工具——它强制只读,强制产出 plan。
9.8.2 改了没跑验证
"我已经修改好了"不算数。Agent 偶尔会在没跑测试的情况下宣称完成,理由是"改动很小,显然正确"。这是欺骗性陈述——任何修改都必须跑验证命令,exit code 是唯一事实。
拦截规则:任务 Prompt 里写死验证命令,完成后要求汇报每条命令的 exit code。没有 exit code 的"完成"一律打回。
9.8.3 用子代理做需精确引用的修改
子代理上下文隔离,适合扇出探索或并行实现粗粒度任务,但不适合精确到行号的修改。子代理可能改错文件、改错符号、丢上下文里的关键约束。
拦截规则:
- 子代理做:只读考古、并行实现多个独立模块、跑验证
- 主上下文做:跨文件一致性修改、精确引用修改、架构决策
第 19 章(Subagents)会详细讲子代理的适用边界;这里只强调修 Bug 场景——修复闭环始终在主上下文跑。
9.8.4 过度修复需拦截
Claude 在修复时倾向于"顺手把相关的一起改了"。如果它提议以下动作,应要求收缩:
- 把整个事件系统改成 RxJS / Observable;
- 同时升级 Node、测试框架和所有依赖;
- 为了通过测试删除失败断言;
- 用空
catch吞掉错误; - 加
process.on("unhandledRejection")掩盖局部错误; - 把 fake timer 测试改成无限等待的真实时间测试。
反馈要具体到工程目标:
不要替换架构。保留 EventEmitter 公共接口。
只修复 timer 生命周期和 fetch rejection。
测试必须使用 fake timers,单文件执行时间保持在 1 秒内。
变更面控制在 1 个生产文件 + 1 个测试文件。9.9 验证闭环分层与证据汇报
验证必须分层,从最便宜最相关的跑起:
# 第一层:目标测试(秒级)
npm test -- report-poller.test.js
# 第二层:模块级或全量(秒-分钟级)
npm test
# 第三层:静态检查(秒级)
npm run lint
npm run typecheck让 Claude 汇报证据,不接受"应该没问题":
请给出:
- 修改过的文件(列表)
- 每个验证命令的 exit code
- 新增测试分别证明什么(对应 H1/H2/H3)
- 未验证的运行态风险合格输出(模拟示例):
Changed:
- src/report-poller.js
- test/report-poller.test.js
Verification:
- npm test -- report-poller.test.js → exit 0, 3 passed (H1/H2/H3 全绿)
- npm test → exit 0, 47 passed
- npm run lint → exit 0
- npm run typecheck → exit 0
Remaining risk:
- No integration test verifies process shutdown wiring in src/app.js.
单测覆盖了 ReportPoller 生命周期,但 createApp() 的 shutdown hook
是否正确调用 poller.stop() 尚未端到端验证。建议补一个 supertest 集成测试。最后一步永远是 git diff 人工审查 + /code-review + /security-review(第 13 章)。这是人类保留的控制面,不可让渡——见第 06 章 6.3 节「支点四」。
9.10 小结
陌生代码上修 Bug 的四阶段闭环:
- 只读考古——Explore 子代理(只读)+ grep/glob,产出调用图、资源生命周期、风险假设
- 复现为失败测试——测试即契约,把模糊描述变成机器可检查的断言
- 最小修复——信息密度为王,一次性喂足(错误日志 + 相关文件 + schema + 最近变更),修复闭环读失败→定位→改→验证→自我修正
- 分层验证——目标测试 → 全量测试 → lint → typecheck,汇报 exit code 与剩余风险
三条不可让渡的纪律:
- 先建立事实模型再动手——考古未完成不授权 Edit
- 根因溯源——禁止注释代码、
@ts-ignore、空 catch、it.skip、全局兜底掩盖局部错误 - 改了必须跑验证——exit code 是唯一事实,"应该没问题"不算数
人定方向(考古阶段的假设确认、过拟合的拦截),Claude 做实现与探索(扇出搜索、测试编写、修复闭环)。这是第 06 章 6.8 节「你做架构决策和关键审查,Claude 做实现和探索」在 Bug 修复场景的具体落地。
上一章:08-测试与验证体系.md 下一章:10-代码审查与质量门禁.md