让 Codex 调查回归前,先用 Git bisect 找到配送费改变的那次提交
同一笔订单、固定业务预期,在提交历史中寻找第一次变化。
- 固定边界输入和仓库外的业务预期
- 把目标失败、不可测版本与运行器故障分开
- 复核引入提交和父提交的具体收费结果
- 保留 skip 歧义,检查 replay 与恢复状态
1. 问题出在刚好满 100 元的那一单
普通配送原本满 100 元免配送费。某次更新后,99.99 元的订单仍收 8 元,100.01 元的订单仍免费,恰好 100 元的订单却多收了 8 元。最新代码只有一个字符值得怀疑,但如果最近有几十次改动,直接让 Codex 阅读所有差异,很容易先得到一段听起来合理的解释,随后才发现解释没有绑定到一次可重复的失败。
这个练习先把问题变成固定输入,再让 Git 找出行为变化所在的提交。我们用九个真正创建出来的临时提交重演调查:定位回归、处理一个无法测试的中间版本、查看引入差异,并验证退出二分后回到了原分支。最后还故意跳过关键边界,观察结论如何退化为两个候选。
初版实验于 2026 年 10 月 13 日在 Linux x86_64、Git 2.52.0、Python 3.12.14 上实际执行。本文没有启动 Codex 或其他模型;“给 Codex 的请求”是可采用的协作方式,测试证据来自下面公开的 Git 与 Python 程序。示例不读取既有项目,不联网,也不安装依赖。
2. 让规格留在被搜索的历史之外
先写下业务答案,再实现探针。这里约定普通配送未满 100 元收 800 分,达到门槛收 0 分;自提始终为 0 分;偏远配送固定收 1200 分,不参加免配送活动。这只是为了练习而定的规则,不代表任何商家的实际收费。
| 场景 | 订单金额 / 分 | 配送方式 | 预期配送费 / 分 |
|---|---|---|---|
| 门槛之下 | 9999 | standard | 800 |
| 恰好到门槛 | 10000 | standard | 0 |
| 门槛之上 | 10001 | standard | 0 |
| 自提 | 9999 | pickup | 0 |
| 偏远配送 | 10000 | remote | 1200 |
expected.json 把这些答案直接写成数字,不调用 shipping_fee 来生成“预期”。否则实现错了,预期也跟着错,测试照样变绿。探针和这份规格放在临时仓库外;二分切换历史版本时,它们不会随代码一起变老,也不会突然从某次提交中消失。
这五项足以观察当前回归,不是完整的配送计价验收。负数金额、未知配送方式、优惠券、币种、税费与地址校验均没有纳入实验。实际工程应先确认业务定义,并把复现输入缩到足以区分好坏版本的程度。
3. 九次提交里有一段已知的兼容性空白
lab.py 先生成这条线性历史。c01 实现正确规则;c02 只补文档;c03 把 API_SCHEMA 标记改成 2,用来模拟探针明确不支持的一次接口试验;c04 恢复支持的协议。c05 将条件从大于等于改成严格大于。后续提交只加注释或文档,不修复错误。
为了让实验的答案可检查,源码会先构造已知历史,再运行二分,最后才逐项审计九个版本。真实调查不会预先知道哪个提交有问题;后置的全历史审计是这份教学夹具的验收步骤,用来防止“脚本说找到了,所以它一定找对了”的循环证明。
可测试版本在这里呈现一次稳定的好坏切换。如果同一个输入时好时坏,或历史中曾修复后又重新引入,单次二分给出的边界就不能代表所有发生过的回归。先稳定探针,必要时缩小到一次明确的发布区间。
4. 在空目录复跑,读出可以核对的结果
准备一个新的空目录,把后文的 lab.py、probe.py、expected.json、verify.py 四份文件保存进去,使用 UTF-8 编码。在同一目录执行:
python -I -B lab.py evidence
python -I -B verify.py evidence
系统需要已有且可信的 Git 与 Python 3。实验只在系统临时目录创建仓库;运行结束会删除这个仓库,输出目录保留证据。evidence 已存在时会直接报错,不覆盖旧记录。复跑可以换成 evidence-second,并让两条命令使用同一个新目录名。
本机正常运行的关键输出如下,最后一行的目录路径会随保存位置改变:
Normal bisect: 2cd637d06a398d9740d3f342be8ba7caf4678d65 (c05)
History probe exit codes: 0,0,125,0,1,1,1,1,1
Run exit codes: normal=0, ambiguous=2, abort=128
Temporary repository removed; evidence: <本次输出目录>
PASS: c05, five fee cases, 0/1/125/128, ambiguous c05/c06, replay and clean reset
PASS: four unrelated fee signatures abort with 128
这里的退出码有两层:每个历史版本的探针用 1 表示抓到了目标回归;正常完成的 git bisect run 自己返回 0。第二轮的 2 表示本机没有得到唯一答案,第三轮的 128 表示运行被中止。不能把所有非零数字都概括成“实验失败”,也不能只见整个 Python 程序退出为零就宣布每个历史版本都通过了。
提交使用固定作者、内容、日期与 SHA-1 格式,在本环境的两次运行中哈希相同。验收器仍以实际创建的 c05 标签对应对象为准,不把上面的字面哈希当成跨平台保证;路径、Git 诊断文字和系统版本信息也可能不同。
5. 探针要分得清回归、不可测和自身故障
Git 官方文档规定:0 归为 good,1 到 127 中除 125 以外的值归为 bad,125 表示跳过,其他退出值会中止。本文选择只产生 0、1、125、128 四种结果,并在 JSON 输出中同时写原因与实际收费。这里的 1 只认已确认的失败特征:standard_boundary 实际收费 800 分,另外四个用例全部通过。自提或偏远配送出错、门槛收费变成其他金额,或目标与其他失败同时出现,都返回 128,先调查原因,不把它们自动当成本次回归。可以按运行版本查阅Git 2.52.0 的 bisect 文档。
probe.py 仅把已识别的 API_SCHEMA = 2 归为不可测。源文件找不到、函数不存在、语法错误或普通运行时异常均返回 128,让调查停下来。若把所有异常都包成 1,Git 会开始寻找“最早让测试工具出错的提交”;若一概包成 125,又可能把真正的问题藏进大片空白。
同样,不要把一条尚未验证过的测试命令直接交给 bisect run。命令不存在或不可执行时,常见 shell 错误码是 127 或 126,它们属于上述 bad 范围。先独立运行完整命令,确认解释器路径、入口文件和退出码含义,再让它自动判断历史。
本例的 --skip-sha256 和 --force-abort 是专门制造负例的开关。前者按已知夹具源码的哈希跳过 c05,后者立即返回 128;它们不参与正常查找,也不提供任意真实仓库的安全隔离。runpy 会执行被测 Python 文件,实际项目仍应只在已批准的测试环境中运行。
6. 找到 c05 以后,核对父提交与具体差异
运行器在开始前先把同一探针分别用在 c01 与 c09,要求返回值恰好是 0 和 1。如果你误把 c05 说成“已知正确”,探针仍会报门槛用例失败,起点资格就不成立。发布标签、版本号或同事的记忆都不能替代这一步。
在这个独立临时仓库中,核心操作相当于下面几行。路径是说明用的占位符;完整 lab.py 会提供实际绝对路径,并保存每个命令的输出。
git bisect start c09 c01 --
git bisect run /绝对路径/python -I -B /仓库外/probe.py
git bisect log
git diff --no-ext-diff --no-textconv c05^ c05 -- shipping.py
git bisect reset
正常路径实际先检查 c05,随后遇到 c03 的 125,再检查 c02 与 c04。最终定位到 c05。后置审计表明父提交 c04 的五项费用是 800、0、0、0、1200;c05 的费用是 800、800、0、0、1200,唯一改变仍是门槛用例。
- return 0 if total_cents >= 10000 else 800
+ return 0 if total_cents > 10000 else 800
这份证据支持“c05 首次让选定区间内的门槛用例失败”。要决定产品上如何修复,还应核对该规则是否仍被业务认可,以及完整测试套件是否覆盖其他计费分支。本例到定位与验证为止,不自动回滚、不生成生产补丁,也没有执行项目发布。
bisect.log 保存判断过程。实验 reset 后用它 replay,得到同一 c05;这证明判断记录能重建搜索状态。replay 没有重新运行每个收费测试,不能代替复测。最后的 reset 经程序检查已回到 main 的 c09,工作目录为空净状态,refs/bisect 下也没有残留引用。
7. 跳过关键边界时,两个候选就是最终答案
第二轮把区间缩到已知正确的 c04 和已知错误的 c06,再故意令 c05 返回 125。c06 比 c05 多一行费用单位注释,收费逻辑仍然错误。由于缺少 c05 的结果,这一轮无法区分“c05 引入”和“c06 引入”。本机输出中的候选部分为:
The first bad commit could be any of:
2cd637d06a398d9740d3f342be8ba7caf4678d65
675d8f165ea44696ac73feb3327036b935162971
We cannot bisect more!
这两个对象依次是 c05 与 c06,git bisect run 返回 2。verify.py 检查候选都出现,并确认输出没有宣称某一个“is the first bad commit”。即使读过源码的人已经知道 c05 有错,也不能把这一轮的自动结果写成“唯一定位成功”。需要补足不可测版本的环境、换一个能区分它们的探针,或保留候选范围。
第三轮直接让探针返回 128,Git 中止并返回 128。另一个独立负例对不存在 shipping.py 的空目录运行探针,也返回 128。它们检查的是调查遇到工具故障时能否停下,不是给这些版本贴上业务 bad 标签。
负例与正常结果都保存在 report.json;命令参数、stdout、stderr 和退出码保存在 commands.json。culprit.patch 与 bisect.log 另外保留,方便把“怎么得到结论”和“结论对应哪段修改”分开审阅。
8. 把这套方法交给 Codex 时,需要交代什么
最有价值的输入通常是已经确认的失败样本、允许操作的仓库,以及一个真实通过的旧版本。只有“昨天还能用”时,可以先请 Codex 收集线索;找到能重复判断的样本后,再允许它切换历史版本。不要在尚未确认用途的工作区里直接开始自动二分。
请在已批准的独立测试副本中调查配送费回归。
已知正确提交:<填写实际提交>;已知错误提交:<填写实际提交>。
复现输入:普通配送,金额 10000 分;业务预期配送费 0 分。
请先用同一探针重测两个端点,并补 9999、10001 分及特殊配送对照。
把探针放在搜索历史之外,写清通过、目标失败、不可测与中止的条件。
允许切换这个测试副本的历史;不要改动我正在编辑的工作区。
记录每次提交、输入、实际值、命令、退出码与源码摘要。
找到候选后复测它与父提交;有 skip 歧义时列出全部剩余候选。
本次只定位,不自动修改、提交、推送或回滚业务代码。
结束后恢复开始位置,并报告仍然没有验证的范围。
本文的普通二分会检出历史版本,所以默认会改变这个独立仓库的工作目录和 HEAD。现实项目若已有未提交内容,应先选择获准的隔离副本,并核对依赖与测试数据;不要把自动 stash、clean 或 reset --hard 当作省事的准备动作。
也先别急着给 bisect start 加 shipping.py 路径过滤。配送规则可能受配置、公共金额函数或依赖锁文件影响,过早过滤会把真正改变行为的提交排除在外。本例搜索整条合成历史,找到结果后才把 diff 的展示范围缩到 shipping.py。
带合并提交的工程也不能照搬“找到后只看一个父提交”的结论。应先明确调查的是分支内部的引入点,还是变更进入主线的时刻。本文只验证线性历史,没有验证合并图、子模块、LFS、外部数据库或依赖安装过程。
9. 复现性检查覆盖到哪里
脚本为 Git 子进程重建环境,只继承可信 PATH,另外设置临时 HOME、XDG 路径和固定语言。不继承外部 GIT_DIR、GIT_WORK_TREE、GIT_INDEX_FILE、GIT_CONFIG_* 等重定向;全局与系统 Git 配置被隔离,仓库使用空模板和空钩子目录。初始化参数可对照Git 2.52.0 的 init 文档。这些设置仅作用于脚本创建的仓库。
除了正常运行,还用带有 Git 路径重定向、签名配置注入和额外 PYTHONPATH 的父环境复跑了一次。两轮提交映射、端点结果、全历史收费、replay 结果和最终清理状态相同。输出中的临时绝对路径不同,因此没有把两个 report.json 的字节完全相同作为要求。
verify.py 独立检查手写预期、每个版本的实际费用、引入提交、负例结果、patch 的确切增删行和恢复后的状态;它还核对四份输入文件的 SHA-256,避免测试结束后悄悄换掉源码。哈希与本地校验器用于发现偏差,不是可信签名,不能抵抗有人同时改写全部文件。
2026 年 10 月 20 日在 Linux x86_64、Git 2.52.0、Python 3.12.14 上补充复验时,发现旧探针会把任何用例不匹配都归为 1,与“只寻找指定门槛回归”的约定不一致。现在 lab.py 还在仓库外建立四个独立负例:仅自提收费错误、仅偏远配送收费错误、门槛改收 799 分、门槛与自提同时出错。verify.py 按手写费用向量核对这些结果,要求全部返回 128;原九提交定位、skip 歧义、replay 和恢复状态也再次通过。这些负例不会改动用于二分的九提交历史。
下方是全部运行输入。lab.py 中的 base 字符串就是 shipping.py 的初始内容,后续九次修改也完整列在循环里;无需另找示例仓库、私有夹具或下载链接。程序结束会清理临时仓库,所以实验留下的是证据文件,不能在退出后继续对那个临时目录执行 Git 命令。
10. 完整文件:expected.json
这份预期按上面的业务规则手写。history_exit_codes 是夹具验收答案,probe.py 只读取 cases,不借已知提交标签决定收费结果。unexpected_fee_probes 单独规定四种非目标失败的收费与失败项,供验证器复核。
{
"cases": [
{"name": "standard_below", "total_cents": 9999, "method": "standard", "expected_cents": 800},
{"name": "standard_boundary", "total_cents": 10000, "method": "standard", "expected_cents": 0},
{"name": "standard_above", "total_cents": 10001, "method": "standard", "expected_cents": 0},
{"name": "pickup", "total_cents": 9999, "method": "pickup", "expected_cents": 0},
{"name": "remote", "total_cents": 10000, "method": "remote", "expected_cents": 1200}
],
"history_exit_codes": [0, 0, 125, 0, 1, 1, 1, 1, 1],
"first_bad_tag": "c05",
"ambiguous_tags": ["c05", "c06"],
"normal_run_exit_code": 0,
"ambiguous_run_exit_code": 2,
"abort_run_exit_code": 128,
"missing_input_exit_code": 128,
"unexpected_fee_probes": {
"pickup_only": {"failed": ["pickup"], "actual_cents": [800, 0, 0, 800, 1200]},
"remote_only": {"failed": ["remote"], "actual_cents": [800, 0, 0, 0, 800]},
"boundary_other_amount": {"failed": ["standard_boundary"], "actual_cents": [800, 799, 0, 0, 1200]},
"mixed_failures": {"failed": ["standard_boundary", "pickup"], "actual_cents": [800, 800, 0, 800, 1200]}
}
}
11. 完整文件:probe.py
探针读取当前版本的 shipping.py 和仓库外的固定用例。正常路径不会读取提交名称来决定好坏;只把已确认的门槛收费特征判为 1,其他收费偏差中止调查。
import argparse
import hashlib
import json
from pathlib import Path
import runpy
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--repo", default=".")
parser.add_argument("--skip-sha256", default="")
parser.add_argument("--force-abort", action="store_true")
args = parser.parse_args()
source = Path(args.repo) / "shipping.py"
def finish(code, **data):
print(json.dumps({"exit_code": code, **data}, sort_keys=True))
raise SystemExit(code)
if args.force_abort:
finish(128, reason="intentional runner failure")
try:
raw = source.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
if digest == args.skip_sha256:
finish(125, reason="intentional boundary skip", source_sha256=digest)
module = runpy.run_path(str(source))
if module.get("API_SCHEMA") == 2:
finish(125, reason="known unsupported API schema 2", source_sha256=digest)
if module.get("API_SCHEMA") != 1:
finish(128, reason="unexpected API schema", source_sha256=digest)
case_file = Path(__file__).resolve().with_name("expected.json")
cases = json.loads(case_file.read_text(encoding="utf-8"))["cases"]
rows = []
for case in cases:
actual = module["shipping_fee"](case["total_cents"], case["method"])
rows.append({**case, "actual_cents": actual})
except Exception as exc:
finish(128, reason=type(exc).__name__ + ": " + str(exc))
failed = [r["name"] for r in rows if r["actual_cents"] != r["expected_cents"]]
data = {"source_sha256": digest, "failed": failed, "cases": rows}
if not failed:
finish(0, **data)
boundary = next(r for r in rows if r["name"] == "standard_boundary")
if failed == ["standard_boundary"] and boundary["actual_cents"] == 800:
finish(1, **data)
finish(128, reason="unexpected fee mismatch; not the target regression", **data)
12. 完整文件:lab.py
运行器拒绝覆盖输出目录,创建并清理自己的临时仓库。它不接受已有仓库路径。
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile
HERE = Path(__file__).resolve().parent
OUT = Path(sys.argv[1] if len(sys.argv) > 1 else "evidence").resolve()
OUT.mkdir(parents=True, exist_ok=False)
GIT = shutil.which("git")
if not GIT:
raise SystemExit("Git is required; no dependencies will be installed")
PYTHON = str(Path(sys.executable).resolve())
PROBE = str(HERE / "probe.py")
records = []
report = {"platform": platform.platform(), "python": sys.version.split()[0]}
base = '''API_SCHEMA = 1
def shipping_fee(total_cents, method):
if method == "pickup":
return 0
if method == "remote":
return 1200
return 0 if total_cents >= 10000 else 800
'''
with tempfile.TemporaryDirectory(prefix="shipping-bisect-") as temporary:
root = Path(temporary)
repo = root / "repo"
repo.mkdir()
home = root / "home"
home.mkdir()
hooks = root / "empty-hooks"
hooks.mkdir()
template = root / "empty-template"
template.mkdir()
env = {
"PATH": os.environ.get("PATH", os.defpath),
"HOME": str(home), "XDG_CONFIG_HOME": str(home),
"LANG": "C", "LC_ALL": "C", "TZ": "UTC",
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull,
"GIT_ATTR_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0",
"GIT_PAGER": "cat", "PYTHONDONTWRITEBYTECODE": "1",
}
def command(argv, check=True, cwd=repo, extra=None):
result = subprocess.run(argv, cwd=cwd, env={**env, **(extra or {})},
text=True, capture_output=True)
record = {"argv": argv, "cwd": str(cwd), "returncode": result.returncode,
"stdout": result.stdout, "stderr": result.stderr}
records.append(record)
if check and result.returncode:
raise RuntimeError(json.dumps(record, ensure_ascii=False))
return record
def git(*args, check=True, extra=None):
return command([GIT, "--no-pager", *args], check=check, extra=extra)
def probe(*args, target=repo):
result = command([PYTHON, "-I", "-B", PROBE, "--repo", str(target), *args],
check=False)
return {"returncode": result["returncode"],
"result": json.loads(result["stdout"])}
report["git"] = git("--version")["stdout"].strip()
git("init", "-q", "--initial-branch=main", "--object-format=sha1",
"--template=" + str(template))
for key, value in {
"user.name": "Shipping Lab", "user.email": "lab@example.invalid",
"core.hooksPath": str(hooks), "commit.gpgSign": "false",
"core.autocrlf": "false", "core.fsmonitor": "false",
}.items():
git("config", "--local", key, value)
commits = {}
for number in range(1, 10):
if number == 1:
(repo / "shipping.py").write_text(base, encoding="utf-8")
message = "baseline shipping rules"
elif number == 2:
(repo / "README.txt").write_text("All amounts use integer cents.\n")
message = "document cents unit"
elif number == 3:
(repo / "shipping.py").write_text(base.replace("API_SCHEMA = 1", "API_SCHEMA = 2"))
message = "try incompatible API schema"
elif number == 4:
(repo / "shipping.py").write_text(base)
message = "restore supported API schema"
elif number == 5:
(repo / "shipping.py").write_text(base.replace(">= 10000", "> 10000"))
message = "simplify free shipping condition"
elif number == 6:
source = (repo / "shipping.py").read_text()
(repo / "shipping.py").write_text("# Fees are integer cents.\n" + source)
message = "clarify fee unit"
else:
with (repo / "README.txt").open("a") as handle:
handle.write("Documentation revision " + str(number) + "\n")
message = "update shipping documentation " + str(number)
git("add", "--", "shipping.py", "README.txt") if number > 1 else git("add", "--", "shipping.py")
stamp = "2026-10-13T00:00:" + f"{number:02d}" + "+0000"
git("commit", "-q", "-m", message, extra={
"GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp})
tag = f"c{number:02d}"
git("tag", tag)
commits[tag] = git("rev-parse", "HEAD")["stdout"].strip()
report["commits"] = commits
tip = commits["c09"]
def checkout(tag):
git("checkout", "-q", "--detach", tag)
report["endpoint_probes"] = {}
for tag in ("c01", "c09"):
checkout(tag)
report["endpoint_probes"][tag] = probe()
if (report["endpoint_probes"]["c01"]["returncode"],
report["endpoint_probes"]["c09"]["returncode"]) != (0, 1):
raise RuntimeError("Endpoint validation failed; do not start bisect")
git("checkout", "-q", "main")
git("bisect", "start", "c09", "c01", "--")
normal = git("bisect", "run", PYTHON, "-I", "-B", PROBE, check=False)
report["normal"] = {**normal, "found": git("rev-parse", "refs/bisect/bad")["stdout"].strip()}
log = git("bisect", "log")["stdout"]
(OUT / "bisect.log").write_text(log)
(OUT / "culprit.patch").write_text(git("diff", "--no-ext-diff", "--no-textconv", "c05^", "c05", "--", "shipping.py")["stdout"])
git("bisect", "reset")
report["normal_reset_head"] = git("rev-parse", "HEAD")["stdout"].strip()
git("bisect", "replay", str(OUT / "bisect.log"))
report["replay_found"] = git("rev-parse", "refs/bisect/bad")["stdout"].strip()
git("bisect", "reset")
# Audit the whole synthetic history after the binary search.
report["history"] = {}
for tag in commits:
checkout(tag)
report["history"][tag] = probe()
boundary_digest = report["history"]["c05"]["result"]["source_sha256"]
report["rejected_good_endpoint"] = {"tag": "c05", "probe": report["history"]["c05"]}
git("checkout", "-q", "main")
git("bisect", "start", "c06", "c04", "--")
report["ambiguous"] = git("bisect", "run", PYTHON, "-I", "-B", PROBE,
"--skip-sha256", boundary_digest, check=False)
git("bisect", "reset")
git("bisect", "start", "c09", "c01", "--")
report["abort"] = git("bisect", "run", PYTHON, "-I", "-B", PROBE,
"--force-abort", check=False)
git("bisect", "reset")
empty = root / "missing-source"
empty.mkdir()
report["missing_input"] = probe(target=empty)
# Unrelated fee failures must not enter the target-regression search.
unexpected_sources = {
"pickup_only": base.replace('if method == "pickup":\n return 0',
'if method == "pickup":\n return 800'),
"remote_only": base.replace("return 1200", "return 800"),
"boundary_other_amount": base.replace(
"return 0 if total_cents >= 10000 else 800",
"return 799 if total_cents == 10000 else (0 if total_cents > 10000 else 800)"),
"mixed_failures": base.replace(">= 10000", "> 10000").replace(
'if method == "pickup":\n return 0',
'if method == "pickup":\n return 800'),
}
report["unexpected_fee_probes"] = {}
for name, source in unexpected_sources.items():
target = root / name
target.mkdir()
(target / "shipping.py").write_text(source, encoding="utf-8")
report["unexpected_fee_probes"][name] = probe(target=target)
report["final_head"] = git("rev-parse", "HEAD")["stdout"].strip()
report["final_branch"] = git("symbolic-ref", "--short", "HEAD")["stdout"].strip()
report["final_status"] = git("status", "--porcelain")["stdout"]
report["bisect_refs_after_reset"] = git("for-each-ref", "--format=%(refname)", "refs/bisect/")["stdout"]
report["input_sha256"] = {
name: hashlib.sha256((HERE / name).read_bytes()).hexdigest()
for name in ("lab.py", "probe.py", "expected.json", "verify.py")}
(OUT / "report.json").write_text(json.dumps(report, indent=2) + "\n")
(OUT / "commands.json").write_text(json.dumps(records, indent=2) + "\n")
print("Normal bisect: " + report["normal"]["found"] + " (c05)")
print("History probe exit codes: " + ",".join(str(v["returncode"]) for v in report["history"].values()))
print("Run exit codes: normal={normal}, ambiguous={ambiguous}, abort={abort}".format(
**{k: report[k]["returncode"] for k in ("normal", "ambiguous", "abort")}))
print("Temporary repository removed; evidence: " + str(OUT))
13. 完整文件:verify.py
验证器比较独立预期与观察记录,遇到偏差立即以非零状态退出。
import hashlib
import json
from pathlib import Path
import sys
here = Path(__file__).resolve().parent
out = Path(sys.argv[1] if len(sys.argv) > 1 else "evidence").resolve()
expected = json.loads((here / "expected.json").read_text())
r = json.loads((out / "report.json").read_text())
def require(condition, message):
if not condition:
raise SystemExit("FAIL: " + message)
for name, digest in r["input_sha256"].items():
require(hashlib.sha256((here / name).read_bytes()).hexdigest() == digest,
"input changed: " + name)
tags = [f"c{i:02d}" for i in range(1, 10)]
require(list(r["history"]) == tags, "history shape")
require([r["history"][tag]["returncode"] for tag in tags] ==
expected["history_exit_codes"], "independent history exit codes")
for tag, rc in zip(tags, expected["history_exit_codes"]):
result = r["history"][tag]["result"]
require(result["exit_code"] == rc, "probe code: " + tag)
if rc in (0, 1):
require([{k: row[k] for k in ("name", "total_cents", "method", "expected_cents")}
for row in result["cases"]] == expected["cases"], "case identity: " + tag)
actual = [row["actual_cents"] for row in result["cases"]]
want = [800, 0 if rc == 0 else 800, 0, 0, 1200]
require(actual == want, "independent fee outputs: " + tag)
require(result["failed"] == ([] if rc == 0 else ["standard_boundary"]),
"failure is only the boundary: " + tag)
found = r["commits"][expected["first_bad_tag"]]
require(r["normal"]["found"] == found == r["replay_found"], "culprit and replay")
for key in ("normal", "ambiguous", "abort"):
require(r[key]["returncode"] == expected[key + "_run_exit_code"], key + " run code")
require(found + " is the first bad commit" in r["normal"]["stdout"], "normal conclusion")
ambiguous = r["ambiguous"]["stdout"] + r["ambiguous"]["stderr"]
require("The first bad commit could be any of:" in ambiguous, "ambiguity is explicit")
for tag in expected["ambiguous_tags"]:
require(r["commits"][tag] in ambiguous, "ambiguous candidate: " + tag)
require("is the first bad commit" not in ambiguous, "no unique result for ambiguity")
require(r["missing_input"]["returncode"] == expected["missing_input_exit_code"], "missing source aborts")
require(set(r["unexpected_fee_probes"]) == set(expected["unexpected_fee_probes"]),
"unexpected fee probe coverage")
for name, want in expected["unexpected_fee_probes"].items():
observed = r["unexpected_fee_probes"][name]
require(observed["returncode"] == observed["result"]["exit_code"] == 128,
"unrelated failure aborts: " + name)
require(observed["result"]["failed"] == want["failed"] and
[row["actual_cents"] for row in observed["result"]["cases"]] == want["actual_cents"],
"independent unrelated fee outputs: " + name)
require(r["rejected_good_endpoint"]["probe"]["returncode"] == 1, "false good rejected")
require(r["endpoint_probes"]["c01"]["returncode"] == 0 and
r["endpoint_probes"]["c09"]["returncode"] == 1, "endpoint sanity")
require(r["normal_reset_head"] == r["final_head"] == r["commits"]["c09"], "reset returns to tip")
require(r["final_branch"] == "main" and r["final_status"] == "" and
r["bisect_refs_after_reset"] == "", "clean branch with no bisect refs")
patch = (out / "culprit.patch").read_text()
require("- return 0 if total_cents >= 10000 else 800" in patch and
"+ return 0 if total_cents > 10000 else 800" in patch, "exact regression patch")
print("PASS: c05, five fee cases, 0/1/125/128, ambiguous c05/c06, replay and clean reset")
print("PASS: four unrelated fee signatures abort with 128")
为什么不用最新提交中的测试文件?
测试也可能在历史中增删或改变断言。这个练习把已确认的业务用例放在仓库外,避免搜索过程中判断规则跟着版本漂移;实际项目仍需确认测试入口与旧版本兼容。
125 能用于所有构建失败吗?
不能笼统使用。先确认失败属于与本次回归无关、已经识别的不可测情况。本文只自动跳过明确的协议标记,其余异常中止,以免掩盖问题。
运行成功但剩下两个候选,算定位完成吗?
可以交付候选范围,但不能声称已找到唯一提交。本文第二轮实际返回 2,剩下 c05 和 c06,需要补充区分它们的证据。
能直接把示例用于我的项目吗?
四份文件可以在空目录复跑。真实项目需要重新确认业务预期、好坏端点、运行授权和依赖环境;本文没有验证任意仓库的测试安全性,也没有调用模型替你完成调查。