From e284d3774dca8bba364d6acff35b1d1afb347dba Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 30 Jul 2026 13:00:59 +0200 Subject: [PATCH] fix: enforce storage and tooling invariants --- .gitignore | 6 +- README.md | 65 ++++++++++++++ docs/SIMULATION_STATE_SCHEMA.md | 11 ++- docs/local_quality_gate.md | 19 ++-- simulation/state/SimulationStateRecord.gd | 26 +++++- simulation/state/StorageStateRecord.gd | 29 +++++- tests/simulation_state_serialization_test.gd | 93 ++++++++++++++++++-- tools/quality.ps1 | 35 ++++++-- tools/quality.sh | 29 +++++- 9 files changed, 283 insertions(+), 30 deletions(-) create mode 100644 README.md diff --git a/.gitignore b/.gitignore index 80d07ce..58408c9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ # Godot C# / Mono generated files .mono/ -data_*/ +/data_*/ mono_crash.* *.csproj *.sln @@ -57,7 +57,11 @@ crash_handler* .DS_Store .AppleDouble .LSOverride +# Finder's custom-folder icon file ends with a carriage return. Keep this +# broad match, but explicitly allow real source directories named "icons". Icon? +!**/icons/ +!**/icons/** ._* .DocumentRevisions-V100/ .Spotlight-V100/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5be812 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# The Steward + +The Steward is a Godot 4.7 simulation prototype about readable autonomous +village life in a magical Jajce-inspired valley. The project is built as a +living systems garden: simulation state is authoritative, while scenes provide +presentation, navigation, and interaction geometry. + +## Quick start + +Requirements: + +- Godot 4.7.x; +- Python 3; +- the pinned development tools from `requirements-dev.txt`. + +Set up the local tools with `uv`: + +```bash +uv venv .venv +uv pip install --python .venv/bin/python -r requirements-dev.txt +``` + +Open `project.godot` in Godot 4.7 and run the main scene. + +## Quality gate + +Run the complete local gate before committing: + +```bash +./tools/quality.sh +``` + +On Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\quality.ps1 +``` + +For a faster changed-GDScript formatting and lint pass, while still running +the Godot checks and scenarios: + +```bash +./tools/quality.sh --changed +``` + +The gate enforces Godot 4.7, the pinned formatter and linter, headless project +scenarios, and the vendored GUT suite. See +[`docs/local_quality_gate.md`](docs/local_quality_gate.md) for setup and +troubleshooting. + +## Project guide + +- [`AGENTS.md`](AGENTS.md) — development loop, architecture rules, validation, + documentation, and Git expectations; +- [`docs/PROJECT_CONTEXT.md`](docs/PROJECT_CONTEXT.md) — vision, current + implementation, and constraints; +- [`docs/ARCHITECTURE_OVERVIEW.md`](docs/ARCHITECTURE_OVERVIEW.md) — runtime + ownership and dependency map; +- [`docs/LEARNING_ROADMAP.md`](docs/LEARNING_ROADMAP.md) — milestone order and + the current next slice; +- [`docs/README.md`](docs/README.md) — complete documentation map. + +Start with the current code and tests when they disagree with a plan, preserve +in-progress work, and prefer one complete gameplay proof over a broad +abstraction. diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index a8c3aa6..7bc4e00 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -78,7 +78,16 @@ NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds StorageStateRecord entries; world-schema v1 migrates legacy village food and wood into the stable `village_pantry` and `village_woodpile` records. Parsed storage values are canonicalized so save/restore continuation retains -byte-stable checksums. +byte-stable checksums. Current parsing rejects empty item IDs, negative or +non-finite amounts, negative or non-finite capacity, and contents above +capacity. Runtime deposit and withdrawal requests also reject empty item IDs +and non-finite amounts without mutating authoritative stock. When the authored +pantry or woodpile is present, current world parsing also requires its +authoritative food or wood amount to match the duplicated village summary; +contradictory saves are rejected instead of silently changing checksum during +restore. World-v1 migration expands a storage's inferred capacity when needed +so valid legacy stock above the current default is preserved rather than +discarded. SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and `next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event diff --git a/docs/local_quality_gate.md b/docs/local_quality_gate.md index a57a70e..e24e6da 100644 --- a/docs/local_quality_gate.md +++ b/docs/local_quality_gate.md @@ -14,7 +14,8 @@ parser errors, and LLM-generated garbage before committing. ``` On Windows, use `.venv/Scripts/python.exe` for the `--python` value. -- **Godot 4** binary on `PATH`, or set the `GODOT_BIN` environment variable +- **Godot 4.7.x** binary on `PATH`, or set the `GODOT_BIN` environment + variable. The gate rejects other engine series before importing or testing. - **GUT 9.7.1** is vendored under `addons/gut` for Godot 4.7. No separate package-manager install is needed. @@ -24,7 +25,8 @@ The downloaded release archive SHA-256 is `14969aa46adc84aa08cdd21b9f6d1a64addd92ae60b36f02d0521ed305aa4086`. The quality and format scripts discover `.venv` automatically; activating it -is optional. +is optional. Missing `gdformat` or `gdlint` is a gate failure rather than a +silent skip. ## Usage @@ -71,6 +73,7 @@ If everything passes: ``` QUALITY RESULT: PASS +Godot: 4.7.stable.official.5b4e0cb0f godot-import PASS gdformat PASS @@ -87,6 +90,7 @@ On failure, only relevant errors and fix suggestions are shown: ``` QUALITY RESULT: FAIL +Godot: 4.7.stable.official.5b4e0cb0f godot-import PASS gdformat FAIL @@ -113,18 +117,20 @@ to the terminal. | Variable | Description | |---|---| -| `GODOT_BIN` | Path to the Godot executable (auto-detected from PATH if unset) | +| `GODOT_BIN` | Path to the Godot 4.7.x executable (auto-detected if unset) | ## CI setup -To add this to CI, install the dependencies and run: +To add this to CI, provide a Godot 4.7.x executable, install the dependencies, +and run: ```bash python -m pip install -r requirements-dev.txt ./tools/quality.sh ``` -The script exits non-zero on any failure, so it will fail the CI step. +The script exits non-zero on any failure, including a wrong Godot series or +missing pinned formatter/linter, so it will fail the CI step. The shell gate isolates Godot's cross-platform user-data paths under `logs/quality/godot_profile`. Before running scenarios it imports project and @@ -134,4 +140,5 @@ parse or load markers are failures because headless Godot can report those errors while returning a zero process exit code. Every Godot subprocess has a portable watchdog on stock macOS as well as Linux/Windows, and nonzero `gdformat`/`gdlint` exits fail the gate even when their output is not a familiar -diagnostic string. +diagnostic string. Import receives a 60-second timeout on both shell and +PowerShell; the focused checks retain their 30-second timeout. diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 5a6aed8..48586ff 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -288,6 +288,26 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: storage_ids[storage_id] = true storage_records_by_id[storage_id] = storage_record record.storages.append(storage_record) + var pantry := ( + storage_records_by_id.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageStateRecord + ) + if ( + pantry != null + and not is_equal_approx( + float(record.village.data["food"]), pantry.get_amount(SimulationIds.RESOURCE_FOOD) + ) + ): + return null + var woodpile := ( + storage_records_by_id.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageStateRecord + ) + if ( + woodpile != null + and not is_equal_approx( + float(record.village.data["wood"]), woodpile.get_amount(SimulationIds.RESOURCE_WOOD) + ) + ): + return null var event_ids := {} var event_records_by_id := {} @@ -689,7 +709,8 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary StorageStateRecord . create( SimulationIds.STORAGE_VILLAGE_PANTRY, - {String(SimulationIds.RESOURCE_FOOD): initial_food} + {String(SimulationIds.RESOURCE_FOOD): initial_food}, + maxf(StorageStateRecord.DEFAULT_CAPACITY, initial_food) ) . to_dictionary() ), @@ -697,7 +718,8 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary StorageStateRecord . create( SimulationIds.STORAGE_VILLAGE_WOODPILE, - {String(SimulationIds.RESOURCE_WOOD): initial_wood} + {String(SimulationIds.RESOURCE_WOOD): initial_wood}, + maxf(StorageStateRecord.DEFAULT_CAPACITY, initial_wood) ) . to_dictionary() ) diff --git a/simulation/state/StorageStateRecord.gd b/simulation/state/StorageStateRecord.gd index 404b442..bd8a252 100644 --- a/simulation/state/StorageStateRecord.gd +++ b/simulation/state/StorageStateRecord.gd @@ -2,6 +2,7 @@ class_name StorageStateRecord extends RefCounted const SCHEMA_VERSION := 1 +const DEFAULT_CAPACITY := 100.0 var data: Dictionary @@ -11,7 +12,7 @@ func _init(record_data: Dictionary = {}) -> void: static func create( - storage_id: StringName, initial_amounts: Dictionary, capacity: float = 100.0 + storage_id: StringName, initial_amounts: Dictionary, capacity: float = DEFAULT_CAPACITY ) -> StorageStateRecord: return StorageStateRecord.new( { @@ -32,12 +33,28 @@ static func from_dictionary(record_data: Dictionary) -> StorageStateRecord: return null if not record_data["amounts"] is Dictionary: return null + var capacity := float(record_data["capacity"]) + if not is_finite(capacity) or capacity < 0.0: + return null var normalized := record_data.duplicate(true) normalized["schema_version"] = SCHEMA_VERSION - normalized["capacity"] = float(record_data["capacity"]) + normalized["capacity"] = capacity var normalized_amounts := {} - for item_id in record_data["amounts"]: - normalized_amounts[String(item_id)] = float(record_data["amounts"][item_id]) + var total_amount := 0.0 + for raw_item_id in record_data["amounts"]: + var item_id := String(raw_item_id) + var amount := float(record_data["amounts"][raw_item_id]) + if ( + item_id.is_empty() + or normalized_amounts.has(item_id) + or not is_finite(amount) + or amount < 0.0 + ): + return null + total_amount += amount + if not is_finite(total_amount) or total_amount > capacity: + return null + normalized_amounts[item_id] = amount normalized["amounts"] = normalized_amounts return StorageStateRecord.new(normalized) @@ -62,6 +79,8 @@ func get_available_capacity() -> float: func deposit(item_id: StringName, requested_amount: float) -> float: + if item_id.is_empty() or not is_finite(requested_amount) or requested_amount <= 0.0: + return 0.0 var accepted := minf(maxf(requested_amount, 0.0), get_available_capacity()) if accepted <= 0.0: return 0.0 @@ -70,6 +89,8 @@ func deposit(item_id: StringName, requested_amount: float) -> float: func withdraw(item_id: StringName, requested_amount: float) -> float: + if item_id.is_empty() or not is_finite(requested_amount) or requested_amount <= 0.0: + return 0.0 var removed := minf(maxf(requested_amount, 0.0), get_amount(item_id)) if removed <= 0.0: return 0.0 diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index 76d6f1c..b6d20c4 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -24,6 +24,7 @@ func _run() -> void: _test_relationship_schema_rejection() _test_opportunity_schema_rejection() _test_animal_schema_rejection() + _test_storage_schema_rejection() _test_schema_rejection() if failures.is_empty(): @@ -154,6 +155,80 @@ func _test_schema_rejection() -> void: manager.free() +func _test_storage_schema_rejection() -> void: + var valid_storage := ( + StorageStateRecord + . create(&"schema_test_storage", {String(SimulationIds.RESOURCE_FOOD): 1.0}, 2.0) + . to_dictionary() + ) + + var negative_amount := valid_storage.duplicate(true) + negative_amount["amounts"][String(SimulationIds.RESOURCE_FOOD)] = -1.0 + _check( + StorageStateRecord.from_dictionary(negative_amount) == null, + "Storage records should reject negative item amounts" + ) + + var non_finite_amount := valid_storage.duplicate(true) + non_finite_amount["amounts"][String(SimulationIds.RESOURCE_FOOD)] = NAN + _check( + StorageStateRecord.from_dictionary(non_finite_amount) == null, + "Storage records should reject non-finite item amounts" + ) + + var invalid_capacity := valid_storage.duplicate(true) + invalid_capacity["capacity"] = -1.0 + _check( + StorageStateRecord.from_dictionary(invalid_capacity) == null, + "Storage records should reject negative capacity" + ) + + var over_capacity := valid_storage.duplicate(true) + over_capacity["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 3.0 + _check( + StorageStateRecord.from_dictionary(over_capacity) == null, + "Storage records should reject contents above capacity" + ) + + var empty_item_id := valid_storage.duplicate(true) + empty_item_id["amounts"] = {"": 1.0} + _check( + StorageStateRecord.from_dictionary(empty_item_id) == null, + "Storage records should reject empty item IDs" + ) + + var storage := StorageStateRecord.from_dictionary(valid_storage) + _check(storage != null, "A valid bounded storage record should still parse") + if storage != null: + _check( + storage.deposit(SimulationIds.RESOURCE_FOOD, NAN) == 0.0, + "Storage deposits should reject non-finite requests" + ) + _check( + storage.withdraw(SimulationIds.RESOURCE_FOOD, INF) == 0.0, + "Storage withdrawals should reject non-finite requests" + ) + _check( + is_equal_approx(storage.get_amount(SimulationIds.RESOURCE_FOOD), 1.0), + "Rejected storage requests should not mutate authoritative stock" + ) + + var manager := _create_manager(42) + var mismatched_food: Dictionary = manager.create_state_record().to_dictionary() + mismatched_food["village"]["food"] = float(mismatched_food["village"]["food"]) + 1.0 + _check( + SimulationStateRecord.from_dictionary(mismatched_food) == null, + "World records should reject village food that disagrees with pantry authority" + ) + var mismatched_wood: Dictionary = manager.create_state_record().to_dictionary() + mismatched_wood["village"]["wood"] = float(mismatched_wood["village"]["wood"]) + 1.0 + _check( + SimulationStateRecord.from_dictionary(mismatched_wood) == null, + "World records should reject village wood that disagrees with woodpile authority" + ) + manager.free() + + func _test_legacy_resource_migration() -> void: var migrated := ResourceStateRecord.from_dictionary( { @@ -205,6 +280,10 @@ func _test_legacy_world_storage_migration() -> void: var legacy_data: Dictionary = manager.create_state_record().to_dictionary() legacy_data["schema_version"] = SimulationStateRecord.LEGACY_SCHEMA_VERSION legacy_data.erase("storages") + var legacy_food := StorageStateRecord.DEFAULT_CAPACITY + 25.0 + var legacy_wood := StorageStateRecord.DEFAULT_CAPACITY + 35.0 + legacy_data["village"]["food"] = legacy_food + legacy_data["village"]["wood"] = legacy_wood var migrated := SimulationStateRecord.from_dictionary(legacy_data) _check(migrated != null, "World schema v1 should migrate authored storage") if migrated != null: @@ -219,14 +298,12 @@ func _test_legacy_world_storage_migration() -> void: ( pantry != null and woodpile != null - and is_equal_approx( - pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food - ) - and is_equal_approx( - woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood - ) + and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), legacy_food) + and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), legacy_wood) + and is_equal_approx(pantry.get_available_capacity(), 0.0) + and is_equal_approx(woodpile.get_available_capacity(), 0.0) ), - "World migration should preserve legacy village resources in their storage" + "World migration should preserve legacy stock above the default storage capacity" ) manager.free() @@ -842,6 +919,7 @@ func _test_opportunity_schema_rejection() -> void: for storage_data in already_supplied["storages"]: if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY: storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 1.0 + already_supplied["village"]["food"] = 1.0 _check( SimulationStateRecord.from_dictionary(already_supplied) == null, "An open pantry need should reject a save whose target is already supplied" @@ -985,6 +1063,7 @@ func _test_opportunity_schema_rejection() -> void: func _build_opportunity_world(manager: Node, resolved: bool) -> Dictionary: var world: Dictionary = manager.create_state_record().to_dictionary() world["simulation"]["tick_count"] = 10 + world["village"]["food"] = 1.0 if resolved else 0.0 for storage_data in world["storages"]: if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY: storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = ( diff --git a/tools/quality.ps1 b/tools/quality.ps1 index 588bf22..24ae760 100644 --- a/tools/quality.ps1 +++ b/tools/quality.ps1 @@ -16,6 +16,7 @@ $OVERALL = $false $ERRORS = @() $FIXES = @() $OWNED_GDSCRIPT_ROOTS = @("player", "simulation", "tests", "tools", "world") +$REQUIRED_GODOT_SERIES = "4.7" # -- tool detection ----------------------------------------------------------- function ConvertTo-ConsoleGodotPath($Path) { @@ -128,7 +129,10 @@ function To-ResPath($p) { } function Invoke-GodotWithTimeout { - param([string]$ArgumentString) + param( + [string]$ArgumentString, + [int]$TimeoutSeconds = 30 + ) $psi = New-Object Diagnostics.ProcessStartInfo $psi.FileName = $GODOT $psi.Arguments = $ArgumentString @@ -148,7 +152,7 @@ function Invoke-GodotWithTimeout { } $outputTask = $p.StandardOutput.ReadToEndAsync() $errorTask = $p.StandardError.ReadToEndAsync() - $exited = $p.WaitForExit(30000) + $exited = $p.WaitForExit($TimeoutSeconds * 1000) if (-not $exited) { $p.Kill() $p.WaitForExit() @@ -167,6 +171,18 @@ if (-not $GODOT) { Write-Host "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH." exit 1 } +$godotVersionOutput = @(& $GODOT --version 2>&1) +$godotVersionExit = $LASTEXITCODE +if ($godotVersionExit -ne 0 -or $godotVersionOutput.Count -eq 0) { + Write-Host "ERROR: Could not query the Godot version from '$GODOT'." + exit 1 +} +$GODOT_VERSION = ([string]$godotVersionOutput[0]).Trim() +$requiredVersionPattern = '^' + [regex]::Escape($REQUIRED_GODOT_SERIES) + '([.-]|$)' +if ($GODOT_VERSION -notmatch $requiredVersionPattern) { + Write-Host "ERROR: Godot $REQUIRED_GODOT_SERIES.x is required; found '$GODOT_VERSION'." + exit 1 +} # -- 0. Godot import bootstrap ------------------------------------------------ $importResult = "PASS" @@ -182,10 +198,13 @@ $needsImport = (-not $hasProjectClasses) -or ($changedGd.Count -gt 0) if ($needsImport) { $importArgs = "--headless --path `"$ROOT`" --import" - $importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs + $importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs -TimeoutSeconds 60 $importOutput | Add-Content -Path "$LOG/godot-import.log" -Encoding utf8 if ($importExit -eq $null -or $importExit -ne 0) { $importResult = "FAIL" + if ($importExit -eq $null) { + Add-Content -Path "$LOG/godot-import.log" -Value "[timed out after 60s]" -Encoding utf8 + } } } $hasProjectClasses = (Test-Path $classCache) -and @@ -210,8 +229,10 @@ $fmtResult = "PASS" $fmtFailed = $false Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8 if (-not $fmtAvail) { - $fmtResult = "SKIPPED" + $fmtResult = "FAIL" Add-Content -Path "$LOG/gdformat.log" -Value "gdformat not found -- install requirements-dev.txt (see docs/local_quality_gate.md)" -Encoding utf8 + $ERRORS += "gdformat: required formatter not found" + $FIXES += "Install the pinned dev tools from requirements-dev.txt" } else { if ($Changed) { $files = Get-ChangedGd @@ -260,8 +281,10 @@ $lintResult = "PASS" $lintFailed = $false Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8 if (-not $lintAvail) { - $lintResult = "SKIPPED" + $lintResult = "FAIL" Add-Content -Path "$LOG/gdlint.log" -Value "gdlint not found -- install requirements-dev.txt (see docs/local_quality_gate.md)" -Encoding utf8 + $ERRORS += "gdlint: required linter not found" + $FIXES += "Install the pinned dev tools from requirements-dev.txt" } else { if ($Changed) { $files = Get-ChangedGd @@ -393,6 +416,7 @@ if ($importResult -eq 'FAIL' -or $fmtResult -eq 'FAIL' -or $lintResult -eq 'FAIL Write-Host "" if ($OVERALL) { Write-Host "QUALITY RESULT: FAIL" } else { Write-Host "QUALITY RESULT: PASS" } +Write-Host "Godot: $GODOT_VERSION" Write-Host "" Write-Host " godot-import $importResult" Write-Host " gdformat $fmtResult" @@ -430,6 +454,7 @@ Write-Host "" if ($OVERALL) { $resultText = "FAIL" } else { $resultText = "PASS" } $summary = @" QUALITY RESULT: $resultText +Godot: $GODOT_VERSION godot-import $importResult gdformat $fmtResult diff --git a/tools/quality.sh b/tools/quality.sh index a5e261e..b76ddd4 100755 --- a/tools/quality.sh +++ b/tools/quality.sh @@ -18,6 +18,7 @@ OVERALL=false ERRORS=() FIXES=() OWNED_GDSCRIPT_ROOTS=(player simulation tests tools world) +REQUIRED_GODOT_SERIES="4.7" # -- tool detection ----------------------------------------------------------- console_godot_path() { @@ -50,6 +51,7 @@ 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 } @@ -144,6 +146,19 @@ 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 ------------------------------------------------ @@ -185,8 +200,10 @@ fmt_result="PASS" fmt_failed=false : > "$LOG/gdformat.log" if ! has_gdformat; then - fmt_result="SKIPPED" + 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) @@ -231,8 +248,10 @@ lint_result="PASS" lint_failed=false : > "$LOG/gdlint.log" if ! has_gdlint; then - lint_result="SKIPPED" + 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) @@ -294,9 +313,9 @@ if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check godot_result="FAIL" 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 + while IFS= read -r errline; do ERRORS+=("godot: $errline") - done + done < <(grep -iE "error|warning|parse|syntax" "$LOG/godot-check.log" 2>/dev/null | head -20) FIXES+=("Fix Godot parser errors") fi @@ -356,6 +375,7 @@ if $OVERALL; then else echo "QUALITY RESULT: PASS" fi +echo "Godot: $GODOT_VERSION" echo "" echo " godot-import $import_result" echo " gdformat $fmt_result" @@ -393,6 +413,7 @@ echo "" { echo "QUALITY RESULT: $($OVERALL && echo FAIL || echo PASS)" + echo "Godot: $GODOT_VERSION" echo "" echo " godot-import $import_result" echo " gdformat $fmt_result"