Files
gplan-2/tools/run_all_gates.sh
T
DKChan 9c36c7a237 工程(工程清理批·用户令): 六件直改合批——①palette_check基线建档+双门②机检化: golden/palette_baseline两PNG(token92色∪素材快照1274色=并集1346色)+gen_palette_baseline.py建档刷新件+闸脚本05b色板闸(code域,quick/影响面SKIP)+头注基线纪律, 门①语义检查与48计数上限不承载(口径归art-bible owner,呈报); ②extract_checks补check_quiet静默通道: CALL_RE/DEF_RE扩双通道+collect_call按匹配长偏移+quiet列记channel:check_quiet, 自证三重(双计等式2155==2155/新旧对拍1719→2133=+414入账/往返--diff PASS零差异), test-conventions§5.2局限备案句随批换代; ③样张S1b四色墨×PANEL_DATA垫牌≥4.5:1色值对表断言(读数4.97–5.62与审计复算逐位一致, 样张31/31全绿; V10基线对拍图artwork环境件--save-baseline重建); ④main.gd死preload清理10条(登记8条复核多出2条, 7具名消费面已迁main_ui_build拆件自持, 逐条grep复核零代码使用), presentation 17套件全绿+冒烟15项0失败兜底; ⑤注释换代: ui_kit TEX_HERO_PORTRAIT「48px档」→原稿分辨率+显示档读PORTRAIT_DISPLAY_H, path_check头注删日期批注记; ⑥CAMP_HEADER_BAND退役评估: 4处消费面核过(关卡屏2/商店1/KeystoneBadge工厂1), 退役删除不成立, 处置=改名中性化归ui_kit令牌批顺带(零值变化); ⑦run_all_gates 02传参核查=d32ff342已完成漏删零改动; 待办清单7条目整条删除(状态机出口), pipeline_gate PASS(33条目/49总表行); 产出物=reports/工程改动报告-工程清理批-2026-11-02.md
2026-11-02 06:29:27 +08:00

596 lines
31 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+内容树哈希+各闸退出码+存量基线比对+时间戳);
# 本地 CI(tools/ci_runner.sh,2026-10-30 用户裁)读回执写 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"
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)" "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
# 横切触面表——关联档选闸的横切回落判定清单(数值·存档·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 "$(cygpath -m "$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 "$(cygpath -m "$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