All 7 verification cases pass. The gate correctly catches the "lying exit 0" scenario (GUT exits 0 while the log shows [Failed]/Some tests FAILED!), catches compile failures and leaks, propagates GUT's own non-zero exit, and — critically — does not false-positive on clean logs or unconverted bypass prints (which is exactly why converting bypass scripts is a mandatory part of the fix). Full solution follows.
TerritoryMap was called statically — TerritoryMap.get_territory(...) — but no such static method existed. Every script that preload/extends that class failed to parse, which cascaded: GUT could not load those test scripts ("Failed to load script") and the dependent test bodies never ran, producing the bulk of the 664+ failures.godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://src/tests -gexit printed the failures but still exited 0 (GUT's exit-code path through the headless main loop is unreliable), so CI went green._ready() that hand-roll checks with print() + counters — GUT never sees those prints as assertions, so they can't fail the gate even in principle.The fix is four coordinated changes: (A) an honest wrapper that treats the log as the source of truth, (B) converting bypass scripts to real assert_* calls, (C) fixing the production parse error, (D) CI wiring that removes continue-on-error.
scripts/run_gut_full_suite.shThe gate never trusts GUT's process exit code alone. It tees all output to a log, greps for every known failure marker, and exits non-zero if any marker is present or GUT exited non-zero.
#!/usr/bin/env bash
# scripts/run_gut_full_suite.sh — honest GUT full-suite gate
#
# Why: `godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://src/tests -gexit`
# has been observed to exit 0 even when the log shows failed assertions,
# compile failures, and leaked RIDs. This wrapper treats the LOG as truth.
#
# Usage: scripts/run_gut_full_suite.sh
# Env: GODOT_BIN (default godot), GUT_TEST_DIR (default res://src/tests),
# GUT_LOG (default <project>/gut_full_suite.log), GUT_EXTRA (extra GUT args)
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GODOT_BIN="${GODOT_BIN:-godot}"
TEST_DIR="${GUT_TEST_DIR:-res://src/tests}"
LOG_FILE="${GUT_LOG:-"$PROJECT_DIR/gut_full_suite.log"}"
EXTRA_ARGS="${GUT_EXTRA:-}"
# Every known marker of a broken suite (matched case-insensitively).
# Bracketed markers never match benign summary text like "Failing asserts: 0".
MARKERS=(
'\[Failed\]' # GUT per-assertion failure line
'Some tests FAILED' # GUT end-of-run summary when >=1 assertion failed
'Failed to load script' # a test script could not be compiled/loaded
'Parse Error' # GDScript parse/compile error in any loaded script
'SCRIPT ERROR' # runtime script error
'leaked at exit' # RID / Object leaks reported at exit
'resources still in use' # resource leak at exit
'ERROR:' # any Godot engine error line
)
cd "$PROJECT_DIR"
# 1) Run the suite; tee stdout+stderr into the log so markers printed outside
# GUT's own -glog file (engine errors, Godot's leak report) are captured.
# shellcheck disable=SC2086
"$GODOT_BIN" --headless -s addons/gut/gut_cmdln.gd \
-gdir="$TEST_DIR" -gexit $EXTRA_ARGS \
> >(tee "$LOG_FILE") 2>&1
GUT_EXIT=$?
# 2) Fold GUT's own editor log in as well (belt and braces).
[ -f "$PROJECT_DIR/.gut_editor_log.txt" ] && cat "$PROJECT_DIR/.gut_editor_log.txt" >> "$LOG_FILE"
# 3) Scan the captured output for failure markers.
HIT=""
for marker in "${MARKERS[@]}"; do
grep -qiE "$marker" "$LOG_FILE" && HIT="$HIT marker='$marker'"
done
# 4) Decide: any marker, or a non-zero GUT exit, fails the gate.
if [ -n "$HIT" ]; then
echo "GUT GATE: FAILED - suite log contains failure marker(s):$HIT" >&2
echo "GUT GATE: full log at $LOG_FILE" >&2
exit 1
fi
if [ "$GUT_EXIT" -ne 0 ]; then
echo "GUT GATE: FAILED - GUT process exited with code $GUT_EXIT (log at $LOG_FILE)" >&2
exit 1
fi
echo "GUT GATE: PASS - no failure markers found and GUT exited 0. Log: $LOG_FILE"
exit 0
assert_* callsA bypass script prints failures GUT never sees. Convert it to a real GUT test class so failures surface as [Failed] lines (which the wrapper then catches).
# BEFORE (bypass) — src/tests/test_territory_checks.gd
extends Node
var _failures := 0
func _ready() -> void:
var t = TerritoryMap.get_territory(10, 10) # also broke on the parse error
if t == null or t.id != "A1":
_failures += 1
print("FAILED: territory at (10,10) is null but expected A1")
if _failures == 0:
print("all checks passed")
get_tree().quit(0) # always exits 0 → the gate lied
# AFTER (real GUT test) — same file
extends GutTest
func test_territory_lookup_returns_expected_territory() -> void:
var t: Territory = TerritoryMap.get_territory(10, 10)
assert_not_null(t, "Territory at (10,10) should exist")
assert_eq(t.id, "A1", "Expected territory A1 at (10,10)")
func test_territory_out_of_bounds_is_null() -> void:
assert_null(TerritoryMap.get_territory(-1, 99999), "OOB coords must be null")
Rules enforced during the conversion sweep:
- Test scripts must extends GutTest; never hand-roll _ready() check loops or get_tree().quit().
- Replace every print("FAILED: ...") with the matching assert_* (assert_eq, assert_not_null, assert_null, assert_true, assert_no_new_orphans(callable)).
- Non-test helper files must not match GUT's test-file pattern (avoid test_*.gd naming or exclude them from -gdir), so GUT doesn't "load" them as tests and silently skip them.
Make the static call link. Either the method existed only as an instance method, or under a different name; expose the static entry point the callers use:
# src/core/territory_map.gd
class_name TerritoryMap
const _by_key: Dictionary = { "10,10": preload("res://src/territories/a1.tres"), ... }
# AFTER: static lookup so `TerritoryMap.get_territory(...)` parses and links.
static func get_territory(px: int, py: int) -> Territory:
return _by_key.get("%d,%d" % [px, py])
Then verify no parse errors remain across the tree before re-running the suite:
for f in $(find src -name '*.gd'); do
godot --headless --check-only -s "$f" || echo "PARSE FAIL: $f"
done
With the parse error gone, dependent tests load again; the failure count drops from 664+ to the real remaining failures, which the wrapper now fails on honestly.
func test_texture_rid_is_created() -> void:
var rid := RenderingServer.texture_2d_create(tex)
assert_true(rid.is_valid())
# no leak: free before the test exits
RenderingServer.free_rid(rid)
func test_no_orphans_left_behind() -> void:
assert_no_new_orphans(func():
var node := Node.new()
node.free())
If a leak is ever reintroduced, ERROR: ... leaked at exit / resources still in use appears in the log and the wrapper fails the gate.
continue-on-error)# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install headless Godot
run: |
curl -Lo godot.zip https://github.com/godotengine/godot/releases/download/4.2.2-stable/Godot_v4.2.2-stable_linux.x86_64.zip
unzip godot.zip && mv Godot_v4.2.2-stable_linux.x86_64 /usr/local/bin/godot
chmod +x /usr/local/bin/godot
- name: Run GUT full suite (honest gate)
run: ./scripts/run_gut_full_suite.sh
shell: bash
# Intentionally NO "continue-on-error: true" — a red suite must red the job.
- name: Upload GUT log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: gut-log
path: gut_full_suite.log
For GitLab CI, the same idea: ./scripts/run_gut_full_suite.sh as a script step with allow_failure: false (the default), and artifacts:when: on_failure.
I verified the wrapper end-to-end in a scratch project (/tmp/gutgate) with a godot shim that replays canned GUT logs. Each case checks the wrapper's exit code:
| Case | Log contents | GUT exit | Wrapper exit | Result |
|---|---|---|---|---|
| clean | [PASSED] lines, All tests passed. |
0 | 0 | PASS |
| lying gate | [Failed]: + Some tests FAILED! |
0 (the lie) | 1 | FAILED ✓ |
| compile-fail | Failed to load script + Parse Error: Function "get_territory()" not found |
0 | 1 | FAILED ✓ |
| leaks | ERROR: 2 RIDs leaked at exit + resources still in use |
0 | 1 | FAILED ✓ |
| non-zero GUT exit | clean log | 3 | 1 | FAILED ✓ |
| unconverted bypass | FAILED check: ... print only |
0 | 0 (by design) | — |
| converted bypass | [Failed]: from a real assert_* |
0 | 1 | FAILED ✓ |
Edge cases verified:
- No false positives on clean runs: Failing asserts: 0, 0 failed, and All tests passed. do not match the bracketed \[Failed\] marker or the summary markers.
- The exact reported failure mode is caught: the "lying gate" case (GUT exits 0 while the log shows failures) now exits 1.
- Exit-code propagation preserved: a non-zero GUT exit still fails the gate even if the log looks clean.
- Bypass-print limitation is intentional: an unconverted bypass script only printing "FAILED check:" cannot be caught by any log-grep — that is precisely why Fix B (conversion to assert_*) is mandatory, not optional; once converted, the same failure becomes a [Failed] line and the gate catches it (case 7).
- Harness sanity: an initial false failure in the harness (shim misparsing godot args) was diagnosed and fixed; the wrapper itself was unchanged between runs.
Not verifiable in this environment: actual GDScript compilation of Fix B/C/D and a real Godot run — no Godot binary or project is present here. Those are standard GUT APIs (GutTest, assert_*, assert_no_new_orphans) and standard Godot script syntax, and the parse-error fix pattern (exposing a static method) is the canonical resolution for the reported TerritoryMap.get_territory failure.
{"problem_class":"gdscript-godot-test-gate-exit-code","model":"deepseek-v4-flash","result":"passed","tests":7}
Solved by Pi Agent (deepseek-v4-flash).