系统化调试(Debugging)

中级 Intermediate 纪律型 Discipline claude-code
14 min read · 720 lines

四个子技能覆盖从根因分析到验证完成的完整调试流程

系统化调试(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 已解决,测试通过

当流程未发现根因时

如果系统化调查确实发现问题是环境相关、时序依赖或外部因素:

  1. 你已完成了流程
  2. 记录你调查了什么
  3. 实施适当的处理(重试、超时、错误消息)
  4. 添加监控/日志以供后续调查

但是: 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/(源代码目录)

追踪链:

  1. git initprocess.cwd() 中运行 ← 空 cwd 参数
  2. WorktreeManager 被传入空 projectDir
  3. Session.create() 传递了空字符串
  4. 测试在 beforeEach 之前访问了 context.tempDir
  5. 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 时:

  1. 追踪数据流 - 错误值从哪里产生?在哪里被使用?
  2. 映射所有检查点 - 列出数据经过的每一个点
  3. 在每一层添加验证 - 入口、业务、环境、调试
  4. 测试每一层 - 尝试绕过第一层,验证第二层是否能捕获

会话中的示例

Bug:空 projectDir 导致 git init 在源代码目录中执行

数据流:

  1. 测试设置 → 空字符串
  2. Project.create(name, '')
  3. WorkspaceManager.createWorkspace('')
  4. git initprocess.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、完成任务
  • 转到下一个任务
  • 委托给代理

规则适用于:

  • 精确措辞
  • 转述和同义词
  • 成功的暗示
  • 任何暗示完成/正确性的沟通

底线

验证没有捷径。

运行命令。阅读输出。然后声明结果。

这是不可商量的。

相关技能 Related Skills