#!/usr/bin/env pwsh # quality.ps1 — Local quality gate for GDScript projects (Windows). # Usage: pwsh ./tools/quality.ps1 [-Changed] [--changed] param([switch]$Changed) if ($args -contains '--changed') { $Changed = $true } $ROOT = Split-Path -Parent $PSScriptRoot Set-Location $ROOT $LOG = "logs/quality/latest" New-Item -ItemType Directory -Path $LOG -Force | Out-Null $OVERALL = $false $ERRORS = @() $FIXES = @() $OWNED_GDSCRIPT_ROOTS = @("player", "simulation", "tests", "tools", "world") # -- tool detection ----------------------------------------------------------- function Find-Godot { if ($env:GODOT_BIN) { return $env:GODOT_BIN } try { return (Get-Command godot -ErrorAction Stop).Source } catch {} try { return (Get-Command godot4 -ErrorAction Stop).Source } catch {} $common = @( "$env:ProgramFiles\Godot\godot.exe", "${env:ProgramFiles(x86)}\Godot\godot.exe", "$env:LOCALAPPDATA\Godot\godot.exe", "$env:USERPROFILE\AppData\Local\Godot\godot.exe" ) foreach ($p in $common) { if (Test-Path $p) { return $p } } $bundled = Get-ChildItem "${env:ProgramFiles(x86)}\Godot" -Filter "Godot*_console.exe" -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1 if ($bundled) { return $bundled.FullName } return $null } function Test-GdFormat { if (Get-Command gdformat -ErrorAction SilentlyContinue) { return $true } try { $null = & python -m gdtoolkit.formatter --help 2>&1 if ($LASTEXITCODE -eq 0) { return $true } } catch {} try { $null = & python3 -m gdtoolkit.formatter --help 2>&1 if ($LASTEXITCODE -eq 0) { return $true } } catch {} return $false } function Test-GdLint { if (Get-Command gdlint -ErrorAction SilentlyContinue) { return $true } try { $null = & python -m gdtoolkit.linter --help 2>&1 if ($LASTEXITCODE -eq 0) { return $true } } catch {} try { $null = & python3 -m gdtoolkit.linter --help 2>&1 if ($LASTEXITCODE -eq 0) { return $true } } catch {} return $false } function Invoke-GdFormat { $args = $args if (Get-Command gdformat -ErrorAction SilentlyContinue) { & gdformat @args 2>&1 } else { & python -m gdtoolkit.formatter @args 2>&1 } } function Invoke-GdLint { $args = $args if (Get-Command gdlint -ErrorAction SilentlyContinue) { & gdlint @args 2>&1 } else { & python -m gdtoolkit.linter @args 2>&1 } } function Get-ChangedGd { $f = git diff --name-only HEAD -- '*.gd' 2>$null return @($f | Where-Object { $_ -ne '' }) } function To-ResPath($p) { $p = $p.Replace('\', '/') if ($p.StartsWith($ROOT)) { $p = $p.Substring($ROOT.Length + 1) } if ($p.StartsWith('./')) { $p = $p.Substring(2) } return "res://$p" } function Invoke-GodotWithTimeout { param([string]$ArgumentString) $psi = New-Object Diagnostics.ProcessStartInfo $psi.FileName = $GODOT $psi.Arguments = $ArgumentString $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $p = [Diagnostics.Process]::Start($psi) $outputTask = $p.StandardOutput.ReadToEndAsync() $errorTask = $p.StandardError.ReadToEndAsync() $exited = $p.WaitForExit(30000) if (-not $exited) { $p.Kill() $p.WaitForExit() $output = $outputTask.Result $err = $errorTask.Result return $output + $err, $null } $output = $outputTask.Result $err = $errorTask.Result return ($output + $err), $p.ExitCode } # -- godot -------------------------------------------------------------------- $GODOT = Find-Godot if (-not $GODOT) { Write-Host "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH." exit 1 } # -- 1. gdformat -------------------------------------------------------------- $fmtAvail = Test-GdFormat $fmtResult = "PASS" Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8 if (-not $fmtAvail) { $fmtResult = "SKIPPED" Add-Content -Path "$LOG/gdformat.log" -Value "gdformat not found -- install: pip install gdtoolkit" -Encoding utf8 } else { if ($Changed) { $files = Get-ChangedGd if ($files.Count -eq 0) { Add-Content -Path "$LOG/gdformat.log" -Value "No changed .gd files to check." -Encoding utf8 } else { $total = 0; $bad = 0 foreach ($f in $files) { if (-not (Test-Path $f)) { continue } $total++ $out = Invoke-GdFormat "--check" $f $out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8 if ($LASTEXITCODE -ne 0) { $bad++ } } $msg = "($bad/$total files need formatting)" Add-Content -Path "$LOG/gdformat.log" -Value $msg -Encoding utf8 } } else { $formatArgs = @("--check") + $OWNED_GDSCRIPT_ROOTS $out = Invoke-GdFormat @formatArgs $out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8 } } if (Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" -Quiet) { $fmtResult = "FAIL" $lines = Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" | Select-Object -ExpandProperty Line foreach ($line in $lines) { $p = ($line -replace '^would reformat ', '').Trim() $rp = To-ResPath $p $ERRORS += "$rp needs formatting" } $FIXES += "Run gdformat to auto-format files" } # -- 2. gdlint ---------------------------------------------------------------- $lintAvail = Test-GdLint $lintResult = "PASS" Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8 if (-not $lintAvail) { $lintResult = "SKIPPED" Add-Content -Path "$LOG/gdlint.log" -Value "gdlint not found -- install: pip install gdtoolkit" -Encoding utf8 } else { if ($Changed) { $files = Get-ChangedGd if ($files.Count -eq 0) { Add-Content -Path "$LOG/gdlint.log" -Value "No changed .gd files to lint." -Encoding utf8 } else { foreach ($f in $files) { if (-not (Test-Path $f)) { continue } $out = Invoke-GdLint $f $out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8 } } } else { $out = Invoke-GdLint @OWNED_GDSCRIPT_ROOTS $out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8 } } $lintLines = Select-String -Pattern '^[^ ]+:\d+:\d+:' -Path "$LOG/gdlint.log" | Select-Object -ExpandProperty Line if ($lintLines) { $lintResult = "FAIL" foreach ($line in $lintLines) { $parts = $line -split ':', 4 if ($parts.Count -lt 4) { continue } $file = $parts[0]; $lineno = $parts[1]; $msg = $parts[3] $rp = To-ResPath $file $ERRORS += "$rp`:$lineno $msg" } foreach ($line in $lintLines) { $parts = $line -split ':', 5 if ($parts.Count -lt 5) { continue } $rule = $parts[3]; $fname = Split-Path -Leaf $parts[0] $FIXES += "Fix $rule in $fname" } } # -- 3. godot headless check -------------------------------------------------- $godotResult = "PASS" Set-Content -Path "$LOG/godot-check.log" -Value "" -Encoding utf8 $godotArgs = "--headless --path `"$ROOT`" --script res://tests/simulation_definitions_test.gd" $godotOutput, $godotExit = Invoke-GodotWithTimeout -ArgumentString $godotArgs $godotOutput | Out-File -FilePath "$LOG/godot-check.log" -Encoding utf8 if ($godotExit -eq $null) { $godotResult = "FAIL" Add-Content -Path "$LOG/godot-check.log" -Value "[timed out after 30s]" -Encoding utf8 } elseif ($godotExit -eq 0) { $godotResult = "PASS" } else { $godotResult = "FAIL" $godotOutput | Select-String -Pattern 'error|warning|parse|syntax' | Select-Object -First 20 | ForEach-Object { $ERRORS += "godot: $_" } $FIXES += "Fix Godot parser errors" } # -- 4. project scenario tests ------------------------------------------------ $scenarioResult = "PASS" Set-Content -Path "$LOG/scenarios.log" -Value "" -Encoding utf8 $scenarioTests = Get-ChildItem "tests" -Filter "*_test.gd" | Sort-Object Name foreach ($test in $scenarioTests) { Add-Content -Path "$LOG/scenarios.log" -Value "[RUN] $($test.Name)" -Encoding utf8 $testArgs = "--headless --path `"$ROOT`" --script res://tests/$($test.Name)" $testOutput, $testExit = Invoke-GodotWithTimeout -ArgumentString $testArgs $testOutput | Add-Content -Path "$LOG/scenarios.log" -Encoding utf8 if ($testExit -eq $null -or $testExit -ne 0) { $scenarioResult = "FAIL" $ERRORS += "scenario: $($test.Name) failed or timed out" } } if ($scenarioResult -eq "FAIL") { $FIXES += "Fix failing project scenario tests" } # -- 5. GUT (optional) -------------------------------------------------------- $gutResult = "SKIPPED" Set-Content -Path "$LOG/gut.log" -Value "" -Encoding utf8 if (Test-Path "addons/gut/gut_cmdln.gd") { $gutArgs = "--headless --path `"$ROOT`" -s addons/gut/gut_cmdln.gd --quit" $gutOutput, $gutExit = Invoke-GodotWithTimeout -ArgumentString $gutArgs $gutOutput | Out-File -FilePath "$LOG/gut.log" -Encoding utf8 if ($gutExit -eq $null) { $gutResult = "FAIL" Add-Content -Path "$LOG/gut.log" -Value "[timed out after 30s]" -Encoding utf8 $ERRORS += "gut: GUT test timed out after 30 seconds" $FIXES += "Fix GUT tests (timeout)" } elseif ($gutExit -eq 0) { $gutResult = "PASS" } else { $gutResult = "FAIL" $gutOutput | Select-String -Pattern 'fail|error|assert' | Select-Object -First 20 | ForEach-Object { $ERRORS += "gut: $_" } $FIXES += "Fix failing GUT tests" } } # -- summary ------------------------------------------------------------------ if ($fmtResult -eq 'FAIL' -or $lintResult -eq 'FAIL' -or $godotResult -eq 'FAIL' -or $scenarioResult -eq 'FAIL' -or $gutResult -eq 'FAIL') { $OVERALL = $true } Write-Host "" if ($OVERALL) { Write-Host "QUALITY RESULT: FAIL" } else { Write-Host "QUALITY RESULT: PASS" } Write-Host "" Write-Host " gdformat $fmtResult" Write-Host " gdlint $lintResult" Write-Host " godot-check $godotResult" Write-Host " scenarios $scenarioResult" Write-Host " gut $gutResult" $seen = @{} foreach ($e in $ERRORS) { if ($seen.ContainsKey($e)) { continue } $seen[$e] = $true Write-Host " $e" } if ($OVERALL -and $FIXES.Count -gt 0) { Write-Host "" Write-Host " NEXT FIX:" $seenFix = @{} $idx = 1 foreach ($fix in $FIXES) { if ($seenFix.ContainsKey($fix)) { continue } $seenFix[$fix] = $true Write-Host " $idx. $fix" $idx++ } } Write-Host "" Write-Host "Full logs:" Write-Host " $LOG/" Write-Host "" # save summary if ($OVERALL) { $resultText = "FAIL" } else { $resultText = "PASS" } $summary = @" QUALITY RESULT: $resultText gdformat $fmtResult gdlint $lintResult godot-check $godotResult scenarios $scenarioResult gut $gutResult "@ Set-Content -Path "$LOG/summary.txt" -Value $summary -Encoding utf8 if ($OVERALL) { exit 1 } else { exit 0 }