Files
gplan-2/tools/run_all_gates.sh
T
DKChan 7aee5eb71a docs(流程文档迁出GDD批): 待办清单与体验验证清单移到仓库根并去掉编号前缀
两文件是流水线流程件而非游戏描述件,此前住在 design/gdd/ 造成三处不适配:
gdd_lint 必须为它们逐条关掉体裁规则、格式权威却写在 doc-conventions §12、
与同族的 roadmap(在仓库根)分居两处。迁到根后按 roadmap.md 的无编号风格改名。

- 迁出:design/gdd/06-待办清单.md → 待办清单.md;07-体验验证清单.md → 体验验证清单.md
- 指向改写 53 个文件:GDD 各系统文档指路存根、四份规约、02 根索引、README/roadmap/AGENTS
- 两份清单自身 74 处出站链接按新基址重指(../art → design/art、systems → design/gdd/systems)
- gdd_lint:待办/体验验证两文件的 R1/R3/R5/R7 豁免行随迁删除(已脱离扫描面),后门表清零
- pipeline_gate 默认路径、log_analysis/render_gate 称谓、repo_structure_audit 根文件白名单同步
- 保护面补写:两份清单列入实现批禁写清单(AGENTS 两类批条 / process-conventions §12 / 架构文档域表),
  条目状态只由流水线各段推进
- 范围外未动(用户令「全不碰」):reports/ 与 design/research/ 的 22 处历史指向、game/ 两处注释
2026-10-21 00:09:40 +08:00

374 lines
18 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 重闸唯一入口(迭代完成前必须全量跑一遍所有测试)——
# 闸清单的唯一定义处。文档只写「收口跑本脚本」,禁在文档里另列闸件清单(那正是 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/08c 流水线闸
# +10 熵增闸)
# ——本批改动不触及 game/ 时用:design/、根规则文件、tools/ 工具脚本、.zcode/ 配置等
# 一切非 game 改动的本域验收。
# --quick 全量档的中段自检形态:跳过渲染闸(窗口化,需开显示器)与静态扫描;完成出库禁用。
# 不带参数 = 全量档(两域全跑),触发条件=同批混触 game/ 与文档面。闸件(tools/ 下机检脚本
# 与闸壳)自身的改动不占合回全量——其质量义务=落批时的变异验证(test-conventions §6);
# 闸真崩溃时合回闸 fail-closed(跑不出回执即拦),不以全量为兜底。
# **域划分只在本脚本里定义一处**——文档只写「按改动域跑本脚本」,禁另列闸清单(清单散落多处
# 正是既往漏跑 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——待办清单/10 两闸直接崩(假红)、09 的 FAIL 行被写成乱码字节(指纹漂移,
# 与基线比必判「红项清单变化」假红)、末尾汇总自身崩掉打不出结论。
export PYTHONIOENCODING=utf-8
# 回执与日志是中间产物(不入库),但 .gitignore 本身必须入库——漏了就等于把它们纳入版本管理。
# 每次跑批前自愈重建,避免「清目录重跑」把 .gitignore 一起带走。
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 <code|docs|both>
local dom="$1"
[ "$dom" = "both" ] && return 0
[ "$DOMAIN" = "full" ] && return 0
[ "$DOMAIN" = "$dom" ] && return 0
return 1
}
# ── 素材齐盘预检 ──
# game/assets/art/** 与一切 *.import 边车都不入 git(.gitignore 裁定「素材不入 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/seal_mark.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 "06b 变更记录节" "changelog.log" "$PY" "$REPO/tools/gdd_lint.py" --changelog-only
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
# ── 8c. 流水线闸(待办清单条目状态机+roadmap 迭代总表+一次交叉核,判据=doc-conventions §12)──
gate "08c 流水线闸" "pipeline.log" "$PY" "$REPO/tools/pipeline_gate.py"
fi
# ── 6c. 回放确定性对拍(条件闸件;属代码域)──
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 跳过/条件闸件未触面)——没有结果可比,不判闸况变化。
# 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