用户令「迭代完成前必须全量跑一遍所有测试」。起因是 iter27 台账只记 probe --click 未记 --full,t17_defer_probe 红了整个迭代无人发现——根因是闸清单散落 CLAUDE.md / process-conventions.md / test-conventions.md 三处、彼此不一致、且无任何机器校验。 - tools/run_all_gates.sh(新):全部验证闸的唯一入口与唯一清单,串跑冒烟/单元/探针 --click/--full/渲染/doc 三闸/静态扫描/熵增闸共十道,写回执 game/tests/.gates/receipt.json。 与 baseline.json 比对判定:基线红且红项清单未变=存量红零新增(不判失败), 基线绿转红或红项清单变化=新增红(判红)。python 解释器自动解析带 PIL 的解释器 并前置 PATH(渲染闸三个脚本依赖 PIL,用错解释器会假红)。 - .zcode/hooks/worktree-guard.sh:Bash 分支加合回闸——merge/rebase/cherry-pick 进 master 前按目标分支反查 worktree 回执,校验 commit 匹配/非 quick/无新增红三态, 任一不满足 exit 2 拦截(九态实测:匹配放行、commit 错/quick/含红/无回执/rebase 全拦)。 - game/smoke/t17_defer_probe.gd:隔离面从 main.save_manager = iso_c 改 ProbeUiUtil.isolate_save(iter27 已确立口径)。原写法对 getter-only 属性赋值无效 ——组合根 save_manager/meta 是 autoload 读视图,隔离必须落在单例上。修复后 --full 转绿。 - doc_link 三处死链归零:数据配置.md 已退役探针指路、元进度.md/天赋树.md 已改名文档链接。 - CLAUDE.md / process-conventions.md §3第10条 / test-conventions.md §4 三处 「交付前跑什么」统一为一句话=跑 run_all_gates.sh,删散落清单。 闸况:冒烟 0 / 单元 0 / --click 0 / --full 0 / 渲染 0 / doc_link 0 / doc_term 0 / 熵增 0;gdd_lint 与 static_scan 为存量红,已落 baseline.json 待判读清偿。
234 lines
10 KiB
Bash
234 lines
10 KiB
Bash
#!/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; }
|
||
|
||
# ── 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
|
||
|
||
mkdir -p "$STAMP_DIR"
|
||
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"
|
||
# ── 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
|