Codex 修一个 bug 把附近改坏:6 个 collateral 来源 + 限定 blast radius

报的 bug 修好了,相邻两个功能挂了——限定改动范围、强制 caller 清单、优先在调用方加 guard 而不是改共享 util。

你提了:「修一下 booking 流程里的时区 bug」。Codex 的 PR 把 bug 修好了——顺手改了 formatDate() 处理 null 的方式,而这个函数被另外 14 个地方调用。Booking 流程能用了,但 email 服务现在发出 1970-01-01——原本它依赖 null 被显示成「—」是一份隐含合同。

第一反应是「Codex 把东西改坏了」。但真正的根因是 blast radius 没限——Codex 有权改共享代码、没审计 consumer、你也没让它审。修法:按 scope 限定改动 + merge 前要 consumer 影响报告 + 优先在 caller 端加 guard 而不是改 util。

常见原因

按命中率从高到低:

1. Fix 改了被多处调用的共享 util

formatDateparseUserserializeErrorgetEnv 这些到处用的工具。里面一个「小改」会扩散到每个 consumer,其中一半 Codex 根本没读过。

如何判断git diff --stat 看 patch——只要 util / common 文件出现在 diff 里、却只有一个 feature 的测试在跑,就是漏审了其他 consumer。

2. Codex 没改签名但改了语义

函数还是返回 string,但现在缺值时返回 "" 而不是 null。类型系统认为没变化,做 if (result === null) 的 caller 静默坏了。

如何判断:看函数体里改了 return 值、throw 行为或副作用——但签名没变。这类是 TypeScript 看不见的破坏。

3. Patch 加了新的前置条件 / 校验

getUser(id) 之前接受任意 string;Codex 加了「id 不是 UUID 就 throw」。原来传数字 ID 的 caller(“/users/42”)现在崩。

如何判断:函数顶部新增 throw 语句是高危信号。每个 caller 都过一遍输入形状。

4. Codex 改了副作用

saveSession(user) 原来同时写 Redis。Codex「清理」时把 Redis 写删了,理由是「函数名是 save 不是 cache」。session 读缓存全失败。

如何判断:函数体里被删的 I/O 行(写 DB、入队、发事件)——而函数名又不明显地暗示这个 I/O。

5. 测试只盖到 fix 没盖到邻居

Codex 给原 bug 加了回归测试,测试过;但 email 服务依赖同一个 util、没有测试——所以 regression 上线才暴露。

如何判断:看测试 diff——只有一个新测试覆盖了被修的路径,周围路径都没测。

6. Fix 在文件间挪了代码,别处 import 断了

Codex 把一个 helper 挪到 lib/helpers.ts。一些文件还按 utils/format.ts 老位置 import,挪过去后没 re-export——不相关的文件报类型错。

如何判断pnpm typecheck 在 Codex diff 外的文件报错,新错是 import 解析失败。

最短修复路径

按收益从高到低,前 2 步防住 80% 的连带破坏。

Step 1:在 prompt 里限定 blast radius

任务:修 [具体 bug] 在 [具体 file/feature]。

约束:
- 只能改 [目标路径] 下的文件。
- 不允许动任何共享 util(lib/、utils/、common/),除非显式批准。
- 不允许改函数签名或返回合同。
- 必须动共享 util 时,停下来列出所有 caller。

「停下来列 caller」是关键杠杆——逼 Codex 在动手前把 blast radius 显出来。

Step 2:动共享文件前要 consumer 影响报告

应用 patch 前先产出报告:
1. 列出每个 import 你要改的符号的文件:
   grep -rn "import.*formatDate" --include="*.ts" --include="*.tsx" .
2. 对每个 caller,一句话说明:
   - 它传什么形状的输入
   - 它期待什么返回值
   - 你的改动是否保持这个合同
3. 标出任何合同被破坏的 caller,修或上报。

报告过了再 apply。

Step 3:优先在 caller 端加 guard,不要改 util

booking 流程要 formatDate 对 null 不同处理时,别改 formatDate——改 booking 那个调用方:

// 差——改共享 util,blast radius = 所有 caller
function formatDate(d: Date | null): string {
  if (!d) return "—"; // 改了
  return d.toISOString();
}

// 好——caller 端 guard,blast radius = 只 booking
function renderBookingDate(d: Date | null): string {
  if (!d) return "—";
  return formatDate(d);
}

经验法则:行为是 feature-specific 的,guard 就放在 feature 里。

Step 4:跑比 bug 自己测试更宽的网

prompt 里加:

apply 后不仅跑 bug 自己的测试,还要:
- 你改过的 util 所在文件的全部测试
- grep 出每个引用了被改符号的测试文件

每个文件 pass/fail 报告一下。

Monorepo:

# 找 import 了被改文件的测试
git diff --name-only origin/main -- 'src/**' | while read f; do
  symbol=$(basename "$f" .ts)
  grep -rln "from.*${symbol}" --include="*.test.ts" --include="*.spec.ts" . | sort -u
done

Step 5:要改共享 util 时显式授权

确实要改时,授权要写明:

本次任务允许改 `lib/format.ts`。
apply 前:
1. 列出每个 consumer(`grep -rn "from.*lib/format"`)。
2. 对每个 consumer,把用到被改 export 的那一行贴给我。
3. 等我确认再 apply。

把横切修改当 refactor(刻意 + 评审),而不是”修 bug 顺手”。

Step 6:给热 util 加 consumer 测试层

被 >5 处使用的 util,加一个 consumer 合同测试文件:

// lib/format.consumers.test.ts
import { formatDate } from "./format";

describe("formatDate consumers", () => {
  it("booking flow: null 显示成 '—'", () => {
    expect(formatDate(null, { fallback: "—" })).toBe("—");
  });
  it("email service: null 显示成空字符串", () => {
    expect(formatDate(null, { fallback: "" })).toBe("");
  });
});

Codex 之后再改 formatDate,这个文件立刻 fail,能指出哪个 consumer 的合同破了。

预防建议

  • AGENTS.md 默认规则:动共享 util(lib/utils/common/)必须先列 caller
  • 任何动共享文件的 patch,merge 前要 consumer 影响报告
  • 优先在 caller 端加 guard,blast radius 留在局部
  • 热 util 测试文件加 consumer 合同测试,不只是单元测试
  • 共享改动后跑更宽的测试网——整个 feature suite,不只 bug 自己的测
  • 把”动 util”当 refactor 看待:独立 PR、独立评审、刻意授权

相关阅读

标签: #Codex #Coding Agent #排查 #排查 #连带破坏