308 lines
12 KiB
Bash
308 lines
12 KiB
Bash
#!/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=18
|
|
|
|
# Step 0 — Game data schema validation
|
|
_verify_step 0 $TOTAL "game data JSON schemas" \
|
|
python3 "$REPO_ROOT/tools/validate-game-data.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 <crate>/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
|
|
}
|