Diagnosing Bugs
针对棘手 bug 的纪律。只有在明确有理有据时才跳过某些 phase。
探索 codebase 时,阅读 CONTEXT.md(如果存在)以获得相关模块清晰的 mental model(心智模型),并检查你正在接触区域的 ADR。
Redact(脱敏)
本 skill 要求你展示命令、输出和捕获的产物。先对每个 secret 脱敏 — 用 <REDACTED> 代替。针对 env vars 构建回路,这样凭据留在环境中,而不是留在你展示的内容里。捕获的产物带有 auth headers:只引用携带信号的几行。
如果脱敏后的输出不足以诊断 bug,直说并询问用户。
Phase 1 — 构建反馈回路
这才是本 skill 的核心。 其他一切都是机械性的。如果你有一个针对该 bug 的紧致通过/失败信号 — 一个能在_这个_ bug 上变红的信号 — 你就会找到原因;bisection(二分)、假设检验和 instrumentation 都只是在消费它。如果你没有这样的信号,再怎么盯着代码看也救不了你。
在这里投入不成比例的努力。要激进。要有创造力。拒绝放弃。
构建反馈回路的方法 — 大致按这个顺序尝试
- 失败的测试 — 在任何能触达 bug 的 seam 上 — unit、integration、e2e。
- Curl / HTTP 脚本 — 针对运行中的 dev server。
- CLI 调用 — 用 fixture 输入,把 stdout 与已知良好的快照做 diff。
- 无头浏览器脚本(Playwright / Puppeteer)— 驱动 UI,在 DOM/console/network 上做断言。
- 重放捕获的 trace。 把真实的网络请求 / payload / 事件日志保存到磁盘;在隔离环境中通过代码路径重放它。
- 一次性 harness。 启动系统的一个最小子集(一个 service,依赖用 mock),用一次函数调用触发 bug 代码路径。
- Property / fuzz 回路。 如果 bug 是 "sometimes wrong output(偶尔输出错误)",运行 1000 个随机输入,寻找失败模式。
- Bisection harness。 如果 bug 出现在两个已知状态(commit、数据集、版本)之间,把 "boot at state X, check, repeat(在状态 X 启动、检查、重复)" 自动化,这样你就可以对它
git bisect run。 - 差分回路(Differential loop)。 让同一输入分别通过旧版本和新版本(或两种配置),对输出做 diff。
- HITL bash 脚本。 最后的手段。如果必须由人来点击,用
scripts/hitl-loop.template.sh驱动_他们_,这样回路仍然是有结构的。捕获的输出反馈给你。
构建出正确的反馈回路,bug 就修好了 90%。
收紧回路
把回路当作产品来对待。一旦你有了_一条_回路,就收紧它:
- 我能让它更快吗?(缓存 setup、跳过无关的初始化、缩小测试范围。)
- 我能让信号更锐利吗?(对具体症状做断言,而不是 "didn't crash(没崩溃)"。)
- 我能让它更确定吗?(固定时间、给 RNG 播种、隔离文件系统、冻结网络。)
一个 30 秒的 flaky 回路和没有回路相比几乎毫无优势;一个 2 秒的确定性回路才是紧致的 — 那是调试的超能力。
非确定性 bug
目标不是干净的 repro(复现),而是更高的复现率。把触发器循环 100 次、并行化、加压、收窄时间窗口、注入 sleep。50% 概率 flake 的 bug 是可调试的;1% 的不是 — 不断提高复现率,直到它可调试。
当你确实无法构建回路时
停下来,明确说明。列出你尝试过的东西。向用户请求:(a) 访问能复现它的环境,(b) 一份脱敏的捕获产物(HAR 文件、日志转储、core dump、带时间戳的屏幕录制),或 (c) 添加临时生产 instrumentation 的许可。不要在没有回路的情况下继续假设。
完成标准 — 一条能变红的紧致回路
当回路紧致且能变红(red-capable)时,Phase 1 就完成了:你能说出一条命令 — 一个脚本路径、一次测试调用、一条 curl — 而且你已经至少运行过一次(展示这条调用及其输出,已脱敏),并且它:
- Red-capable(能变红) — 它驱动真实的 bug 代码路径,并对用户的确切症状做断言,因此它能在这个 bug 上变红,修复后变绿。不是 "runs without erroring(运行不报错)" — 它必须能够_抓住这个具体的 bug_。
- Deterministic(确定性) — 每次运行结果一致(flaky bug:按上文,固定一个高复现率)。
- Fast(快速) — 秒级,而不是分钟级。
- Agent-runnable(agent 可运行) — 你可以无人值守地运行它;只有通过
scripts/hitl-loop.template.sh才有人类在环。
如果你发现自己在这条命令存在之前就开始读代码构建理论,停下来 — 直接跳到假设正是本 skill 要阻止的失败。 没有能变红的命令,就没有 Phase 2。
Phase 2 — 复现 + 最小化
运行回路。看着它变红 — bug 出现了。
确认:
- 回路产生用户描述的失败模式 — 而不是恰好邻近的另一个失败。找错 bug = 修错东西。
- 失败可以跨多次运行复现(或者,对于非确定性 bug,复现率足够高,足以作为调试依据)。
- 你已经捕获确切症状(错误消息、错误输出、缓慢的耗时),这样后续 phase 可以验证修复确实解决了它。
最小化
一旦它变红,把 repro 缩小到仍然变红的最小场景。一次一个地削减输入、调用者、配置、数据和步骤,每次削减后重新运行回路 — 只保留对失败起承重作用的东西。
为什么要费这个劲:最小化的 repro 缩小了 Phase 3 中的假设空间(剩下可怀疑的活动部件更少),并在 Phase 5 中变成干净的 regression test。
当每个剩余元素都是承重的时完成 — 移除其中任何一个都会让回路变绿。
在完成复现并且最小化之前,不要继续。
Phase 3 — 提出假设
在测试任何假设之前,生成 3–5 个排好序的假设。只生成单个假设会锚定在第一个看似合理的想法上。
每个假设必须是可证伪的:陈述它做出的预测。
格式:"如果 是原因,那么 <改变 Y> 会让 bug 消失 / <改变 Z> 会让它更糟。"
如果你无法陈述预测,这个假设只是一种感觉(vibe)— 丢弃它或把它打磨锋利。
在测试之前把排序后的列表展示给用户。 他们通常拥有能瞬间重新排序的 domain 知识("我们刚部署了一个对 #3 的改动"),或者知道他们已经排除的假设。便宜的检查点,巨大的时间节省。不要被它阻塞 — 如果用户不在(AFK),就按你的排序继续。
Phase 4 — 插桩(Instrument)
每个探针必须对应 Phase 3 中的一个具体预测。一次只改变一个变量。
工具偏好:
- Debugger / REPL 检查 — 如果环境支持的话。一个断点胜过十条日志。
- 定向日志 — 放在能区分假设的边界处。
- 绝不 "log everything and grep(什么都打日志然后 grep)"。
给每条 debug 日志打上唯一前缀标签,例如 [DEBUG-a4f2]。最后的清理就变成一次 grep。没打标签的日志存活;打了标签的日志被清除。
Perf 分支。 对于性能 regression,日志通常是错的。替代方案:先建立基线测量(计时 harness、performance.now()、profiler、query plan),然后二分。先测量,后修复。
Phase 5 — 修复 + regression test
在修复之前写 regression test — 但前提是存在一个正确的 seam。
正确的 seam 是指测试能在调用点按 bug 实际发生的方式驱动真实的 bug 模式。如果唯一可用的 seam 太浅(当 bug 需要多个调用者时只有一个单调用者测试、无法复现触发 bug 的调用链的 unit test),那里的 regression test 只会带来虚假的信心。
如果不存在正确的 seam,这本身就是发现。 把它记下来。是 codebase 架构阻止了 bug 被锁死。为下一个 phase 标记这一点。
如果存在正确的 seam:
- 把最小化的 repro 变成那个 seam 上的失败测试。
- 看着它失败。
- 应用修复。
- 看着它通过。
- 针对原始的(未最小化的)场景重新运行 Phase 1 反馈回路。
Phase 6 — 清理
在宣布完成之前必须做到:
- 原始 repro 不再复现(重新运行 Phase 1 回路)
- Regression test 通过(或缺少 seam 的情况已被记录)
- 所有
[DEBUG-...]instrumentation 已被移除(grep该前缀) - 一次性 prototypes 已删除(或移到明确标记的 debug 位置)
- 被证明正确的假设写进了 commit / PR message — 这样下一位调试者能学到