Harness 工程进展:合并、事件与恢复 · 2026年10月1日 · 10 分钟

从问题登记册到合并关口:一次“全绿”漏洞的工程复盘

把修复经验变成可执行约束,还需要证明约束实际运行在将要合入的代码上。一次通过数足够却漏掉守卫的反例,推动了逐目标审计、合并后树验证和关口负对照。

Harness 工程进展:合并、事件与恢复 · 阅读系列导读 →

一次测试结束,退出码是零,通过数也达到要求,合并关口仍然可能失守。这里的漏洞很具体:登记册里有一条必须执行的测试,它已经消失;同一份文件中的其他测试贡献了足够多的通过数,让总数审计接受了结果。

这是 Harness 在九月底到十月初的一轮工程加固中暴露的反例。那轮工作的目标,是把反复出现的问题归成类,为每类留下可执行守卫,再让合并动作必须经过这些守卫。反例迫使我们重新检查其中的证明关系:问题类有记录、测试套件全绿、代码可以合入,三者之间各需要什么证据?

修过一个实例,怎样才算约束住一类问题

普通回归测试通常从一个已知失败出发,固定输入,断言修复后的输出。这是必要的工作,但当相同错误出现在多个调用点时,逐个补测试会留下一个剩余问题:新调用点并不一定经过旧夹具,也不一定被旧断言观察到。

例如,一个事件读取接口只查热数据,另一个接口能够在热层落空后查冷归档。某个调用点改用了完整接口,原来的故障就被修复了;另一个地方仍可能因为名字或使用习惯选择热层接口。这里需要保留的知识,是“哪些场景必须读取完整数据”,以及“代码中哪些形态会违反这个要求”。只保存修复记录,很难把这一判断稳定地交给下一次开发。

问题类登记册为这类知识提供了结构。每个条目写明问题定义、历史实例、逃逸阶段、根因、守卫入口和残余范围。登记册因此可以表达“当前修复到了哪一层”,也可以保留尚未消除的条件。守卫则把其中能判定的部分变成测试:它可以扫描调用点、核对允许的例外,也可以运行一条行为路径,确认错误输入受到拒绝。适合静态检查的结构约束和需要动态验证的行为约束,在同一类问题下配合工作。

这种表示随即带来了新的维护责任。登记的测试如果改名、移走,甚至从默认测试集合中退出,记录仍可能看起来完整。因此,登记册有自己的元测试:解析测试文件的抽象语法树,核对文件和函数存在,检查命名是否满足默认收集规则,并拒绝不进入日常测试集的入口。元测试还校验条目形态、编号与归属关系,避免登记册在维护中逐渐失真。

起点问题类登记册定义 · 历史实例 · 残余范围
01

常规守卫

检查当前代码

符合约束 → 通过
02

判别性测试

人造违规被发现

外层测试通过
03

登记册元测试

入口存在 · 命名

默认测试收集可见
执行目标集合 · 去重保留每个目标 → 所属问题类的映射
图一。登记册选择常规守卫和独立判别性测试,元测试核对登记册及入口。判别性测试通过,表示人造违规被检测逻辑正确发现;它与“当前源码符合约束”承担不同的证据责任。

图一呈现了登记册与测试之间的关系:常规守卫、判别性测试和登记册元测试共同组成执行目标。静态核对能证明入口存在,尚不能证明入口本次执行了。这个区别把我们带到了关口。

为什么四次通过仍然不够

用一个重写的合成例子,可以准确保留漏洞的结构。假设关口有四个目标:整份 test_demo.py、另一份文件中的两个具体测试,以及 test_demo.py 中一个已经删除的函数。整份文件实际包含两条仍然存在的测试。执行结果便可能有四条通过记录:两条来自整份文件,两条来自具体测试。

旧审计比较“通过数是否不少于目标数”。四对四,条件成立;已删除函数却没有对应的执行记录。目标列表和测试用例列表不是一一对应的,一个文件选择器可以展开出多条测试。把两种列表压成整数,丢掉了关口真正关心的身份信息。

登记目标 · 4 个
  • F整份文件
  • A具体测试
  • B具体测试
  • D已删函数
真实 PASSED · 4 条
  • f₁属于 F
  • f₂属于 F
  • A目标 A 通过
  • B目标 B 通过

D 没有对应的通过记录;输出总数仍然相同。

只核计数4 ≥ 4 → 放行要求拒绝的回归:失败
逐目标核对D 未覆盖 → 拒绝要求拒绝的回归:通过
图二。四个目标和四条通过记录并不构成一一对应。文件 F 提供了两次通过,已删除函数 D 仍然没有证据。负对照保持输入不变,把审计换回只核计数;若此时回归测试失败,说明它能够识别这个特定盲区。

图二把这个反例展开:同样的通过总数,在按数量比较时合格,在逐目标映射时缺一条证据。修复后的审计保留目标列表,从 pytest 的 PASSED 行提取实际测试标识,再按选择语义逐项检查。函数目标可以匹配参数化后缀,文件目标可以匹配文件内的测试,目录目标可以匹配其下的文件。每个目标都要至少有一条真实通过记录覆盖它。

# 重写后的审计逻辑;变量与示例名称均为合成
if run.exit_code != 0:
    reject(run.failure_details)
if not run.summary_is_parseable:
    reject("缺少可审计结果")
if run.has_any("skip", "deselect", "xfail", "xpass"):
    reject("守卫没有全数正常通过")
passed = extract_passed_test_ids(run.output)
missing = []
for target in registry_targets:
    if not any(covers(target, test) for test in passed):
        missing.append(target)
if missing:
    reject(missing, owning_problem_classes)

这项检查也说明了 pytest 成功与关口成功的关系。pytest 允许跳过测试,并单独报告预期失败;默认配置下,预期外通过也未必让套件失败。这些机制支持真实的测试开发流程,但不能直接承担“所有必需守卫已正常通过”的合并契约。相关语义见 pytest 的 skip 与 xfail 文档。关口因此把 skip、deselect、xfail、xpass 视为未满足契约,并在无法解析收尾统计时拒绝给出通过结论。

逐目标核对采用的是“至少一条匹配记录”规则,没有凭空证明所有参数组合都被覆盖。覆盖范围仍取决于守卫的选择粒度和断言设计。它解决的是已声明目标静默消失的问题,让缺失目标能够被点名,并映射回所属问题类。并行 pytest 在收集错误时若没有给出具体名称,关口再用串行 --collect-only 复查,补足可定位的诊断。

负对照让“加强了检查”成为可检验的判断

测试修复后的审计能够报错,还不够有说服力。一个弱夹具也可能因为通过数不足而报错,这样便无法分辨它抓住了身份丢失,还是仅仅触发了旧的数量规则。

为此,回归夹具刻意让整份文件多贡献一条测试,使通过数恰好等于目标数。然后把逐目标审计临时换回仅核计数的旧行为。历史保存的负对照记录显示:旧行为接受了这份结果并放行合并,要求拒绝合并的回归测试随之失败;恢复逐目标核对后,同一个反例被拒绝,缺失入口得到点名。这让回归测试具备了区分两种实现的能力。

同样的思路也写进了问题类登记册。每类除了常规守卫,还必须有独立的 discrimination 测试,也就是判别性测试。它构造一个违反类约束的人造实例,验证守卫会因正确原因变红。登记册不允许把守卫自身的入口重复填成判别性测试,否则“守卫通过了”就会被循环用作“守卫能够发现问题”的证据。

这形成了两层检验:第一层让问题实例挑战守卫;第二层让错误的关口实现挑战关口回归。图二下方的对照保留同一份输入,只替换审计规则,让第二层检验可见。静态扫描如果没有匹配任何代码,也可能一直通过;把一条受禁调用植入合成源码后仍然通过,就能暴露这种空检查。负对照不能穷尽一类问题的所有变体,但能排除若干看似全绿、实际没有判别力的实现。

关口必须检查将要合入的组合

即便目标逐一通过,验证对象仍可能选错。设一个分支从旧主干拉出,增加了新的守卫;与此同时,主干前移,加入了一处新守卫应当发现的错误。分支末端没有那处错误,单独测试分支会全绿。真正合并后,新守卫和错误实例才第一次同时存在。

因此,关口的输入是主干和候选分支的两个固定提交。默认构造器使用 git merge-tree --write-tree 计算三方合并的结果,再把所得 tree 写成不挂分支引用的临时提交,创建 detached worktree 执行守卫。Git 的这项命令能够计算合并而不修改当前索引或工作目录,参见 git-merge-tree 文档。出现合并冲突时,关口得到的是无法建立验证对象的错误状态。

共同旧版本

主干增加违规实例

候选增加新守卫

合并后树:新守卫 + 违规实例
  1. 01

    构造探针提交与临时工作树

    PYTHONPATH 指向探针源码,并确认导入来源。

  2. 02

    锁外运行准入与守卫

    逐目标核对真实 PASSED。

  3. 03

    取得合并锁,重读两个输入

    主干 tip · 候选 tip

输入有效 → 合并

相关代码前移 → 拒绝旧结论

白名单数据前移 → 留痕复用

复用还要求候选分支未变。
图三。分支末端可能没有主干后来引入的违规实例,合并后树才同时包含实例与新守卫。守卫在锁外执行;取得合并锁后重读两个输入,确认结论仍能用于当前合并。未准入意味着验证尚未发生。

图三中的关键对象是合并后树:测试、登记册和业务代码都从这棵树取得。验证也必须检查 Python 实际导入的源码位置。仅把测试工作目录切过去,仍可能因环境中的搜索路径而导入另一份已安装或旧工作树中的包。关口把探针树的源码目录设为 PYTHONPATH,用准入包装选择的解释器导入包,核对解析后的实际路径确实位于该源码目录;符合要求后才执行守卫。来源不符合要求时停止,避免把另一版本的通过结果归到当前候选上。

登记册也在这棵树上读取,执行目标由它统一推出:登记册元测试、每类守卫和判别性测试一起进入关口,重复目标只执行一次,但保留多个所属类。格式坏了就判红。主干已有的登记册或问题类若在合并结果中消失,也会被拒绝;否则删除约束本身就可能成为绕过检查的方式。

测试结论还需要跨过等待时间

合并后树固定了验证对象,资源竞争和并发写入仍会影响验证流程。测试通过准入包装启动;包装返回退出码 75 表示没有取得运行资格。关口在等待预算内重试,预算耗尽则返回 not_admitted。这是一次尚未发生的验证,既没有证明类约束成立,也没有发现类约束被违反。

测试期间主干还可能前移。这条合并路径先在锁外运行守卫,通过后取得共享合并锁,再重新读取主干和候选分支的末端。锁约束并发合并动作,版本复核确认准备合入的输入与验证输入仍然相容。代码变更导致 tip 前移时,关口拒绝沿用旧结论;若候选分支未变,主干的新增路径又严格落在数据目录白名单内,则可以保留守卫结论,并记录复用的原因。允许复用的前提是变化没有影响代码、测试或登记册,而不是把任何后来的提交都视作无关。

这一轮工作的收获,是让“某类问题已受到约束”拥有一条可追踪的证据链:类定义指向守卫,判别性测试检查守卫,执行审计确认目标实际通过,合并后树固定验证对象,输入复核约束结论的有效期。当前实现和回归材料支持这些具体检查,历史负对照也支持计数盲区确实被堵住。下一次同类逃逸出现时,扩展类定义、守卫和反例,重新检验这条证据链。