你提了:「修一下 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
formatDate、parseUser、serializeError、getEnv 这些到处用的工具。里面一个「小改」会扩散到每个 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 #排查 #排查 #连带破坏