#!/usr/bin/env bash # quality.sh — Local quality gate for GDScript projects. # Usage: ./tools/quality.sh [--changed] # --changed Only check changed .gd files (gdformat/gdlint only) set -uo pipefail IFS=$'\n\t' ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" || exit 1 LOG="logs/quality/latest" mkdir -p "$LOG" GODOT_PROFILE="$ROOT/logs/quality/godot_profile" mkdir -p "$GODOT_PROFILE" CHANGED=false [[ "${1:-}" == "--changed" ]] && CHANGED=true OVERALL=false ERRORS=() FIXES=() OWNED_GDSCRIPT_ROOTS=(player simulation tests tools world) REQUIRED_GODOT_SERIES="4.7" # -- tool detection ----------------------------------------------------------- console_godot_path() { local candidate="${1:-}" [[ -z "$candidate" ]] && return 1 candidate="${candidate%\"}" candidate="${candidate#\"}" candidate="${candidate//\\//}" if [[ "$candidate" == *.exe ]]; then local console_candidate="${candidate%.exe}_console.exe" if [[ -f "$console_candidate" ]]; then echo "$console_candidate"; return 0; fi fi if [[ -f "$candidate" ]]; then echo "$candidate"; return 0; fi return 1 } find_project_godot() { local metadata="$ROOT/.godot/editor/project_metadata.cfg" [[ -f "$metadata" ]] || return 1 local line path line="$(grep -m 1 '^executable_path=' "$metadata" 2>/dev/null || true)" [[ -n "$line" ]] || return 1 path="${line#executable_path=}" path="${path%\"}" path="${path#\"}" console_godot_path "$path" } find_godot() { if [[ -n "${GODOT_BIN:-}" ]]; then console_godot_path "$GODOT_BIN" && return 0; echo "$GODOT_BIN"; return 0; fi find_project_godot && return 0 for c in godot godot4; do command -v "$c" &>/dev/null && echo "$c" && return 0; done console_godot_path "/Applications/Godot.app/Contents/MacOS/Godot" && return 0 return 1 } has_gdformat() { [[ -x "$ROOT/.venv/bin/gdformat" ]] && return 0 command -v gdformat &>/dev/null && return 0 python -m gdtoolkit.formatter --help &>/dev/null 2>&1 } run_gdformat() { if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then "$ROOT/.venv/bin/gdformat" "$@" elif command -v gdformat &>/dev/null; then gdformat "$@" else python -m gdtoolkit.formatter "$@" fi } has_gdlint() { [[ -x "$ROOT/.venv/bin/gdlint" ]] && return 0 command -v gdlint &>/dev/null && return 0 python -m gdtoolkit.linter --help &>/dev/null 2>&1 } run_gdlint() { if [[ -x "$ROOT/.venv/bin/gdlint" ]]; then "$ROOT/.venv/bin/gdlint" "$@" elif command -v gdlint &>/dev/null; then gdlint "$@" else python -m gdtoolkit.linter "$@" fi } changed_gd() { { git diff --name-only HEAD -- '*.gd' 2>/dev/null git ls-files --others --exclude-standard -- '*.gd' 2>/dev/null } | sort -u || true } res_path() { local p="$1" p="${p//\\//}" if [[ "$p" == "$ROOT"* ]]; then p="${p#"$ROOT"/}"; fi p="${p#./}" echo "res://$p" } contains_element() { local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1 } run_with_timeout() { local seconds="$1" shift if command -v timeout &>/dev/null; then timeout "$seconds" "$@" return $? fi local marker="$LOG/.timeout-$$-${RANDOM}" : > "$marker" "$@" & local command_pid=$! ( sleep "$seconds" if kill -0 "$command_pid" 2>/dev/null; then echo "timeout" > "$marker" kill "$command_pid" 2>/dev/null || true sleep 2 kill -KILL "$command_pid" 2>/dev/null || true fi ) & local watchdog_pid=$! wait "$command_pid" local status=$? kill "$watchdog_pid" 2>/dev/null || true wait "$watchdog_pid" 2>/dev/null || true if [[ -s "$marker" ]]; then status=124 fi rm -f "$marker" return "$status" } # -- godot -------------------------------------------------------------------- GODOT="$(find_godot)" || true if [[ -z "$GODOT" ]]; then echo "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH." exit 1 fi if ! GODOT_VERSION="$("$GODOT" --version 2>&1)"; then echo "ERROR: Could not query the Godot version from '$GODOT'." exit 1 fi GODOT_VERSION="${GODOT_VERSION%%$'\n'*}" GODOT_VERSION="${GODOT_VERSION%$'\r'}" case "$GODOT_VERSION" in "$REQUIRED_GODOT_SERIES"|"$REQUIRED_GODOT_SERIES".*|"$REQUIRED_GODOT_SERIES"-*) ;; *) echo "ERROR: Godot $REQUIRED_GODOT_SERIES.x is required; found '$GODOT_VERSION'." exit 1 ;; esac GODOT_ENV=(env "HOME=$GODOT_PROFILE" "APPDATA=$GODOT_PROFILE" "LOCALAPPDATA=$GODOT_PROFILE") # -- 0. Godot import bootstrap ------------------------------------------------ import_result="PASS" : > "$LOG/godot-import.log" class_cache="$ROOT/.godot/global_script_class_cache.cfg" needs_import=false [[ ! -f "$class_cache" ]] && needs_import=true if [[ -f "addons/gut/gut_cmdln.gd" ]] && ! grep -q '"class": &"GutTest"' "$class_cache" 2>/dev/null; then needs_import=true fi [[ -n "$(changed_gd)" ]] && needs_import=true if $needs_import; then run_with_timeout 60 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1 import_exit=$? if [[ $import_exit -ne 0 ]]; then import_result="FAIL" if [[ $import_exit -eq 124 ]]; then echo "[timed out after 60s]" >> "$LOG/godot-import.log" fi fi fi if [[ ! -f "$class_cache" ]] || ! grep -q '"class": &"SimNPC"' "$class_cache"; then import_result="FAIL" fi if [[ -f "addons/gut/gut_cmdln.gd" ]] && ! grep -q '"class": &"GutTest"' "$class_cache" 2>/dev/null; then import_result="FAIL" fi if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-import.log"; then import_result="FAIL" fi if [[ "$import_result" == "FAIL" ]]; then ERRORS+=("godot-import: failed to build the global class cache") FIXES+=("Fix Godot import errors before running headless checks") fi # -- 1. gdformat -------------------------------------------------------------- fmt_result="PASS" fmt_failed=false : > "$LOG/gdformat.log" if ! has_gdformat; then fmt_result="FAIL" echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log" ERRORS+=("gdformat: required formatter not found") FIXES+=("Install the pinned dev tools from requirements-dev.txt") else if $CHANGED; then files=$(changed_gd) if [[ -z "$files" ]]; then echo "No changed .gd files to check." > "$LOG/gdformat.log" else total=0; bad=0 while IFS= read -r f; do [[ -f "$f" ]] || continue total=$((total+1)) if ! run_gdformat --check "$f" >> "$LOG/gdformat.log" 2>&1; then bad=$((bad+1)) fmt_failed=true fi done <<< "$files" echo "($bad/$total files need formatting)" >> "$LOG/gdformat.log" fi else if ! run_gdformat --check "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdformat.log" 2>&1; then fmt_failed=true fi fi fi if $fmt_failed; then fmt_result="FAIL" fi if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then while IFS= read -r line; do line="${line#would reformat }" rp=$(res_path "$line") ERRORS+=("$rp needs formatting") done < <(grep "would reformat" "$LOG/gdformat.log") FIXES+=("Run gdformat to auto-format files") elif $fmt_failed; then ERRORS+=("gdformat: formatter exited non-zero; see $LOG/gdformat.log") FIXES+=("Fix the gdformat error before continuing") fi # -- 2. gdlint ---------------------------------------------------------------- lint_result="PASS" lint_failed=false : > "$LOG/gdlint.log" if ! has_gdlint; then lint_result="FAIL" echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log" ERRORS+=("gdlint: required linter not found") FIXES+=("Install the pinned dev tools from requirements-dev.txt") else if $CHANGED; then files=$(changed_gd) if [[ -z "$files" ]]; then echo "No changed .gd files to lint." > "$LOG/gdlint.log" else while IFS= read -r f; do [[ -f "$f" ]] || continue if ! run_gdlint "$f" >> "$LOG/gdlint.log" 2>&1; then lint_failed=true fi done <<< "$files" fi else if ! run_gdlint "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdlint.log" 2>&1; then lint_failed=true fi fi fi if $lint_failed; then lint_result="FAIL" fi if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then while IFS= read -r line; do [[ "$line" =~ ^([^:]+):([0-9]+):([0-9]+):[^:]+:(.*) ]] || continue file="${BASH_REMATCH[1]}" lineno="${BASH_REMATCH[2]}" msg="${BASH_REMATCH[4]}" msg="${msg## }" rp=$(res_path "$file") ERRORS+=("$rp:$lineno $msg") done < <(grep -E "^[^ ]+:[0-9]+:" "$LOG/gdlint.log") while IFS= read -r line; do [[ "$line" =~ ^([^:]+):([0-9]+):([0-9]+):([^:]+):(.*) ]] || continue rule="${BASH_REMATCH[4]}" fname=$(basename "${BASH_REMATCH[1]}") FIXES+=("Fix $rule in $fname") done < <(grep -E "^[^ ]+:[0-9]+:" "$LOG/gdlint.log") elif $lint_failed; then ERRORS+=("gdlint: linter exited non-zero; see $LOG/gdlint.log") FIXES+=("Fix the gdlint error before continuing") fi # -- 3. godot headless check -------------------------------------------------- godot_result="PASS" : > "$LOG/godot-check.log" if run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then godot_result="PASS" else ec=$? godot_result="FAIL" if [[ $ec -eq 124 ]]; then echo "[timed out after 30s]" >> "$LOG/godot-check.log" fi fi if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check.log"; then godot_result="FAIL" fi if [[ "$godot_result" == "FAIL" ]]; then while IFS= read -r errline; do ERRORS+=("godot: $errline") done < <(grep -iE "error|warning|parse|syntax" "$LOG/godot-check.log" 2>/dev/null | head -20) FIXES+=("Fix Godot parser errors") fi # -- 4. project scenario tests ------------------------------------------------ scenario_result="PASS" : > "$LOG/scenarios.log" for test in tests/*_test.gd; do echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log" if ! run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then scenario_result="FAIL" ERRORS+=("scenario: $(basename "$test") failed or timed out") fi done if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then scenario_result="FAIL" ERRORS+=("scenario: Godot reported a script load or parse error") fi if [[ "$scenario_result" == "FAIL" ]]; then FIXES+=("Fix failing project scenario tests") fi # -- 5. GUT ------------------------------------------------------------------- gut_result="PASS" : > "$LOG/gut.log" if [[ ! -f "addons/gut/gut_cmdln.gd" ]]; then gut_result="FAIL" ERRORS+=("gut: pinned addon is missing") FIXES+=("Restore addons/gut from pinned GUT v9.7.1") else gut_command=( "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" -s addons/gut/gut_cmdln.gd -gexit -gdisable_colors ) run_with_timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1 gut_exit=$? if [[ $gut_exit -eq 0 ]]; then gut_result="PASS" else gut_result="FAIL" FIXES+=("Fix failing GUT tests") fi if grep -qE "Some GUT class_names have not been imported|SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/gut.log"; then gut_result="FAIL" ERRORS+=("gut: addon or unit tests failed to load") FIXES+=("Run Godot 4.7 headless import and fix GUT load errors") fi fi # -- summary ------------------------------------------------------------------ if [[ "$import_result" == "FAIL" || "$fmt_result" == "FAIL" || "$lint_result" == "FAIL" || "$godot_result" == "FAIL" || "$scenario_result" == "FAIL" || "$gut_result" == "FAIL" ]]; then OVERALL=true fi echo "" if $OVERALL; then echo "QUALITY RESULT: FAIL" else echo "QUALITY RESULT: PASS" fi echo "Godot: $GODOT_VERSION" echo "" echo " godot-import $import_result" echo " gdformat $fmt_result" echo " gdlint $lint_result" echo " godot-check $godot_result" echo " scenarios $scenario_result" echo " gut $gut_result" seen_err=("__quality_sentinel__") for e in "${ERRORS[@]:-}"; do [[ -z "$e" ]] && continue contains_element "$e" "${seen_err[@]}" && continue seen_err+=("$e") echo " $e" done if $OVERALL && [[ ${#FIXES[@]} -gt 0 ]]; then echo "" echo " NEXT FIX:" seen_fix=("__quality_sentinel__") idx=1 for fix in "${FIXES[@]:-}"; do [[ -z "$fix" ]] && continue contains_element "$fix" "${seen_fix[@]}" && continue seen_fix+=("$fix") echo " $idx. $fix" idx=$((idx+1)) done fi echo "" echo "Full logs:" echo " $LOG/" echo "" { echo "QUALITY RESULT: $($OVERALL && echo FAIL || echo PASS)" echo "Godot: $GODOT_VERSION" echo "" echo " godot-import $import_result" echo " gdformat $fmt_result" echo " gdlint $lint_result" echo " godot-check $godot_result" echo " scenarios $scenario_result" echo " gut $gut_result" } > "$LOG/summary.txt" $OVERALL && exit 1 || exit 0