合并 master(21cd0f2e) 进 iter40: 接回挂死防护·probe_gate件批(run_all_gates/probe_gate/run_all.gd超时守护/undo_proof/reshape_cards工具+process-conventions/architecture/test-conventions规约面+待办清账)——iter40侧同改文件仅run_all.gd(manifest wr8登记行),与守护改动异段预期自动合并

This commit is contained in:
DKChan committed 2026-10-28 04:18:32 +08:00
commit 185bf81560
10 files changed
+516 -44

No files matched your search

@@ -226,7 +226,7 @@
|---|---|---|---|---|
| **run_smoke**(冒烟测试运行器) | `tools/run_smoke.py` | 冒烟测试总入口与判定权:headless 以项目运行目标拉起 `res://tests/smoke_boot.tscn`(极小启动场景——autoload 在位、配置表全载、主屏实例化,零逻辑判定)+ 三闸判定 | 进程退出码(0=PASS / 1=FAIL) | 三闸齐备、判定无洞:①退出码 = 0;②摘要行「[smoke] 摘要:N 项检查,0 项失败」在案且失败数为 0;③输出扫 `SCRIPT ERROR` / `Parse Error` 标记行。外部 wall-clock 超时在位(超时 = FAIL) |
| **run_system_test**(系统测试运行器) | `tools/run_system_test.py` | 系统测试总入口:headless 跑真实玩法路径断言面(宿主=`res://tests/system_test.gd`)+ 三闸判定(口径同 run_smoke,摘要行兼容两种宿主格式) | 进程退出码(0=PASS / 1=FAIL) | 三闸齐备(同 run_smoke:退出码/摘要失败数/脚本错误扫描;wall-clock 超时在位) |
| **run_all**(单元与集成测试) | `game/tests/run_all.gd` | 40 套统一入口(39 单元+1 集成,2026-10-22 实数):套件 manifest 逐条「套件 → 期望退出码」,致命类 expect=1 独立子进程实跑;`negative` 字段标负面套件(仅豁免错误扫描一闸) | 逐套件结果 + 汇总退出码(任一套件失败 quit(1)) | 套件级退出码协议在位;三硬闸齐备(入口内扫子套件输出 + 编排层扫 `unit.log`;`Kit.finish` 零断言返回专用码 2)。子进程输出须经系统命令解释器重定向落盘再读——`OS.execute` 的输出管道在本机恒空 |
| **run_all**(单元与集成测试) | `game/tests/run_all.gd` | 64 套统一入口(61 单元+3 集成,2026-10-28 实数):套件 manifest 逐条「套件 → 期望退出码」,致命类 expect=1 独立子进程实跑;`negative` 字段标负面套件(仅豁免错误扫描一闸);选跑态 `--only=<词[,词…]>`(直跑形态对套件路径子串匹配只跑命中套件,零命中=用法错 quit(2),仅供开发期最窄确认——不产闸回执、不作验收凭据,批末全量闸不经选跑分支) | 逐套件结果 + 汇总退出码(任一套件失败 quit(1)) | 套件级退出码协议在位;三硬闸齐备(入口内扫子套件输出 + 编排层扫 `unit.log`;`Kit.finish` 零断言返回专用码 2)。子进程输出须经系统命令解释器重定向落盘再读——`OS.execute` 的输出管道在本机恒空 |
| **probe_gate**(场景探针) | `tools/probe_gate.sh` | headless 场景探针段编排:默认段 ui_input_probe / ui_struct_probe / layout_probe / ui_contrast_probe / **screen_flow_probe**(屏壳切换),`--click` 加跑 ui_click_probe,`--full` 加跑 ui_probe / ui_tune_probe / t17_defer_probe / fx_probe;孤儿 Godot 预清 + 批末残留清点(必须 0) | 段级 FAIL 输出 + 汇总退出码 | 每段 timeout 守卫在位(挂死 124/137 = FAIL,不静默);判定统一走 `verdict` 函数——退出码为唯一权威判据,PASS 串降级为附加判据,逐段跑 `tools/error_scan.py` |
| **render_gate**(视觉 golden) | `tools/render_gate.sh` | 窗口化渲染闸:①shot_probe 七屏截图 → shot_checker A-D 语义断言;②fx_shot_probe / board_shot_probe 取证(超时/零字节/文件缺失任一 = FAIL);③棋盘 golden 逐像素容差比对(差 ≤16 / changed ≤2%,provisional)+ 菜单 golden;④批末孤儿残留清零 | 段级 FAIL 输出 + 汇总退出码 | 查退出码在位(每段 timeout 守卫 + 退出码/取证文件双检);**SCRIPT ERROR 扫描缺失**;断言数下限不适用(判定主体 = golden 容差与取证文件存在性,非断言计数) |
| **static_scan**(静态扫描域) | `game/tests/static_scan.sh` | 静态扫描域 S1–S10 可执行载体(grep 规则 + `tools/audit_type_hints.py`〔S9〕+ `tools/audit_file_headers.py`〔S10〕) | fail 聚合退出码(任一硬失败规则命中即 exit 1) | 查退出码在位;SCRIPT ERROR 扫描与断言数下限不适用(逐规则命中即红,无运行时错误面与断言计数);S3 现为 warning 级、不参与 fail 判定(见下表) |
+1 -1
View File
@@ -10,7 +10,7 @@
改动按文件性质分两车道(2026-10-26 用户裁,取代 2026-09-19 立的「一切改动走 worktree」铁律;判据与 §3 第 8 条改动域划分同一):
1. **worktree 批车道(唯一强制面=代码面)**:一切触及代码面(`game/` 下非 `.md` 的行为载体:代码、测试脚本、探针、场景、数据表、素材)的改动——迭代实现批与触及代码面的工程改动(如纯测试维护)——先建独立 worktree:`git worktree add ../g-plan2-<任务名> -b <任务分支>`;所有改动与验证都在 worktree 内进行(未跟踪文件不会出现在新 worktree,需手工搬运);建区后立刻跑 `tools/setup_worktree_userdir.sh`——给该区独立 Godot user 目录(测试夹具、试玩日志、截图产物不与其他区共用;主区不生成,真实档保持原位)。迭代批开工细则见 §3 与 iter 技能
2. **worktree 批收尾**:批次收口顺序固定为**冻结 → 件核 → 并 master → 跑一次域闸 → 合回**——代码与断言改完即冻结,全仓件核 grep 查残留;master 已被别的批次推进时先 `git merge master` 解冲突(必须在跑闸之前,闸跑完再并=那次闸白跑),然后按本批改动域跑**一次** `tools/run_all_gates.sh`(见 §3.9),全绿后在主工作区 `git merge --no-ff <任务分支>`,再 `git worktree remove`+删分支。闸后代码面不得再改一个字节(回执作废须重跑),只允许追加 `reports/`、`design/` 等文档件提交。游戏类测试(冒烟/单测/MC/探针)批内默认零执行(两入口铁规,见 §4),用户点名或当场问才跑
2. **worktree 批收尾**:批次收口顺序固定为**冻结 → 件核 → 并 master → 重塑 → 跑一次域闸 → 合回**——代码与断言改完即冻结,全仓件核 grep 查残留;master 已被别的批次推进时先 `git merge master` 解冲突(必须在跑闸之前,闸跑完再并=那次闸白跑);**重塑**=迭代分支(iter<N> 形)在跑闸前执行 `python tools/reshape_cards.py`,把分支线性史按卡末态快照压成一卡一笔(可选追加一笔纯文档收尾),master 台账由此每卡一行——工具自带「重塑前后树逐字节一致」断言与 backup 分支,不改任何文件内容;非迭代工程小批免重塑;然后按本批改动域跑**一次** `tools/run_all_gates.sh`(见 §3.9),全绿后在主工作区 `git merge --no-ff <任务分支>`(合回闸对迭代分支数卡校验,未归卡提交拦下),再 `git worktree remove`+删分支。闸后代码面不得再改一个字节(回执作废须重跑),只允许追加 `reports/`、`design/` 等文档件提交。游戏类测试(冒烟/单测/MC/探针)批内默认零执行(两入口铁规,见 §4),用户点名或当场问才跑
3. **直改车道(代码面之外的一切面)**:`design/`、`reports/`、`tools/`、根规则与清单文件、`game/` 内 `.md`、`.zcode/`、`.qoder/`——主工作区直接改、直接提交,无 worktree、无冻结/件核/合并仪式。验收=commit 点 git pre-commit 钩子(`tools/hooks/pre-commit`,经 `core.hooksPath` 挂载,在位性由 run_all_gates.sh 跑批自检)现场跑 `tools/run_all_gates.sh --docs`(秒级文本扫描):绿即落地,红即拦下,修红后重新 commit 钩子自动重跑;跑完把闸输出呈报对话即完成义务。预期大体量改动(跨文档结构重构等)时由用户开工点名自愿开 worktree,非规则强制
4. **执法分层(全机械,零自觉依赖)**:①git pre-commit 钩子——直改车道的闸机检,并对主工作区直提代码面的提交一律拦(代码面唯一合法路径=worktree 批合回);任务 worktree 内的代码面提交跳过(归批末闸+合回闸管,不打扰迭代批中间提交)。②`.zcode/hooks/worktree-guard.sh`(PreToolUse,本机件不入 git)——agent 侧:主工作区代码面的 Edit/Write 与 staged 含代码面的 commit 拦截、`git commit --no-verify` 拦截(该旗标仅属用户)、合回闸回执六况校验(§3.9)。git 层钩子对一切提交者生效(含用户手工提交,紧急出口 `--no-verify`);ZCode 层只约束 agent
+1
View File
@@ -105,6 +105,7 @@
- **撤销后仍然绿**:说明断言没钉在这个改动上,判缺漏;不得用「代码能跑通」代替。
- **判据绑可观测提交面**:端到端断言的判据要绑「被测改动特有的可观测提交面」(信号发射、事件、入参、绘制记录),不绑多层防御之后的终态——否则改动被下游兜底吸收时终态不变、断言假绿。例:探针「雾不扩可点域」首版只看屏/选路态终态,命中面放宽后断言仍绿;改绑 `route_chosen` 发射面后即红。
- 每张任务卡至少一条撤销证明记录;持久化、生命周期、存档、跨战共享这类接线逐行做。
- **合法手段只有真撤销(2026-10-24 用户立:全项目禁做变异类测试)**——改断言极性、置空库存/状态、改名键、删调用行等「人为制造红」都属伪造证据,一律禁止;证明只认「回退本批真实改动 → 断言红 → 还原复绿」。自动化载体=`tools/undo_proof.py`(给最窄测试命令与撤销对象,自动出三连红绿回执并自还原),人工三连跑由此免做。
### 8.5 禁止削弱测试
+4 -3
View File
@@ -58,7 +58,7 @@ tests/
|---|---|---|---|
| `tools/run_smoke.py`(冒烟测试) | 这版能否启动、能否加载表、能否进主屏——零逻辑判定 | 退出码+脚本错误扫描 | 开机路径/表加载改动后的快速自检;收口由闸编排 |
| `tools/run_system_test.py`(系统测试) | 真实玩法路径的行为断言面(推理→战斗→结算→进度推进) | 退出码+脚本错误扫描 | 玩法链路改动的调试期确认 |
| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(40 套件 manifest) | 退出码+三硬闸(见下) | 新套件单跑调试;逻辑改动的最小单元复跑 |
| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) |
| `tools/probe_gate.sh`(场景探针,`--click`/`--full` 两模式) | UI 可达性、可见性、状态——真实实例化场景+真实输入路径 | 探针自检+退出码 | 新增 UI 区域的配套探针直跑与断言复核 |
| `tools/render_gate.sh`(渲染 golden) | 画面基线——截图与基线图逐像素容差比对 | golden 比对脚本(依赖 PIL) | 主题/素材/排版类改动;窗口化运行,需开显示器 |
| `game/tests/static_scan.sh`(S 域) | 项目纪律+第三方质量规则:RNG 单点/core 纯度/类型标注/文件头/注释禁词/旧符号残留;S13=gdstyle 质量规则(重复 load、循环内 await、循环内分配、不可达代码等真缺陷类;基线键=文件|规则|函数名(类/文件级=文件|规则,无数字条目带消息),数字漂移不假红,同键指标恶化超基线登记值即红) | 退出码(逐规则 FAIL 行) | 改产品码前后的快速自检;新增纪律先补扫描规则再修码 |
@@ -75,6 +75,7 @@ python tools/run_smoke.py
# 单元与集成测试(闸 02)——Windows 须用 console 版 exe 取 stdout;工作目录 = game/(含 project.godot 那一层)
godot --headless --script tests/run_all.gd # 统一入口(本地闸 02 闸调用点)
godot --headless --script tests/run_all.gd -- --only=battle_settlement # 选跑态——只跑路径含该词的套件(开发期最窄确认;零命中=用法错 exit 2,不产闸回执)
godot --headless --script tests/unit/example_test.gd
# 系统测试(闸 01)——过渡期宿主 smoke_all.gd;分钟级
@@ -87,8 +88,8 @@ godot --headless --path . res://probe/<probe>.tscn
timeout 120s godot --headless --script tests/run_all.gd
```
> 套件 manifest 现况:`_suites()` 登记 **40 条**(39 单元+1 集成,2026-10-22 实数;口径=manifest 条目数,
> 随套件增减浮动)——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、core(秘宝结构冻结/断点续玩/存档槽位/结算五段等)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言)、presentation(节奏队列/奖励浮层/字体资产/地形瓦片集/模态编排等);集成 1 条存 `tests/integration/`。致命类独立进程 `expect=1`;负面套件 `negative=true` 只豁免错误扫描一闸。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。
> 套件 manifest 现况:`_suites()` 登记 **64 条**(61 单元+3 集成,2026-10-28 实数;口径=manifest 条目数,
> 随套件增减浮动)——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、core(秘宝结构冻结/断点续玩/存档槽位/结算五段等)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言)、presentation(节奏队列/奖励浮层/字体资产/地形瓦片集/模态编排等);集成 3 条存 `tests/integration/`。致命类独立进程 `expect=1`;负面套件 `negative=true` 只豁免错误扫描一闸。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。
**协议要点**(D2 冒烟协议):
- `--script` 载体 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)`
+52 -9
View File
@@ -1,5 +1,8 @@
# tests/run_all.gd — 单元测试统一入口(双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b)
# 用法:godot --headless --path game --script res://tests/run_all.gd (本地闸 02 闸包装外部超时:timeout 120s …)
# 选跑态(开发期直跑专用):追加 `-- --only=<词[,词…]>` ——对套件 res:// 路径做子串匹配,只跑命中套件;
# 零命中/空值=用法错 quit(2)。选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据;
# 批末全量闸不带参数不经选跑分支——默认(无参数)行为逐字节不变。
# 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。
# 执行形态:逐套件起独立 Godot 子进程实跑
#(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符,
@@ -13,24 +16,51 @@ const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"]
func _init() -> void:
print("[run_all] unit/contract suite entry — %d suites" % _suites().size())
# 选跑态解析:--only=<词[,词…]>(经 -- 分隔的 user args)——对套件 res:// 路径做子串匹配。
# 空值/零命中=用法错 quit(2)(fail-closed:禁把零套件当绿);选跑态仅供开发期最窄确认,
# 不产闸回执、不作验收凭据;批末全量闸不带参数,不走此分支。
var only_terms := PackedStringArray()
var has_only := false
for a in OS.get_cmdline_user_args():
if a.begins_with("--only="):
has_only = true
var term := a.trim_prefix("--only=").strip_edges()
if term != "":
only_terms.append(term)
if has_only and only_terms.is_empty():
push_error("[run_all] --only= 空值——用法错(选跑态禁把零套件当全量)")
quit(2)
return
var manifest := _suites()
var suites := manifest
var filtered := false
if has_only:
suites = _filter_suites(manifest, only_terms)
if suites.is_empty():
push_error("[run_all] FILTERED RUN — --only=「%s」零命中(manifest 共 %d 套件)——用法错" % [",".join(only_terms), manifest.size()])
quit(2)
return
filtered = true
print("[run_all] FILTERED RUN — 选跑 %d/%d 套件(--only=%s)——仅供开发期最窄确认,不产闸回执、不作验收凭据" % [suites.size(), manifest.size(), ",".join(only_terms)])
print("[run_all] unit/contract suite entry — %d suites" % suites.size())
if DisplayServer.get_name() != "headless":
push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)")
var failures := 0
var ran := 0
for suite in _suites():
failures += _run_suite(suite, ran)
for suite in suites:
failures += _run_suite(suite, ran, filtered)
ran += 1
# 实跑套件数校验:manifest 有条目却被静默跳过(早退/continue)必须判红,不能只凭 failures。
if ran != _suites().size():
push_error("[run_all] 实跑套件数 %d ≠ manifest 条目数 %d——有套件未跑" % [ran, _suites().size()])
# 实跑套件数校验:清单有条目却被静默跳过(早退/continue)必须判红,不能只凭 failures。
# 选跑态对过滤后清单校验;全量态与原 manifest 条目数同一。
if ran != suites.size():
push_error("[run_all] 实跑套件数 %d ≠ 清单条目数 %d——有套件未跑" % [ran, suites.size()])
quit(1)
return
if failures > 0:
push_error("[run_all] %d 个套件失败" % failures)
quit(1)
return
print("[run_all] all green (%d suites)" % _suites().size())
print("[run_all] all green (%d suites%s)" % [suites.size(), ",filtered" if filtered else ""])
quit(0)
@@ -112,7 +142,7 @@ func _suites() -> Array:
]
func _run_suite(suite: Dictionary, idx: int) -> int:
func _run_suite(suite: Dictionary, idx: int, filtered: bool = false) -> int:
var path: String = suite.get("path", "")
var expect: int = int(suite.get("expect", 0))
if path.is_empty():
@@ -126,7 +156,7 @@ func _run_suite(suite: Dictionary, idx: int) -> int:
# 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空
#(实测 0 行),故整条命令交给 cmd /c 并重定向到日志,退出码由 cmd 原样回传。
var proj := ProjectSettings.globalize_path("res://")
var log := proj + "/tests/.gates/run_all_%02d.log" % idx
var log := proj + "/tests/.gates/run_all%s_%02d.log" % ["_filter" if filtered else "", idx]
DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates")
var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1' % [bin, proj, path, log]
var out := PackedStringArray()
@@ -149,6 +179,19 @@ func _run_suite(suite: Dictionary, idx: int) -> int:
return 0
# 选跑过滤:对套件 res:// 路径做子串匹配,任一词命中即选入,词序无关。
# 只在选跑态调用——全量态走原 manifest,条目与顺序零变化。
func _filter_suites(manifest: Array, terms: PackedStringArray) -> Array:
var out: Array = []
for suite in manifest:
var path: String = suite.get("path", "")
for t in terms:
if path.contains(t):
out.append(suite)
break
return out
# 引擎二进制必须取 console 版:OS.get_executable_path() 返回的是 GUI 子系统 exe
#(Godot_v4.7.2-stable_win64.exe),它起的子进程 stdout/stderr 句柄无效——输出全丢,
# 脚本错误扫描就成了扫空气。GODOT_BIN 优先,其次按命名推导 *_console.exe。
+51 -21
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# 探针闸正主——headless 安全段固化,禁再手拼同款 Godot 命令。
# 三闸:①每段 timeout 守卫(挂死=FAIL 不静默)②段前预清+段后清本 game 目录孤儿 Godot
# 三闸:①每段 timeout 守卫+超时整树回收(挂死=FAIL 不静默;taskkill /T 连引擎子进程一起杀,
# 不再只杀 wrapper 留孤儿)②段前预清+段后清本 game 目录孤儿 Godot
# (按命令行精确匹配本 game 路径,不误杀其他 worktree 会话的合法进程)③末尾残留清点=批末闸(必须 0)。
# 段:ui_input_probe=默认必跑;ui_struct_probe=默认必跑(
# 全 UI 屏结构断言:按钮互压/等宽/出屏/零尺寸/标题屏双列);ui_contrast_probe=默认必跑(
@@ -75,6 +76,35 @@ kill_orphans() {
local p; for p in $(orphans); do taskkill //F //PID "$p" >/dev/null 2>&1; done
}
# 每段跑批统一走本函数:后台启动记 MSYS PID→WINPID,守卫进程到点先 taskkill /T 整树回收
# (顺序要紧:先杀 wrapper 再清树会让 /T 走不到已死的父进程,引擎子进程照样泄漏——
# 2026-10-25 在册挂死根源),后 kill -9 收 MSYS 侧。正常退出不动进程树;段后 kill_orphans 照跑兜底。
run_suite() { # run_suite <timeout秒> <日志> <场景 res:// 路径>
local t="$1" log="$2" scene="$3"
local kf="$TMP/killed.$RANDOM"
rm -f "$kf"
"$G" --headless --path "$GAME_WIN" "$scene" >"$log" 2>&1 &
local pid=$!
local wpid=""
wpid="$(ps -W 2>/dev/null | awk -v p="$pid" '$1==p{print $4; exit}')"
( sleep "$t" 2>/dev/null
touch "$kf"
[ -n "$wpid" ] && taskkill //F //T //PID "$wpid" >/dev/null 2>&1
kill -9 "$pid" 2>/dev/null
true ) &
local guard=$!
wait "$pid"; local st=$?
kill "$guard" 2>/dev/null; wait "$guard" 2>/dev/null
# taskkill 强杀后 MSYS wait 可能报 0——超时判定以守卫落下的标志文件为准(与 124 同判)。
if [ "$st" -ge 128 ] || [ -f "$kf" ]; then
st=124
echo "[probe_gate] 套件超时(${t}s),已整树回收(PID=$pid/WINPID=${wpid:-未取到——靠孤儿清杀兜底})"
fi
rm -f "$kf"
kill_orphans
return "$st"
}
# 冷检出刷导入(.godot 不入 git,fresh worktree 首跑一次性付)
if [ ! -f "$GAME/.godot/uid_cache.bin" ]; then
echo "[probe_gate] 冷检出:editor --quit 刷导入(一次性,可到 1-2 分钟)"
@@ -86,39 +116,39 @@ pre="$(orphans | wc -l | tr -d ' ')"
if [ "$pre" -gt 0 ]; then echo "[probe_gate] 预清本闸残留 Godot×$pre"; kill_orphans; fi
echo "======== ui_input_probe ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_input_probe.tscn >"$TMP/uip.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/uip.log" res://probe/ui_input_probe.tscn
st=$?
grep -E "PASS|FAIL|问题" "$TMP/uip.log" | head -12
verdict "ui_input_probe" "$TMP/uip.log" "$st" || _rc=1
echo "======== ui_struct_probe ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_struct_probe.tscn >"$TMP/stp.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/stp.log" res://probe/ui_struct_probe.tscn
st=$?
grep -E "PASS|FAIL" "$TMP/stp.log" | head -12
verdict "ui_struct_probe" "$TMP/stp.log" "$st" || _rc=1
echo "======== layout_probe(分辨率几何·两档) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/layout_probe.tscn >"$TMP/lay.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/lay.log" res://probe/layout_probe.tscn
st=$?
grep -E "PASS|FAIL" "$TMP/lay.log" | head -12
verdict "layout_probe" "$TMP/lay.log" "$st" || _rc=1
echo "======== ui_contrast_probe ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_contrast_probe.tscn >"$TMP/ctp.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/ctp.log" res://probe/ui_contrast_probe.tscn
st=$?
grep -E "PASS|FAIL" "$TMP/ctp.log" | head -12
verdict "ui_contrast_probe" "$TMP/ctp.log" "$st" || _rc=1
echo "======== screen_flow_probe(L3 屏壳切换·迭代 29 T4) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/screen_flow_probe.tscn >"$TMP/sfp.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/sfp.log" res://probe/screen_flow_probe.tscn
st=$?
grep -E "screen_flow_probe" "$TMP/sfp.log" | tail -3
verdict "screen_flow_probe" "$TMP/sfp.log" "$st" "screen_flow_probe" || _rc=1
if [ "$WITH_CLICK" -eq 1 ]; then
echo "======== ui_click_probe 行为走查 ========"
timeout 240 "$G" --headless --path "$GAME_WIN" res://probe/ui_click_probe.tscn >"$TMP/clk.log" 2>&1
st=$?; kill_orphans
run_suite 240 "$TMP/clk.log" res://probe/ui_click_probe.tscn
st=$?
grep -E "click_probe" "$TMP/clk.log" | tail -4
verdict "ui_click_probe" "$TMP/clk.log" "$st" "click_probe" || _rc=1
fi
@@ -127,26 +157,26 @@ fi
# ui_tune_probe 49.6s/t17_defer_probe 63.0s/fx_probe 21.9s,timeout 120=2-7×余量) ──
if [ "$WITH_FULL" -eq 1 ]; then
echo "======== ui_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_probe.tscn >"$TMP/flp.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/flp.log" res://probe/ui_probe.tscn
st=$?
grep -E "\[ui_probe\]" "$TMP/flp.log" | tail -2
verdict "ui_probe" "$TMP/flp.log" "$st" || _rc=1
echo "======== ui_tune_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/ui_tune_probe.tscn >"$TMP/flt.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/flt.log" res://probe/ui_tune_probe.tscn
st=$?
grep -E "\[tune_probe\]" "$TMP/flt.log" | tail -2
verdict "ui_tune_probe" "$TMP/flt.log" "$st" || _rc=1
echo "======== t17_defer_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/t17_defer_probe.tscn >"$TMP/fld.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/fld.log" res://probe/t17_defer_probe.tscn
st=$?
grep -E "\[t17_defer\]" "$TMP/fld.log" | tail -2
verdict "t17_defer_probe" "$TMP/fld.log" "$st" || _rc=1
echo "======== fx_probe(--full 收编) ========"
timeout 120 "$G" --headless --path "$GAME_WIN" res://probe/fx_probe.tscn >"$TMP/flx.log" 2>&1
st=$?; kill_orphans
run_suite 120 "$TMP/flx.log" res://probe/fx_probe.tscn
st=$?
grep -E "\[fx_probe\]" "$TMP/flx.log" | tail -2
verdict "fx_probe" "$TMP/flx.log" "$st" || _rc=1
fi
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""一卡一提交上岸套件的重塑工具:把迭代分支的线性提交史按卡末态快照压成一卡一笔。
分支内自由提交(T 段、修复、文书随做随提),master 台账只收每张卡一笔提交(可选追加
一笔纯文档收尾)。本工具在批末「并 master 之后、跑闸之前」执行:以每张卡最后一笔提交
的文件树建新提交、逐卡链在 master 尖上;自带「重塑前后树逐字节一致」断言与 backup
分支,重塑只改账面、不改变任何文件内容。
用法:
python tools/reshape_cards.py --branch iter40 \\
--card 卡1=<该卡最后一笔提交> --card 卡2=<...> [--tail <收尾最后一笔>] [--dry-run]
可选 --msg 卡1="一行结论"(多条)补提交说明;缺省说明=卡号本身。
规则:
1. master 尖必须是分支祖先(批末先并 master;不满足即拒绝,防快照回退 master 内容)。
2. 各卡边界必须是分支第一-parent 链上的提交、按链序递增;最后一笔卡边界或收尾边界
必须恰为分支尖——否则存在未归卡提交,拒绝执行并列出。
3. 收尾段的改动必须全是文档面(game/ 下非 .md 之外的文件);含代码面先归卡。
4. 分支工作区必须干净(脏改动会被快照丢弃,拒绝执行)。
非迭代工程小批免重塑(合回闸只对 iter<N> 形分支做数卡校验)。原分支史保留在
backup/<分支名>-<时间戳> 分支上,可随时回溯。
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
def run(args: list[str], check: bool = True, inp: str | None = None) -> str:
r = subprocess.run(
["git", "-C", str(REPO)] + args,
capture_output=True, text=True, encoding="utf-8", errors="replace",
input=inp, 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 is_doc_face(p: str) -> bool:
# 代码面=game/ 下非 .md 的行为载体;收尾段只许文档面。
return not (p.startswith("game/") and not p.lower().endswith(".md"))
def worktree_of(branch: str) -> str | None:
wl = run(["worktree", "list", "--porcelain"])
for block in wl.split("\n\n"):
if ("branch refs/heads/%s" % branch) in block:
for line in block.splitlines():
if line.startswith("worktree "):
return line[9:].strip()
return None
def die(msg: str) -> None:
sys.stderr.write("[重塑] 拒绝执行——%s\n" % msg)
sys.exit(1)
def main() -> None:
ap = argparse.ArgumentParser(description="按卡末态快照把迭代分支重塑成一卡一笔")
ap.add_argument("--branch", required=True, help="迭代分支名(iter<N> 形)")
ap.add_argument("--card", action="append", required=True, metavar="卡1=<提交>",
help="该卡在分支上的最后一笔提交,按卡序逐卡给出")
ap.add_argument("--tail", metavar="<提交>", default=None,
help="可选收尾段(纯文档面)的最后一笔提交")
ap.add_argument("--msg", action="append", default=[], metavar='卡1="一行结论"',
help="各卡/收尾的提交说明,可多条")
ap.add_argument("--dry-run", action="store_true", help="只打印重塑计划,不动任何引用")
args = ap.parse_args()
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
branch = args.branch
if not re.fullmatch(r"iter\d+", branch):
die("分支名 %s 不是 iter<N> 形——非迭代批免重塑" % branch)
master_tip = run(["rev-parse", "master"])
old_tip = run(["rev-parse", branch])
rc = subprocess.run(["git", "-C", str(REPO), "merge-base", "--is-ancestor",
master_tip, old_tip], capture_output=True).returncode
if rc != 0:
die("master 尖 %s 不是分支 %s 的祖先——先回分支并 master(git merge master)再重塑,"
"否则快照会把 master 新内容回退掉" % (master_tip[:8], branch))
wt = worktree_of(branch)
if wt:
st = run(["-C", wt, "-c", "core.quotepath=false", "status", "--porcelain"])
if st:
die("分支 %s 的工作区(%s)有未提交改动——快照取自提交树,脏改动会被丢弃;"
"先提交或储藏再重塑\n%s" % (branch, wt, st))
# 分支未检出时引用移动不影响任何工作区,无需检查脏树
base = run(["merge-base", "master", branch])
chain = [x for x in run(["rev-list", "--first-parent", "%s..%s" % (base, branch)]).splitlines() if x]
chain.reverse() # 老 → 新
if not chain:
die("分支 %s 相对 master 无自有提交,无可重塑" % branch)
idx = {sha: i for i, sha in enumerate(chain)}
msgs: dict[str, str] = {}
for m in args.msg:
k, _, v = m.partition("=")
msgs[k.strip()] = v.strip()
cards: list[tuple[str, int]] = []
for c in args.card:
label, _, sha = c.partition("=")
full = run(["rev-parse", sha.strip()])
if full not in idx:
die("卡边界 %s(%s)不在分支 %s 相对 master 的第一-parent 链上" % (label, sha, branch))
cards.append((label.strip(), idx[full]))
cards.sort(key=lambda x: x[1])
if len({i for _, i in cards}) != len(cards):
die("卡边界重复指向同一笔提交")
tail_i: int | None = None
if args.tail:
full = run(["rev-parse", args.tail.strip()])
if full not in idx:
die("收尾边界 %s 不在分支第一-parent 链上" % args.tail)
tail_i = idx[full]
if cards and tail_i <= cards[-1][1]:
die("收尾边界必须排在最后一笔卡边界之后")
last = tail_i if tail_i is not None else (cards[-1][1] if cards else -1)
if last != len(chain) - 1:
rest = chain[last + 1:]
die("最后一笔边界之后还有 %d 笔未归卡提交——归入所属卡或收尾后重试:\n %s"
% (len(rest), "\n ".join(run(["log", "--no-walk", "--format=%h %s", s]) for s in rest[:8])))
# 收尾段只许文档面
if tail_i is not None:
prev = chain[cards[-1][1]] if cards else base
files = run(["-c", "core.quotepath=false", "diff", "--name-only", prev, chain[tail_i]]).splitlines()
bad = [f for f in files if not is_doc_face(f.strip())]
if bad:
die("收尾段含代码面改动(应归卡):\n " + "\n ".join(bad[:10]))
# 建新链:master 尖起,逐卡快照
plan: list[tuple[str, str, str]] = [] # (说明, 边界 sha, 新提交占位)
segs: list[tuple[str, int]] = cards + ([("收尾", tail_i)] if tail_i is not None else [])
parent = master_tip
new_shas: list[str] = []
n_match = re.fullmatch(r"iter(\d+)", branch)
itn = n_match.group(1) if n_match else branch
for k, (label, i) in enumerate(segs, start=1):
boundary = chain[i]
tree = run(["rev-parse", "%s^{tree}" % boundary])
if label == "收尾":
subject = "iter%s-收尾" % itn
else:
subject = "iter%s-卡%d" % (itn, k)
if label in msgs:
subject += ":" + msgs[label]
cp = run(["commit-tree", tree, "-p", parent], inp=subject + "\n")
new_shas.append(cp)
parent = cp
plan.append((subject, boundary, cp))
new_tip = parent
diff = run(["diff", "--stat", old_tip, new_tip])
if diff:
die("安全断言失败:重塑前后树不一致(不应发生,请带输出报缺陷)\n%s" % diff)
print("[重塑] 计划(分支 %s,原尖 %s):" % (branch, old_tip[:8]))
for subject, boundary, cp in plan:
print(" %s ← %s" % (subject, run(["log", "--no-walk", "--format=%h %s", boundary])[:70]))
print("[重塑] 树一致断言通过;backup 分支将指向原尖 %s" % old_tip[:8])
if args.dry_run:
print("[重塑] --dry-run:未动任何引用。")
return
backup = "backup/%s-%s" % (branch, time.strftime("%Y%m%d-%H%M%S"))
run(["branch", backup, old_tip])
if wt:
run(["-C", wt, "reset", "--hard", new_tip])
else:
run(["update-ref", "refs/heads/%s" % branch, new_tip])
print("[重塑] 完成:分支 %s → %s(%d 笔卡提交);原史留 %s"
% (branch, new_tip[:8], len(cards), backup))
print("[重塑] 下一步:按本批改动域跑 tools/run_all_gates.sh,全绿后主工作区合回。")
if __name__ == "__main__":
main()
+47 -8
View File
@@ -3,7 +3,7 @@
# 闸清单的唯一定义处。文档只写「收口跑本脚本」,禁在文档里另列闸件清单(那正是 iter27
# 漏跑 probe_gate --full 的成因:清单散落三份文档、彼此不一致、且无机器校验)。
#
# 跑完写回执 game/tests/.gates/receipt.json(commit + 各闸退出码 + 存量基线比对 + 时间戳);
# 跑完写回执 game/tests/.gates/receipt.json(commit+内容树哈希+各闸退出码+存量基线比对+时间戳);
# .zcode/hooks/worktree-guard.sh 在主工作区 git merge 时校验本回执,缺或红即拦截。
#
# 本脚本是合回前的终局动作:跑之前先冻结(代码与断言不再改)并把 master 并入本分支;每批只跑
@@ -14,6 +14,8 @@
# --code 只跑代码域闸(00 冒烟测试/01 系统测试/02 单元与集成测试/03 探针 --click/
# 04 探针 --full/05 渲染闸/09 静态扫描/11 回放确定性对拍+10 熵增闸)
# ——本批改动含 game/ 下非 .md 的行为载体(代码/测试脚本/探针/场景/数据表/素材)时用。
# 影响面选闸:本批 game/ 改动全在测量面(game/tests、game/sim)时,00/03/04/05 记
# SKIP(测量面不触开机路径/场景树/渲染输出;映射定义在脚本中部,判定保守)。
# --docs 只跑文档域闸(06 gdd_lint/07 doc_link/08 doc_term/08c 流水线闸/08d 引用可核
# +10 熵增闸)
# ——本批改动只有文档件时用:game/ 内的 .md(README、生成清单)、design/、根规则文件、
@@ -152,6 +154,23 @@ fi
declare -a NAMES=() EXITS=() LOGS=()
FAILED=0
# ── 影响面选闸(2026-10-28 用户裁):本批 diff 相对 master 分叉点的 game/ 改动若全部落在
# 测量面(game/tests/**、game/sim/**——测试与模拟驱动件,不触开机路径、场景树与渲染
# 输出),则 00 冒烟/03/04 探针/05 渲染闸记 SKIP。判定保守取向:diff 含任一行为面
# 文件即全部实跑;01/02/09 恒跑(测试宿主与静态扫描正是测量面改动的验证载体)。
# 触发面映射只在本脚本一处定义;SKIP 与 --quick 同构,回执记 not_run,不作闸况变化。
NONBEHAVIOR=0
if in_domain code; then
_base="$(git -C "$REPO" merge-base master HEAD 2>/dev/null || true)"
_gfiles="$(git -C "$REPO" -c core.quotepath=false diff --name-only "${_base:-HEAD}" HEAD -- game/ 2>/dev/null || true)"
# 反选(测量面之外的行)非空=含行为面。不用 grep -q:本机 git-bash 的 -q -v 组合退出码反常。
_behavior="$(echo "$_gfiles" | grep -vE '^(game/tests/|game/sim/)' || true)"
if [ -n "$_gfiles" ] && [ -z "$_behavior" ]; then
NONBEHAVIOR=1
echo "[gates] 影响面选闸:game/ 改动全在测量面(tests/sim)——00 冒烟/03/04 探针/05 渲染闸 → SKIP"
fi
fi
echo "闸机跑批 · 域=$DOMAIN · 仓库 $REPO"
echo "起跑 commit: $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '非git目录')"
echo "python: $PY"
@@ -197,21 +216,32 @@ gate_unit() { # run_all 是 --script 模式,退出码为唯一判据
# ══ 代码域(0–5)══
if in_domain code; then
# ── 0. 冒烟测试(极小启动场景:真实开机路径存活检查,零逻辑判定,秒级)──
gate "00 冒烟测试" "smoke_boot.log" "$PY" "$REPO/tools/run_smoke.py"
if [ $NONBEHAVIOR -eq 1 ]; then
NAMES+=("00 冒烟测试"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 00 冒烟测试 → SKIP(影响面选闸:测量面改动不触开机路径)"
else
gate "00 冒烟测试" "smoke_boot.log" "$PY" "$REPO/tools/run_smoke.py"
fi
# ── 1. 系统测试(真实玩法路径断言面;宿主=game/tests/system_test.gd,缺省 --script 由 run_system_test.py 持有)──
gate "01 系统测试" "system_test.log" "$PY" "$REPO/tools/run_system_test.py"
# ── 2. 单元与集成测试 ──
gate_unit
# ── 3. 场景探针 · 行为走查段 ──
gate "03 探针 --click" "probe_click.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --click
# ── 4. 场景探针 · 断言段(iter27 漏跑的就是这一段)──
gate "04 探针 --full" "probe_full.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --full
# ── 5. 渲染面(窗口化,依赖 PIL)──
if [ $QUICK -eq 0 ]; then
gate "05 渲染闸" "render.log" bash "$REPO/tools/render_gate.sh" "$GAME"
if [ $NONBEHAVIOR -eq 1 ]; then
NAMES+=("03 探针 --click"); EXITS+=("SKIP"); LOGS+=("")
NAMES+=("04 探针 --full"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 03/04 探针 → SKIP(影响面选闸:测量面改动不触场景树与探针断言面)"
else
gate "03 探针 --click" "probe_click.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --click
gate "04 探针 --full" "probe_full.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --full
fi
# ── 5. 渲染面(窗口化,依赖 PIL)──
if [ $QUICK -eq 1 ] || [ $NONBEHAVIOR -eq 1 ]; then
NAMES+=("05 渲染闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 05 渲染闸 → SKIP(--quick)"
echo ""; echo "[gates] 05 渲染闸 → SKIP(--quick 或 影响面选闸:测量面改动不触渲染输出)"
else
gate "05 渲染闸" "render.log" bash "$REPO/tools/render_gate.sh" "$GAME"
fi
fi
# ══ 文档域(6–8c)══
@@ -304,6 +334,14 @@ def head():
except Exception:
return "unknown"
def tree_hash():
# 内容树哈希:合回闸按树对齐判定「合回内容=跑闸内容」(重塑/合并会改写 commit 号)
try:
return subprocess.run(["git", "-C", repo, "rev-parse", "HEAD^{tree}"],
capture_output=True, text=True, timeout=5).stdout.strip()
except Exception:
return "unknown"
def fingerprint(exit_code, log):
"""红项指纹:只取 exit!=0 时的 FAIL 关键行集合,忽略时序/耗时噪声。
绿闸一律记 'green'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。"""
@@ -370,6 +408,7 @@ with open(receipt_path, "w", encoding="utf-8") as f:
json.dump({
"commit": head(),
"commit_short": head()[:8],
"tree": tree_hash(),
"timestamp": subprocess.run(["date", "-Iseconds"], capture_output=True, text=True).stdout.strip(),
"quick_mode": quick == "1",
"domain": domain,
+159
View File
@@ -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 <f1,f2> --base <rev>
可选 --out <json 路径> 落回执;--timeout <秒> 限制单次测试命令时长(缺省 3600)。
--revert-commit:按该笔提交触过的文件集回退到其父提交版本(改动须已提交在分支上)。
--revert-files+--base:把指定文件回退到 <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()
+1 -1
View File
@@ -538,7 +538,7 @@
#### 闸与探针批上下文挂死防护——probe_gate 进程树回收与 run_all 阻塞面超时(2026-10-25 登记,来源=批次移交)
**实况**:证据=迭代 36 批末闸机观测(非本批引入)。原登记两件存量噪声,件①(battle_log_view.gd:17 类型声明错致运行期 TypedArray 错误、日志失真)已随迭代 38 卡⑥ 收口出账(git 提交 ce49a8ee),本条只剩件②:`level_shop_economy_test` 在闸批量上下文存在挂死形态(直跑恒绿),`tools/probe_gate.sh` 的 per-suite timeout 只杀 wrapper 不杀引擎子进程,孤儿泄漏会殃及后续全部 Godot 启动(批末四次闸跑批中断的根源,处置=清杀泄漏进程+删重建 `game/.godot`)。挂死防护=闸壳/测试基建,按改动四类属**工程改动走直改通道**。
- 直改落点:tools/probe_gate.sh(per-suite 进程树回收)+game/tests/run_all.gd(OS.execute 阻塞面超时守护)——测试基建直改
- 直改落点:tools/probe_gate.sh(per-suite 进程树回收)+game/tests/run_all.gd(OS.execute 阻塞面超时守护)——测试基建直改。**2026-10-28 用户裁四批全做,probe_gate 件已落**(run_suite 整树回收:启动记 WINPID → 超时先 `taskkill /T` 杀全树再 `kill -9` 收 MSYS 侧〔顺序颠倒会让 /T 走不到已死父进程〕→ 标志文件判超时〔taskkill 强杀后 MSYS wait 可能报 0〕;默认五段实跑全绿+超时/正常双路径机制验证通过)。run_all.gd 件属代码面(game/tests),按车道铁律留待触及测试基建的 worktree 批顺带,本条目保持待执行。
**改动面**:工具代码