Files
Claude-Code-Game-Studios/.claude/scripts/project-coherence.sh
T
DonchitosandClaude Opus 5.5 be8993bbc5 Release 1.1.3 — long skills keep their later steps
Fix release for 1.1.2: the 14 longest skills keep their later phases
after a compaction, a question nobody answered is never taken as an
answer, settings read the same from a subfolder, the commit check works
for a project in a subfolder of a larger repository, every skill opens
with a title, and the shipped allow rules no longer approve deleting a
branch or writing files without asking. Details in CHANGELOG.md; upgrade
steps in UPGRADING.md (v1.1.2 → v1.1.3).

Refs #83
Refs #133

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185en4t76fPn7315q4c3nko
2026-10-08 17:52:48 +11:00

312 lines
15 KiB
Bash

#!/usr/bin/env bash
#
# project-coherence.sh — compare what `project.yaml` DECLARES against what the
# project actually IS.
#
# WHY THIS EXISTS
#
# Three defects share one shape: `project.yaml` is treated as the source of truth
# and is never reconciled against the artifact it describes.
#
# 1 engine.version says 4.7.2; project.godot says 4.6; the binary is 4.6.1
# 2 engine.rendering says Forward+; project.godot says gl_compatibility
# 3 commands.build names an export preset with no export_presets.cfg
#
# The second is why this is a script and not another paragraph. `/setup-engine`
# ALREADY carries an emphatic, source-cited warning naming both of the exact
# mistakes that produce it ("Jolt is Godot's default 3D engine", "Forward+ is
# desktop-focused"). Prose is in place and does not hold on its own. What was
# missing was a step that reads back what was written.
#
# F1's root cause was subtler and is fixed elsewhere: `/setup-engine` is told to
# write an `Installed at pin time` row into VERSION.md, and no VERSION.md had
# such a row. There was no slot, so the step vanished leaving no trace. The row
# now ships in all three reference files, and check 1 below reads it.
#
# OBSERVATIONS, NEVER VERDICTS
#
# Per CLAUDE.md: a script that scores or judges will eventually contradict a mode
# or override it cannot see. This one prints what it compared and what it found,
# and always exits 0. The reader decides. A check that cannot run says so by
# name — silence is never treated as agreement.
set -u
export LC_ALL=C
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -f "$CLAUDE_PROJECT_DIR/project.yaml" ]; then
ROOT="$CLAUDE_PROJECT_DIR"
fi
cd "$ROOT" || exit 0
DIFFERS=0
CHECKED=0
SKIPPED=0
say() { printf '%s\n' "$*"; }
match() { CHECKED=$((CHECKED+1)); say "[MATCH] $*"; }
differs() { CHECKED=$((CHECKED+1)); DIFFERS=$((DIFFERS+1)); say "[DIFFERS] $*"; }
skipped() { SKIPPED=$((SKIPPED+1)); say "[NOT CHECKED] $*"; }
# Read one `key: value` from a named top-level block of project.yaml.
# Deliberately simple: this file is machine-written and two levels deep.
yaml_block_value() {
block="$1"; key="$2"
awk -v b="$block" -v k="$key" '
$0 ~ "^"b":" { inb=1; next }
inb && /^[a-zA-Z_]/ { inb=0 }
inb && $0 ~ "^[[:space:]]+"k":" {
sub("^[[:space:]]*"k":[[:space:]]*", "")
gsub(/^"|"$/, "")
print; exit
}
' project.yaml 2>/dev/null
}
summarise() {
say ""
say "checked $CHECKED · differs $DIFFERS · not checked $SKIPPED"
say ""
say "Observations only — no verdict. 'not checked' is not agreement:"
say "each line above names why that comparison could not be made."
exit 0
}
# major.minor of the first version in $1, with Unity's 6000.N read as 6.N.
vmm() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+' | head -1 | sed -E 's/^6000\./6./'; }
say "=== project coherence ==="
if [ ! -f project.yaml ]; then
skipped "everything — no project.yaml at the repo root. Run /start."
summarise
fi
ENGINE="$(yaml_block_value engine name)"
VERSION="$(yaml_block_value engine version)"
RENDERING="$(yaml_block_value engine rendering)"
PHYSICS="$(yaml_block_value engine physics)"
if [ -z "$ENGINE" ]; then
skipped "every engine check — project.yaml has no engine.name. Run /setup-engine."
summarise
fi
ENGINE_LC="$(printf '%s' "$ENGINE" | tr 'A-Z' 'a-z')"
VERFILE="docs/engine-reference/$ENGINE_LC/VERSION.md"
# --- 1. VERSION.md carries a recorded installed-version probe -----------------
if [ ! -f "$VERFILE" ]; then
skipped "pinned-reference checks — $VERFILE does not exist"
else
PINNED="$(grep -E '^\| \*\*Engine Version\*\* \|' "$VERFILE" \
| sed -E 's/^\| \*\*Engine Version\*\* \| *//; s/ *\|$//')"
INSTALLED_ROW="$(grep -E '^\| \*\*Installed at pin time\*\* \|' "$VERFILE" \
| sed -E 's/^\| \*\*Installed at pin time\*\* \| *//; s/ *\|$//')"
if [ -z "$INSTALLED_ROW" ]; then
differs "$VERFILE has no 'Installed at pin time' row. /setup-engine is required to write one; its absence means the probe result was never recorded, not that the versions agree."
elif printf '%s' "$INSTALLED_ROW" | grep -qE 'NOT DETERMINED'; then
skipped "pinned-vs-installed — $VERFILE records '$INSTALLED_ROW'. A probe that did not run has not established a match."
else
match "VERSION.md records the installed version at pin time: $INSTALLED_ROW"
fi
# --- 2. project.yaml version vs the pinned reference ------------------------
# Compared on major.minor, as check 3 does: engine.version is usually the full
# release (4.6.1, 6000.3.23f1) and the pin the series (Godot 4.6, Unity 6.3
# LTS). Unity numbers the same editor two ways -- 6000.3 is marketed as 6.3.
WANT2="$(vmm "$VERSION")"; GOT2="$(vmm "$PINNED")"
if [ -z "$PINNED" ]; then
skipped "project.yaml version vs pinned reference — no Engine Version row in $VERFILE"
elif [ -z "$VERSION" ]; then
skipped "project.yaml version vs pinned reference — project.yaml has no engine.version"
elif printf '%s' "$PINNED" | grep -qF "$VERSION" || { [ -n "$WANT2" ] && [ "$WANT2" = "$GOT2" ]; }; then
match "engine.version ($VERSION) agrees with $VERFILE ($PINNED)"
else
differs "engine.version is '$VERSION' but $VERFILE pins '$PINNED'. Agents consult the reference file, so they will answer for a version the project does not declare."
fi
fi
# --- 3. Declared version vs the binary actually installed ---------------------
PROBE=""
case "$ENGINE_LC" in
godot)
# The executable named in commands.test (quoted, or its first word), else
# engine.path, else `godot` on PATH -- the same fallback order dev-story
# and smoke-check use. commands.* now carries the full editor path once
# /setup-engine has run, so a bare `command -v godot` alone reported "no
# Godot binary found on PATH" even on a correctly configured project.
GEXE=""
for GCAND in \
"$(yaml_block_value commands test | grep -oE '"[^"]*"' | head -1 | tr -d '"')" \
"$(yaml_block_value commands test | awk '{print $1}')" \
"$(yaml_block_value engine path)" \
"godot"; do
[ -n "$GCAND" ] || continue
if command -v "$GCAND" >/dev/null 2>&1 || [ -x "$GCAND" ]; then
GEXE="$GCAND"
break
fi
done
[ -n "$GEXE" ] && PROBE="$("$GEXE" --version 2>/dev/null | head -1)"
;;
unity)
# The editor named in commands.test (/setup-engine writes its full, quoted
# path), else the Hub folder for engine.version, picked by OS -- Unity's
# Hub installs to a different path on each. Never bare `Unity`: on PATH
# that is often Unity's separate CLI, which rejects -version with exit 2.
UEXE="$(yaml_block_value commands test | grep -oE '"[^"]*/Unity(\.exe)?"' | head -1 | tr -d '"')"
if [ -z "$UEXE" ] && [ -n "$VERSION" ]; then
case "$(uname -s)" in
Darwin) UEXE="/Applications/Unity/Hub/Editor/$VERSION/Unity.app/Contents/MacOS/Unity" ;;
Linux) UEXE="$HOME/Unity/Hub/Editor/$VERSION/Editor/Unity" ;;
*) UEXE="C:/Program Files/Unity/Hub/Editor/$VERSION/Editor/Unity.exe" ;;
esac
fi
[ -n "$UEXE" ] && [ -x "$UEXE" ] && PROBE="$("$UEXE" -version 2>/dev/null | head -1)"
;;
unreal)
# Never start the editor to learn its version: UnrealEditor-Cmd has no
# -version flag (UE 5.8), so it opened the editor and ran until killed, and
# /smoke-check's step 0 waited for ever. Read the engine's own
# Engine/Build/Build.version instead, under the first engine root found:
# engine.path (/setup-engine writes the UE root there), the editor path in
# commands.test, the launcher folder for the .uproject's EngineAssociation,
# then UnrealEditor-Cmd on PATH. A candidate that is not itself a root is
# cut back to the folder above its last /Engine. A source build's
# EngineAssociation is a GUID with no folder to derive, so it adds nothing.
UASSOC="$(sed -n 's/.*"EngineAssociation"[[:space:]]*:[[:space:]]*"\([0-9][0-9.]*\)".*/\1/p' ./*.uproject 2>/dev/null | head -1)"
ULAUNCH=""
if [ -n "$UASSOC" ]; then
case "$(uname -s)" in
Darwin) ULAUNCH="/Users/Shared/Epic Games/UE_$UASSOC" ;;
Linux) ;;
*) ULAUNCH="C:/Program Files/Epic Games/UE_$UASSOC" ;;
esac
fi
UROOT=""
for UCAND in \
"$(yaml_block_value engine path)" \
"$(yaml_block_value commands test | grep -oE '"[^"]*UnrealEditor[^"]*"' | head -1 | tr -d '"')" \
"$ULAUNCH" \
"$(command -v UnrealEditor-Cmd 2>/dev/null)"; do
[ -n "$UCAND" ] || continue
UCAND="$(printf '%s' "$UCAND" | tr '\\' '/')"
for UTRY in "$UCAND" "$(printf '%s' "$UCAND" | sed -E 's#^(.*)/Engine(/.*)?$#\1#')"; do
if [ -f "$UTRY/Engine/Build/Build.version" ]; then UROOT="$UTRY"; break 2; fi
done
done
if [ -n "$UROOT" ]; then
UBV="$(tr -d '\r\n' < "$UROOT/Engine/Build/Build.version")"
UMAJ="$(printf '%s' "$UBV" | grep -oE '"MajorVersion"[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+$')"
UMIN="$(printf '%s' "$UBV" | grep -oE '"MinorVersion"[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+$')"
UPAT="$(printf '%s' "$UBV" | grep -oE '"PatchVersion"[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+$')"
[ -n "$UMAJ" ] && [ -n "$UMIN" ] && PROBE="$UMAJ.$UMIN${UPAT:+.$UPAT} (Engine/Build/Build.version)"
fi
;;
esac
if [ -z "$PROBE" ]; then
if [ "$ENGINE_LC" = "unity" ]; then
skipped "declared version vs installed binary — no Unity editor at the path in commands.test or in the Hub folder for engine.version. A probe that could not run has not established absence."
elif [ "$ENGINE_LC" = "godot" ]; then
skipped "declared version vs installed binary — no Godot executable found (commands.test, engine.path, PATH). A probe that could not run has not established absence."
elif [ "$ENGINE_LC" = "unreal" ]; then
skipped "declared version vs installed engine — no readable Engine/Build/Build.version under engine.path, the editor in commands.test, the launcher folder for the .uproject's EngineAssociation, or UnrealEditor-Cmd on PATH. A probe that could not run has not established absence."
else
skipped "declared version vs installed binary — no $ENGINE binary found on PATH. A probe that could not run has not established absence."
fi
elif [ -z "$VERSION" ]; then
skipped "declared version vs installed binary — project.yaml has no engine.version"
else
# Compare on major.minor: a patch difference is normal and not worth a flag.
WANT="$(vmm "$VERSION")"
GOT="$(vmm "$PROBE")"
if [ -n "$WANT" ] && [ "$WANT" = "$GOT" ]; then
match "engine.version $VERSION matches the installed binary ($PROBE)"
else
differs "engine.version is '$VERSION' but the installed binary reports '$PROBE'. Build, test and smoke commands all target the declared version."
fi
fi
# --- 4/5. Godot: declared rendering + physics vs the real project file --------
if [ "$ENGINE_LC" != "godot" ]; then
skipped "project-file checks — implemented for Godot only; $ENGINE has no single equivalent file this script can read"
elif [ ! -f project.godot ]; then
skipped "project-file checks — no project.godot in this repo"
else
METHOD="$(grep -E '^renderer/rendering_method' project.godot \
| sed -E 's/.*= *"?([a-z_]+)"?.*/\1/' | head -1)"
if [ -z "$METHOD" ]; then
skipped "rendering — project.godot sets no renderer/rendering_method (Godot then uses its own default, which this script will not guess)"
elif [ -z "$RENDERING" ]; then
skipped "rendering — project.yaml has no engine.rendering"
else
case "$RENDERING" in
Compatibility|compatibility|"GL Compatibility") WANT_M="gl_compatibility" ;;
"Forward+"|forward_plus|"Forward Plus") WANT_M="forward_plus" ;;
Mobile|mobile) WANT_M="mobile" ;;
*) WANT_M="" ;;
esac
if [ -z "$WANT_M" ]; then
skipped "rendering — engine.rendering '$RENDERING' is not one this script maps to a Godot rendering_method"
elif [ "$WANT_M" = "$METHOD" ]; then
match "engine.rendering ($RENDERING) agrees with project.godot ($METHOD)"
else
differs "engine.rendering is '$RENDERING' (expects $WANT_M) but project.godot runs '$METHOD'. Whichever is wrong, an agent reading project.yaml will advise for the wrong renderer."
fi
fi
# Jolt is Godot's default 3D physics engine; 2D is unchanged (Godot Physics
# 2D). See docs/engine-reference/godot/modules/physics.md. Declaring Jolt on a
# 2D project sends the implementer to tune a backend the game does not use.
if [ -z "$PHYSICS" ]; then
skipped "physics — project.yaml has no engine.physics"
elif printf '%s' "$PHYSICS" | grep -qiE 'jolt'; then
if grep -qE '(Node2D|CharacterBody2D|RigidBody2D|Area2D|StaticBody2D)' \
$(find src -name '*.gd' -o -name '*.tscn' 2>/dev/null) /dev/null 2>/dev/null; then
differs "engine.physics is 'Jolt', but 2D nodes were found under src/. Jolt is Godot's default 3D engine; 2D still uses Godot Physics 2D (docs/engine-reference/godot/modules/physics.md)."
else
match "engine.physics ($PHYSICS) — no 2D nodes found under src/ to contradict it"
fi
else
match "engine.physics ($PHYSICS)"
fi
fi
# --- 6. Declared commands reference files that exist -------------------------
BUILD_CMD="$(yaml_block_value commands build)"
TEST_CMD="$(yaml_block_value commands test)"
if [ -z "$BUILD_CMD" ] && [ -z "$TEST_CMD" ]; then
skipped "command checks — project.yaml declares no commands.build or commands.test"
else
if printf '%s' "$BUILD_CMD" | grep -qE 'export-(debug|release|pack)'; then
if [ -f export_presets.cfg ]; then
match "commands.build names an export preset and export_presets.cfg exists"
else
differs "commands.build is '$BUILD_CMD' but there is no export_presets.cfg. Godot cannot resolve the preset, so this command fails the first time CI or /smoke-check runs it."
fi
fi
# Godot names its runner as res://addons/gdUnit4/bin/GdUnitCmdTool.gd. Match the
# res:// prefix and strip it: a class that stops at the colon checked
# //addons/... and reported a present runner as missing.
RUNNER="$(printf '%s' "$TEST_CMD" | grep -oE '(res://)?[A-Za-z0-9_/.-]+\.(gd|cs|py|sh)' | head -1)"
RUNNER="${RUNNER#res://}"
if [ -n "$RUNNER" ]; then
if [ -f "$RUNNER" ]; then
match "commands.test runner exists ($RUNNER)"
else
case "$RUNNER" in
addons/gdUnit4/*) HINT=" gdUnit4 is not installed at addons/gdUnit4/ (capital U) — see /test-setup." ;;
*) HINT="" ;;
esac
differs "commands.test is '$TEST_CMD' but '$RUNNER' does not exist, so this command cannot run as written.$HINT"
fi
fi
fi
summarise