fix: enforce storage and tooling invariants
This commit is contained in:
+5
-1
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)] = (
|
||||
|
||||
+30
-5
@@ -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
|
||||
|
||||
+25
-4
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user