Files
gplan-2/tools/run_all_gates.sh
T
DKChan 8f66ae1559 docs(roadmap): roadmap 压成「里程碑时间线+迭代总表」+流水线闸注册上电 08c——删除「当前位置」节与主路线表全部巨型行(各迭代过程叙述/T 段明细/读数/闸数字/裁决记录退役归 git 史,116→89 行),新建六列迭代总表 30 行一行一迭代(状态=已完成+落地日期自「✅ 完成/已入库」句提取,迭代 17 任务书日期与文本冲突从文本=2026-10-07;需求卡片列 design/research 实存十卡写实路径、卡片已退役十一迭代写保留字、迭代 1~8/10 无卡片制填「—」;编入时间 9~12/14~30 从文本与编入提交可考取真、其余「—」;预计周期历史行全「—」不做 ≤2 周判;实现报告 iter27/28/29/30 写 reports/ 实存路径、其余二十六迭代写退役保留字),M3/M4 里程碑语义立规注(2026-10-09 用户立规)压缩三行后置于里程碑时间线表后语义全保留,执行纪律/批次 loop 规范/维护纪律三节原样保留(含未迁 process-conventions 的迭代粒度界定等立规),执行纪律第 3 条「当前位置挂起清单」指针随节删除改指 active.md 未决面;pipeline_gate.py 机检扩容对齐新表(需求卡片/编入时间/预计周期三列「—」合法=历史行豁免+实现报告列已完成=存在路径或退役保留字、对「—」维持判红,头注旧格式声明换代),doc-conventions §12.2 四列规则同步补「历史迭代不可考填—」与已完成报告列严格句;run_all_gates.sh 文档域 08b 之后注册「08c 流水线闸」(照抄相邻行形态,闸序其余零改动)+头注 --docs 闸清单补 08c+域横幅 6–8c;baseline.json 手工加入 08c 绿记录(新闸件无基线记录必判闸况变化拦合回收执,其余条目原样保留);06 修存量退役词四笔「随批呈报→顺带呈报」(b4671802 回填批带入、doc_term --strict 首次全量跑暴露,非本批引入)。自检:python tools/pipeline_gate.py=PASS(06 条目 51·总表数据行 30·交叉核 0 链全过)exit 0;bash tools/run_all_gates.sh --docs=六闸全绿无新增红 exit 0
2026-10-18 05:15:32 +08:00

375 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
# 重闸唯一入口(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 <code|docs|both>
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