Files
gamedev-the-steward/tools/quality.sh
T
2026-08-12 20:10:54 +02:00

544 lines
17 KiB
Bash
Executable File

#!/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)
CROSS_PLATFORM_PLUGIN_FILES=(
addons/terrain_3d/terrain.gdextension
addons/terrain_3d/bin/libterrain.macos.debug.framework/libterrain.macos.debug
addons/terrain_3d/bin/libterrain.macos.release.framework/libterrain.macos.release
addons/terrain_3d/bin/libterrain.windows.debug.x86_64.dll
addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll
)
REQUIRED_GODOT_SERIES="4.7"
RUNNING_ON_MACOS=false
[[ "${OSTYPE:-}" == darwin* ]] && RUNNING_ON_MACOS=true
# -- 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
if console_godot_path "$GODOT_BIN"; then return 0; fi
echo "ERROR: GODOT_BIN does not point to a Godot executable: $GODOT_BIN" >&2
return 1
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_python_module() {
local module="$1"
local python_command
for python_command in python python3; do
if command -v "$python_command" &>/dev/null &&
"$python_command" -m "$module" --help &>/dev/null 2>&1; then
return 0
fi
done
return 1
}
run_python_module() {
local module="$1"
shift
local python_command
for python_command in python python3; do
if command -v "$python_command" &>/dev/null &&
"$python_command" -m "$module" --help &>/dev/null 2>&1; then
"$python_command" -m "$module" "$@"
return $?
fi
done
return 127
}
has_gdformat() {
[[ -x "$ROOT/.venv/bin/gdformat" ]] && return 0
command -v gdformat &>/dev/null && return 0
has_python_module gdtoolkit.formatter
}
run_gdformat() {
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
"$ROOT/.venv/bin/gdformat" "$@"
elif command -v gdformat &>/dev/null; then
gdformat "$@"
else
run_python_module gdtoolkit.formatter "$@"
fi
}
has_gdlint() {
[[ -x "$ROOT/.venv/bin/gdlint" ]] && return 0
command -v gdlint &>/dev/null && return 0
has_python_module gdtoolkit.linter
}
run_gdlint() {
if [[ -x "$ROOT/.venv/bin/gdlint" ]]; then
"$ROOT/.venv/bin/gdlint" "$@"
elif command -v gdlint &>/dev/null; then
gdlint "$@"
else
run_python_module gdtoolkit.linter "$@"
fi
}
changed_gd() {
{
git diff --name-only HEAD -- '*.gd' 2>/dev/null
git ls-files --others --exclude-standard -- '*.gd' 2>/dev/null
} | grep -v '^addons/' | 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
}
is_allowed_scenario_diagnostic() {
local diagnostic="$1"
# Godot 4.7 can fail to read the system certificate store on macOS when HOME
# is intentionally isolated. Scenario tests do not perform network access.
if $RUNNING_ON_MACOS &&
[[ "$diagnostic" == 'ERROR: Condition "ret != noErr" is true. Returning: ""' ]]; then
return 0
fi
# Pinned Terrain3D 1.0.2 invokes this Godot 4.7 compatibility method while
# loading its vendored GDExtension. Remove this exception with that vendor fix.
if [[ "$diagnostic" == "WARNING: instance_reset_physics_interpolation() is deprecated." ]]; then
return 0
fi
# Godot 4.7's dummy headless renderer retains exactly one DummyShader RID at
# process teardown. A different RID type or count remains a gate failure.
if [[ "$diagnostic" == "ERROR: 1 RID allocations of type 'N13RendererDummy15MaterialStorage11DummyShaderE' were leaked at exit." ]]; then
return 0
fi
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. Cross-platform plugin payload -----------------------------------------
platform_assets_result="PASS"
: > "$LOG/platform-assets.log"
for plugin_file in "${CROSS_PLATFORM_PLUGIN_FILES[@]}"; do
if [[ ! -f "$plugin_file" ]]; then
platform_assets_result="FAIL"
echo "Missing $plugin_file" >> "$LOG/platform-assets.log"
ERRORS+=("platform-assets: missing res://$plugin_file")
fi
done
if [[ "$platform_assets_result" == "FAIL" ]]; then
FIXES+=("Restore the complete pinned Terrain3D 1.0.2 package")
fi
# -- 1. 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
# -- 2. 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
# -- 3. 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
# -- 4. 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
# -- 5. 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
compatibility_test="tests/jajce_presentation_quality_test.gd"
compatibility_label="$(basename "$compatibility_test") (gl_compatibility)"
echo "[RUN] $compatibility_label" >> "$LOG/scenarios.log"
if ! run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --rendering-method gl_compatibility --path "$ROOT" --script "res://$compatibility_test" >> "$LOG/scenarios.log" 2>&1; then
scenario_result="FAIL"
ERRORS+=("scenario: $compatibility_label failed or timed out")
fi
unexpected_scenario_diagnostics=()
current_scenario="unknown scenario"
while IFS= read -r raw_line; do
raw_line="${raw_line%$'\r'}"
if [[ "$raw_line" == "[RUN] "* ]]; then
current_scenario="${raw_line#* }"
continue
fi
diagnostic="$raw_line"
diagnostic="${diagnostic#"${diagnostic%%[![:space:]]*}"}"
diagnostic="${diagnostic%"${diagnostic##*[![:space:]]}"}"
if [[ ! "$diagnostic" =~ ^((SCRIPT[[:space:]])?ERROR|WARNING): ]]; then
continue
fi
if ! is_allowed_scenario_diagnostic "$diagnostic"; then
unexpected_scenario_diagnostics+=("$current_scenario: $diagnostic")
fi
done < "$LOG/scenarios.log"
if [[ ${#unexpected_scenario_diagnostics[@]} -gt 0 ]]; then
scenario_result="FAIL"
ERRORS+=(
"scenario: Godot reported ${#unexpected_scenario_diagnostics[@]} unallowlisted ERROR/WARNING diagnostic(s)"
)
for diagnostic in "${unexpected_scenario_diagnostics[@]:0:20}"; do
ERRORS+=("scenario: $diagnostic")
done
fi
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
# -- 6. 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 [[ "$platform_assets_result" == "FAIL" || "$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 " platform-assets $platform_assets_result"
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 " platform-assets $platform_assets_result"
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