Files
gplan-2/tools/run_all_gates.sh
T
DKChan be0cab8356 Merge branch 'master' into qa-harness
# Conflicts:
#	.gitattributes
2026-11-06 05:24:31 +08:00

689 lines
37 KiB
Bash
Executable File
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
# 漏跑场景探针扩展段的成因:清单散落三份文档、彼此不一致、且无机器校验)。
#
# 跑完写回执 game/tests/.gates/receipt.json(commit+内容树哈希+各闸退出码+存量基线比对+时间戳);
# 强制验证点=合回 master 后 post-merge 钩子推送触发的远端跑批;远端结果用 tools/ci_check.sh 现查(只呈报、
# 不落盘——2026-11-04 用户裁去掉回执等待件与本地判定缓存)。本脚本手动调用(--code/--docs/--gates/全量档)
# 只服务开发期按域跑批;--gates 档另为远端纯闸件/管线批的执行形态(domain_route.sh 判 gates → ci.yml 映射 --gates)。
# 跑闸时代码面带着未提交改动,回执会标 dirty_game(验的不是 HEAD 字节)。
#
# 用法:tools/run_all_gates.sh [--code|--docs|--gates|--premerge|--quick]
# --premerge 合回前置检查档(2026-11-06 用户裁):只跑冒烟测试+单元与集成测试全套件(4 分片,
# 热导入缓存约 2 分钟)——主工作区 git merge --no-ff 落合并提交前由 pre-merge-commit
# 钩子自动调用的拦截面。退出码聚合即判定(三硬闸口径),不落回执、不比基线——
# 验证台账归合回后远端 CI,本档只做当场拦截。快进合并不触发钩子;--no-verify 仅属用户。
# --code 只跑代码域闸(冒烟测试/系统测试/单元与集成测试/场景探针/
# 渲染闸/静态扫描+熵增闸)
# ——本批改动含 game/ 下非 .md 的行为载体(代码/测试脚本/探针/场景/数据表/素材)时用。
# 关联档选闸(2026-10-29 用户裁,推广自 2026-10-28 影响面选闸):冒烟测试/系统测试/
# 静态扫描三件保险恒跑不降;场景探针与渲染闸维持影响面口径(本批 game/ 改动
# 全在测量面 game/tests、game/sim 时记 SKIP);单元与集成测试改「选套件跑」——套件集=
# 需求卡片申报 ∪ diff 推导并集(算法与套件族映射表见脚本中部),横切面(公共件/数据表/
# autoload/数值·存档·RNG·结算触面)回落全套件;回执 scope 字段记 full|related。
# --docs 只跑文档域闸(gdd_lint/doc_link/doc_term/流水线闸/引用可核/
# 能力审计+熵增闸)
# ——本批改动只有文档件时用:game/ 内的 .md(README、生成清单)、design/、根规则文件、
# .zcode/ 配置等一切非代码面改动(tools/ 与 .gitea/ 只含批走 --gates 档,见下)。
# --gates 闸件自检档(2026-11-05 用户裁):只跑不启引擎的机检面——文档域 python 机检件
# (gdd_lint/变更记录节/doc_link/doc_term/流水线闸/引用可核/能力审计)+熵增闸
# +静态扫描+闸壳自检(bash -n 过全部 bash 闸件与管线件)。各机检件的命令与判据口径
# 与原档一致,不另造判据。本批改动只含 tools/ 与 .gitea/(闸件与管线自身)时用——
# 纯闸件/管线批合回后远端照跑本档(domain_route.sh 判 gates),闸件改动不再零验证。
# --quick 全量档的中段自检形态:跳过渲染闸(窗口化,需开显示器)与静态扫描;完成出库禁用。
# 不带参数 = 全量档(两域全跑),触发条件=同批既有代码面改动又有文档件改动。闸真崩溃时
# 合回闸 fail-closed(跑不出回执即拦),不以全量为兜底。
# **域划分只在本脚本里定义一处**——文档只写「按改动域跑本脚本」,禁另列闸清单(清单散落多处
# 正是既往漏跑场景探针扩展段的成因)。
#
# 判定口径:每道闸与 game/tests/.gates/baseline.json 里的存量基线比对——
# 基线绿 + 现绿 → 绿
# 基线红 + 现红且清单未变 → 「存量红·对基线零新增」,不判失败(但会在回执里留名)
# 基线红 + 现红但清单新增 → 红(新增红项)
# 任意闸 exit 与基线不符 → 红
# 本次未跑的闸(--quick 跳过/影响面选闸 SKIP)→ 不比闸况,只记进回执 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"
. "$REPO/tools/gate_platform.sh" # 跨平台件(平台探测/路径形式/带超时执行)
QUICK=0
DOMAIN="full"
while [ $# -gt 0 ]; do
case "$1" in
--quick) QUICK=1 ;;
--code|--docs|--gates|--premerge)
[ "$DOMAIN" = "full" ] || { echo "用法: --code/--docs/--gates/--premerge 互斥"; exit 2; }
DOMAIN="${1#--}" ;;
*) echo "用法: tools/run_all_gates.sh [--code|--docs|--gates|--premerge|--quick]"; exit 2 ;;
esac
shift
done
# 每个代码面 worktree 用独立 Godot user 目录(game/override.cfg 由 setup 脚本自愈生成;主区 no-op)。
# 并行批共用一套夹具、日志与截图目录会互相踩盘造成假红——隔离建不起来就拒跑(fail-closed)。
# 隔离只服务代码域(Godot 运行面);文档域与闸件自检档不启引擎,跳过省 0.8 秒。
if [ "$DOMAIN" = "code" ] || [ "$DOMAIN" = "full" ] || [ "$DOMAIN" = "premerge" ]; then
"$REPO/tools/setup_worktree_userdir.sh" "$REPO" || {
echo "[gates] FAIL — user 目录隔离未建立,拒绝在共享目录上跑闸。"
exit 1
}
fi
# 控制台编码:本仓库闸件输出中文与 ✅/✗ 符号。Windows GBK 控制台下 python 打印这些字符会抛
# UnicodeEncodeError——待办清单/熵增两闸直接崩(假红)、静态扫描的 FAIL 行被写成乱码字节(指纹漂移,
# 与基线比必判「红项清单变化」假红)、末尾汇总自身崩掉打不出结论。
export PYTHONIOENCODING=utf-8
# 代码面(game/ 下非 .md 的行为载体)是否带着未提交改动(只算已跟踪文件——闸件自身产物都落在
# gitignore 面)。脏树跑出来的回执记的是 HEAD,验的却是别的字节,回执标 dirty_game 由 CI 落账留痕。
DIRTY_GAME=0
# premerge 档不查脏树:钩子跑在合并提交落笔前,合并内容本就未提交(diff HEAD=待合内容),无回执可比。
if [ "$DOMAIN" != "premerge" ] && [ -n "$(git -C "$REPO" -c core.quotepath=false diff HEAD --name-only -- game/ 2>/dev/null | grep -viE '\.md$')" ]; then
DIRTY_GAME=1
echo "[gates] 警告:代码面(game/ 下非 .md)有未提交改动——本次回执将标 dirty_game,不能用于合回(先提交再跑)。"
fi
# 回执与日志是中间产物(不入库),但 .gitignore 本身必须入库——漏了就等于把它们纳入版本管理。
# 每次跑批前自愈重建,避免「清目录重跑」把 .gitignore 一起带走。
mkdir -p "$STAMP_DIR"
if [ ! -f "$STAMP_DIR/.gitignore" ]; then
printf '# 本目录只入库 baseline.json(存量基线,需随代码走以保证各 worktree 判定口径一致)。\n# receipt.json 是每跑批覆写的当次回执、ci-status.json/ci-history.jsonl 是已退役的 CI 回执判定与流水账(判定只在远端,本机残留)、\n# ci.lock/ci.rerun 是跑批件锁与补跑标记、*.log 是跑批当场日志——均为中间产物,不入库。\nreceipt.json\nci-status.json\nci-history.jsonl\nci.lock\nci.rerun\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)" "python3" "python"; do
[ -z "$cand" ] && continue
# 存在性守卫先行:Windows venv 路径在非 Windows 平台不存在,误选会让 PY 指向幽灵路径整批崩。
command -v "$cand" >/dev/null 2>&1 || [ -x "$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
# gates 档跑文档域机检件——同一命令同一判据,闸件批不启引擎、机检面照跑。
[ "$DOMAIN" = "gates" ] && [ "$dom" = "docs" ] && 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 || [ "$DOMAIN" = "premerge" ]; 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
# 横切触面表——关联档选闸的横切回落判定清单(数值·存档·RNG·结算触面;单点定义,勿在别处复制)。
CROSSCUT_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/sim/adventure_monte_carlo.gd
"
# ── 套件族映射表(关联档·推导半边的唯一事实源)──
# game/ 代码面文件 → tests/ 套件族词(--only 子串词,词=套件目录名)。机械近似:同族内
# 套件覆盖面仍可能宽于被测文件,保守方向=宁多勿漏。横切判定(公共件前缀/project.godot/
# 横切触面表)先于本表执行,故表中不收 damage_math/damage_controller/battle_orchestrator/
# settlement/** 等数值·存档·RNG·结算触面文件——它们恒走横切回落全量。映射不到的文件返回空=横切处理。
suite_terms_for() { # suite_terms_for <仓库相对路径> → stdout: 套件族词(空格分隔,可空)
case "$1" in
# 战斗结算域
game/core/action_economy.gd|game/core/battle_fx_host.gd|game/core/effect_context.gd|\
game/core/effect_engine.gd|game/core/gain_journal.gd|game/core/skill_lottery.gd)
echo "battle_settlement" ;;
game/core/reward_economy.gd|game/core/reward_grant.gd)
echo "battle_settlement core" ;;
game/core/status_container.gd)
echo "battle_settlement contracts" ;;
game/core/unit_container.gd)
echo "battle_settlement data_config" ;;
# 排雷/棋盘域(封印与极性行为跨战斗结算族)
game/core/board_data.gd|game/core/minesweeper_rules.gd)
echo "core battle_settlement" ;;
game/core/board_generator.gd|game/core/node_map_generator.gd|game/core/hero_stats.gd|\
game/core/curio_deck.gd|game/core/curio_stop.gd|game/core/refresh_bank.gd|\
game/core/treasure_items.gd|game/core/attune_loadout.gd|game/core/attunement.gd|\
game/core/meta_state_store.gd|game/core/slot_store.gd|game/core/gp_wallet.gd|\
game/core/shop_session.gd)
echo "core" ;;
# 冒险结构×存档断点×关卡推进(core 族+跨系统集成族)
game/core/adventure_runner.gd|game/core/level_runner.gd)
echo "core integration" ;;
# 配置域表类(autoload 本体与解析器/跨表校验器走横切,不入本表)
game/core/config_domain_*.gd)
echo "data_config core" ;;
# 数据 schema(data_config 族守卫+core 族消费侧校验与跨表校验)
game/data/schemas/*)
echo "data_config core" ;;
# 呈现域
game/presentation/*)
echo "presentation" ;;
# 素材(字体/地形/主题类断言在 presentation 族;探针与渲染闸另有行为面闸管)
game/assets/*)
echo "presentation" ;;
*)
echo "" ;;
esac
}
# ── 关联档选闸(2026-10-29 用户裁,推广自 2026-10-28 影响面选闸)──
# 三件保险恒跑不降:冒烟测试/系统测试/静态扫描(熵增闸照旧两域都跑)。
# 场景探针与渲染闸维持影响面口径:本批 diff 相对 master 分叉点的 game/ 改动若全部落在
# 测量面(game/tests/**、game/sim/**——测试与模拟驱动件,不触场景树与渲染输出)则记 SKIP。
# 单元与集成测试改「选套件跑」:套件集=申报 ∪ 推导 取并集——
# 申报:在册需求卡片(本批 diff 触及的 reports/需求卡片*.md ∪ roadmap「进行中」迭代行登记的
# 卡片)对照表「验收测试」列提到的测试文件名,按文件名作为 --only 子串词归套件;
# 找不到在册卡片则申报集为空并输出注明。
# 推导:按下方套件族映射表把 game/ 代码面文件(非 .md、非测量面)归到套件族;
# 映射不到的文件一律按横切处理。
# 横切回落全量:diff 命中 game/data/tables/**、公共件前缀(config_db*/rng_service*/
# save_manager*/meta_progression*/main*——main* 覆盖组合根拆件与主场景)、
# autoload 注册面(game/project.godot)、或下方横切触面表任一 → 单元与集成测试跑全部套件。
# 测量面批(game/ 改动全在 tests/sim):单元与集成测试照旧全套件跑(不加过滤)。
# 回执 scope 字段:full|related(单元与集成测试跑子集=related);映射表只在本脚本一处定义。
NONBEHAVIOR=0
SCOPE="full"
ONLY_TERMS=""
if in_domain code; then
_base="$(git -C "$REPO" merge-base master HEAD 2>/dev/null || true)"
_gfiles="$(git -C "$REPO" -c core.quotepath=false diff --name-only "${_base:-HEAD}" HEAD -- game/ 2>/dev/null || true)"
# 反选(测量面之外的行)非空=含行为面。不用 grep -q:本机 git-bash 的 -q -v 组合退出码反常。
_behavior="$(echo "$_gfiles" | grep -vE '^(game/tests/|game/sim/)' || true)"
if [ -n "$_gfiles" ] && [ -z "$_behavior" ]; then
NONBEHAVIOR=1
echo "[gates] 影响面选闸:game/ 改动全在测量面(tests/sim)——场景探针/渲染闸 → SKIP(冒烟测试/系统测试/静态扫描三件保险恒跑)"
fi
# ── 选套件推导(只对行为面代码文件做——测量面文件不进推导,其验证就是单元与集成测试/静态扫描自身)──
_codefiles="$(echo "$_gfiles" | grep -viE '\.md$' | grep -vE '^(game/tests/|game/sim/)' || true)"
if [ -n "$_codefiles" ]; then
CROSSCUT=0
_family_terms=""
# 逐文件判定:横切命中即整批回落全量;否则按映射表归族。
while IFS= read -r _f; do
[ -z "$_f" ] && continue
_b="$(basename "$_f")"
case "$_b" in
config_db*|rng_service*|save_manager*|meta_progression*|main*)
CROSSCUT=1; break ;;
esac
[ "$_f" = "game/project.godot" ] && { CROSSCUT=1; break; } # autoload 注册面
for _pat in $CROSSCUT_TOUCH_FACES; do
case "$_f" in
"$_pat"*) CROSSCUT=1; break 2 ;;
esac
done
_t="$(suite_terms_for "$_f")"
[ -n "$_t" ] && _family_terms="$_family_terms $_t"
done <<<"$_codefiles"
# 申报半边:在册需求卡片对照表「验收测试」列的测试文件名(卡片=本批 diff 触及 ∪ roadmap 进行中行登记)。
_cards="$(git -C "$REPO" -c core.quotepath=false diff --name-only "${_base:-HEAD}" HEAD -- 'reports/' 2>/dev/null | grep '需求卡片.*\.md$' || true)"
_roadmap_cards="$(grep '| 进行中 |' "$REPO/roadmap.md" 2>/dev/null | grep -oE 'reports/需求卡片[^|]*\.md' || true)"
_declared_names=""
_card_seen=0
for _c in $_cards $_roadmap_cards; do
_card_seen=1
_cf="$REPO/$_c"
[ -f "$_cf" ] || continue
_names="$(grep -ohE '[a-z0-9_]+_test\.gd' "$_cf" 2>/dev/null | sort -u || true)"
[ -n "$_names" ] && _declared_names="$_declared_names $_names"
done
if [ "$CROSSCUT" -eq 1 ]; then
SCOPE="full"
echo "[gates] 关联档选闸:diff 命中横切面(公共件/数据表/autoload/数值·存档·RNG·结算触面)——单元与集成测试回落全部套件"
else
# 并集去重:推导族词 ∪ 申报测试文件名;并集非空才过滤(映射表保证族词非空,此处双保险)。
_union="$(printf '%s\n' $_family_terms $_declared_names | sed '/^$/d' | sort -u | tr '\n' ' ' || true)"
_union="$(echo "$_union" | xargs 2>/dev/null || true)"
if [ -n "$_union" ]; then
SCOPE="related"
ONLY_TERMS="$_union"
else
SCOPE="full"
fi
fi
if [ "$_card_seen" -eq 0 ]; then
echo "[gates] 关联档选闸·申报:未找到在册需求卡片(diff 与 roadmap 进行中行均无),申报集为空——仅按 diff 推导"
fi
if [ "$SCOPE" = related ]; then
echo "[gates] 关联档选闸:单元与集成测试选套件跑(--only=${ONLY_TERMS})"
fi
fi
fi
# ── 代码域起手自检(闸承载:只省烧闸,不做合回判定——真判定在各闸与合回 guard)──
if in_domain code; then
# worktree 素材同步:素材走 gitignore 不入 git,worktree 冷检出缺件=呈现断言红的环境成因;
# 主工作区直跑=脚本内自动无操作。
bash "$REPO/tools/sync_worktree_assets.sh"
# 熵增预检:垃圾目录/越界文件先于长跑批暴露(熵增闸在批末,撞上才红=整轮白烧)。
if ! "$PY" "$REPO/tools/repo_structure_audit.py" >"$STAMP_DIR/entropy_preflight.log" 2>&1; then
echo "[gates] 起手自检红:熵增预检不过——顶层目录越界/根文件异动,先清理再跑闸(日志 $STAMP_DIR/entropy_preflight.log)"
grep -aE "越界|✗|违例" "$STAMP_DIR/entropy_preflight.log" | tail -5 | sed 's/^/ | /'
exit 1
fi
# master 推进预警(worktree 才会触发):master 已越过本分支=合回 guard 第 6 况必拦,
# 先并 master 再出闸,省一轮白烧;本闸回执照出(开发期跑批不受阻)。
if [ -f "$RECEIPT" ] && ! git -C "$REPO" merge-base --is-ancestor master HEAD 2>/dev/null; then
echo "[gates] 警告:master 已推进至本分支之外——本闸回执照出,但合回前须并 master 重出闸。"
fi
fi
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=$?
# 失败时把关键行回显到终端(红必须看得见——明细行形态 [Rx]/[Sxx]/违规/存量 同回显,
# 远端 runner 只回传终端日志,折叠过窄=红因滞留 runner 侧不可诊断)
if [ $rc -ne 0 ]; then
grep -E "FAIL|红|Error|error|违规|存量|^\[|\] FAIL" "$STAMP_DIR/$log" | tail -60 | sed 's/^/ | /'
fi
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("$log")
return 0
}
gate_unit() { # run_all 是 --script 模式,退出码为唯一判据;分片态=多 Godot 进程各跑一片
local name="单元与集成测试"
# 关联档选跑:把套件词列表经 --only= 传给 run_all(闸机来源标记 --only-source=gates,
# 入口据此打关联档横幅、不落「仅供开发期」免责句);全量态不传任何选跑参数。
local -a _user_args=()
if [ "$SCOPE" = related ] && [ -n "$ONLY_TERMS" ]; then
_user_args+=("--only=${ONLY_TERMS// /,}" "--only-source=gates")
fi
local _shards="${GATE_SHARDS:-4}"
echo ""
echo "════════════════════════════════════════════"
echo " ${name}(--script 模式,退出码为唯一判据)"
if [ "$SCOPE" = related ] && [ -n "$ONLY_TERMS" ]; then
echo " 关联档选跑:--only=$ONLY_TERMS"
fi
if [ "$_shards" -gt 1 ]; then
echo " 分片并行:$_shards 进程(GATE_SHARDS 可调,=1 回落单进程原路径)"
fi
echo "════════════════════════════════════════════"
local rc=0
if [ "$_shards" -le 1 ]; then
local -a _cmd=(--headless --path "$(gate_game_path "$GAME")" --script res://tests/run_all.gd)
[ ${#_user_args[@]} -gt 0 ] && _cmd+=(-- "${_user_args[@]}")
( cd "$REPO" && "$GODOT" "${_cmd[@]}" ) \
>"$STAMP_DIR/unit.log" 2>&1
rc=$?
else
# 分片并行:串行道套件由 run_all 内部分配钉第 1 片(RNG 日志会话跨片互踩,见其头注);
# 各片独立日志,退出码聚合(任一片非 0=闸红);日志按片序合并供错误扫描与指纹取材
#(指纹只取 FAIL 行排序集合、绿闸记 green,片序与合并不改变指纹)。
local -a _pids=()
local k
for k in $(seq 1 "$_shards"); do
( cd "$REPO" && "$GODOT" --headless --path "$(gate_game_path "$GAME")" --script res://tests/run_all.gd \
-- ${_user_args[@]:+"${_user_args[@]}"} "--shard=$k" "--shard-count=$_shards" ) \
>"$STAMP_DIR/unit_shard_$k.log" 2>&1 &
_pids+=($!)
done
local _shard_rc _any=0
for k in $(seq 1 "$_shards"); do
wait "${_pids[$((k - 1))]}"
_shard_rc=$?
echo "[run_all] 分片 $k/$_shards 退出码 $_shard_rc"
[ "$_shard_rc" -ne 0 ] && _any=1
done
[ "$_any" -ne 0 ] && rc=1
: >"$STAMP_DIR/unit.log"
for k in $(seq 1 "$_shards"); do
echo "── unit 分片 $k/$_shards ──" >>"$STAMP_DIR/unit.log"
cat "$STAMP_DIR/unit_shard_$k.log" >>"$STAMP_DIR/unit.log"
done
fi
# 三硬闸之二(脚本错误扫描):子套件的 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
if [ $rc -ne 0 ]; then
grep -E "FAIL res://|超时|ABORT|个套件失败|套件未跑|SCRIPT ERROR|Parse Error" "$STAMP_DIR/unit.log" | tail -80 | sed 's/^/ | /'
fi
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("unit.log")
return 0
}
# ══ 闸件自检段(gates 档专属)══
# 闸壳自检:bash -n 过全部 bash 闸件与管线件——闸件批改的就是这些件,语法坏=下一批真跑闸时才炸,
# 闸件批当场拦住。收件面动态收全:tools/ 全部 .sh+game/tests 顶层 .sh(静态扫描)+hooks/ 无后缀件
# (post-merge/pre-merge-commit);新增 bash 闸件自动入检面,无需维护清单。
gate_shell_selfcheck() {
local name="闸壳自检"
local rc=0 _n=0 _bad=0 _s
local -a _shells=()
while IFS= read -r _s; do [ -n "$_s" ] && _shells+=("$_s"); done < <(
{ find "$REPO/tools" -name '*.sh' 2>/dev/null
find "$REPO/game/tests" -maxdepth 1 -name '*.sh' 2>/dev/null
find "$REPO/tools/hooks" -maxdepth 1 -type f ! -name '*.sh' 2>/dev/null; } | sort -u
)
echo ""
echo "════════════════════════════════════════════"
echo " $name(bash -n 全部 bash 闸件与管线件)"
echo "════════════════════════════════════════════"
: >"$STAMP_DIR/shell_selfcheck.log"
for _s in "${_shells[@]}"; do
[ -f "$_s" ] || continue
_n=$((_n + 1))
if bash -n "$_s" 2>>"$STAMP_DIR/shell_selfcheck.log"; then
echo "ok: ${_s#"$REPO"/}" >>"$STAMP_DIR/shell_selfcheck.log"
else
echo "FAIL: bash -n ${_s#"$REPO"/} 解析失败" >>"$STAMP_DIR/shell_selfcheck.log"
_bad=$((_bad + 1))
fi
done
if [ "$_bad" -eq 0 ]; then
echo "bash -n × $_n 件全过" >>"$STAMP_DIR/shell_selfcheck.log"
else
rc=1
grep -E "FAIL" "$STAMP_DIR/shell_selfcheck.log" | sed 's/^/ | /'
fi
echo "[gates] $name:bash -n × $_n 件,失败 $_bad(日志 shell_selfcheck.log)"
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("shell_selfcheck.log")
return 0
}
# ══ 合回前置检查档(premerge,2026-11-06 用户裁)══
# 冒烟+单元与集成全套件(4 分片)——合回 merge 点的最小人工拦截面;不是全量、不替代远端 CI。
# 选闸推导整段跳过(本档不按 diff 选闸——合回是最后拦截点,恒全套件);退出码聚合即判定,
# 不落回执不比基线(回执与基线面归远端 CI,本档读数只进合并现场)。
if [ "$DOMAIN" = "premerge" ]; then
gate "冒烟测试" "smoke_boot.log" "$PY" "$REPO/tools/run_smoke.py"
gate_unit
fi
# ══ 代码域 ══
if in_domain code; then
# ── 冒烟测试(极小启动场景:真实开机路径存活检查,零逻辑判定,秒级)──
# 三件保险之一(冒烟测试/系统测试/静态扫描):恒跑不降——测量面批也不免(关联档选闸,2026-10-29 用户裁)。
gate "冒烟测试" "smoke_boot.log" "$PY" "$REPO/tools/run_smoke.py"
# ── 系统测试(真实玩法路径断言面;宿主=game/tests/system_test.gd,缺省 --script 由 run_system_test.py 持有)──
gate "系统测试" "system_test.log" "$PY" "$REPO/tools/run_system_test.py"
# ── 单元与集成测试 ──
gate_unit
# ── 场景探针(走查+断言十件一次跑齐;iter27 漏跑断言段事故后固化为无条件整闸)──
if [ $NONBEHAVIOR -eq 1 ]; then
NAMES+=("场景探针"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 场景探针 → SKIP(影响面选闸:测量面改动不触场景树与探针断言面)"
else
gate "场景探针" "probe.log" bash "$REPO/tools/probe_gate.sh" "$GAME"
fi
# ── 渲染闸(窗口化,依赖 PIL)──
if [ $QUICK -eq 1 ] || [ $NONBEHAVIOR -eq 1 ]; then
NAMES+=("渲染闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 渲染闸 → SKIP(--quick 或 影响面选闸:测量面改动不触渲染输出)"
else
gate "渲染闸" "render.log" bash "$REPO/tools/render_gate.sh" "$GAME"
fi
# ── 色板闸(§4.1 新色双门②:素材新增色 vs 基线〔token∪素材快照〕近色判红;
# 门①语义检查属设计面人工核对不入机检;基线刷新=tools/gen_palette_baseline.py,素材批收尾动作)──
if [ $QUICK -eq 1 ] || [ $NONBEHAVIOR -eq 1 ]; then
NAMES+=("色板闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 色板闸 → SKIP(--quick 或 影响面选闸:素材色板面不随测量面改动开闸)"
else
gate "色板闸" "palette.log" "$PY" "$REPO/tools/palette_check.py" "$GAME/assets/art" --baseline "$REPO/game/tests/golden/palette_baseline"
fi
fi
# ══ 文档域 ══
if in_domain docs; then
# 全量档(CI 与手动档同判定面)
gate "gdd_lint" "gdd_lint.log" "$PY" "$REPO/tools/gdd_lint.py"
gate "变更记录节" "changelog.log" "$PY" "$REPO/tools/gdd_lint.py" --changelog-only
gate "doc_link" "doc_link.log" "$PY" "$REPO/tools/doc_link_audit.py" --strict
gate "doc_term" "doc_term.log" "$PY" "$REPO/tools/doc_term_audit.py" --strict
gate "流水线闸" "pipeline.log" "$PY" "$REPO/tools/pipeline_gate.py"
gate "引用可核" "doc_ref.log" "$PY" "$REPO/tools/doc_ref_audit.py" --strict
gate "能力审计" "capability.log" "$PY" "$REPO/tools/capability_audit.py" --strict
fi
# ── 闸件自检段(gates 档专属)──
if [ "$DOMAIN" = "gates" ]; then
gate_shell_selfcheck
fi
# ── 静态扫描(代码域与闸件自检档)+ 熵增闸(各档都跑:它查顶层目录/根文件/图像白名单/退役路径,
# 文档批新增或挪动 design/ 下的文件同样会绊它;纯文本扫描,秒级)──
# premerge 档不入此面:前置检查只盯冒烟+单元,静态扫描与熵增闸由合回后远端 CI 盯。
if [ "$DOMAIN" = "premerge" ]; then
echo ""; echo "[gates] 静态扫描/熵增闸 → 不入前置检查面(premerge;闸况归合回后远端 CI)"
elif [ $QUICK -eq 0 ]; then
if in_domain code || [ "$DOMAIN" = "gates" ]; then
gate "静态扫描" "static_scan.log" bash "$GAME/tests/static_scan.sh"
fi
gate "熵增闸" "entropy.log" "$PY" "$REPO/tools/repo_structure_audit.py"
else
if in_domain code || [ "$DOMAIN" = "gates" ]; then
NAMES+=("静态扫描"); EXITS+=("SKIP"); LOGS+=("")
fi
NAMES+=("熵增闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 静态扫描(代码域)/ 熵增闸 → SKIP(--quick)"
fi
# ── premerge 档汇总:退出码聚合即判定(三硬闸口径),不落回执不比基线 ──
if [ "$DOMAIN" = "premerge" ]; then
RC=0
for _e in "${EXITS[@]}"; do
[ "$_e" -ne 0 ] && RC=1
done
echo ""
echo "════════════════════════════════════════════"
echo " 前置检查汇总(premerge——退出码即判定,不落回执)"
for _i in "${!NAMES[@]}"; do
_s="绿"; [ "${EXITS[$_i]}" -ne 0 ] && _s="红"
printf ' %s:%s(exit=%s)\n' "${NAMES[$_i]}" "$_s" "${EXITS[$_i]}"
done
echo "════════════════════════════════════════════"
exit $RC
fi
# ── 与存量基线比对 ──
"$PY" - "$BASELINE" "$RECEIPT" "$QUICK" "$DOMAIN" "$REPO" "$DIRTY_GAME" "$SCOPE" "$ONLY_TERMS" "${NAMES[@]}" -- "${EXITS[@]}" -- "${LOGS[@]}" <<'PYEOF'
import hashlib, json, os, re, subprocess, sys
baseline_path, receipt_path, quick, domain, repo, dirty_game, scope, only_terms = sys.argv[1:9]
rest = sys.argv[9:]
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 tree_hash():
# 内容树哈希:合回闸按树对齐判定「合回内容=跑闸内容」(重塑/合并会改写 commit 号)
try:
return subprocess.run(["git", "-C", repo, "rev-parse", "HEAD^{tree}"],
capture_output=True, text=True, timeout=5).stdout.strip()
except Exception:
return "unknown"
def fingerprint(exit_code, log):
"""红项指纹:只取 exit!=0 时的 FAIL 关键行集合,忽略时序/耗时噪声
(超时挂死行的 pid=NNN 随跑批变化,归一化为 pid=N 后再入哈希——否则存量红指纹逐轮漂移)。
绿闸一律记 'green'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。"""
# SKIP 属「本闸未实跑」状态(--quick/影响面选闸),指纹一律记 skip——
# 没跑不是闸况变化,只有「未跑 → 跑出结果」或「绿 → 红」才算变化。
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(re.sub(r"pid=\d+", "pid=N", 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)——没有结果可比,不判闸况变化。
# 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],
"tree": tree_hash(),
"timestamp": subprocess.run(["date", "-Iseconds"], capture_output=True, text=True).stdout.strip(),
"quick_mode": quick == "1",
"domain": domain,
"scope": scope,
"scope_terms": [t for t in only_terms.split() if t],
"dirty_game": dirty_game == "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,
"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 dirty_game == "1":
print("【回执不可用于合回】跑闸时代码面(game/ 下非 .md)有未提交改动——先提交到分支、再重跑这一次。")
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 '?')"
# B 覆盖总览面板·执行层六源明细化(观测件非判定件——解析失败打红字不翻闸判,coverage_receipts.py 头注)
"$PY" "$REPO/tools/coverage_receipts.py" --stamp-dir "$STAMP_DIR" || true
echo "════════════════════════════════════════════"
exit $RC