#!/usr/bin/env bash # `./run verify` — full regression-gate pipeline. # # Split out of dev.sh. The pipeline is 15 steps covering data validation, # i18n, objectives dashboard freshness, Rust build/test/clippy/machete/deny/docs, # file-size cap, TS typecheck, GDScript lint (3 trees), and a headless Godot # boot check. Each step times itself; failures abort and print a summary. cmd_verify() { local -a step_names step_times step_results local overall_exit=0 _verify_step() { local step_num="$1" local total="$2" local label="$3" shift 3 echo "" echo -e "${BLUE}[${step_num}/${total}] ${label}${NC}" local t_start t_start=$(date +%s%N) if ! "$@"; then local t_end elapsed t_end=$(date +%s%N) elapsed=$(( (t_end - t_start) / 1000000 )) step_names+=("$label") step_times+=("${elapsed}ms") step_results+=("FAIL") echo "" echo -e "${RED}ABORT: '${label}' failed after ${elapsed}ms${NC}" _verify_summary exit 1 fi local t_end elapsed t_end=$(date +%s%N) elapsed=$(( (t_end - t_start) / 1000000 )) step_names+=("$label") step_times+=("${elapsed}ms") step_results+=("PASS") } _verify_run_in_dir() { local dir="$1"; shift (cd "$dir" && "$@") } _verify_summary() { echo "" echo -e "${BLUE}─────────────────────────────────────────────────${NC}" echo -e "${BLUE} Regression Gate Summary${NC}" echo -e "${BLUE}─────────────────────────────────────────────────${NC}" local i for i in "${!step_names[@]}"; do local result="${step_results[$i]}" local color if [ "$result" = "PASS" ]; then color="$GREEN" else color="$RED" fi printf " %-40s %s%-4s%s %s\n" \ "${step_names[$i]}" \ "$color" "$result" "$NC" \ "${step_times[$i]}" done echo -e "${BLUE}─────────────────────────────────────────────────${NC}" # Count pending steps not yet run local n_pass=0 n_fail=0 for r in "${step_results[@]}"; do if [ "$r" = "PASS" ]; then n_pass=$(( n_pass + 1 )) else n_fail=$(( n_fail + 1 )) fi done if [ "$n_fail" -eq 0 ]; then echo -e " ${GREEN}All ${n_pass} checks passed${NC}" else echo -e " ${RED}${n_fail} check(s) failed, ${n_pass} passed${NC}" fi echo -e "${BLUE}─────────────────────────────────────────────────${NC}" } local TOTAL=19 # Step 0 — Game data schema validation _verify_step 0 $TOTAL "game data JSON schemas" \ python3 "$REPO_ROOT/tools/validate-game-data.py" # Step 17 — Single-colour-system gate (p2-87): no hardcoded colour applied # to a widget in a scene; colours must come from ThemeAssets.color / theme. _verify_step 17 $TOTAL "no hardcoded applied UI colours" \ python3 "$REPO_ROOT/tools/check-ui-color-sources.py" # Step 16 — "Build output never under src/" invariant. # Rule source: .claude/instructions/build-output-locations.md. _verify_step 16 $TOTAL "no build output under src/" \ _verify_no_build_in_src # Step 1 — i18n: no hardcoded user-visible strings outside ThemeVocabulary _verify_step 1 $TOTAL "i18n: no hardcoded UI strings" \ python3 "$REPO_ROOT/tools/validate-i18n.py" # Step 2 — Objectives dashboard freshness # Fails if .project/objectives/README.md is stale vs the per-objective # frontmatter. Run `python3 tools/objectives-report.py` to regenerate. _verify_step 2 $TOTAL "objectives dashboard up-to-date" \ python3 "$REPO_ROOT/tools/objectives-report.py" --check # Step 3 — Rust build _verify_step 3 $TOTAL "cargo build --workspace" \ _verify_run_in_dir "$SIMULATOR_DIR" cargo build --workspace # Step 4 — Rust tests (prefer nextest) _verify_step 4 $TOTAL "cargo test --workspace" \ _cargo_test_workspace # Step 5 — Rust clippy _verify_step 5 $TOTAL "cargo clippy --workspace -D warnings" \ _verify_run_in_dir "$SIMULATOR_DIR" cargo clippy --workspace -- -D warnings # Step 6 — Rust dead-deps scan (optional: cargo-machete) _verify_step 6 $TOTAL "cargo machete (dead deps)" \ _verify_machete # Step 7 — Rust advisories + license check (optional: cargo-deny) _verify_step 7 $TOTAL "cargo deny check" \ _verify_deny # Step 8 — Rust docs build (warnings are hard errors) _verify_step 8 $TOTAL "cargo doc --no-deps --workspace" \ _verify_run_in_dir "$SIMULATOR_DIR" \ env RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace # Step 9 — 500-LOC hard cap across languages _verify_step 9 $TOTAL "file-size 500-LOC cap (.rs/.gd/.ts)" \ _verify_file_size_cap # Step 10 — TS workspace typecheck (pnpm -r) _verify_step 10 $TOTAL "pnpm -r typecheck" \ pnpm -r typecheck # Apply project-local gdlint config before linting. # .project/gdlintrc.local is the source of truth — copy it over before lint. cp "$REPO_ROOT/.project/gdlintrc.local" "$REPO_ROOT/gdlintrc" 2>/dev/null # Step 11 — GDScript lint: engine/src/ _verify_step 11 $TOTAL "gdlint engine/src/" \ gdlint "$GAME_DIR/engine/src/" # Step 12 — GDScript lint: scenes/tests/ _verify_step 12 $TOTAL "gdlint engine/scenes/tests/" \ gdlint "$GAME_DIR/engine/scenes/tests/" # Step 13 — GDScript lint: tests/integration/ _verify_step 13 $TOTAL "gdlint engine/tests/integration/" \ gdlint "$GAME_DIR/engine/tests/integration/" # Step 14 — Godot headless boot: GDExtension + script compilation _verify_step 14 $TOTAL "godot headless boot (no script errors)" \ _godot_headless_boot # Step 15 — Autoplay hang-regression smoke test (p0-10 gate). # Skips silently when neither AUTOPLAY_HOST nor local flatpak is available # so this gate runs opportunistically on dev boxes without a RUN host. _verify_step 15 $TOTAL "autoplay hang smoke (seed 1, T50, 120s budget)" \ _verify_autoplay_smoke # Step 17 — p0-26 tactical port baseline: Normal-vs-Normal 10-seed T300 batch. # Skips automatically when SKIP_BASELINE=1 (CI without apricot) or when # autoplay-batch.sh is absent. On machines with apricot access this is the # regression gate that pins the tactical Rust port to p0-01 quality thresholds. _verify_step 17 $TOTAL "p0-26 tactical port baseline (p0-01 quality gates)" \ _verify_tactical_port_baseline _verify_summary return $overall_exit } # ── Verify step helpers ──────────────────────────────────────────────── _verify_machete() { # Skip with a warning if cargo-machete is not installed — # graceful degrade on machines missing optional tools. if ! _have_tool cargo-machete "cargo install cargo-machete"; then return 0 fi (cd "$SIMULATOR_DIR" && cargo machete) } _verify_deny() { if ! _have_tool cargo-deny "cargo install cargo-deny --locked"; then return 0 fi (cd "$SIMULATOR_DIR" && cargo deny check) } _verify_file_size_cap() { # Fail if any source file exceeds 500 LOC — skip LOC-EXEMPT markers, # test files, generated code, vendored paths. # Scanned roots: src/simulator/**/*.rs, src/game/engine/src/**/*.gd, # src/packages/**/*.ts, public/games/age-of-dwarves/guide/src/**/*.ts. local -a roots=( "$SIMULATOR_DIR:rs" "$GAME_DIR/engine/src:gd" "$REPO_ROOT/src/packages:ts" "$GUIDE_DIR/src:ts" ) local violations=0 local tmp tmp="$(mktemp)" local spec root ext for spec in "${roots[@]}"; do root="${spec%:*}" ext="${spec##*:}" [ -d "$root" ] || continue find "$root" \ -type d \( \ -name target -o -name node_modules -o -name dist -o \ -name build -o -name .local -o -name pkg -o -name coverage \ \) -prune -o \ -type f -name "*.${ext}" ! -name "*.test.ts" ! -name "*.spec.ts" \ ! -name "*.generated.ts" ! -name "*.d.ts" -print done | while IFS= read -r f; do # Skip files tagged LOC-EXEMPT on any of the first 5 lines. if head -n 5 "$f" 2>/dev/null | grep -q "LOC-EXEMPT"; then continue fi local lines lines=$(wc -l < "$f" | tr -d ' ') if [ "$lines" -gt 500 ]; then printf '%6d %s\n' "$lines" "$f" >> "$tmp" fi done if [ -s "$tmp" ]; then violations=$(wc -l < "$tmp" | tr -d ' ') echo -e "${RED}Files exceeding 500-LOC cap (${violations}):${NC}" cat "$tmp" rm -f "$tmp" return 1 fi rm -f "$tmp" return 0 } _verify_tactical_port_baseline() { # Skip when SKIP_BASELINE=1 or when autoplay-batch.sh is absent (no apricot). if [ "${SKIP_BASELINE:-0}" = "1" ]; then echo "SKIP: SKIP_BASELINE=1" return 0 fi if [ ! -x "$REPO_ROOT/tools/autoplay-batch.sh" ]; then echo "SKIP: autoplay-batch.sh not executable" return 0 fi python3 -m pytest "$REPO_ROOT/tools/tests/test_tactical_port_baseline.py" \ -v -k "test_p001_quality_gates_hold_post_tactical_port" \ --tb=short -q } _verify_no_build_in_src() { # Enforce: src/ is source-only. Covers wasm-pack's default /pkg/ # and cargo's default target/ — both must be redirected to .local/build/**. # Rule doc: .claude/instructions/build-output-locations.md. local violations=0 if [ -d "$REPO_ROOT/src/simulator/pkg" ] && [ -n "$(ls -A "$REPO_ROOT/src/simulator/pkg" 2>/dev/null)" ]; then echo -e "${RED}src/simulator/pkg/ has content — wasm-pack output must go to .local/build/wasm/${NC}" violations=$(( violations + 1 )) fi while IFS= read -r target_dir; do if [ -n "$(ls -A "$target_dir" 2>/dev/null)" ]; then echo -e "${RED}${target_dir#$REPO_ROOT/} has content — cargo target must go to .local/build/rust/${NC}" violations=$(( violations + 1 )) fi done < <(find "$REPO_ROOT/src" -type d -name target 2>/dev/null) if [ "$violations" -gt 0 ]; then echo -e "${RED}Rule: build output is never inside src/ (see .claude/instructions/build-output-locations.md).${NC}" return 1 fi return 0 } _verify_autoplay_smoke() { # Skips when no RUN host and no local flatpak — dev boxes without a batch # target still get the rest of the pipeline. if [ -z "${AUTOPLAY_HOST:-}" ] && ! command -v flatpak >/dev/null 2>&1; then echo "SKIP: no AUTOPLAY_HOST and no local flatpak" return 0 fi bash "$REPO_ROOT/tools/ci-autoplay-smoke.sh" } _godot_headless_boot() { # Boot Godot headless and check for SCRIPT ERRORs. # Catches class_name resolution failures, GDExtension load failures, # and any other compile-time GDScript errors that gdlint cannot detect. local log="/tmp/godot_headless_boot_$$.log" $GODOT_BIN --path "$GAME_DIR" --rendering-method gl_compatibility --headless --quit 2>&1 | tee "$log" local errors errors=$(grep -cE "SCRIPT ERROR|^ERROR:" "$log" 2>/dev/null || true) errors="${errors:-0}" rm -f "$log" if [ "$errors" -gt 0 ]; then echo -e "${RED}Found $errors script/load errors in headless boot${NC}" return 1 fi return 0 }