AI重写老旧CLI脚本时,可能改变输出格式、结尾换行或退出码,导致破坏与cron、下游解析器的隐式契约。解法:将CLI输出当作规格,用回归测试锁死行为后再改内部实现。
计费导出脚本把发票打印到 stdout、把日志混进 stderr、还用嵌套 if 语句编码税务规则。团队里已经没人能说出上一次理解那个舍入路径的人是谁了。一个 coding agent 能在几分钟内重写这个模块——而这恰恰说明重写是错误的第一步。
这篇 walkthrough 用的是一个构造的 brownfield 示例,而不是从私有仓库里拿出来的未经核实的生产事故。工作的目标是一个严格的 replay gate,它把进程输出当作缺失的规格说明。一旦 pin 文件锁定,只允许一次内部修改,且前提是 replay 仍然匹配。内部实现可以移动;进程边界不能。
廉价的 patch 并不会让行为变得容易信任
生成速度更快并不会消除与 cron、下游解析器、以及用 grep 过滤日志行的运维人员之间的隐式契约。一个清理了命名规范的 agent 仍然可能改掉末尾换行符、金额格式,或者一个被包装脚本当作成功状态的退出码。这些都是行为变更,哪怕 pull request 读起来像一次重构。
技术债务不会因为 patch 到达的速度快于审查速度就收缩。当未经核实的编辑在只有生产环境还在触达的未文档化边界周围积累时,债务反而会增长。务实的应对不是更大的 prompt 和更广泛的清理。务实的应对是一份冻结的 I/O 记录,下一个 patch 必须逐行满足它。
Pin 文件实际记录了什么
一个 pin 是一次完整的进程执行,而不是关于私有辅助函数的单元断言。pin 日志中的每一行是一个 JSON 对象,拥有一个封闭的字段列表。人类应该能够阅读一个 pin 并用一句话解释这个场景。
id: 场景的稳定名称,例如 tax-exempt-q3argv: 解释器名称之后的参数向量env: 脚本实际读取的那些 keystdin: 记录运行所使用的原始文本exit_code: 从进程观察到的整数状态stdout 和 stderr: 带有文档化解码模式的精确 Unicode 文本cwd_rel: 相对于仓库根目录的工作目录pin 文件是原始作者从未写下的规格说明。如果一个字段不在那个列表里,它还不属于合并契约的一部分。
一个值得 pin 的混乱模块
下面的例子是故意写得别扭的。它会修改全局变量、在 stderr 上打印进度、用临时凑的舍入格式化金额。把它当作带标签的示例代码,而不是经过衡量的生产提取物。
# export_invoices.py — constructed example, not production code
from __future__ import annotations
import json
import os
import sys
from decimal import Decimal, ROUND_HALF_EVEN
TAX = Decimal(os.environ.get("TAX_RATE", "0.0875"))
SEEN = []
def money(n: str) -> str:
d = Decimal(n).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
if os.environ.get("CURRENCY") == "USD":
return f"${d}"
return str(d)
def main(argv: list[str]) -> int:
path = argv[1] if len(argv) > 1 else "-"
raw = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
rows = json.loads(raw) if raw.strip() else []
exempt = os.environ.get("TAX_EXEMPT", "0") == "1"
out = []
for row in rows:
SEEN.append(row["id"])
base = Decimal(str(row["amount"]))
tax = Decimal("0.00") if exempt or row.get("kind") == "internal" else (base * TAX)
tax = tax.quantize(Decimal("0.00"), rounding=ROUND_HALF_EVEN)
line = f"{row['id']},{money(str(base))},{money(str(tax))},{money(str(base + tax))}"
out.append(line)
print(f"wrote {row['id']}", file=sys.stderr)
print("\n".join(out))
print(f"count={len(SEEN)}", file=sys.stderr)
return 0 if out else 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
这个脚本小到足以阅读,但不加 gate 就改进仍然不安全。舍入、豁免标志、空输入退出码、stderr 闲聊都是承重结构。一次仅重命名的 patch 仍然可能破坏下游的 cut 或 cron 邮件过滤器。
在任何生产编辑之前记录 pin
录制器把当前脚本作为 oracle 运行,不解释业务规则。把这个 harness 标记为一份提议的工作流,而不是带有计时或通过率指标的基准。在代码还丑陋的时候就提交 pin。
# record_pins.py — proposed recorder
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
PIN_PATH = ROOT / "pins.jsonl"
def run_case(case: dict) -> dict:
env = os.environ.copy()
for key in ("TAX_RATE", "TAX_EXEMPT", "CURRENCY"):
env.pop(key, None)
env.update(case.get("env") or {})
stdin = case.get("stdin", "")
proc = subprocess.run(
[sys.executable, str(ROOT / "export_invoices.py"), *case.get("argv", ["-"])],
input=stdin,
text=True,
capture_output=True,
env=env,
cwd=ROOT,
check=False,
)
return {
"id": case["id"],
"argv": case.get("argv", ["-"]),
"env": case.get("env") or {},
"stdin_sha256": hashlib.sha256(stdin.encode()).hexdigest(),
"stdin": stdin,
"exit_code": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
"cwd_rel": ".",
}
CASES = [
{
"id": "empty-stdin",
"argv": ["-"],
"env": {"TAX_RATE": "0.0875", "CURRENCY": "USD"},
"stdin": "",
},
{
"id": "mixed-kinds",
"argv": ["-"],
"env": {"TAX_RATE": "0.0875", "CURRENCY": "USD", "TAX_EXEMPT": "0"},
"stdin": json.dumps(
[
{"id": "A-1", "amount": "10.00", "kind": "external"},
{"id": "A-2", "amount": "10.005", "kind": "internal"},
{"id": "A-3", "amount": "0.015", "kind": "external"},
]
),
},
{
"id": "org-exempt",
"argv": ["-"],
"env": {"TAX_RATE": "0.0875", "CURRENCY": "USD", "TAX_EXEMPT": "1"},
"stdin": json.dumps([{"id": "B-9", "amount": "99.99", "kind": "external"}]),
},
]
def main() -> None:
lines = [json.dumps(run_case(c), ensure_ascii=False) for c in CASES]
PIN_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"wrote {len(lines)} pins to {PIN_PATH}")
if __name__ == "__main__":
main()
对未编辑的脚本运行一次录制器,然后在一个不包含重构的 commit 中提交 pins.jsonl。那个 commit 之后,agent 不允许重新生成 pin 来让一个 patch 通过。
python record_pins.py
git add export_invoices.py record_pins.py pins.jsonl
git commit -m "Pin invoice exporter I/O before any refactor"
Replay 是唯一的合并 gate
下面的测试重新注入每个 pin 并将退出码、stdout 和 stderr 作为字符串比较。它在空白符和日志措辞上故意严格要求。如果以后需要规范化,在测试文件中记录那个函数,而不是悄悄编辑 pin。
# test_replay_pins.py
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def load_pins():
text = (ROOT / "pins.jsonl").read_text(encoding="utf-8")
return [json.loads(line) for line in text.splitlines() if line.strip()]
def replay(pin: dict) -> subprocess.CompletedProcess:
env = os.environ.copy()
for key in ("TAX_RATE", "TAX_EXEMPT", "CURRENCY"):
env.pop(key, None)
env.update(pin["env"])
return subprocess.run(
[sys.executable, str(ROOT / "export_invoices.py"), *pin["argv"]],
input=pin["stdin"],
text=True,
capture_output=True,
env=env,
cwd=ROOT / pin["cwd_rel"],
check=False,
)
def test_every_pin_replays():
failures = []
for pin in load_pins():
proc = replay(pin)
if proc.returncode != pin["exit_code"]:
failures.append(f"{pin['id']}: exit {proc.returncode} != {pin['exit_code']}")
if proc.stdout != pin["stdout"]:
failures.append(
f"{pin['id']}: stdout mismatch\n---\n{proc.stdout!r}\n===\n{pin['stdout']!r}"
)
if proc.stderr != pin["stderr"]:
failures.append(
f"{pin['id']}: stderr mismatch\n---\n{proc.stderr!r}\n===\n{pin['stderr']!r}"
)
assert not failures, "\n\n".join(failures)
python -m pytest test_replay_pins.py -q
一个小 hook 让 agent 在试图刷新 oracle 时保持诚实。这个 hook 是一个提议,你可以在审查后放到 .git/hooks/pre-commit 里。
#!/bin/sh
# proposed pre-commit: fail if pins.jsonl changes beside production code
if git diff --cached --name-only | grep -q '^pins\.jsonl$'; then
if git diff --cached --name-only | grep -q '^export_invoices\.py$'; then
echo "refuse: pins.jsonl and export_invoices.py in the same commit"
exit 1
fi
fi
第一次绿色 replay 后的内部编辑
只有在 replay 为绿之后,下一个 patch 才能碰内部实现,而且只能处理一个关注点。在这个示例中,一个合理的第一次编辑是在不改变 print 的前提下去提取舍入逻辑。让 main 保持为唯一的进程入口,这样 argv 和 env 留在一个地方。
提议的规则,标记为未执行的指导而非已完成的重构:
决策表:保留 diff 还是终止会话
下面的表格就是工作流。围绕它的 prompt 是可选的注释,绝不会覆盖红色的 replay。
覆盖率漏洞仍然可能存在。一个没有任何 pin 覆盖的隐藏分支会 replay 为绿但仍然是错的。在生产日志中发现那些分支时添加 pin,而不是在 agent 要求更多自由度时添加。
一个一次性 agent 会话属于哪里
在 pin 存在之后 agent 才有用,因为失败的尝试会显示为红色 replay 而不是看似合理的文字。它不适合作为在脏树上录制当前 oracle 的替代品。
披露:本文是 MonkeyCode 产品推广的一部分。
MonkeyCode 的免费模型访问和免费服务器选项恰好适合这个循环,作为一个一次性的地方来尝试那一次内部编辑。在你自己的 checkout 上录制 pin,提交它们,然后把只有仓库快照复制到那台服务器上。合并决策留在本地 replay,而不是会话记录中。如果你把这个产品从方法中移除,pin-and-replay gate 仍然在笔记本上工作。
局限性,以及谁应该跳过这个
当脚本与时钟、网络或无序映射对话时,进程级 pin 是脆弱的。它们也会过度拟合日志措辞——这在本文中是刻意的,但如果你已经计划了日志重设计则是有害的。隐藏分支的空覆盖率会看起来像成功。
在以下任一情况为真时跳过这种方法:
时钟和网络接缝应该在录制之前注入,而不是在 agent 开始编辑之后。如果你无法冻结时间,你还没有值得合并的 pin。不要把绿色 replay 当作税务规则正确的证明;它只能证明它们没有改变。
一个 pull-request 检查清单,而不是 prompt 倾倒
用这个列表作为审查笔记。它比让 agent 扫一遍文件慢,而那个慢就是控制。
pytest test_replay_pins.py,如果 pins.jsonl 缺失则构建失败git diff --stat在一个混乱的脚本上,生成不再是稀缺资源。与上一个已知进程边界的一致性才是。用 JSONL 锁定那个边界,然后只有在 replay 仍然输出相同契约时才接受 agent diff。