Skip to content

第 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 拿到一句"线上内存泄漏,你看看"就钻进第一个可疑文件,围绕它展开一轮过度重构,改完发现根因在别处,于是回退、再猜、再改。这条路走到底,你会得到一个比原来更烂的代码库,以及一份看起来很忙的提交历史。

正确的路径是四阶段闭环:

text
只读考古 → 复现为失败测试 → 最小修复 → 分层验证
   ↑                                              │
   └──── 验证不通过或发现新事实则回退 ←──────────────┘

这条闭环的核心纪律是:在任何编辑动作之前,先让 Claude 输出"证据文件 + 风险假设",人类确认方向后再授权。测试是精确契约,消除"我觉得修好了"的歧义;信息密度是修复效率的上限,一次性喂足比挤牙膏强十倍;根因溯源是不可让渡的工程纪律,禁止用注释代码或 @ts-ignore 绕过报错。

本章按这四阶段展开,所有命令、终端转录、diff 均为模拟示例(基于一个 Node.js 轮询服务 legacy-report-service),不暗示来自真实仓库。

9.2 第一阶段:陌生代码考古(只读)

9.2.1 用 Explore 子代理做代码考古

陌生代码库的第一步是派一个只读的 Explore 子代理做扇出搜索。Explore 子代理的关键特性:它没有 Edit/Write 权限,不会偷偷改你的代码;它在独立上下文里跑,不污染主对话;它返回的是结论与文件路径,不是文件内容堆砌。

text
用 Explore 子代理搜索 `ReportPoller` 的定义、实例化位置、
生命周期调用(start/stop)和事件消费者(reports/error/stopped)。
只读,不要修改任何文件。
搜索广度:very thorough。
输出:
- 调用图(谁创建、谁启动、谁停止、谁监听)
- 资源生命周期(timer/listener 的创建与释放点)
- 错误传播路径(fetch 失败如何流转)
- 相关测试覆盖了什么、漏了什么
- 高风险假设清单

预期输出(模拟示例):

text
● 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")
text
# 找符号定义
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 把代码翻译成可验证假设:

text
解释 @src/report-poller.js。
不要逐行翻译;请给出:
1. 状态机(IDLE/RUNNING/STOPPED 的迁移条件)
2. 资源的创建与释放点(timer、listener)
3. 异常如何传播(同步抛出?异步 reject?)
4. 可以用测试证明的失败假设(每条对应一个可写的失败测试)

预期产出(模拟示例):

text
状态机:
  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 库——三个方向都"像在修内存泄漏",但只有一个是真的。测试把模糊描述变成机器可检查的事实:

text
"内存泄漏" → "重复 start() 后 fetchReports 被调用两次,预期一次"
"服务停止后还在轮询" → "stop() 后推进 3 秒,fetchReports 调用次数不增加"
"偶发 unhandled rejection" → "fetchReports reject 时 error 事件必须被触发"

一旦变成断言,Agent 就没有"我觉得修好了"的空间——测试要么过要么不过。这是 TDD 驱动修复的本质:用测试消除歧义,而不是用 prompt 描述消除歧义

9.3.2 先写失败测试,不要先改生产代码

任务指令(模拟示例):

text
为 `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)分组汇报。

新增测试(模拟示例):

js
// 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();
  });
});

模拟失败输出:

text
$ 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 的 commitgit log --oneline -10git diff HEAD~3

完整任务指令(模拟示例):

text
/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 执行固定循环:

text
读取失败 → 定位根因 → 最小修改 → 跑目标测试
→ 若失败则读取新错误并继续 → 跑全量验证 → 输出 diff 与风险

模拟执行轨迹:

text
● 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)
  PASS

9.4.3 异步错误处理的典型陷阱

第一次修改经常在异步边界翻车。Claude 加了 try/catch,却忘记 await:

js
// 错误:同步 try/catch 捕不到异步 reject
try {
  this.source.fetchReports();      // ← 没 await,reject 直接逃逸
} catch (error) {
  this.emit("error", error);
}

测试 H3 会立即报错——onError 调用次数为 0。这个失败反馈迫使 Claude 改成真正的异步边界:

js
// 正确: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
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
// @ts-ignore  ← 类型系统在告诉你 user 可能是 null,你把它嘴堵上了
const name = user.name.toUpperCase();
js
// try { ... } catch (e) { /* ignore */ }  ← 错误被吞,生产环境查无此 bug
js
// it.skip("H3: emits an error when the source rejects", ...)  ← 测试不过就跳过

正例(要求):

ts
// 根因:user 类型声明为 User,但实际来自 API 可能是 null
// 修复:收紧类型签名 + 在边界处显式处理
function getUserName(user: User | null): string {
  if (user === null) {
    return "anonymous";
  }
  return user.name.toUpperCase();
}
js
// 根因: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 不释放、闭包持有大对象、缓存无上限。定位模式:

text
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.timer
  • setInterval 回调里 emit("tick"),listener 在 tick 上累积 → 删除中间事件层
  • 闭包捕获 this → 用 bind 或箭头函数显式绑定

9.7.2 异常处理修复模式

异步错误处理的四条规则:

场景错误做法正确做法
async 函数内调用同步 try/catchawaitawaittry/catch
EventEmitter 异步 listener直接 emit 触发 async listenerlistener 内 try/catch,失败时 emit("error")
Promise.then().catch().catch()await + try/catch
全局兜底process.on("unhandledRejection") 掩盖局部边界处理,全局只做日志

关键原则:错误要在离发生点最近的边界处理。全局兜底只用于"局部确实无法处理时记日志 + 优雅降级",不是修复手段。

9.7.3 通用修复 checklist

修任何 Bug 前,Claude 必须能回答:

text
[ ] 根因是什么?(一句话,指向具体文件行号)
[ ] 现象与根因的因果链是什么?
[ ] 哪个测试复现了根因?
[ ] 修复是否引入新的生命周期依赖?
[ ] 修复是否幂等?(重复调用安全)
[ ] 异常路径是否处理?(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 测试改成无限等待的真实时间测试。

反馈要具体到工程目标:

text
不要替换架构。保留 EventEmitter 公共接口。
只修复 timer 生命周期和 fetch rejection。
测试必须使用 fake timers,单文件执行时间保持在 1 秒内。
变更面控制在 1 个生产文件 + 1 个测试文件。

9.9 验证闭环分层与证据汇报

验证必须分层,从最便宜最相关的跑起:

bash
# 第一层:目标测试(秒级)
npm test -- report-poller.test.js

# 第二层:模块级或全量(秒-分钟级)
npm test

# 第三层:静态检查(秒级)
npm run lint
npm run typecheck

让 Claude 汇报证据,不接受"应该没问题":

text
请给出:
- 修改过的文件(列表)
- 每个验证命令的 exit code
- 新增测试分别证明什么(对应 H1/H2/H3)
- 未验证的运行态风险

合格输出(模拟示例):

text
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 的四阶段闭环:

  1. 只读考古——Explore 子代理(只读)+ grep/glob,产出调用图、资源生命周期、风险假设
  2. 复现为失败测试——测试即契约,把模糊描述变成机器可检查的断言
  3. 最小修复——信息密度为王,一次性喂足(错误日志 + 相关文件 + schema + 最近变更),修复闭环读失败→定位→改→验证→自我修正
  4. 分层验证——目标测试 → 全量测试 → lint → typecheck,汇报 exit code 与剩余风险

三条不可让渡的纪律:

  • 先建立事实模型再动手——考古未完成不授权 Edit
  • 根因溯源——禁止注释代码、@ts-ignore、空 catch、it.skip、全局兜底掩盖局部错误
  • 改了必须跑验证——exit code 是唯一事实,"应该没问题"不算数

人定方向(考古阶段的假设确认、过拟合的拦截),Claude 做实现与探索(扇出搜索、测试编写、修复闭环)。这是第 06 章 6.8 节「你做架构决策和关键审查,Claude 做实现和探索」在 Bug 修复场景的具体落地。


上一章:08-测试与验证体系.md 下一章:10-代码审查与质量门禁.md


下一章:全栈功能开发 | 返回目录