diff --git a/design/engineering/test-conventions.md b/design/engineering/test-conventions.md index d8a3f8c3..4cf40700 100644 --- a/design/engineering/test-conventions.md +++ b/design/engineering/test-conventions.md @@ -105,6 +105,7 @@ - **撤销后仍然绿**:说明断言没钉在这个改动上,判缺漏;不得用「代码能跑通」代替。 - **判据绑可观测提交面**:端到端断言的判据要绑「被测改动特有的可观测提交面」(信号发射、事件、入参、绘制记录),不绑多层防御之后的终态——否则改动被下游兜底吸收时终态不变、断言假绿。例:探针「雾不扩可点域」首版只看屏/选路态终态,命中面放宽后断言仍绿;改绑 `route_chosen` 发射面后即红。 - 每张任务卡至少一条撤销证明记录;持久化、生命周期、存档、跨战共享这类接线逐行做。 +- **合法手段只有真撤销(2026-10-24 用户立:全项目禁做变异类测试)**——改断言极性、置空库存/状态、改名键、删调用行等「人为制造红」都属伪造证据,一律禁止;证明只认「回退本批真实改动 → 断言红 → 还原复绿」。自动化载体=`tools/undo_proof.py`(给最窄测试命令与撤销对象,自动出三连红绿回执并自还原),人工三连跑由此免做。 ### 8.5 禁止削弱测试 diff --git a/tools/undo_proof.py b/tools/undo_proof.py new file mode 100644 index 00000000..071fd430 --- /dev/null +++ b/tools/undo_proof.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""撤销证明自动化工具:一条命令完成「改前绿 → 真撤销复红 → 还原复绿」三连并出回执。 + +撤销证明的判据(test-conventions §8.4):只撤销该改动、跑能覆盖它的最窄测试、对应断言 +必须变红、还原后复绿;撤销后仍绿=断言没钉在改动上,判缺漏。本工具把这三连机械化—— +合法性属于「真撤销」(回退本批真实改动),全项目禁做变异类测试(改断言极性、置空状态、 +改名键、删调用行等人为制造红=伪造证据)。 + +用法: + python tools/undo_proof.py --cmd "<最窄测试命令>" --revert-commit <该批提交> + python tools/undo_proof.py --cmd "<最窄测试命令>" --revert-files --base + 可选 --out 落回执;--timeout <秒> 限制单次测试命令时长(缺省 3600)。 + + --revert-commit:按该笔提交触过的文件集回退到其父提交版本(改动须已提交在分支上)。 + --revert-files+--base:把指定文件回退到 (默认 HEAD)版本——适合改动尚未单独 + 成笔、混在当前尖里的场景。 + 测试命令在仓库根以 shell 执行;单套件直跑形态见 game/tests/README.md(测试工具手册)。 + +流程与退出码: + ① 改前基线:测试命令必须绿(红=改前就红,无证明意义,中止)。 + ② 真撤销:指定文件回退到基准版本 → 重跑命令,必须红(红=断言钉在该改动上)。 + ③ 还原:文件恢复到 HEAD → 重跑命令,必须绿。 + ④ 任何一步异常都先还原再退出;三步全过=退出码 0 并打印回执,否则退出码 1。 +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +REPO_DEFAULT = Path(__file__).resolve().parent.parent +REPO = REPO_DEFAULT + + +def run(args: list[str], check: bool = True) -> str: + r = subprocess.run(["git", "-C", str(REPO)] + args, + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=60) + if check and r.returncode != 0: + sys.stderr.write("git %s 失败:%s\n" % (" ".join(args), (r.stderr or "").strip())) + sys.exit(1) + return (r.stdout or "").strip() + + +def run_cmd(cmd: str, timeout: int) -> int: + r = subprocess.run(cmd, shell=True, cwd=str(REPO), capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout) + if r.returncode != 0: + tail = "\n".join((r.stdout or "").splitlines()[-15:] or ["(无输出)"]) + sys.stderr.write(" | 测试命令失败(exit=%d),输出尾部:\n%s\n" % (r.returncode, tail)) + return r.returncode + + +def _in_rev(rev: str, f: str) -> bool: + r = subprocess.run(["git", "-C", str(REPO), "cat-file", "-e", "%s:%s" % (rev, f)], + capture_output=True, timeout=30) + return r.returncode == 0 + + +def apply_rev(rev: str, files: list[str]) -> None: + # 逐文件:基准版本存在 → 恢复到该版本;基准没有(该批新增的文件)→ 从工作区删除。 + for f in files: + if _in_rev(rev, f): + run(["checkout", rev, "--", f]) + else: + subprocess.run(["git", "-C", str(REPO), "rm", "-q", "-f", "--", f], + capture_output=True, text=True, timeout=30) + + +def main() -> None: + ap = argparse.ArgumentParser(description="自动三连出撤销证明红绿回执") + ap.add_argument("--cmd", required=True, help="最窄测试命令(shell 形,仓库根执行)") + ap.add_argument("--revert-commit", default=None, help="该批提交:按其触过的文件集回退到父版本") + ap.add_argument("--revert-files", default=None, help="逗号分隔的文件清单(与 --base 配用)") + ap.add_argument("--base", default="HEAD", help="回退基准版本(默认 HEAD)") + ap.add_argument("--timeout", type=int, default=3600, help="单次测试命令时长上限(秒)") + ap.add_argument("--repo", default=None, help="仓库/worktree 路径(缺省=本脚本所在仓库)") + ap.add_argument("--out", default=None, help="回执 JSON 落盘路径") + args = ap.parse_args() + + global REPO + REPO = Path(args.repo).resolve() if args.repo else REPO_DEFAULT + + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + if bool(args.revert_commit) == bool(args.revert_files): + ap.error("--revert-commit 与 --revert-files 二选一") + + if args.revert_commit: + files = [x.strip() for x in run( + ["-c", "core.quotepath=false", "show", "--name-only", "--format=", + args.revert_commit]).splitlines() if x.strip()] + base = "%s^" % args.revert_commit + if not files: + sys.stderr.write("[撤销证明] 提交 %s 未触碰任何文件,无可撤销\n" % args.revert_commit) + sys.exit(2) + else: + files = [x.strip() for x in args.revert_files.split(",") if x.strip()] + base = args.base + + dirty = run(["-c", "core.quotepath=false", "status", "--porcelain", "--"] + files) + if dirty: + sys.stderr.write("[撤销证明] 指定文件带未提交改动,撤销与还原的基准不清,先提交:\n%s\n" % dirty) + sys.exit(2) + + steps: list[dict[str, object]] = [] + result = "pass" + + def step(name: str, expect: str, code: int) -> bool: + ok = (code == 0) if expect == "绿" else (code != 0) + steps.append({"step": name, "expect": expect, "exit": code, "ok": ok}) + print("[撤销证明] %s:期望%s,实测 exit=%d → %s" + % (name, expect, code, "符合" if ok else "不符合")) + return ok + + print("[撤销证明] 对象文件 %d 个;基准 %s;命令:%s" % (len(files), base, args.cmd)) + try: + if not step("① 改前基线", "绿", run_cmd(args.cmd, args.timeout)): + result = "fail(改前就红——先修绿再做撤销证明)" + else: + apply_rev(base, files) + try: + if not step("② 真撤销复红", "红", run_cmd(args.cmd, args.timeout)): + result = "fail(撤销后仍绿=断言没钉在该改动上,判缺漏)" + else: + apply_rev("HEAD", files) + if not step("③ 还原复绿", "绿", run_cmd(args.cmd, args.timeout)): + result = "fail(还原后未复绿——还原面有残留,查工作区)" + finally: + apply_rev("HEAD", files) + except subprocess.TimeoutExpired: + result = "fail(测试命令超时)" + finally: + # 兜底还原:无论走到哪一步,退出前把对象文件恢复到 HEAD 态 + apply_rev("HEAD", files) + + residue = run(["-c", "core.quotepath=false", "status", "--porcelain", "--"] + files) + if residue: + result = "fail(退出前工作区仍有对象文件残留改动,手工核对)" + + print("─" * 56) + print("[撤销证明] 结论:%s" % result) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + json.dump({"cmd": args.cmd, "base": base, "files": files, + "steps": steps, "result": result}, f, ensure_ascii=False, indent=1) + print("[撤销证明] 回执已落 %s" % args.out) + sys.exit(0 if result == "pass" else 1) + + +if __name__ == "__main__": + main() diff --git a/待办清单.md b/待办清单.md index 6f1797e6..cca3a59c 100644 --- a/待办清单.md +++ b/待办清单.md @@ -2,14 +2,6 @@ 本清单是流水线的唯一登记处:要做的事先在此登记一条,随流水线段推进改状态,做完整条删除;各系统文档的「待办」节只留一行指路存根,不再自建条目。条目格式(标题=登记元数据含可选优先级,正文=实况/缺口/改动面/状态四属性)与状态机以 [doc-conventions §12.1](design/engineering/doc-conventions.md) 为权威。 -#### 撤销证明工具化——undo_proof 自动红绿回执+变异禁令落 test-conventions(2026-10-28 登记,来源=会话提及) - -**实况**:证据=迭代 40 卡① 十例取证人工三连(绿→真撤销复红→还原复绿)且撞 2026-10-24 变异禁令——禁令原文只在 req-card 技能内,test-conventions 指针空挂(iter40 反哺点 B3 在册)。2026-10-28 用户裁工具化:tools/undo_proof.py 对指定套件自动做真撤销(回退指定改动→预期红→还原→预期绿)出回执,替代人工三连;断言配额维持现状不设(用户裁)。 - -**改动面**:工具代码+文档 - -**状态**:待执行 - #### 闸机影响面选闸——代码域闸件 diff 触发面判定与 SKIP 记账(2026-10-28 登记,来源=会话提及) **实况**:证据=术语修正批(三件测试夹具字串)跑了全代码域闸含 02 数值验证;tools/run_all_gates.sh 已有同型先例(11 回放对拍的触面判定+SKIP 记账,PARITY_TOUCH_FACES 机制)。2026-10-28 用户裁推广:按本批 diff 推每闸触发面,未触面闸记 SKIP+理由入回执;触发面映射只写在 run_all_gates.sh 一处;判定保守取向——判不了就跑,不因映射缺漏出假绿。