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

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

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

一句话先修(最快路径):批准 patch 之前先跑 git diff --stat,看 diff 里有没有 lib/utils/common/ 下的文件。只要共享 util 被改了、却只有一个 feature 的测试是绿的,就别 merge。然后用两句话重新 prompt Codex,能挡掉约 80% 的连带破坏:(1)「只改 [目标路径] 下的文件;fix 需要动共享 util 就 STOP,先列出所有 caller」;(2)「apply 前先产出 consumer 影响报告」。想要一道不能被绕过的硬约束,就在 sandbox 层面限定 Codex,而不是靠 prompt 文字——见 Step 0。

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

先判断你属于哪一类

patch 之后的症状最可能的原因跳到
git diff --stat 里出现共享 util(lib/utils/common/fix 改了被多处调用的代码原因 1
返回类型没变,运行时值变了("" 还是 null语义变了、签名没变原因 2
caller 对原本能用的输入开始 throw加了新的前置条件 / 校验原因 3
下游某个读取(缓存、队列、事件)不触发了一个副作用被删了原因 4
bug 自己的测试过了,邻居 feature 在生产挂测试盖了 fix、没盖邻居原因 5
Codex 没打开过的文件报 typecheck代码挪了、import 断了原因 6

常见原因

按命中率从高到低:

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 0 是唯一一道 Codex 没法忽略的。

Step 0:用 sandbox,而不只是 prompt

Prompt 文字是请求,不是围栏——Codex 官方文档自己也写了「there is no guarantee that guidelines inside this file will be followed」。真正硬的控制是 sandbox_modeapproval_policy。截至 2026 年 6 月,Codex CLI 默认跑 --sandbox workspace-write,它已经把写入限制在当前 workspace 内,并在 OS 层把 .git.codex.agents 设为只读——无论模型「想怎么改」。

这个默认值挡不住 Codex 改 workspace 里面 的共享 util——但你可以把 workspace 缩小。从仍然包含这个 bug 的最窄目录启动 Codex,让 lib/utils/ 落在它的可写根之外:

# 把 Codex scope 到这个 feature,而不是整个 repo。
# 现在写这个目录之外的文件,都会触发显式 approval。
cd src/features/booking
codex --sandbox workspace-write --ask-for-approval on-request "fix the timezone bug"

配上 --ask-for-approval on-request(也是 --full-auto 搭配的默认值),任何想动启动目录之外路径的尝试都会停下来先问你——这样改共享 util 会以一个 approval 提示的形式冒出来,而不是一份悄无声息的 diff。修 bug 任务别从 repo 根目录用 --full-auto,更不要用 --yolo / --dangerously-bypass-approvals-and-sandbox:它同时关掉 sandbox 和 approval,正好放开你要限的那个 blast radius。

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、独立评审、刻意授权

怎么确认真的修好了

别只看「bug 的测试绿了」,要确认 blast radius 是零:

  1. git diff --stat origin/main——确认 lib/utils/common/ 下没有文件被改,除非你在 Step 5 显式授权过。
  2. 跑全量测试,不是单个 bug 测试:pnpm test(或 npm test / pytest)。目标测试过、邻居测试挂,正是本文要解决的那个 failure mode。
  3. pnpm typecheck(或 tsc --noEmit)——抓原因 6(import 挪动),以及任何依赖了现在已改返回类型的 caller。
  4. 对每个确实被改的共享符号,grep 它的 caller、逐行扫一眼:grep -rn "formatDate" src/。如果某个 caller 是跟 null"" 或某个具体字符串比较的,确认新行为仍然满足它。
  5. 有 staging 环境的话,merge 前至少对一个 邻居 feature 冒烟测试一遍(这里是:发一封 email)——原因 5 只在运行时暴露。

常见问题

我 prompt 里明明写了「只修 booking 的 bug」,为什么还是会这样? 因为 prompt 是建议性的。模型仍然可以判断改共享 util 是最干净的修法。唯一不能被绕过的限制是 sandbox:从 feature 目录启动 Codex(Step 0),这样目录之外的任何改动都会触发 approval 提示,而不是悄悄改掉。

--sandbox workspace-write 能阻止 Codex 改共享 util 吗? 只有当那些 util 在可写根之外才行。workspace-write sandbox 拦的是写 启动目录之外 的文件,并保护 .git.codex.agents,但同一个 workspace 里的 lib/ 文件夹仍然可以随便改。把 workspace 缩小(cd 进 feature)才能把共享代码推到够不着的地方。

Codex 改了函数返回值,但 TypeScript 没报错,为什么? 因为签名没变——它还是返回 string(原因 2)。把 null 换成 ""、或加一个 throw,对类型系统都是隐形的,因为静态类型一模一样。这类破坏只会通过测试或运行时暴露,所以 Step 4 和 Step 6 要把测试网撑得比 bug 自己的测试更宽。

这算 Codex 的 bug、该我去报吗? 通常不算——这是调用方这边的 scoping 缺口。只有当 Codex 在不询问的情况下写到了 workspace sandbox 之外,才值得报(这种确实被提过,比如 openai/codex issue #11583)。否则修法是限定 scope,而不是等官方 patch。

单点收益最高的预防是什么?AGENTS.md 里放一条默认规则:「动共享 util(lib/utils/common/)前必须先列出 caller」。它不会 100% 被遵守(guideline 不是 guarantee),所以要配上 Step 0 的 sandbox scoping,以及对每个 patch 都跑一遍 git diff --stat 检查。

相关阅读

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