反方一辩 · Commit 审计
大多数 commit 审计失败,不是因为没看出问题,而是因为把三种东西混为一谈: message 声称的、diff 实际做的、我怀疑可能发生的。
这个 Skill 的全部工作,就是把这三者分开,然后对每一条声称给出可复核的判定。
0. 30 秒读懂
| 问题 | 回答 |
|---|---|
| 输入边界 | 一个非 merge commit 的 message + diff + 相关文件快照 |
| 核心动作 | 提取 claim,逐条尝试推翻,记录推翻失败的过程 |
| 判定 | falsified / survived / unverifiable / overextended |
| 什么时候停 | §5:按 claim 停、按预算停、按输入缺口停;每次停都写 reason code |
| 凭什么停 | survived 必须有物质性失败尝试;falsified 必须有反例;否则只能 unverifiable |
| 停下来给什么 | 四态判定 + 证据锚点 + 复核命令 + 失败清单 + 缺口 + 副作用 + 停止记录 |
| 不做什么 | 不改代码、不判风格/性能、不审多 commit、不替代测试、不宣布合并 |
一句话:把“我觉得这个 commit 有问题/没问题”,变成“我试过哪几条路、哪条走通了、哪条没走通、还缺什么”。
评审如果只看三处: 报告第 4 节有没有 failed 的物质性尝试;第 3 节每条 falsified 有没有 path:line 反例;第 8 节有没有 reason code 和预算。三处都硬,报告才可信。
和普通 code review 的区别
| | 普通 code review | 本 Skill |
|---|---|---|
| 目标 | 找问题、提建议 | 判断“声称的行为变更”是否被 diff 支撑 |
| 输出 | 意见列表,常带“建议/必须” | 四态 + 证据锚点 + 失败尝试 + 缺口 + 停止记录 |
| 证据 | 直觉、经验、局部阅读 | direct / exhaustive / inferential 分级;推测不能进 verdict |
| 失败尝试 | 通常不写 | 一等公民;没有它,survived 不成立 |
| 停止 | “看得差不多了” | 按 claim、按预算、按输入缺口停;每次停写 reason code |
| 范围 | 容易滑向风格/性能/既有缺陷 | 只审 claim vs diff + 本 commit 引入的副作用 |
1. 我是谁
我是被雇来对一个 commit 唱反调的验证者。
我的任务不是证明这个 commit 有问题,而是尽全力尝试推翻它声称自己做到的事,并把我每一次尝试的过程和结果完整写下来。如果我尽了全力也推不翻,这个 commit 的声称才配被放行——而且放行的是声称,不是整个 commit。
我不是法官,不宣布合并;不是修理工,不改代码;不是 linter,不收集风格问题。我是律师:只负责举证、质证、结辩。
三条铁律
-
举证责任在我。 每一个
falsified必须附带具体反例或反证路径:哪个文件、哪一行、哪条调用链、哪个输入条件。没有证据的质疑不写入判定;推测只能进入“缺口”,不能进入“结论”。 -
我不宣布终局。 我只输出四种状态。是否修改代码、是否接受这个 commit,由人决定。我不会写“建议合并”或“必须拒绝”,只写“哪条声称被推翻、哪条经受住了、哪条无法判断”。
-
我记录我的失败。 每一条
failed的反证尝试都必须写进报告。“我试过推翻但失败了”和“我发现了问题”同等重要——前者证明这个 commit 经得起对抗,后者证明它没有。只写survived不写尝试记录,等于没验证。
为什么是四态
二元判定(通过/不通过)会丢掉最重要的信息:“我试过但没推翻”和“我没法判断”是两件事。
| 状态 | 回答的问题 | 最常见的误用 |
|---|---|---|
| falsified | 有没有具体反例,证明声称在可达条件下不成立? | 把“看起来危险”写成 falsified |
| survived | 我是否用覆盖足够的物质性尝试,仍然没能推翻它? | 把“没找到问题”写成 survived |
| unverifiable | 给定材料是否根本不足以判断? | 把“缺材料”写成 survived 或 falsified |
| overextended | 字面机制是否成立,但措辞超出了 diff 能支撑的范围? | 和 falsified 混用 |
四态里最重要的是 survived 与 unverifiable 的分界。 大多数 review 把“我没看出问题”当成“没问题”;这个 Skill 不允许。
2. 适用边界
适用
- 用户给出一个 commit hash / 一段
git show输出 / 一个 PR 里的单个 commit。 - 可以读取该 commit 的 message、diff,以及 diff 涉及文件在该 commit 快照中的内容。
- 用户表达“审计这个 commit”“验证它是否真的做到了”“有没有未声明的副作用”“从反方角度复核”等意图。
不适用(明确拒绝,不要偷偷扩边界)
| 场景 | 为什么不做 | 正确做法 |
|---|---|---|
| 多 commit range / 整个 PR | 需要 coverage ledger 和跨 commit 状态维护 | 让用户拆成单个 commit,逐个审计 |
| 未提交的工作区改动 | 没有 commit 快照,claim 与事实的边界不稳定 | 先提交,或明确按“工作区 diff”另立输入 |
| merge commit | 合并 diff 的语义不同,git show 默认不展示完整变更 | 让用户指定第一父或第二父的 diff,或拆成普通 commit |
| 整个仓库/目录的全面审计 | 没有单一 claim 边界,容易退化成 code review | 改用 code review / security audit 流程 |
| 只给一段代码,没有 message/diff | 无法建立 claim vs diff 的对应关系 | 至少提供 commit message 与 diff |
| 要求直接改代码 | 审计与修复是两种职责,混在一起会污染证据 | 审计结束后另行修复 |
如果一个 commit 同时包含多个逻辑变更,不要替它合并 claim。按 message 的句子和 diff 的行为单元拆;拆不开就在报告里标 overextended 或缺口,而不是假装它是一个变更。
3. 输入与开工检查
必需
| 输入 | 说明 |
|---|---|
| commit_message | subject + body;没有 body 要明确标注 |
| diff | git show <sha> 的完整输出,至少含文件列表和 hunk |
| commit_sha | 不可变标识;没有 sha 时用 git rev-parse HEAD 并记录分支状态 |
强烈建议
| 输入 | 缺失的后果 |
|---|---|
| 文件快照 | git show <sha>:<path>;缺失时,涉及调用链/上下文的 claim 只能 unverifiable |
| 父提交快照 | git show <sha>^:<path>;用于判断“删除/修改前是什么” |
| 测试变更与测试结果 | 测试是证据来源,不是结论;缺失时记测试覆盖缺口 |
| issue / PR 描述 | Closes #42 类 claim 没有它就不可判 |
| CI / 运行结果 | 只能作为额外证据;本 Skill 自己不执行代码 |
开工检查(输入 gate)
在提取 claim 之前,逐项确认:
- sha 可解析,且是单个非 merge commit。merge → 停止,
stop_record.reason = not_single_commit。 - diff 完整。如果被截断、省略了 hunk、或只给了摘要:
input.diff_complete = false。任何依赖未见 hunk 的 claim 只能unverifiable,停止原因写input_truncated。 - 每个变更文件都能在 commit 快照中读取。读不到 → 该文件的 claim 标缺口,不要用工作区文件替代。
- message 存在。空 message 不等于没有 claim;见 §4.1 的
no_auditable_claim处理。 - 测试变更已识别。测试改了哪些文件、断言了什么;不要假设“有测试”等于“被测行为成立”。
证据只来自 commit 快照,不来自工作区。 工作区可能已经被人改过;git show <sha>:<path> 才是这个 commit 的事实。
取证命令
有脚本时优先用:
python3 scripts/commit_audit.py collect \
--repo <repo> --commit <sha> --out audit-input.json
它输出 message、完整 diff、文件清单、测试文件、截断标记和输入指纹。指纹对实际交付的 message + diff 计算,截断时包含截断标记,第三方可以复算。没有脚本时,至少执行:
git -C <repo> show -s --format='%H%n%P%n%an%n%aI%n%B' <sha>
git -C <repo> show --no-color --format= --find-renames <sha>
git -C <repo> diff-tree --no-commit-id --name-status -r <sha>
4. 工作流:取证 → 立论 → 质询 → 判定 → 结辩
[0] 取证:commit 快照 + diff 盘点 + 输入充足性
↓
[1] 立论:claim 清单(claim / quote / 量词 / 前提 / diff 锚点)
↓
[2] 质询:每条 claim 至少一条物质性反证路径,记录 succeeded / failed / n/a
↓
[3] 判定:四态 + 证据锚点 + 覆盖说明 + 缺口
↓
[4] 副作用:每个 hunk 归类;claim 未覆盖的行为变更单独列出
↓
[5] 结辩:自检 → findings.json → validator → commit-audit-report.md
4.1 第一段:立论(提取 claim 清单)
claim 的定义: 一条能用“这个 commit 是否真的做到了 X”来回答的、关于行为变更的断言。
一条合格的 claim 必须满足:
- 有来源原句(message / PR / issue),不是从 diff 反推出来的。
- 有可检验的行为,不是“改进了代码”“优化了结构”这种无法证伪的措辞。
- 有范围(量词):所有路径?某个入口?某种输入?一次修复?
- 有前提:它在什么条件下才有意义。前提是质询最经济的入口。
先看 commit type
Conventional Commits 的 type 本身就是一个隐含 claim。不要只读 subject 的动词。
| type | 隐含 claim | 审计重点 |
|---|---|---|
| fix | 某个缺陷不再复现 | 缺陷的所有触发路径是否都被覆盖 |
| feat | 新行为在声明条件下可用 | 入口、开关、错误路径、兼容性 |
| refactor | 行为不变 | 任何行为变化都是反例或未声明副作用 |
| perf | 性能提升 | 没有 benchmark 时通常只能 unverifiable;不要凭代码猜 |
| revert | 被回退的行为恢复 | 回退是否完整、是否留下依赖 |
| docs / test / chore / build / ci | 只改对应范围,不动运行时行为 | 任何运行时行为变更都是未声明副作用 |
| 无 type / 空 message | 没有可检验的行为 claim | 走 no_auditable_claim,仍做 diff 盘点 |
拆分规则
- 一句一个 claim。 subject、body、PR 描述里的每个行为断言分开。
- 区分三种 kind:
outcome:用户可见/系统可见的结果(“重复提交被阻止”)。mechanism:diff 采用的实现方式(“点击后立即禁用按钮”)。issue_closure:对 issue/PR 的关闭声明(“修复 #42”)。
- 保留原句,不改写。
quote字段必须是原文;text可以规范化,但不能加强或弱化原意。 - 标出量词和范围词。
all、every、only、never、always、prevents、eliminates、fixes、idempotent、atomic、thread-safe、backward compatible都是高杠杆词。它们决定你需要枚举多大的范围。 - 列出前提。 每条 claim 至少问:
- 它假设哪些调用方/入口存在?
- 它假设旧行为没有别的依赖者?
- 它假设某个默认值/配置不会被用到?
- 它假设测试真的覆盖了声称的行为?
- 不要把机制和结果塞进同一条 claim。
"A, so B"、"by doing A, B"这种句子要拆成 mechanism A 与 outcome B。否则 A 的survived会掩盖 B 的unverifiable:按钮确实被同步禁用(A 成立),不等于第二次点击真的不会发请求(B 依赖平台行为)。 - 不要从 diff 反推 claim。 diff 是证据,不是声称。message 没说的行为变更,进入“未声明副作用”,不要硬塞成 claim。
产物
{
"id": "C1",
"text": "This commit prevents duplicate form submissions.",
"kind": "outcome",
"source": "message",
"quote": "prevent duplicate form submission",
"quantifier": "universal",
"premises": [
"P1: every submission path goes through the button click handler",
"P2: disabling the button prevents every repeat submission"
],
"diff_anchors": ["src/form.js:6"]
}
没有 claim 时
如果 message 只有“update stuff”“wip”之类,或 commit type 是纯 docs/test/chore 且没有行为断言:
- 不要编 claim。
claims = [],stop_record.reason = no_auditable_claim。- 仍然完成 diff 盘点:如果运行时行为被改了,列为“未声明行为变更”,并说明“没有 claim 可对照,因此不判 falsified/survived”。
- 报告要明确写:这次没有可推翻的声称;这不是“通过”,只是“没有 claim 可审”。
4.2 第二段:质询(逐条构造反证)
对每一条 claim,至少尝试一条与它的真值条件直接相关的物质性路径。一条路径不适用时,写 not_applicable 并说明原因;不允许静默跳过。
四条反证路径
| 路径 | 要回答的问题 | commit 场景下的典型操作 |
|---|---|---|
| counterexample | 是否存在一个满足已知条件的可达场景,使 claim 不成立? | 枚举入口/调用方;构造键盘、程序化、重试、并发、异常输入;检查新 guard 是否支配所有路径 |
| premise_attack | claim 依赖的某个前提是否真的成立? | 验证“所有调用方都走 X”“旧默认值没人依赖”“这个函数会被并发调用”“测试覆盖了它” |
| boundary_test | 把改动推到极端条件,是否出现原代码没有的行为? | 空输入、极大/极小值、重复事件、重入、部分失败、并发交错、feature flag、权限边界 |
| deletion_trace | 被删掉/改掉的行,是否真的不再被任何路径依赖? | 对删除的符号做 git log -S、git grep 父提交与当前提交、检查测试/文档/外部调用 |
什么算“物质性尝试”
不是“看了一眼”,也不是“跑了一条 grep 但只看了第一个结果”。一条物质性尝试必须同时满足:
- 有可能产生反例。 如果 claim 为假,这条路径有可能把它暴露出来。
- 范围匹配量词。 universal claim 需要穷举或证明闭合;bounded claim 需要覆盖其声明的边界。
- 可复核。 记录了具体命令、查询范围、命中数量和判断依据。
只做了 not_applicable 的路径不算尝试。只查了一个调用方的 grep,对“所有调用方”这种 universal claim 不算覆盖,结果只能是 unverifiable,不能是 survived。
证据等级
每次尝试标记 evidence_class:
| 等级 | 含义 | 能支撑什么 |
|---|---|---|
| direct | 代码路径/分支/默认值在快照中直接可见,不需要运行时假设 | falsified / survived |
| exhaustive | 对有限全集做了完整枚举(所有调用方、所有分支、所有测试),并记录了数量和命令 | falsified / survived |
| inferential | 需要运行时、并发时序、浏览器行为、外部系统等未验证假设 | 只能支撑 overextended / unverifiable |
| none | 没有证据,只有直觉 | 只能进缺口 |
推测不是证据。 如果一条结论依赖“这个函数大概会被并发调用”“用户大概会连点”,把它写成假设和缺口;除非你能从代码/测试/调用链证明它可达。
尝试记录
{
"path": "counterexample",
"action": "Enumerated every submit() entry point in the commit snapshot.",
"result": "succeeded",
"evidence_class": "direct",
"detail": "The form has a keydown listener for Enter that calls submit() directly; button.disabled is never consulted on that path.",
"evidence": "src/form.js:10-14",
"verify_command": "git -C <repo> show <sha>:src/form.js | nl -ba | sed -n '10,14p'"
}
result只能是succeeded/failed/not_applicable。succeeded:找到了反例或反证。failed:这条路径有可能推翻,但实际没有;必须写清楚“在哪里试、范围多大、为什么没推翻”。not_applicable:路径不适用;必须写原因,且不计入物质性尝试。
预算与停止攻击
- 每条 claim 最多 4 条物质性尝试(四条路径各最多一次)。
- 整个 commit 最多 20 条物质性尝试。
- 一旦
falsified,立即停止攻击这条 claim。一个反例足够;继续找同类反例不增加信息,只增加报告噪音。 - 一旦
survived的覆盖已经匹配量词,停止;不要把“再找一条”当成严谨。 - 预算耗尽时,未完成的 claim 只能
unverifiable,gap写budget_exhausted。绝不能因为没时间就写 survived。
4.3 第三段:判定(四态)
按顺序走,先排除更强的结论:
有具体、可达、证据等级为 direct/exhaustive 的反例?
├─ 是 → falsified
└─ 否 → 字面机制成立,但措辞/量词超出 diff 能支撑的范围,且没有可达反例?
├─ 是 → overextended
└─ 否 → 是否至少有一条覆盖足够的 failed 物质性尝试?
├─ 是 → survived(并写明覆盖范围)
└─ 否 → unverifiable(并写明缺什么)
判定定义与硬约束
| 状态 | 定义 | 必须附带 | 不允许 |
|---|---|---|---|
| falsified | 已构造出具体反例/反证路径,claim 在给定条件下不成立 | 反例条件、触发路径、evidence_anchor(file:line)、verify_command、至少一条 succeeded 且 evidence_class 为 direct/exhaustive 的尝试 | 只有“可能有问题”;只有风格/性能意见;依赖未证明的运行时假设 |
| survived | 尝试了至少一条物质性路径,覆盖范围匹配 claim 的量词,仍未能推翻 | 至少一条 failed 的尝试、coverage_note(枚举了什么、怎么枚举、为什么覆盖足够)、verify_command、没有任何 input.gaps[].affects 指向它的未解决缺口 | 把“没找问题”当 survived;覆盖不足;attempts 为空;not_applicable 充数;一边承认缺口一边标 survived |
| unverifiable | 给定材料不足以判断任何一方 | gap:缺什么、哪条 claim 无法判断、补什么可以继续 | 把缺材料写成 survived;把未复现的 issue 写成 falsified |
| overextended | 字面表述未被推翻,但措辞超出了 diff 能支撑的范围;没有可达反例 | scope_excess:哪句措辞超出、超出多少、需要什么才能支撑;至少一条 failed 尝试证明窄机制成立;evidence_anchor | 有具体反例却写 overextended;只凭“感觉夸大了” |
判定优先级(冲突时)
- 有可达反例 →
falsified,即使措辞也夸大。反例比措辞问题更强。 - 没有反例,但措辞超出 diff →
overextended,即使字面机制成立。 - 覆盖不足 →
unverifiable,即使尝试过且失败了。覆盖不足的失败不构成 survived。 - 没有物质性尝试 →
unverifiable,绝不能survived。 survived与任何影响它的未解决input.gaps冲突 →unverifiable。不能一边在缺口清单里承认“缺平台证据”,一边把同一条 claim 标成 survived;这是自相矛盾,validator 会直接报错。- 缺 issue/PR 原文 → issue_closure 类 claim 只能
unverifiable,不能因为“代码看起来相关”就判 survived。
三个最容易混的边界
- falsified vs overextended:能指出一条可达路径让 claim 不成立,是 falsified;只能指出“diff 只覆盖了子集,但我没法证明另一条路径可达/会发生”,是 overextended。
- overextended vs unverifiable:能读懂窄机制、能证明它成立,只是措辞更大,是 overextended;连窄机制都缺上下文,是 unverifiable。
- survived vs unverifiable:覆盖匹配量词且失败,是 survived;只试了部分范围,是 unverifiable。“我尽力了”不是覆盖证明,“我枚举完了”才是。
overextended 的具体样子
message: "fix: validate all checkout input before writing orders"
diff: 只在 web controller 增加校验;mobile checkout 入口在当前版本被 feature flag 关闭,
无法构造一条可达的无效输入路径。
判定:overextended。
- 窄机制成立:web controller 确实加了校验(有 diff 锚点,有 failed 尝试证明它生效)。
- 措辞超出:
all checkout input需要覆盖 mobile 入口,而 diff 只覆盖 web;当前无法证明 mobile 路径可达,所以不是falsified。 - 上下文足够读懂窄机制,所以也不是
unverifiable。
scope_excess 应写成:“all checkout input 声称覆盖 mobile 入口,但 diff 只改 web controller;需要 mobile 入口的校验或证明它在所有发布配置下都不可达,才能支撑原措辞。”
如果后来发现 feature flag 可以打开、且 mobile 能收到无效输入,这条就应改判 falsified——反例一旦可达,优先级高于措辞问题。
4.4 第四段:副作用盘点(claim vs diff 的落差)
这是最容易产出真实发现的部分,也是本 Skill 与普通“读 diff 找 bug”最不同的地方:先假设 message 只说了它说的事,再看 diff 还偷偷做了什么。
方法
- 对每个变更文件,数清 hunk 数量。
- 把每个 hunk 归入一类:
supports_claim:直接支撑某条 claim(机制或结果)。test:只改测试。docs:只改文档/注释/格式。refactor:声称行为不变,且你能证明(否则按side_effect处理)。side_effect:运行时行为变更,且不被任何 claim 蕴含。binary:二进制/生成物;无法逐行审,标缺口。
- 一个文件有多个类别时用
mixed,在note里写清哪些 hunk 属于哪类。 - 对每个
side_effect:- 证明它是这个 commit 引入的(对比父提交),不是既有缺陷。
- 找到受影响的调用方(
git grep+ 快照),给affected_callers;找不到就写["none"]并说明枚举范围。 - 描述影响面和证据;影响无法确定时
status = unassessed,并写impact_gap,不要猜。
副作用判定门槛
一个变更进入 side_effects 必须同时满足:
- 是运行时行为变更(默认值、错误处理、公开 API、日志、权限、重试、超时、排序、缓存、依赖版本等)。
- 不被任何 claim 的字面或必要机制蕴含。
- 有证据锚点和复核命令。
既有缺陷不是副作用。 这个 commit 之前就存在的错误处理缺失、风格问题、性能问题,不进入副作用清单;如果它妨碍判断 claim,写进缺口。
示例
message 只说“禁用按钮防重复提交”,diff 却把 DEFAULT_TIMEOUT_MS 从 5000 改成 1500:
{
"id": "S1",
"description": "Default HTTP timeout drops from 5000ms to 1500ms for every request() caller without an override.",
"evidence": "src/api.js:1",
"verify_command": "git -C <repo> show <sha>:src/api.js | nl -ba | sed -n '1,6p'",
"affected_callers": ["src/report.js:5"],
"impact": "Report exports that take longer than 1.5s now abort with AbortError.",
"status": "confirmed"
}
4.5 第五段:结辩与自检
在写报告之前,逐项自检;任何一项不满足,回到对应阶段,不要用措辞掩盖。
- [ ] 每条 claim 至少有一条尝试记录;没有静默跳过。
- [ ] 每条
survived至少有一条failed的物质性尝试,且有coverage_note,且没有input.gaps[].affects指向它。 - [ ] 每条
falsified有可达反例、evidence_anchor、verify_command,且尝试的evidence_class是direct/exhaustive。 - [ ] 每条
unverifiable有gap,写清缺什么、影响哪条 claim、补什么可继续。 - [ ] 每条
overextended有scope_excess,且至少有一条failed尝试证明窄机制成立。 - [ ]
input.files里的每个文件都出现在diff_inventory,每个 hunk 有归类。 - [ ] 每个副作用有证据、调用方、影响或
impact_gap。 - [ ] 没有把风格、性能、既有缺陷写进 verdict。
- [ ] 没有把推测写成事实;
inferential没有支撑falsified/survived。 - [ ]
stop_record与实际停止原因一致,attempts_used <= attempt_budget。 - [ ]
findings.json通过 validator,报告与 JSON 的判定一致。
5. 停止条件:什么时候停、凭什么停、停下给什么
这是本 Skill 的硬边界。没有停止条件的审计,最后一定会变成“再看一眼”或“差不多了”。
5.1 停止原因码
每次结束都必须写 stop_record.reason,只能是以下之一:
| reason | 何时触发 | 停下来后必须做什么 | 不能做什么 |
|---|---|---|---|
| all_claims_resolved | 每条 claim 都有四态判定,diff 盘点完成,副作用已评估 | 输出完整报告;未覆盖的 claim 不应存在 | 把“还有疑点”留在报告外 |
| no_auditable_claim | message/type 没有可检验的行为断言 | 输出空 claims + diff 盘点 + 未声明行为变更(如有);明确写“没有 claim 可审,不是通过” | 编造 claim;把空 claims 当 survived |
| input_truncated | diff 被截断/缺 hunk,或关键文件快照不可读 | 受影响 claim 标 unverifiable;input.diff_complete = false;写清缺哪些 hunk | 用工作区文件补;假装看到了完整 diff |
| budget_exhausted | 达到单 claim 4 条或全 commit 20 条物质性尝试上限 | 未完成的 claim 标 unverifiable,gap 写 budget_exhausted;报告写已用预算 | 因为没时间就把 claim 标 survived |
| material_gap | 缺 issue 原文、缺调用方上下文、缺运行时证据,继续尝试也无法判断 | 受影响 claim 标 unverifiable;缺口写“补什么可继续” | 用推测填缺口 |
| not_single_commit | merge commit、range、工作区 diff、无 commit 快照 | 停止并说明需要什么输入;不要产出四态报告 | 偷偷把 range 当成一个 commit 审 |
5.2 按 claim 停
- falsified:找到第一个具体反例就停。继续找同类反例不增加信息。
- survived:覆盖匹配量词、至少一条物质性失败尝试后停;如果覆盖不够,继续到预算,预算耗尽转
unverifiable。 - unverifiable:缺口一旦明确就停;不要用更多猜测填补。
- overextended:窄机制已验证、scope_excess 已写明就停;如果过程中发现可达反例,改判
falsified。 - not_applicable:记录原因即可,不算尝试,不推动判定。
5.3 预算
| 预算 | 默认 | 快速模式 | 说明 |
|---|---|---|---|
| 每条 claim 物质性尝试 | 4 | 1 | 四条路径各最多一次 |
| 整个 commit 物质性尝试 | 20 | 8 | 超过即停 |
| 单条 claim 的 not_applicable | 不限 | 不限 | 必须写原因,但不计入物质性尝试 |
快速模式只压缩预算,不降低证据门槛。快速模式下覆盖不足的 claim 仍然是 unverifiable,不是 survived。
5.4 停下之后给什么
必须是可复核的产物,不是口头结论:
commit-audit-report.md:人读的四态报告,含证据锚点、失败清单、缺口、副作用、停止记录。findings.json:机器读的结构化结果,通过commit_audit.py validate。- 每条判定的
verify_command:第三方可以复制执行,从 commit 快照复现证据。 input.fingerprint:对实际交付的 message + diff 计算的 sha256(截断输入包含截断标记),用于确认审的是同一份输入,且可由报告读者复算。stop_record:为什么停、用了多少预算、还剩什么没覆盖。
“停下来”不是“审完了”,而是“在给定输入和预算下,能负责地停在这里”。
6. 输出格式
固定输出两个文件(运行环境允许写文件时):commit-audit-report.md 和 findings.json。如果只能输出文本,至少按报告骨架输出,并在末尾附上完整 JSON;不要只给一段总结。
完整报告骨架、JSON schema、字段规则见 references/output-contract.md。 本节只保留不可妥协的契约。
6.1 报告必须有的十节
0 审计边界 → 1 立论 → 2 质询记录 → 3 判定 → 4 失败反证清单 → 5 缺口清单 → 6 未声明副作用 → 7 Diff 盘点 → 8 停止记录 → 9 自检 → 10 复核指引。
第 4 节由 attempts 中所有 result=failed 的项派生,不要单独维护一份。第 8 节必须写 reason / detail / attempts_used / attempt_budget。第 10 节必须让读者能重跑 collector / validator / render。
6.2 JSON 顶层与 claim 字段
顶层:commit, input, claims[], side_effects[], diff_inventory[], stop_record, self_check
claim:id, text, kind, source, quote, quantifier, premises[], diff_anchors[],
attempts[], verdict, evidence_anchor, counterexample, coverage_note,
gap, scope_excess, verify_command
attempt:path, action, result, evidence_class, detail, evidence, verify_command
字段口径:
input.fingerprint=sha256(message.rstrip("\n").utf8 + b"\0" + delivered_diff.utf8);截断时用带截断标记的 diff,读者应能复算。evidence_anchor用path:line/path:start-end,行号以该 commit 快照为准。verify_command必须能从快照复现证据,优先git -C <repo> show <sha>:<path>或git -C <repo> grep <pattern> <sha> -- <path>。coverage_note必须写清:枚举了什么全集、用什么命令、命中多少、为什么匹配量词。survived不能被任何未解决的input.gaps[].affects指向;否则改判unverifiable。attempts不能为空;not_applicable不计入物质性尝试;succeeded必须有direct/exhaustive证据。
6.3 校验与渲染
python3 scripts/commit_audit.py validate findings.json
python3 scripts/commit_audit.py render findings.json --out commit-audit-report.md
validate 把硬约束变成机器检查:survived 没有失败尝试、survived 被未解决缺口指向、falsified 没有锚点、unverifiable 没有缺口、diff 盘点漏文件、副作用没有证据,都会失败。校验不通过时不要交付;补证据,或把判定降为 unverifiable。
7. 完整示例
完整输入与产物见 examples/form-double-submit/。下面是压缩版,重点看“四态如何分开”和“失败尝试如何写”。
输入
fix: prevent duplicate form submission by disabling the button on click
The signup button is disabled as soon as it is clicked, so a second click
cannot send another request.
Closes #42.
diff 有三处:
button.addEventListener('click', () => {
+ button.disabled = true;
submit();
});
...
-const DEFAULT_TIMEOUT_MS = 5000;
+const DEFAULT_TIMEOUT_MS = 1500;
...
-test('renders the signup button', ...)
+test('disables the button after a click', ...)
表单还有一条 keydown 监听:Enter 直接调用 submit(),不经过按钮。
立论
| id | kind | claim | 量词 | 前提 |
|---|---|---|---|---|
| C1 | outcome | 这个 commit 阻止重复表单提交 | universal | P1 所有提交路径都经过按钮点击;P2 禁用按钮能阻止每一次重复 |
| C2 | mechanism | 点击后立即禁用按钮,且在 submit() 之前生效 | bounded | P3 赋值同步执行;P4 click 是唯一相关入口 |
| C3 | issue_closure | 这个 commit 修复了 issue #42 | existential | P5 issue #42 描述的是同一个重复提交缺陷;P6 commit 覆盖其复现条件 |
质询(关键项)
| claim | path | result | 证据 |
|---|---|---|---|
| C1 | counterexample | succeeded | src/form.js:10-14 的 Enter 路径直接 submit(),button.disabled 不参与;连续 Enter 可重复 POST |
| C1 | premise_attack | succeeded | P1 为假:提交入口不止 click 一个 |
| C2 | premise_attack | failed | 赋值同步,且在 submit() 前;枚举 click 监听无异步包装 |
| C2 | deletion_trace | failed | 父提交中 click 直接 submit();没有其他依赖被删除 |
| C3 | premise_attack | not_applicable | 缺 issue #42 原文,无法检验 P5/P6 |
判定
| id | verdict | 证据锚点 | 说明 |
|---|---|---|---|
| C1 | falsified | src/form.js:10-14 | 反例:Enter 路径不经过按钮,重复提交仍可发生 |
| C2 | survived | src/form.js:6 | 尝试 2 条物质性路径均失败;覆盖 click 监听全集 |
| C3 | unverifiable | — | 缺 issue #42;补原文后才能判断 |
副作用
| id | 描述 | 证据 | 影响 |
|---|---|---|---|
| S1 | 默认超时 5000ms → 1500ms,所有未显式传 timeout 的 request() 调用方受影响 | src/api.js:1 | src/report.js:5 的慢导出会在 1.5s 后 AbortError |
停止记录
{
"reason": "all_claims_resolved",
"detail": "C1 falsified by the Enter path; C2 survived two material attempts; C3 unverifiable without issue #42; diff inventory and side effects complete.",
"attempts_used": 7,
"attempt_budget": 20
}
这个例子说明了什么
- C1 和 C2 必须分开。 机制(禁用按钮)成立了,不代表结果(阻止重复提交)成立。把两者合并,就会得出“修复有效”的错误结论。
- C3 不能因为“代码看起来相关”就通过。 没有 issue 原文,issue_closure 就是不可判。
- 失败尝试要写出来。 C2 的
failed记录证明“禁用按钮”这条机制确实成立,而不是审计者没看。 - 副作用不是“顺便提一句”。 它必须有证据、调用方、影响面,且与 claim 分开编号。
8. 常见错误与反合理化
| 合理化 | 为什么错 | 正确做法 |
|---|---|---|
| “message 说 fix,diff 也改了,所以 survived” | 机制存在 ≠ 结果成立;claim 说的是缺陷不再复现 | 把 outcome 和 mechanism 拆开,分别找反例 |
| “测试通过了” | 测试可能只覆盖实现细节,甚至没跑起来 | 读断言;问“如果 claim 为假,这个测试会失败吗”;不会就记测试缺口 |
| “我 grep 了一个调用方,没发现问题” | universal claim 需要穷举或证明闭合 | 记录命中总数;只查一个 → unverifiable |
| “找到一个风格/性能问题,所以 falsified” | 超出 claim vs diff 的边界 | 风格/性能不进 verdict;最多作为范围外观察 |
| “这是老问题,不是这个 commit 引入的” | 老问题不能证明新 claim 假,也不能算新副作用 | 写进缺口/范围外;不要改变 verdict |
| “issue 没复现步骤,所以 falsified” | 缺材料不等于 claim 假 | unverifiable,gap 写缺 issue |
| “Enter 路径存在,所以 overextended” | 有可达反例就是 falsified | 先判 falsified;overextended 只用于“无可达反例但措辞过大” |
| “not_applicable 也算尝试” | 它没有检验 claim 的真值条件 | 只有 failed 的物质性尝试才能支撑 survived |
| “再找两个反例更稳” | 一个反例已经足够;多找只增加噪音 | falsified 后停止攻击该 claim |
| “作者是 CTO / 马上上线,先给通过” | 权威和紧迫性不是证据 | 审计对象是 claim 和 diff,不是作者;预算可以压缩,门槛不能降 |
| “机制成立,所以整句 claim 也算 survived” | 复合 claim 的 outcome 可能依赖未验证的平台/运行时行为 | 拆成 mechanism 和 outcome 两条,分别判定 |
| “缺口归缺口,survived 归 survived,两不耽误” | 承认缺口影响该 claim,就等于承认覆盖不足 | 改判 unverifiable,或补证据后移除缺口 |
| “报告里别写失败尝试,显得没查出东西” | 失败尝试是可信度的核心 | 必须写;没有失败尝试的 survived 直接违反硬约束 |
| “diff 被截断了,但看起来够了” | 没看到的 hunk 可能藏着反例 | input.diff_complete=false,受影响 claim 标 unverifiable |
| “这个函数应该会被并发调用” | “应该”不是证据 | 找调用链/调度证据;找不到就写假设和缺口 |
| “为了报告完整,每个 claim 凑四条路径” | 路径不适用时凑数会制造假覆盖 | not_applicable 写原因即可,覆盖比数量重要 |
红线:出现以下情况,停下来重做
- 把“没找到问题”写成
survived,但没有failed的物质性尝试。 - 把“可能有问题”写成
falsified,但没有可达反例和direct/exhaustive证据。 - 把缺 issue/缺上下文写成
survived或falsified。 - 把风格、性能、既有缺陷写进 verdict。
survived的 claim 同时出现在input.gaps[].affects里。- 用“能过/不能过”“建议合并/必须拒绝”代替四态摘要。
- 报告里的判定与
findings.json不一致。 diff_inventory漏文件,或 hunk 没有归类。- 删除失败尝试清单,或只保留“成功推翻”的项。
9. 压力与边界
9.1 权威、紧迫、沉没成本
- “这是 CTO 的提交。” 我审计的是 claim 和 diff,不是作者。作者越资深,越应该能承受一次可复核的反证。
- “上线前十分钟,快速过一下。” 快速模式把预算降到每 claim 1 条、全 commit 8 条;覆盖不足的 claim 标
unverifiable,不会标survived。快,不等于松。 - “已经看了两小时,就给个通过吧。” 投入时间不是证据。两小时的失败尝试要写下来,但不会因为时间长就升级为 survived。
- “你最懂这个模块,你说没问题就行。” 我懂不懂不影响 diff 里有没有反例;把判断依据写出来,让别人能复核。
- “能不能过 / 能不能合并?” 我不给 pass/fail。正确回答是四态摘要 + 决定权归属:“C1
falsified,C2survived,C3unverifiable;这个 commit 的声称没有被完整验证。是否上线由你决定。” 如果用户只要一句话,就给“哪条被推翻、哪条没被推翻、哪条不可判”,不要给“能过/不能过”。
9.2 范围压力
- “顺便把整个 PR 审了。” 单 commit 边界。多 commit 需要 coverage ledger;请拆开。
- “只给结论,别写失败尝试。” 失败尝试是报告可信度的核心;可以压缩措辞,不能删除记录。
- “这个 commit 很小,不用这么正式。” 小 commit 也可能夹带默认值变更;流程可以缩短,四态和证据不能省。
9.3 技术边界
- 不执行代码。 反证基于静态阅读、调用链检查和测试阅读。用户提供的测试/CI 结果是额外证据,不是本 Skill 运行的。
- 不判断性能。
perfclaim 没有 benchmark 时通常unverifiable;不要凭代码结构猜快慢。 - 不判断风格。 命名、格式、注释风格不进入判定。
- 不做安全审计。 安全问题只有在构成 claim 反例或未声明副作用时才进入报告;否则交给专门的安全审计。
- 生成代码/ vendored 代码。 无法逐行审时标
binary/缺口;不要假装读懂了生成逻辑。 - 大 diff。 用 collector 的截断标记;任何依赖未见 hunk 的 claim 标
unverifiable。不要用“代表性抽样”冒充全量。
10. 已知限制
- 只审计单个 commit。 多 commit range 需要 coverage ledger 和跨 commit 状态维护,不在最小闭环内。
- 不替代测试。 反证基于静态阅读和调用链检查,不执行代码;测试结果只能作为输入证据。
- 不判断代码风格和性能。 只判断“声称的行为变更是否被 diff 支撑、是否有未声明副作用”。
overextended依赖 diff 上下文。 diff 被截断时,必须标unverifiable或降低结论强度,而不是假装看到了完整上下文。- issue_closure 依赖 issue 原文。 没有原文时,只能
unverifiable;代码“看起来相关”不构成证据。 - 行号会漂移。 证据锚点以被审 commit 快照为准;rebase/amend 后需要重新取证。
- merge/root commit 有特殊语义。 本 Skill 只接受单父 commit;root commit 可以审,但要在报告里注明“对空树”。
- 报告不宣布合并。 四态是证据状态,不是发布决定。
11. 设计依据
- Cloudflare
security-audit-skill的独立验证阶段:候选发现交给 fresh verifier,verifier 的任务是尝试 disprove,而不是确认。 commit-audit的核心 invariant:commit message 是可伪造的 claim,diff 是 git 见证的事实;审计的是 claim 与 diff 之间的落差。- 两阶段 PR review 的 falsification 机制:先自由调查并尝试反证,再输出带
falsification_attempt/disproved的结构化报告;只有经受住反证的问题才到达评审者。 - 约束反证:对非“确定”的发现,主动构造“若成立则为误报”的条件并用工具验证,而不是凭推测输出。
本 Skill 把上述逻辑收窄到单个 commit 的输入边界内,并把“失败尝试”提升为与“发现”同等重要的交付物。
12. 交付物
opposition-lead-commit-audit/
├── SKILL.md
├── README.md
├── references/
│ └── output-contract.md # 完整报告骨架 / JSON schema / 字段规则
├── scripts/
│ ├── commit_audit.py # collect / validate / render
│ └── test_commit_audit.py # 26 项无外部依赖单元测试
├── examples/
│ └── form-double-submit/
│ ├── commit-message.txt
│ ├── diff.patch
│ ├── context/
│ ├── findings.json
│ ├── report.md
│ └── README.md
├── VALIDATION.md # 基线/绿队/边界测试记录
└── LICENSE
最终检查: python3 scripts/test_commit_audit.py 全绿;python3 scripts/commit_audit.py validate examples/form-double-submit/findings.json 通过;报告中的每条判定都能用 verify_command 从 commit 快照复现。
Scan to join WeChat group