三行净额0.30、税率19%的发票,旧代码每行先四舍五入到分再相加,账单是1.08;新代码把原始金额加总后只舍入一次,账单变成1.07。测试全绿,账单少了一分钱。
![]()
Agent 的最后一条消息读起来很漂亮:“把发票总额计算从 InvoiceController 挪进了 InvoiceTotals 用例。无行为变更。所有测试通过。”你去查,测试确实通过。问题在于,测试可能漏掉了那一处变化。
旧控制器把每行的毛额舍入到分,再累加。新类把原始金额相加,最后舍入一次。三行 0.30 净额、19% 税:每行是 0.357,舍入成 0.36,旧代码收 1.08;新代码加出 1.071,舍入成 1.07。如果你的测试夹具里每张发票都只有一行,两个版本结果一致,套件保持绿色,而账单悄悄掉了一分钱。
绿色的套件只证明这次重构没有破坏测试所检查的东西,它对没人写测试的输入一无所知。GitHub 的 Scientist 是一个“用于谨慎重构关键路径”的库,它的 README 里写着:“测试可以帮助引导你的重构,但你真正想要的是在负载下比较当前行为与重构后的行为。”
今天就能做一个本地的小型版本:把一个 worktree 检出到起点,另一个放 agent 的分支,把同一批录制好的输入分别回放给两边,遮掉每次运行都会变的部分,再 diff 输出。下面这套脚本大约 50 行 bash,适用于大多数 CLI 或 HTTP 服务,只需要 git、jq 和 curl。
第一步:把起点放进独立 worktree
git worktree 是链接到同一仓库的第二个工作目录。你需要一个给 agent 的分支(可能已经有了),另一个给 agent 起步时所在的那个提交。
要跟那个起始提交比,而不是跟今天的 main 比。如果 agent 干活期间 main 动过,跟它 diff 会把 agent 的改动和别人的改动混在一起。git merge-base 能找到分支分叉的那个提交:
- cd ~/code/app
- base=$(git merge-base main hermes/extract-invoice-totals)
- git worktree add --detach ../app-base "$base"
- cp .env ../app-base/.env
- (cd ../app-base && composer install -q)
传提交哈希而不是分支名,可以绕开 git 的一条规则:一个分支同一时间只能在一个 worktree 里检出。文档说,当 commit-ish“是分支名且已被另一个 worktree 检出”时,add 会拒绝。新建的 worktree 只含被跟踪的文件,所以 .env 和 vendor/ 这类被忽略的东西得复制或安装,也就是上面最后两行做的事。
同样的输入,两个版本的代码,一份 diff。用完 git worktree remove ../app-base 清理。按文档,除非加 --force,它拒绝删除有修改或未跟踪文件的 worktree。
第二步:录制像生产环境的输入
cases 文件每行一条 shell 命令。每行在每个 worktree 里各跑一次,HTTP 用例的 APP_URL 指向那一侧的服务器。以 # 开头的行跳过。
- php artisan invoice:show 1001 --json
- php artisan invoice:show 1002 --json
- php artisan invoice:show 1003 --json --currency=EUR
- php artisan invoice:show 999999 --json
- curl -s -w "\n%{response_code}\n" "$APP_URL/api/invoices/1001"
- curl -s -w "\n%{response_code}\n" "$APP_URL/api/invoices/abc"
invoice:show 命令代表你的应用暴露的任何 Artisan 命令或 CLI。把无聊的用例、多行的用例、不存在的 ID 和格式错误的 ID 都放进去。curl 的 -w 在传输结束后写出文本和信息,所以 %{response_code} 把状态码放进输出,一个变成 500 的 200 就会在 diff 里现形。
HTTP 输入不必自己编。nginx 默认的 combined 日志格式把方法和路径放在第 6、7 个字段,真实的 GET 路径可以直接从访问日志里捞出来:
- awk '$6 == "\"GET" { print $7 }' access.log \
- | sort -u | head -200 \
- | sed 's|.*|curl -s -w "\\n%{response_code}\\n" "$APP_URL&"|' \
- >> tests/diff/cases.txt
提交之前先读一遍结果。查询字符串可能带着 token 和个人数据,路径里有 $ 会被 shell 展开。除非两边各有自己的数据库副本,否则只保留 GET:回放一个 POST 会改掉下一个用例读到的行。
第三步:脚本
存成 scripts/difftest.sh,chmod +x,在 agent 开始之前连同 cases 文件一起提交到 main。
脚本开头用 set -euo pipefail,用 mktemp -d 建临时目录并用 trap 清理。它定义了几条正则:日期、时钟、时区拼成的时间戳,以及 UUID。如果 cases 里有非注释行带 APP_URL,而 BASE_URL 或 CAND_URL 没设,脚本直接报错退出。
normalize 函数先判断文件是不是 JSON,是就用 jq -S 排序输出(可加 JQ_FILTER),不是就原样 cat,然后用 sed 把时间戳和 UUID 替换成空格。
run_case 函数进入指定目录,把 APP_URL 设成对应侧地址,用 bash -c 执行命令,输出重定向到文件,失败时追加 exit= 状态码。
主循环逐行读 cases,跳过空行和注释,给每个用例编号,在 base 和 cand 各跑一次。如果这一行带 APP_URL 且两边输出里都有 exit=,打印“NO RESPONSE”并计为失败。否则用 diff -u 比较两份输出,有差异就打印“DIFF”和具体差异,并计失败数。
脚本跑在分支自己的 worktree 里,任务作为 agent 的第一个提示词。agent 可以是 Claude Code、Codex 或其他 CLI agent,那就是第一步里已经建好的第二个 worktree。
Hermes 会从你的仓库读 .hermes/worktree.toml 配方。它的 done_when 列表存放任务的默认检查,按 README 的说法,“你自己的检查可以决定任务何时完成”。copy 只接收 git 忽略的文件,所以 .env 必须在里面。
- setup = ["composer install -q"]
- copy = [".env"]
- done_when 里放 composer test,以及一条带 BASE_URL、CAND_URL 和 difftest.sh 的多行命令
三引号字符串是 TOML 的多行形式:行尾的反斜杠会去掉换行和其后的缩进,所以第二条检查是一条命令。
在开始任务之前先建好 base worktree,否则第一条检查没有脚本可跑。此时任务起始分支的顶端就是 agent 将要分叉的提交,所以 git worktree add --detach ../app-base main 给出的就是第一步那个文件夹。
不传第三个参数时,脚本从 base 文件夹读 cases。脚本和 cases 都来自 base worktree,它位于 agent 的 worktree 之外。否则一个被告知“让这个检查通过”的 agent 可以改 cases.txt 直到 diff 安静下来。那个 . 假定检查从任务的 worktree 运行,也就是 composer test 必须运行的地方。如果你的检查从别处启动,就传 worktree 的路径。Review Desk 的 Worktrees 标签页会列出任务 worktree 及其分支。
HTTP 用例需要两个服务器分别跑在那两个 URL 的端口上。Hermes 既能开普通 shell 也能开 agent,分屏把两者并排放:一边 base 服务器,一边分支的服务器。检查通过后,Review Desk(⌘G 或 Ctrl+Shift+G)按轮次展示改动,Land 会提交、开 pull request 或合并回任务起始的分支,并支持撤销。
只对承诺无行为变更的任务把 diff 放进 done_when。功能任务上 diff 本来就该非空,一个见差异就失败的检查会挡住你要求的那个改动。这类任务手动跑脚本,把 diff 当审查辅助来读。
这套东西会在哪里断
diff 只覆盖你录下来的输入,仅此而已。如果 cases 文件里没有多行发票,开头那个舍入变化照样溜过去。把精力花在 cases 文件上,每有一个 bug 进到生产就补一条。
有副作用的东西需要隔离。邮件、队列任务、支付和写共享数据库都会跑两次,每边一次。把两个 worktree 指向假实现或各自的副本,或者把这些路径留给测试套件。
遮不掉的不确定性会让检查一直红。数据库返回的无序列表可以在 JQ_FILTER 里排序,随机采样需要固定种子。如果结果依赖墙上时钟,删掉这个用例。时间戳正则也很粗:它遮掉输出里每一个日期时间,包括重构可能弄坏的那个。如果日期对被改的代码很重要,就在 JQ_FILTER 里按字段名遮。
开头那个重构——把逻辑从 Laravel 控制器挪到端口后面的用例——正是 Decoupled PHP 讲的动作:一次一片地把领域从框架底下挪出来。像这样的差分检查,就是用来证明每一片挪动之后行为没变的,不管是你挪的还是 agent 挪的。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.