#!/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 [--quick] # --quick 跳过渲染闸(窗口化,需开显示器)与静态扫描(纯文本扫描),供迭代中段自检; # 收口出库禁用,必须不带本参数跑全量。 # # 判定口径:每道闸与 game/tests/.gates/baseline.json 里的存量基线比对—— # 基线绿 + 现绿 → 绿 # 基线红 + 现红且清单未变 → 「存量红·对基线零新增」,不判失败(但会在回执里留名) # 基线红 + 现红但清单新增 → 红(新增红项) # 任意闸 exit 与基线不符 → 红 # 基线文件首次自动生成(把当次结果当基线),此后每次跑批与之比。 # # 退出码: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 [ "${1:-}" = "--quick" ] && QUICK=1 [ $# -gt 1 ] && { echo "用法: tools/run_all_gates.sh [--quick]"; exit 2; } # 回执与日志是中间产物(不入库),但 .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 declare -a NAMES=() EXITS=() LOGS=() FAILED=0 echo "重闸全量跑批 · 仓库 $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=$? [ $rc -ne 0 ] && tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /' NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("unit.log") return 0 } # ── 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 # ── 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" # ── 7. 静态扫描域 + 熵增闸 ── if [ $QUICK -eq 0 ]; then gate "09 静态扫描" "static_scan.log" bash "$GAME/tests/static_scan.sh" gate "10 熵增闸" "entropy.log" "$PY" "$REPO/tools/repo_structure_audit.py" else NAMES+=("09 静态扫描"); EXITS+=("SKIP"); LOGS+=("") NAMES+=("10 熵增闸"); EXITS+=("SKIP"); LOGS+=("") echo ""; echo "[gates] 09 静态扫描 / 10 熵增闸 → SKIP(--quick)" fi # ── 与存量基线比对 ── "$PY" - "$BASELINE" "$RECEIPT" "$QUICK" "$REPO" "${NAMES[@]}" -- "${EXITS[@]}" -- "${LOGS[@]}" <<'PYEOF' import hashlib, json, os, subprocess, sys baseline_path, receipt_path, quick, repo = sys.argv[1:5] rest = sys.argv[5:] 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'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。""" if exit_code in ("0", "SKIP"): 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 = [], [] 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 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", "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, "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) 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