599 lines
32 KiB
Bash
Executable File
599 lines
32 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 验证闸唯一入口(全部验证闸的清单与域划分)——
|
||
# 闸清单的唯一定义处。文档只写「验证跑本脚本」,禁在文档里另列闸件清单(那正是 iter27
|
||
# 漏跑 probe_gate --full 的成因:清单散落三份文档、彼此不一致、且无机器校验)。
|
||
#
|
||
# 跑完写回执 game/tests/.gates/receipt.json(commit+内容树哈希+各闸退出码+存量基线比对+时间戳);
|
||
# 远端 CI(2026-11-03 用户裁)读回执判定,回执经 tools/ci_watch.sh remote 轮询 Gitea API 写 ci-status.json——
|
||
# 强制验证点=合回 master 后 post-merge 钩子推送触发的远端跑批;本脚本手动调用(--code/--docs/全量档)只服务开发期按域跑批。
|
||
# 跑闸时代码面带着未提交改动,回执会标 dirty_game(验的不是 HEAD 字节),CI 判定照常落账留痕。
|
||
#
|
||
# 用法:tools/run_all_gates.sh [--code|--docs|--quick]
|
||
# --code 只跑代码域闸(00 冒烟测试/01 系统测试/02 单元与集成测试/03 场景探针/
|
||
# 05 渲染闸/09 静态扫描+10 熵增闸)
|
||
# ——本批改动含 game/ 下非 .md 的行为载体(代码/测试脚本/探针/场景/数据表/素材)时用。
|
||
# 关联档选闸(2026-10-29 用户裁,推广自 2026-10-28 影响面选闸):00 冒烟/01 系统测试/
|
||
# 09 静态扫描三件保险恒跑不降;03 场景探针与 05 渲染闸维持影响面口径(本批 game/ 改动
|
||
# 全在测量面 game/tests、game/sim 时记 SKIP);02 单元与集成改「选套件跑」——套件集=
|
||
# 需求卡片申报 ∪ diff 推导并集(算法与套件族映射表见脚本中部),横切面(公共件/数据表/
|
||
# autoload/数值·存档·RNG·结算触面)回落全套件;回执 scope 字段记 full|related。
|
||
# --docs 只跑文档域闸(06 gdd_lint/07 doc_link/08 doc_term/08c 流水线闸/08d 引用可核/
|
||
# 08e 能力审计+10 熵增闸)
|
||
# ——本批改动只有文档件时用:game/ 内的 .md(README、生成清单)、design/、根规则文件、
|
||
# tools/ 工具脚本、.zcode/ 配置等一切非代码面改动。
|
||
# --quick 全量档的中段自检形态:跳过渲染闸(窗口化,需开显示器)与静态扫描;完成出库禁用。
|
||
# 不带参数 = 全量档(两域全跑),触发条件=同批既有代码面改动又有文档件改动。闸件(tools/ 下机检脚本
|
||
# 与闸壳)自身的改动不占合回全量——其质量义务=落批时人工确认闸可跑通且判据口径未变;
|
||
# 闸真崩溃时合回闸 fail-closed(跑不出回执即拦),不以全量为兜底。
|
||
# **域划分只在本脚本里定义一处**——文档只写「按改动域跑本脚本」,禁另列闸清单(清单散落多处
|
||
# 正是既往漏跑 probe_gate --full 的成因)。
|
||
#
|
||
# 判定口径:每道闸与 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)
|
||
[ "$DOMAIN" = "full" ] || { echo "用法: --code 与 --docs 互斥"; exit 2; }
|
||
DOMAIN="${1#--}" ;;
|
||
*) echo "用法: tools/run_all_gates.sh [--code|--docs|--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" ]; then
|
||
"$REPO/tools/setup_worktree_userdir.sh" "$REPO" || {
|
||
echo "[gates] FAIL — user 目录隔离未建立,拒绝在共享目录上跑闸。"
|
||
exit 1
|
||
}
|
||
fi
|
||
|
||
# 控制台编码:本仓库闸件输出中文与 ✅/✗ 符号。Windows GBK 控制台下 python 打印这些字符会抛
|
||
# UnicodeEncodeError——待办清单/10 两闸直接崩(假红)、09 的 FAIL 行被写成乱码字节(指纹漂移,
|
||
# 与基线比必判「红项清单变化」假红)、末尾汇总自身崩掉打不出结论。
|
||
export PYTHONIOENCODING=utf-8
|
||
|
||
# 代码面(game/ 下非 .md 的行为载体)是否带着未提交改动(只算已跟踪文件——闸件自身产物都落在
|
||
# gitignore 面)。脏树跑出来的回执记的是 HEAD,验的却是别的字节,回执标 dirty_game 由 CI 落账留痕。
|
||
DIRTY_GAME=0
|
||
if [ -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
|
||
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
|
||
|
||
# 横切触面表——关联档选闸的横切回落判定清单(数值·存档·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 影响面选闸)──
|
||
# 三件保险恒跑不降:00 冒烟/01 系统测试/09 静态扫描(10 熵增闸照旧两域都跑)。
|
||
# 03 场景探针与 05 渲染闸维持影响面口径:本批 diff 相对 master 分叉点的 game/ 改动若全部落在
|
||
# 测量面(game/tests/**、game/sim/**——测试与模拟驱动件,不触场景树与渲染输出)则记 SKIP。
|
||
# 02 单元与集成改「选套件跑」:套件集=申报 ∪ 推导 取并集——
|
||
# 申报:在册需求卡片(本批 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)、或下方横切触面表任一 → 02 跑全部套件。
|
||
# 测量面批(game/ 改动全在 tests/sim):02 照旧全套件跑(不加过滤)。
|
||
# 回执 scope 字段:full|related(02 跑子集=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)——03 场景探针/05 渲染闸 → SKIP(00/01/09 三件保险恒跑)"
|
||
fi
|
||
# ── 02 选套件推导(只对行为面代码文件做——测量面文件不进推导,其验证就是 02/09 自身)──
|
||
_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·结算触面)——02 回落全部套件"
|
||
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] 关联档选闸:02 选套件跑(--only=${ONLY_TERMS})"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# ── 代码域起手自检(闸承载:只省烧闸,不做合回判定——真判定在 00–11 与合回 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=$?
|
||
# 失败时把关键行回显到终端(红必须看得见)
|
||
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 模式,退出码为唯一判据;分片态=多 Godot 进程各跑一片
|
||
local name="02 单元与集成测试"
|
||
# 关联档选跑:把套件词列表经 --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[@]}" "--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
|
||
[ $rc -ne 0 ] && tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /'
|
||
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("unit.log")
|
||
return 0
|
||
}
|
||
|
||
# ══ 代码域(0–5)══
|
||
if in_domain code; then
|
||
# ── 0. 冒烟测试(极小启动场景:真实开机路径存活检查,零逻辑判定,秒级)──
|
||
# 三件保险之一(00/01/09):恒跑不降——测量面批也不免(关联档选闸,2026-10-29 用户裁)。
|
||
gate "00 冒烟测试" "smoke_boot.log" "$PY" "$REPO/tools/run_smoke.py"
|
||
# ── 1. 系统测试(真实玩法路径断言面;宿主=game/tests/system_test.gd,缺省 --script 由 run_system_test.py 持有)──
|
||
gate "01 系统测试" "system_test.log" "$PY" "$REPO/tools/run_system_test.py"
|
||
# ── 2. 单元与集成测试 ──
|
||
gate_unit
|
||
# ── 3. 场景探针(走查+断言十件一次跑齐;iter27 漏跑断言段事故后固化为无条件整闸)──
|
||
if [ $NONBEHAVIOR -eq 1 ]; then
|
||
NAMES+=("03 场景探针"); EXITS+=("SKIP"); LOGS+=("")
|
||
echo ""; echo "[gates] 03 场景探针 → SKIP(影响面选闸:测量面改动不触场景树与探针断言面)"
|
||
else
|
||
gate "03 场景探针" "probe.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --click --full
|
||
fi
|
||
# ── 5. 渲染面(窗口化,依赖 PIL)──
|
||
if [ $QUICK -eq 1 ] || [ $NONBEHAVIOR -eq 1 ]; then
|
||
NAMES+=("05 渲染闸"); EXITS+=("SKIP"); LOGS+=("")
|
||
echo ""; echo "[gates] 05 渲染闸 → SKIP(--quick 或 影响面选闸:测量面改动不触渲染输出)"
|
||
else
|
||
gate "05 渲染闸" "render.log" bash "$REPO/tools/render_gate.sh" "$GAME"
|
||
fi
|
||
# ── 5b. 色板闸(§4.1 新色双门②:素材新增色 vs 基线〔token∪素材快照〕近色判红;
|
||
# 门①语义检查属设计面人工核对不入机检;基线刷新=tools/gen_palette_baseline.py,素材批收尾动作)──
|
||
if [ $QUICK -eq 1 ] || [ $NONBEHAVIOR -eq 1 ]; then
|
||
NAMES+=("05b 色板闸"); EXITS+=("SKIP"); LOGS+=("")
|
||
echo ""; echo "[gates] 05b 色板闸 → SKIP(--quick 或 影响面选闸:素材色板面不随测量面改动开闸)"
|
||
else
|
||
gate "05b 色板闸" "palette.log" "$PY" "$REPO/tools/palette_check.py" "$GAME/assets/art" --baseline "$REPO/game/tests/golden/palette_baseline"
|
||
fi
|
||
fi
|
||
# ══ 文档域(6–8e)══
|
||
if in_domain docs; then
|
||
# 全量档(CI 与手动档同判定面)
|
||
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
|
||
gate "08c 流水线闸" "pipeline.log" "$PY" "$REPO/tools/pipeline_gate.py"
|
||
gate "08d 引用可核" "doc_ref.log" "$PY" "$REPO/tools/doc_ref_audit.py" --strict
|
||
gate "08e 能力审计" "capability.log" "$PY" "$REPO/tools/capability_audit.py" --strict
|
||
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" "$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 '?')"
|
||
echo "════════════════════════════════════════════"
|
||
exit $RC
|