系统化调试(Debugging)
一套系统化调试方法论的集合,确保在尝试修复之前进行彻底调查。
概述
系统化调试包含四个子技能,覆盖从根因分析到验证完成的完整流程:
| 子技能 | 用途 |
|---|---|
| 系统化调试(Systematic Debugging) | 四阶段调试框架,铁律:未调查根因不得修复 |
| 根因追踪(Root Cause Tracing) | 沿调用栈向上回溯,找到原始触发点 |
| 纵深防御验证(Defense-in-Depth) | 在数据经过的每一层都进行验证,使 bug 在结构上不可能发生 |
| 完成前验证(Verification Before Completion) | 运行验证命令并确认输出后才能声称成功 |
何时使用
- 生产环境出现 bug → 从系统化调试开始
- 错误深藏在堆栈跟踪中 → 使用根因追踪
- 修复一个 bug 时 → 找到根因后应用纵深防御
- 即将声称"完成了" → 使用完成前验证
快速分派
| 症状 | 子技能 |
|---|---|
| 测试失败、意外行为 | 系统化调试 |
| 错误出现在错误的位置 | 根因追踪 |
| 同一个 bug 反复出现 | 纵深防御 |
| 需要确认修复是否有效 | 完成前验证 |
核心哲学
"系统化调试比猜测-检查式的盲目尝试要快得多。"
来自实际调试会话的数据:
- 系统化方法:15-30 分钟修复
- 随机修复方法:2-3 小时的无效挣扎
- 首次修复成功率:95% vs 40%
---|------| | "问题很简单,不需要流程" | 简单问题也有根因。流程对简单 bug 很快。 | | "紧急情况,没时间走流程" | 系统化调试比猜测尝试更快。 | | "先试这个,然后再调查" | 第一次修复就设定了模式。从一开始就做对。 | | "修复确认有效后再写测试" | 未经测试的修复不会持久。先测试来证明。 | | "一次多个修复可以节省时间" | 无法隔离哪个有效。会导致新 bug。 | | "参考太长了,我改编这个模式" | 部分理解保证出 bug。完整阅读。 | | "我看到了问题,让我修复它" | 看到症状 ≠ 理解根因。 | | "再试一次修复"(2次以上失败后) | 3次以上失败 = 架构问题。质疑模式,不要再次修复。 |
快速参考
| 阶段 | 关键活动 | 成功标准 |
|---|---|---|
| 1. 根因 | 阅读错误、复现、检查变更、收集证据 | 理解了什么以及为什么 |
| 2. 模式 | 寻找可运行示例、对比 | 识别差异 |
| 3. 假设 | 形成理论、最小化测试 | 确认或形成新假设 |
| 4. 实施 | 创建测试、修复、验证 | Bug 已解决,测试通过 |
当流程未发现根因时
如果系统化调查确实发现问题是环境相关、时序依赖或外部因素:
- 你已完成了流程
- 记录你调查了什么
- 实施适当的处理(重试、超时、错误消息)
- 添加监控/日志以供后续调查
但是: 95% 的"没有根因"案例都是调查不充分。
实际影响
来自调试会话的数据:
- 系统化方法:15-30 分钟修复
- 随机修复方法:2-3 小时的无效挣扎
- 首次修复成功率:95% vs 40%
- 引入新 bug:接近零 vs 经常发生
附录B:子技能 - 根因追踪(Root Cause Tracing)
概述
Bug 通常在调用栈深处暴露(git init 在错误的目录中执行、文件创建在错误的位置、数据库打开了错误的路径)。你的直觉是在错误出现的地方修复,但那只是在治疗症状。
核心原则: 沿调用链向上回溯,直到找到原始触发点,然后在源头修复。
何时使用
使用场景:
- 错误发生在执行深处(不在入口点)
- 堆栈跟踪显示很长的调用链
- 不清楚无效数据从哪里产生
- 需要找到是哪个测试/代码触发了问题
追踪流程
1. 观察症状
错误:git init 在 /Users/jesse/project/packages/core 中失败
2. 找到直接原因
什么代码直接导致了这个问题?
await execFileAsync('git', ['init'], { cwd: projectDir });
3. 问:谁调用了这个?
WorktreeManager.createSessionWorktree(projectDir, sessionId)
→ 被 Session.initializeWorkspace() 调用
→ 被 Session.create() 调用
→ 被测试中的 Project.create() 调用
4. 继续向上追踪
传递了什么值?
projectDir = ''(空字符串!)- 空字符串作为
cwd会解析为process.cwd() - 那就是源代码目录!
5. 找到原始触发点
空字符串从哪里来?
const context = setupCoreTest(); // 返回 { tempDir: '' }
Project.create('name', context.tempDir); // 在 beforeEach 之前访问了!
添加堆栈跟踪
当无法手动追踪时,添加诊断工具:
// 在有问题的操作之前
async function gitInit(directory: string) {
const stack = new Error().stack;
console.error('DEBUG git init:', {
directory,
cwd: process.cwd(),
nodeEnv: process.env.NODE_ENV,
stack,
});
await execFileAsync('git', ['init'], { cwd: directory });
}
关键: 在测试中使用 console.error()(不要用 logger - 可能不会显示)
运行并捕获:
npm test 2>&1 | grep 'DEBUG git init'
分析堆栈跟踪:
- 寻找测试文件名
- 找到触发调用的行号
- 识别模式(同一个测试?同一个参数?)
找出哪个测试造成了污染
如果某些东西在测试期间出现但你不知道是哪个测试:
使用二分查找脚本:@find-polluter.sh
./find-polluter.sh '.git' 'src/**/*.test.ts'
逐个运行测试,在第一个污染者处停止。
实际示例:空 projectDir
症状: .git 被创建在 packages/core/(源代码目录)
追踪链:
git init在process.cwd()中运行 ← 空 cwd 参数- WorktreeManager 被传入空 projectDir
- Session.create() 传递了空字符串
- 测试在 beforeEach 之前访问了
context.tempDir - setupCoreTest() 初始时返回
{ tempDir: '' }
根因: 顶层变量初始化访问了空值
修复: 将 tempDir 改为 getter,如果在 beforeEach 之前访问则抛出异常
同时添加了纵深防御:
- 第一层:Project.create() 验证目录
- 第二层:WorkspaceManager 验证非空
- 第三层:NODE_ENV 守卫拒绝在 tmpdir 之外执行 git init
- 第四层:git init 之前的堆栈跟踪日志
关键原则
永远不要只在错误出现的地方修复。 回溯找到原始触发点。
堆栈跟踪技巧
- 在测试中: 使用
console.error()而非 logger - logger 可能被抑制 - 在操作之前: 在危险操作之前记录日志,而不是在失败之后
- 包含上下文: 目录、cwd、环境变量、时间戳
- 捕获堆栈:
new Error().stack显示完整调用链
实际影响
来自调试会话(2025-10-03)的数据:
- 通过5层追踪找到根因
- 在源头修复(getter 验证)
- 添加了4层防御
- 1847 个测试通过,零污染
附录C:子技能 - 纵深防御验证(Defense-in-Depth Validation)
概述
当你修复了一个由无效数据引起的 bug 时,在一个地方添加验证感觉就够了。但那个单一检查可以被不同的代码路径、重构或模拟(mock)绕过。
核心原则: 在数据经过的每一层都进行验证。使 bug 在结构上不可能发生。
为什么需要多层
单一验证:"我们修复了 bug" 多层验证:"我们使 bug 变得不可能"
不同层捕获不同情况:
- 入口验证捕获大多数 bug
- 业务逻辑捕获边界情况
- 环境守卫防止特定上下文中的危险操作
- 调试日志在其他层失效时提供帮助
四层防御
第一层:入口点验证
目的: 在 API 边界拒绝明显无效的输入
function createProject(name: string, workingDirectory: string) {
if (!workingDirectory || workingDirectory.trim() === '') {
throw new Error('workingDirectory 不能为空');
}
if (!existsSync(workingDirectory)) {
throw new Error(`workingDirectory 不存在: ${workingDirectory}`);
}
if (!statSync(workingDirectory).isDirectory()) {
throw new Error(`workingDirectory 不是目录: ${workingDirectory}`);
}
// ... 继续
}
第二层:业务逻辑验证
目的: 确保数据对当前操作有意义
function initializeWorkspace(projectDir: string, sessionId: string) {
if (!projectDir) {
throw new Error('工作空间初始化需要 projectDir');
}
// ... 继续
}
第三层:环境守卫
目的: 防止在特定上下文中执行危险操作
async function gitInit(directory: string) {
// 在测试中,拒绝在临时目录之外执行 git init
if (process.env.NODE_ENV === 'test') {
const normalized = normalize(resolve(directory));
const tmpDir = normalize(resolve(tmpdir()));
if (!normalized.startsWith(tmpDir)) {
throw new Error(
`在测试期间拒绝在临时目录之外执行 git init: ${directory}`
);
}
}
// ... 继续
}
第四层:调试工具
目的: 为事后分析捕获上下文
async function gitInit(directory: string) {
const stack = new Error().stack;
logger.debug('即将执行 git init', {
directory,
cwd: process.cwd(),
stack,
});
// ... 继续
}
应用此模式
当你发现 bug 时:
- 追踪数据流 - 错误值从哪里产生?在哪里被使用?
- 映射所有检查点 - 列出数据经过的每一个点
- 在每一层添加验证 - 入口、业务、环境、调试
- 测试每一层 - 尝试绕过第一层,验证第二层是否能捕获
会话中的示例
Bug:空 projectDir 导致 git init 在源代码目录中执行
数据流:
- 测试设置 → 空字符串
Project.create(name, '')WorkspaceManager.createWorkspace('')git init在process.cwd()中运行
添加的四层防御:
- 第一层:
Project.create()验证非空/存在/可写 - 第二层:
WorkspaceManager验证 projectDir 非空 - 第三层:
WorktreeManager在测试中拒绝在 tmpdir 之外执行 git init - 第四层:git init 之前的堆栈跟踪日志
结果: 全部 1847 个测试通过,bug 不可能再现
关键洞察
四层防御都是必要的。在测试过程中,每一层都捕获了其他层遗漏的 bug:
- 不同的代码路径绕过了入口验证
- 模拟(mock)绕过了业务逻辑检查
- 不同平台上的边界情况需要环境守卫
- 调试日志识别了结构性的误用
不要停留在单一验证点。 在每一层都添加检查。
附录D:子技能 - 完成前验证(Verification Before Completion)
概述
在没有验证的情况下声称工作完成是不诚实,而非高效。
核心原则: 证据优先于声明,始终如此。
违反此规则的字面要求就是违反此规则的精神。
铁律
没有最新验证证据,不得声称完成
如果你在本条消息中没有运行验证命令,你就不能声称它通过了。
关卡函数
在声明任何状态或表达满意之前:
1. 识别:什么命令能证明这个声明?
2. 运行:执行完整命令(最新的、完整的)
3. 阅读:完整输出,检查退出码,计算失败数
4. 验证:输出是否确认了声明?
- 如果否:用证据陈述实际状态
- 如果是:声明时附上证据
5. 只有这时:才能做出声明
跳过任何一步 = 在撒谎,而非在验证
常见的失败模式
| 声明 | 需要的证据 | 不充分的 |
|---|---|---|
| 测试通过 | 测试命令输出:0 个失败 | 之前的运行结果、"应该通过" |
| Linter 无错误 | Linter 输出:0 个错误 | 部分检查、推断 |
| 构建成功 | 构建命令:退出码 0 | Linter 通过、日志看起来不错 |
| Bug 已修复 | 原始症状测试:通过 | 代码已修改、假设已修复 |
| 回归测试有效 | 红-绿循环已验证 | 测试只通过了一次 |
| 代理已完成 | VCS diff 显示变更 | 代理报告"成功" |
| 需求已满足 | 逐行核对清单 | 测试通过 |
危险信号 - 立即停下
- 使用"应该"、"可能"、"看起来"
- 在验证前表达满意("很好!"、"完美!"、"搞定了!"等)
- 即将在没有验证的情况下提交/推送/创建 PR
- 信任代理的成功报告
- 依赖部分验证
- 想着"就这一次"
- 疲惫了想结束工作
- 任何暗示成功但未运行验证的措辞
防止合理化
| 借口 | 现实 |
|---|---|
| "现在应该可以了" | 运行验证 |
| "我很有信心" | 信心 ≠ 证据 |
| "就这一次" | 没有例外 |
| "Linter 通过了" | Linter ≠ 编译器 |
| "代理说成功了" | 独立验证 |
| "我累了" | 疲惫 ≠ 借口 |
| "部分检查就够了" | 部分验证什么也证明不了 |
| "换个说法所以规则不适用" | 精神重于字面 |
关键模式
测试:
正确:[运行测试命令] [看到:34/34 通过] "所有测试通过"
错误:"现在应该通过了" / "看起来正确"
回归测试(TDD 红-绿循环):
正确:编写 → 运行(通过)→ 还原修复 → 运行(必须失败)→ 恢复 → 运行(通过)
错误:"我写了一个回归测试"(没有红-绿验证)
构建:
正确:[运行构建] [看到:退出码 0] "构建通过"
错误:"Linter 通过了"(Linter 不检查编译)
需求:
正确:重读计划 → 创建核对清单 → 逐项验证 → 报告差距或完成
错误:"测试通过了,阶段完成"
代理委托:
正确:代理报告成功 → 检查 VCS diff → 验证变更 → 报告实际状态
错误:信任代理报告
为什么这很重要
来自 24 个失败记忆:
- 合作伙伴说"我不相信你" - 信任被破坏
- 未定义的函数被发布 - 会导致崩溃
- 缺失的需求被发布 - 功能不完整
- 在错误的完成声明上浪费时间 → 重新引导 → 返工
- 违反了:"诚实是核心价值。如果你撒谎,你会被替换。"
何时应用
始终在以下情况之前:
- 任何形式的成功/完成声明
- 任何满意的表达
- 任何关于工作状态的正面陈述
- 提交、创建 PR、完成任务
- 转到下一个任务
- 委托给代理
规则适用于:
- 精确措辞
- 转述和同义词
- 成功的暗示
- 任何暗示完成/正确性的沟通
底线
验证没有捷径。
运行命令。阅读输出。然后声明结果。
这是不可商量的。