#!/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" CHANGED=false [[ "${1:-}" == "--changed" ]] && CHANGED=true OVERALL=false ERRORS=() FIXES=() OWNED_GDSCRIPT_ROOTS=(player simulation tests tools world) # -- tool detection ----------------------------------------------------------- find_godot() { if [[ -n "${GODOT_BIN:-}" ]]; then echo "$GODOT_BIN"; return 0; fi for c in godot godot4; do command -v "$c" &>/dev/null && echo "$c" && return 0; done return 1 } find_gdformat() { if command -v gdformat &>/dev/null; then echo "gdformat"; return 0; fi if python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.formatter"; return 0; fi return 1 } find_gdlint() { if command -v gdlint &>/dev/null; then echo "gdlint"; return 0; fi if python -m gdtoolkit.linter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.linter"; return 0; fi return 1 } changed_gd() { git diff --name-only HEAD -- '*.gd' 2>/dev/null || 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 } need_timeout() { command -v timeout &>/dev/null } # -- 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 # -- 1. gdformat -------------------------------------------------------------- fmt_tool=$(find_gdformat) || true fmt_result="PASS" : > "$LOG/gdformat.log" if [[ -z "$fmt_tool" ]]; then fmt_result="SKIPPED" echo "gdformat not found — install: pip install gdtoolkit" > "$LOG/gdformat.log" 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 ! $fmt_tool --check "$f" >> "$LOG/gdformat.log" 2>&1; then bad=$((bad+1)) fi done <<< "$files" echo "($bad/$total files need formatting)" >> "$LOG/gdformat.log" fi else $fmt_tool --check "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdformat.log" 2>&1 || true fi fi if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then fmt_result="FAIL" 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") fi # -- 2. gdlint ---------------------------------------------------------------- lint_tool=$(find_gdlint) || true lint_result="PASS" : > "$LOG/gdlint.log" if [[ -z "$lint_tool" ]]; then lint_result="SKIPPED" echo "gdlint not found — install: pip install gdtoolkit" > "$LOG/gdlint.log" 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 $lint_tool "$f" >> "$LOG/gdlint.log" 2>&1 || true done <<< "$files" fi else $lint_tool "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdlint.log" 2>&1 || true fi fi if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then lint_result="FAIL" 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") fi # -- 3. godot headless check -------------------------------------------------- godot_result="PASS" : > "$LOG/godot-check.log" if need_timeout; then if timeout 30 "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then godot_result="PASS" else ec=$? if [[ $ec -eq 124 ]]; then godot_result="FAIL" echo "[timed out after 30s — project runs continuously]" >> "$LOG/godot-check.log" else godot_result="FAIL" fi fi else if "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then godot_result="PASS" else godot_result="FAIL" fi fi if [[ "$godot_result" == "FAIL" ]]; then grep -iE "error|warning|parse|syntax" "$LOG/godot-check.log" 2>/dev/null | head -20 | while IFS= read -r errline; do ERRORS+=("godot: $errline") done 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 need_timeout; then if ! timeout 30 "$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 elif ! "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then scenario_result="FAIL" ERRORS+=("scenario: $(basename "$test") failed") fi done if [[ "$scenario_result" == "FAIL" ]]; then FIXES+=("Fix failing project scenario tests") fi # -- 5. GUT (optional) -------------------------------------------------------- gut_result="SKIPPED" : > "$LOG/gut.log" if [[ -f "addons/gut/gut_cmdln.gd" ]]; then if $GODOT --headless --path "$ROOT" -s addons/gut/gut_cmdln.gd >> "$LOG/gut.log" 2>&1; then gut_result="PASS" else gut_result="FAIL" grep -iE "fail|error|assert" "$LOG/gut.log" 2>/dev/null | head -20 | while IFS= read -r errline; do ERRORS+=("gut: $errline") done FIXES+=("Fix failing GUT tests") fi fi # -- summary ------------------------------------------------------------------ if [[ "$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 "" echo " gdformat $fmt_result" echo " gdlint $lint_result" echo " godot-check $godot_result" echo " scenarios $scenario_result" echo " gut $gut_result" seen_err=() for e in "${ERRORS[@]}"; do contains_element "$e" "${seen_err[@]}" && continue seen_err+=("$e") echo " $e" done if $OVERALL && [[ ${#FIXES[@]} -gt 0 ]]; then echo "" echo " NEXT FIX:" seen_fix=() idx=1 for fix in "${FIXES[@]}"; do 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 "" 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