#!/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 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=$? # 失败时把关键行回显到终端(红必须看得见——明细行形态 [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="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[@]:+"${_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 } # ══ 代码域(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