#!/usr/bin/env bash # 重闸唯一入口(2026-10-15 用户令「迭代完成前必须全量跑一遍所有测试」)—— # 闸清单的唯一定义处。文档只写「收口跑本脚本」,禁在文档里另列闸件清单(那正是 iter27 # 漏跑 probe_gate --full 的成因:清单散落三份文档、彼此不一致、且无机器校验)。 # # 跑完写回执 game/tests/.gates/receipt.json(commit + 各闸退出码 + 存量基线比对 + 时间戳); # .zcode/hooks/worktree-guard.sh 在主工作区 git merge 时校验本回执,缺或红即拦截。 # # 用法:tools/run_all_gates.sh [--code|--docs|--quick] # --code 只跑代码域闸(01 冒烟/02 单元/03 探针 --click/04 探针 --full/05 渲染闸/09 静态扫描 # /11 回放确定性对拍+10 熵增闸)——本批改动触及 game/ 时用(游戏工程的唯一代码验收域)。 # --docs 只跑文档域闸(06 gdd_lint/07 doc_link/08 doc_term/08b active.md 体量/08c 流水线闸 # +10 熵增闸) # ——本批改动不触及 game/ 时用:design/、根规则文件、tools/ 工具脚本、.zcode/ 配置等 # 一切非 game 改动的本域验收。 # --quick 全量档的中段自检形态:跳过渲染闸(窗口化,需开显示器)与静态扫描;完成出库禁用。 # 不带参数 = 全量档(两域全跑),触发条件=同批混触 game/ 与文档面。闸件(tools/ 下机检脚本 # 与闸壳)自身的改动不占合回全量——其质量义务=落批时的变异验证(test-conventions §6); # 闸真崩溃时合回闸 fail-closed(跑不出回执即拦),不以全量为兜底。 # **域划分只在本脚本里定义一处**——文档只写「按改动域跑本脚本」,禁另列闸清单(清单散落多处 # 正是 iter27 漏跑 probe_gate --full 的成因)。 # # 判定口径:每道闸与 game/tests/.gates/baseline.json 里的存量基线比对—— # 基线绿 + 现绿 → 绿 # 基线红 + 现红且清单未变 → 「存量红·对基线零新增」,不判失败(但会在回执里留名) # 基线红 + 现红但清单新增 → 红(新增红项) # 任意闸 exit 与基线不符 → 红 # 本次未跑的闸(--quick 跳过/条件闸件未触面)→ 不比闸况,只记进回执 not_run # 基线文件首次自动生成(把当次结果当基线),此后每次跑批与之比。 # 分域跑批只比本域跑过的闸——域外闸不入 NAMES,不产生「基线绿 → 现红」的假变化。 # # 退出码:0=全绿或仅存量红零新增;1=有新增红/闸况变化;2=用法错 set -u REPO="$(cd "$(dirname "$0")/.." && pwd)" GAME="$REPO/game" STAMP_DIR="$GAME/tests/.gates" RECEIPT="$STAMP_DIR/receipt.json" BASELINE="$STAMP_DIR/baseline.json" QUICK=0 DOMAIN="full" for _a in "$@"; do case "$_a" in --quick) QUICK=1 ;; --code|--docs) [ "$DOMAIN" = "full" ] || { echo "用法: --code 与 --docs 互斥"; exit 2; } DOMAIN="${_a#--}" ;; *) echo "用法: tools/run_all_gates.sh [--code|--docs|--quick]"; exit 2 ;; esac done # 控制台编码:本仓库闸件输出中文与 ✅/✗ 符号。Windows GBK 控制台下 python 打印这些字符会抛 # UnicodeEncodeError——06/10 两闸直接崩(假红)、09 的 FAIL 行被写成乱码字节(指纹漂移, # 与基线比必判「红项清单变化」假红)、末尾汇总自身崩掉打不出结论。2026-10-16 实踩。 export PYTHONIOENCODING=utf-8 # 回执与日志是中间产物(不入库),但 .gitignore 本身必须入库——漏了就等于把它们纳入版本管理。 # 每次跑批前自愈重建,避免「清目录重跑」把 .gitignore 一起带走(2026-10-15 实踩)。 mkdir -p "$STAMP_DIR" if [ ! -f "$STAMP_DIR/.gitignore" ]; then printf '# 本目录只入库 baseline.json(存量基线,需随代码走以保证各 worktree 判定口径一致)。\n# receipt.json 是每跑批覆写的当次回执、*.log 是跑批当场日志——两者都是中间产物,不入库。\nreceipt.json\n*.log\n' \ > "$STAMP_DIR/.gitignore" echo "[gates] 已自愈重建 $STAMP_DIR/.gitignore" fi # ── python 解释器解析:优先带 PIL 的 gamedev venv(渲染闸的 shot_checker/golden_compare 依赖), # 回落 PATH 上的 python。渲染闸的三个脚本要 PIL,用错解释器会假红。 PY="" for cand in "/d/dev/python/envs/gamedev/Scripts/python.exe" "$(command -v python 2>/dev/null)" "python"; do [ -z "$cand" ] && continue if "$cand" -c "import PIL" >/dev/null 2>&1; then PY="$cand"; break; fi [ -z "$PY" ] && PY="$cand" done GODOT="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}" # 把带 PIL 的解释器目录前置到 PATH——render_gate.sh / shot_checker.py / golden_compare.py / # golden_board_crop.py 内部各自调 `python`,继承本 PATH;用错解释器会因缺 PIL 假红。 case "$PY" in */Scripts/python.exe|*/Scripts/python) export PATH="$(dirname "$PY"):$PATH" ;; esac # ── 域判定:这道闸属哪个域、本次跑不跑 ── in_domain() { # in_domain local dom="$1" [ "$dom" = "both" ] && return 0 [ "$DOMAIN" = "full" ] && return 0 [ "$DOMAIN" = "$dom" ] && return 0 return 1 } # ── 素材齐盘预检(2026-10-17 加)── # game/assets/art/** 与一切 *.import 边车都不入 git(.gitignore 2026-09-29 裁定「素材不入 git」), # 所以 git worktree 检出的工程天生缺全部美术素材:main_theme.tres 解析失败 → 按钮五个样式盒全空 # → 冒烟 V1 成片假红;渲染闸又会因素材缺位把两段 golden 直接跳过(见 render_gate.sh)。 # 缺位即拒跑——禁带着空盘跑完,把「什么都没比」的回执当全绿合回。 if in_domain code; then _miss="" for a in assets/art/ui/button_stone.png assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ assets/art/board/flag_seal.png assets/art/character/character_hero_idle.png \ assets/art/board/terrain_wall_n_dungeon.png assets/art/board/terrain_floor_dungeon_1.png \ assets/art/board/terrain_wall_n_underdark.png assets/art/board/terrain_floor_underdark_1.png \ assets/art/board/terrain_wall_n_deep_underdark.png assets/art/board/terrain_floor_deep_underdark_1.png; do [ -f "$GAME/$a" ] || _miss="$_miss $a" [ -f "$GAME/$a.import" ] || _miss="$_miss $a.import" done [ -f "$GAME/.godot/uid_cache.bin" ] || _miss="$_miss .godot/uid_cache.bin(导入未刷)" if [ -n "$_miss" ]; then echo "[gates] 素材齐盘预检 FAIL — 本工作区缺:$_miss" echo " 处置:cp -r <主工作区>/game/assets/art/. <本区>/game/assets/art/" echo " 再刷导入:$GODOT --headless --path <本区>/game --editor --quit" echo " (--import 不生成新素材的 .import 边车,必须走 --editor --quit)" exit 1 fi echo "[gates] 素材齐盘预检 PASS(10 件素材+边车+导入缓存在位)" fi declare -a NAMES=() EXITS=() LOGS=() FAILED=0 echo "重闸跑批 · 域=$DOMAIN · 仓库 $REPO" echo "起跑 commit: $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '非git目录')" echo "python: $PY" echo "godot: $GODOT" # ── 闸件执行:每道闸的输出落独立日志,供基线比对取指纹 ── gate() { # gate <名称> <日志名> <命令...> local name="$1" log="$2"; shift 2 echo "" echo "════════════════════════════════════════════" echo " $name" echo "════════════════════════════════════════════" "$@" >"$STAMP_DIR/$log" 2>&1 local rc=$? # 失败时把关键行回显到终端(红必须看得见) if [ $rc -ne 0 ]; then grep -E "FAIL|红|Error|error" "$STAMP_DIR/$log" | tail -12 | sed 's/^/ | /' fi NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("$log") return 0 } gate_unit() { # run_all 是 --script 模式,退出码为唯一判据 local name="02 单元套件" echo "" echo "════════════════════════════════════════════" echo " $name(--script 模式,退出码为唯一判据)" echo "════════════════════════════════════════════" ( cd "$REPO" && "$GODOT" --headless --path "$(cygpath -m "$GAME")" --script res://tests/run_all.gd ) \ >"$STAMP_DIR/unit.log" 2>&1 local rc=$? # 三硬闸之二(脚本错误扫描):子套件的 SCRIPT ERROR/Parse Error 只写 stderr,入口内收不到, # 只能在编排层扫本闸日志。命中即红——此类错误不改退出码、且会中断所在函数的剩余断言。 if [ "$rc" -eq 0 ] && ! "$PY" "$REPO/tools/error_scan.py" "$STAMP_DIR/unit.log" >>"$STAMP_DIR/unit.log" 2>&1; then rc=1 tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /' fi [ $rc -ne 0 ] && tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /' NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("unit.log") return 0 } # ══ 代码域(1–5)══ if in_domain code; then # ── 1. 系统级断言面(冒烟三闸:退出码+摘要失败数+SCRIPT ERROR 扫描,判定无洞)── gate "01 冒烟全量" "smoke.log" "$PY" "$REPO/tools/run_smoke.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" else NAMES+=("05 渲染闸"); EXITS+=("SKIP"); LOGS+=("") echo ""; echo "[gates] 05 渲染闸 → SKIP(--quick)" fi fi # ══ 文档域(6–8c)══ if in_domain docs; then # ── 6. 文档三闸 ── gate "06 gdd_lint" "gdd_lint.log" "$PY" "$REPO/tools/gdd_lint.py" gate "07 doc_link" "doc_link.log" "$PY" "$REPO/tools/doc_link_audit.py" --strict gate "08 doc_term" "doc_term.log" "$PY" "$REPO/tools/doc_term_audit.py" --strict # ── 6b. 状态文件体量闸(active.md 只记现在:条目数/快照行数/编号/死指针四条硬上限)── gate "08b active.md 体量" "active_state.log" "$PY" "$REPO/tools/audit_active_state.py" # ── 8c. 流水线闸(06 条目状态机+roadmap 迭代总表+一次交叉核,判据=doc-conventions §12)── gate "08c 流水线闸" "pipeline.log" "$PY" "$REPO/tools/pipeline_gate.py" fi # ── 6c. 回放确定性对拍(条件闸件——迭代 29 T3 收编;属代码域)── if in_domain code; then # 触面判定:本批 diff 相对 master 分叉点,命中下列路径面之一即「触面」。 # 触面也**不自动跑**——对拍调的是 MC 脚本,属「MC/统计类跑批发起前须用户批准」红线管辖; # 闸只负责每次跑批都把触面判定做一遍并显式要求批准,杜绝「该跑却没人记得」。 # 用户批准后设 PARITY_APPROVED=1 重跑本脚本即实跑三变体对拍。 PARITY_TOUCH_FACES=" game/data/tables/ game/core/rng_service.gd game/core/save_manager.gd game/core/meta_progression.gd game/core/settlement/ game/core/damage_math.gd game/core/damage_controller.gd game/core/battle_orchestrator.gd game/smoke/adventure_monte_carlo.gd " _base="$(git -C "$REPO" merge-base master HEAD 2>/dev/null || true)" _changed_files="$(git -C "$REPO" diff --name-only "${_base:-HEAD}" HEAD 2>/dev/null || true)" _touch_hit=0 for _f in $_changed_files; do for _pat in $PARITY_TOUCH_FACES; do case "$_f" in "$_pat"*) _touch_hit=1 ;; esac done done _parity_name="11 回放确定性对拍" if [ "$_touch_hit" -eq 0 ]; then NAMES+=("$_parity_name"); EXITS+=("SKIP"); LOGS+=("") echo ""; echo "[gates] $_parity_name → SKIP(本批 diff 未触数值/存档/RNG/结算面)" elif [ "${PARITY_APPROVED:-0}" = "1" ]; then gate "$_parity_name" "parity.log" "$PY" "$REPO/tools/parity_anchor.py" check else NAMES+=("$_parity_name"); EXITS+=("NEED-APPROVAL"); LOGS+=("") echo "" echo "[gates] $_parity_name → 本批 diff 触及数值/存档/RNG/结算面,对拍需用户批准发起" echo " (批准后设 PARITY_APPROVED=1 重跑本脚本即实跑;不得借机重锚)" fi fi # ── 7. 静态扫描(代码域)+ 熵增闸(两域都跑:它查顶层目录/根文件/图像白名单/退役路径, # 文档批新增或挪动 design/ 下的文件同样会绊它;纯文本扫描,秒级)── if [ $QUICK -eq 0 ]; then if in_domain code; then gate "09 静态扫描" "static_scan.log" bash "$GAME/tests/static_scan.sh" fi gate "10 熵增闸" "entropy.log" "$PY" "$REPO/tools/repo_structure_audit.py" else if in_domain code; then NAMES+=("09 静态扫描"); EXITS+=("SKIP"); LOGS+=("") fi NAMES+=("10 熵增闸"); EXITS+=("SKIP"); LOGS+=("") echo ""; echo "[gates] 09 静态扫描(代码域)/ 10 熵增闸 → SKIP(--quick)" fi # ── 与存量基线比对 ── "$PY" - "$BASELINE" "$RECEIPT" "$QUICK" "$DOMAIN" "$REPO" "${NAMES[@]}" -- "${EXITS[@]}" -- "${LOGS[@]}" <<'PYEOF' import hashlib, json, os, subprocess, sys baseline_path, receipt_path, quick, domain, repo = sys.argv[1:6] rest = sys.argv[6:] sep = rest.index("--") names = rest[:sep] tail = rest[sep + 1:] sep2 = tail.index("--") exits = tail[:sep2] logs = tail[sep2 + 1:] stamp_dir = os.path.dirname(receipt_path) def head(): try: return subprocess.run(["git", "-C", repo, "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip() except Exception: return "unknown" def fingerprint(exit_code, log): """红项指纹:只取 exit!=0 时的 FAIL 关键行集合,忽略时序/耗时噪声。 绿闸一律记 'green'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。""" # SKIP / NEED-APPROVAL 都属「本闸未实跑」状态,指纹一律记 skip—— # 两者互变不是闸况变化(同是没跑),只有「未跑 → 跑出结果」或「绿 → 红」才算变化。 if exit_code in ("0", "SKIP", "NEED-APPROVAL"): return "green" if exit_code == "0" else "skip" if not log: return "red-nolog" p = os.path.join(stamp_dir, log) if not os.path.exists(p): return "red-nolog" keys = [] with open(p, encoding="utf-8", errors="replace") as f: for line in f: s = line.strip() if not s or "FAIL" not in s: continue keys.append(s[:160]) if not keys: return "red-nokeys" return hashlib.sha1("\n".join(sorted(set(keys))).encode("utf-8")).hexdigest()[:12] now = {} for n, e, l in zip(names, exits, logs): now[n] = {"exit": e, "fp": fingerprint(e, l)} first_run = not os.path.exists(baseline_path) if first_run: with open(baseline_path, "w", encoding="utf-8") as f: json.dump({"note": "存量基线·首次自动生成;闸况变化时本脚本会判红,需人工确认后重生成", "gates": now}, f, ensure_ascii=False, indent=1) base = dict(now) else: with open(baseline_path, encoding="utf-8") as f: base = json.load(f).get("gates", {}) changed, inherited_red, not_run = [], [], [] for n in names: b, c = base.get(n), now[n] if b is None: changed.append("%s:无基线记录(新增闸件)" % n) continue if b["exit"] == c["exit"] and b["fp"] == c["fp"]: continue if c["fp"] == "skip": # 本闸这次没跑(--quick 跳过/条件闸件未触面)——没有结果可比,不判闸况变化。 # 2026-10-17 修:此前 SKIP 落进「基线绿 → 现红」分支,任何 --quick 回执都自带假红。 not_run.append(n) continue if b["exit"] != "0" and c["exit"] != "0" and b["fp"] == c["fp"]: continue # 同款存量红,清单未变 if b["exit"] == "0" and c["exit"] != "0": changed.append("%s:基线绿 → 现红(exit=%s)" % (n, c["exit"])) elif b["exit"] != "0" and c["exit"] == "0": inherited_red.append("%s:基线红 → 现绿(改善)" % n) else: changed.append("%s:红项清单变化(基线 %s → 现 %s)" % (n, b["fp"], c["fp"])) stock_red = [n for n, e in zip(names, exits) if e not in ("0", "SKIP")] all_green = not changed with open(receipt_path, "w", encoding="utf-8") as f: json.dump({ "commit": head(), "commit_short": head()[:8], "timestamp": subprocess.run(["date", "-Iseconds"], capture_output=True, text=True).stdout.strip(), "quick_mode": quick == "1", "domain": domain, "first_baseline_run": first_run, "gates": [{"name": n, "exit": e, "fingerprint": now[n]["fp"]} for n, e in zip(names, exits)], "changed_vs_baseline": changed, "inherited_red": inherited_red, "not_run": not_run, "all_green": all_green, }, f, ensure_ascii=False, indent=1) print("") print("─" * 60) if first_run: print("首次跑批:已生成存量基线 %s" % baseline_path) print("本次结果作为基线,后续与之比对。") if changed: print("【闸况变化,需处理】") for c in changed: print(" ✗ " + c) if inherited_red: print("【改善】") for c in inherited_red: print(" ↓ " + c) if not_run: print("【本次未跑(域外/--quick/未触面)】" + "、".join(not_run)) stock = [n for n, e in zip(names, exits) if e not in ("0", "SKIP")] if stock: print("【存量红·对基线零新增】" + "、".join(stock)) if all_green: print("【验收通过】闸况与基线一致,无新增红。") sys.exit(0) sys.exit(1) PYEOF RC=$? echo "" echo "════════════════════════════════════════════" printf ' 回执:%s\n 基线:%s\n commit:%s\n' "$RECEIPT" "$BASELINE" "$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '?')" echo "════════════════════════════════════════════" exit $RC