diff --git a/CLAUDE.md b/CLAUDE.md index 8ee2cbb9..993b63e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ 工作目录 = 切片目录本身(含 `project.godot` 的那一层): -- 冒烟(全量 headless 断言):`…console.exe --headless --path . --script res://smoke/smoke_all.gd` +- 冒烟(全量 headless 断言):`python tools/run_smoke.py`(包装器三闸:退出码+摘要失败数+SCRIPT ERROR 扫描——脚本错误不计冒烟失败数且静默中断所在函数剩余断言,2026-09-19 台账 84 立闸)。底层命令 = `…console.exe --headless --path . --script res://smoke/smoke_all.gd` - 场景级探针(呈现层回归):`…console.exe --headless --path . res://smoke/ui_probe.tscn`;另有 `ui_tune_probe.tscn`(数值调整板)、`ui_click_probe.tscn`(合成鼠标事件行为探针) - 调参期批量工具:`--script res://smoke/level_monte_carlo.gd`(关卡期望扫描)· `--script res://smoke/reward_expectation.gd`(奖励获取期望) - 交付前验收 = smoke + 全部场景探针全绿(测试细则见 `docs/code-conventions.md`) diff --git a/tools/run_smoke.py b/tools/run_smoke.py new file mode 100644 index 00000000..662fe89b --- /dev/null +++ b/tools/run_smoke.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +r"""run_smoke.py — 冒烟运行器(脚本错误外部门禁) + +用法: + python tools/run_smoke.py # 默认跑切片全量冒烟(本机 Godot 固定路径) + python tools/run_smoke.py --script res://smoke/smoke_all.gd + python tools/run_smoke.py --godot <引擎路径> --path <切片目录> --timeout 180 + +背景(2026-09-19 台账 83 实证): GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、 +不计入冒烟失败数,且会静默中断所在冒烟函数的剩余断言——首跑「309 项 0 失败」实为断言未跑全。 +进程内无脚本错误陷阱 API(GDScript 无 try/catch),故闸设在外部:本包装除退出码与摘要行外, +另扫输出中的 SCRIPT ERROR / Parse Error,命中即判失败。 + +三闸: ① 进程退出码 = 0;② 摘要行「冒烟完成:N 项检查,0 项失败」在案且失败数为 0; + ③ 输出无「SCRIPT ERROR」「Parse Error」标记行。 +""" +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" +DEFAULT_SLICE = ROOT / "prototypes" / "dungeon-sweeper-vertical-slice" +DEFAULT_SCRIPT = "res://smoke/smoke_all.gd" +SUMMARY_RE = re.compile(r"冒烟完成:(\d+) 项检查,(\d+) 项失败") +ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error") + + +def main(): + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + ap = argparse.ArgumentParser(description="冒烟运行器(脚本错误外部门禁)") + ap.add_argument("--godot", default=DEFAULT_GODOT) + ap.add_argument("--path", default=str(DEFAULT_SLICE), help="切片项目目录(含 project.godot)") + ap.add_argument("--script", default=DEFAULT_SCRIPT) + ap.add_argument("--timeout", type=int, default=180, help="外部超时秒数(ADR-0004 硬性要求)") + args = ap.parse_args() + + cmd = [args.godot, "--headless", "--path", args.path, "--script", args.script] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=args.timeout) + except subprocess.TimeoutExpired: + print(f"[run_smoke] FAIL:外部超时(>{args.timeout}s——ADR-0004 硬性要求)") + return 1 + out = (proc.stdout or "") + (proc.stderr or "") + sys.stdout.write(out) + if out and not out.endswith("\n"): + print() + + gates = [] + if proc.returncode != 0: + gates.append(f"退出码 = {proc.returncode}(≠0)") + m = SUMMARY_RE.search(out) + if m is None: + gates.append("摘要行缺失(未检出「冒烟完成:N 项检查,M 项失败」——入口跑完前中断?)") + elif int(m.group(2)) != 0: + gates.append(f"冒烟失败 {m.group(2)} 项 / 共 {m.group(1)} 项") + hits = [ln.strip() for ln in out.splitlines() if any(k in ln for k in ERROR_MARKERS)] + if hits: + gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计冒烟失败数," + "且中断所在函数的剩余断言,必须人工排查)") + + if m is not None: + print(f"[run_smoke] 摘要: {m.group(0)}") + if gates: + print("[run_smoke] FAIL——") + for g in gates: + print(f" - {g}") + return 1 + print("[run_smoke] PASS(退出码 / 摘要失败数 / 脚本错误扫描 三闸全过)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())