Release 1.1.2 — the default path works end to end

Fix release for 1.1.1: the minimal path end to end, engine commands that
run on Godot, Unity and Unreal, hook warnings you can see, and verdicts
that say when they could not check. Details in CHANGELOG.md; upgrade
steps in UPGRADING.md (v1.1.1 → v1.1.2).

Fixes #67
Fixes #131

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuJn2FdynEybfL5KC697nJ
This commit is contained in:
Donchitos
2026-09-29 21:32:19 +10:00
co-authored by Claude Opus 5.5
parent 7ed2c3e9c4
commit b21fa0f7f2
383 changed files with 22888 additions and 10618 deletions
+382 -24
View File
@@ -36,28 +36,328 @@ fi
INPUT=$(cat)
# Parse command -- use jq if available, fall back to grep
# Most Bash and PowerShell calls do not mention git at all. Leave before any
# parsing: this hook runs on every one of them, and the parse is most of its
# cost (~350 ms a call measured; the early exit is the shell start-up alone).
# Tested only after the "tool_input" key: matching the whole payload also
# tests cwd and transcript_path, so a project path containing "GitHub" or
# "digital" never took this exit and paid the full parse on every call.
case "$INPUT" in *'"tool_input"'*[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
# Parse command -- use jq if available, fall back to grep. The fallback's string
# body is (\\.|[^"\\])* so an escaped quote (-m \"msg\") does not end it early.
if command -v jq >/dev/null 2>&1; then
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
else
COMMAND=$(echo "$INPUT" | grep -oE '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"command"[[:space:]]*:[[:space:]]*"//;s/"$//')
# LC_ALL=C: in a UTF-8 locale an emoji in the command makes this grep match
# nothing, and an empty COMMAND skips every check below.
COMMAND=$(echo "$INPUT" | LC_ALL=C grep -oE '"command"[[:space:]]*:[[:space:]]*"(\\.|[^"\\])*"' | head -1 | LC_ALL=C sed 's/"command"[[:space:]]*:[[:space:]]*"//;s/"$//')
# The grep leaves the JSON escapes in place; decode them once, here, and
# never jq's output. \\ goes first, through a placeholder: decoded after \r,
# the path C:\\Users\\me\\review lost the `r` of `review` (a segment
# starting t or n became a tab or a line break), the -C check below found
# no repository, and the commit went unchecked. A \" left escaped ended a
# quoted branch name in a stray backslash; a \t or \r left escaped never
# closed a tab-indented `<<-EOF` or a CRLF here-doc, dropping every command
# after it.
COMMAND=$(printf '%s\n' "$COMMAND" \
| LC_ALL=C awk '{ gsub(/\\\\/, "\002"); gsub(/\\r/, ""); gsub(/\\t/, "\t"); gsub(/\\n/, "\n"); gsub(/\\"/, "\""); print }' \
| tr '\002' '\\')
fi
# Print the first segment of $2 that runs `git $1`, anywhere in the command.
# Anchoring on ^git let every chained form through unchecked:
# `git add -A && git commit`, `git -C . commit`, `cd x && git commit`,
# `GIT_DIR=.git git commit`, a commit on a second line. Segments split on
# && || ; & | ( ) backticks and quotes, and on newlines. Before a segment's
# `git`, VAR=value words, `env`/`sudo`/`doas`/`xargs`/`nice`/`timeout` with
# their flags (a flag's value and timeout's duration included), shell keywords
# (`if`, `do`, `{`, `time`, ...) and a path, either slash (`/usr/bin/git`,
# `C:\Git\cmd\git.exe`) are allowed, and a QUOTED path to git ("C:\Program
# Files\Git\cmd\git.exe") is read as plain `git`; after it, git's own options
# (-C <dir>, -c <k=v>, --git-dir <dir> and the other long options that take a
# separate value, -p, --flag[=v]). A quoted value of git's own options --
# `-C "my repo"`, `--git-dir ".git"`, `-c user.name="A B"`, `--work-tree="."`,
# `-C "$(pwd)"` -- is unquoted first and its spaces and shell operators carried
# as \001, or the split cut `git` off from its subcommand and the commit went
# unchecked; an unquoted `$(pwd)` with no space in it is one word, too. Only
# when it follows `git`: a shell's own `bash -c "git commit"` was unquoted the
# same way, which glued the inner command into one word and skipped the check,
# and the quote after `=` in `grep "key=" f && git commit -m "x"` closes a
# string rather than opening a value. All splitting is awk: BSD sed writes a
# literal `n` for `\n`, so on macOS a sed split missed every chained form.
# Over-splitting only costs a check that finds nothing staged; under-splitting
# is the bypass. Kept identical in validate-push.sh.
#
# `git` itself is matched case-insensitively (`Git`, `GIT`) and a bare quoted
# `"git"` or `'git'` (no path) reads as plain `git` too, the same as a quoted
# path ending in it. `stdbuf`, `winpty` and `command -p` join `env`/`sudo`/...
# as prefixes that take flags. A line ending in a lone `\` is joined with the
# next before any of this runs, so `git \` then `commit` on the next line is
# still one command; `1<<y` inside `$((...))` is left-shift, not a here-doc
# opener, even though it matches `<<` the same as a real one. A quoted WORD
# with no shell-meaningful character inside it (a quoted branch name, `"main"`)
# is unquoted in place rather than read as a delimiter; a quoted string that
# DOES contain one is left for the here-doc/command handling above.
#
# A here-document body is text, not commands: a commit message, a file being
# written. Read as commands, a message quoting `git push origin main` warned
# about a push, and `cat > notes.md <<EOF` mentioning git commit blocked on
# staged data. So the body is dropped -- UNLESS its line runs an interpreter
# (bash <<EOF, python - <<EOF), whose here-doc IS commands: dropping that
# would be the bypass. `<<<` is a here-string, not a here-doc. An interpreter
# may carry a path (`/bin/bash <<EOF`). The delimiter is the whole word, or the
# whole quoted string (`<<'END-NOTES'`), and a `<<` inside a quoted string
# (`echo "a<<b"`) opens nothing -- `$(cat <<EOF` inside one still does. Either
# mistake left a here-doc that never closed and dropped every later line, a
# real commit included. A PowerShell single-quote here-string (`@'` ... `'@`)
# is the same kind of text and is dropped the same way; the double-quote form
# (`@"` ... `"@`) is left alone, because PowerShell itself expands `$(...)`
# inside it, and that content is still live.
git_segment() {
printf '%s\n' "$2" \
| awk '{ while ($0 ~ /\\$/) { line = $0; sub(/\\$/, "", line); if ((getline nxt) > 0) { $0 = line nxt } else { $0 = line; break } } print }' \
| awk '
pd != "" { t = $0; sub(/\r$/, "", t); if (t ~ /^'"'"'@/) pd = ""; next }
d != "" { t = $0; sub(/\r$/, "", t); if (tabs) sub(/^\t+/, "", t); if (t == d) d = ""; next }
{ print }
match($0, /<<-?[ \t]*("[^"]*"|'"'"'[^'"'"']*'"'"'|\\?[A-Za-z_][^ \t\r;&|()<>"'"'"'\\]*)/) && substr($0, RSTART - 1, 1) != "<" \
&& substr($0, 1, RSTART - 1) !~ /\$\(\([^)]*$/ \
&& $0 !~ /(^|[^A-Za-z0-9_.-])(bash|sh|zsh|dash|ksh|fish|pwsh|powershell|python[0-9.]*|py|node|ruby|perl|php|eval|source)([ \t]|$)/ {
t = substr($0, RSTART, RLENGTH); q = ""
for (i = 1; i < RSTART; i++) {
c = substr($0, i, 1)
if (q == "") { if (c == "\"" || c == "'"'"'") q = c }
else if (c == q || (q == "\"" && c == "$" && substr($0, i + 1, 1) == "(")) q = ""
}
if (q == "") { d = t; tabs = (d ~ /^<<-/); sub(/^<<-?[ \t]*/, "", d); gsub(/[\\"'"'"']/, "", d) }
}
# A PowerShell single-quote here-string body (opened by @ then a
# single quote, closed by a single quote then @) is literal text,
# the same as a bash here-doc -- UNLESS it is the double-quote form,
# which PowerShell itself expands $(...) inside, so that form is
# left unmarked and flows through unchanged.
$0 ~ /@'"'"'[ \t]*\r?$/ { pd = "x" }' \
| awk '{
s = $0; out = ""
gsub(/"([^"]*[\/\\])?[Gg][Ii][Tt](\.exe)?"/, "git", s); gsub(/'"'"'([^'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?'"'"'/, "git", s)
gsub(/\$\([^ \t;&|()`"'"'"']*\)|`[^ \t;&|()`"'"'"']*`/, "$_", s)
while (match(s, /(-[Cc]|--(git-dir|work-tree|namespace|super-prefix|config-env|attr-source))[ \t]+("[^"]*"|'"'"'[^'"'"']*'"'"')|=("[^"]*"|'"'"'[^'"'"']*'"'"')/)) {
p = out substr(s, 1, RSTART - 1); a = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
if (a ~ /^=/) {
f = "="; ok = p ~ /(^|[ \t;&|(){`"'"'"'])([^ \t;&|(){`"'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?([ \t]+[^ \t;&|(){`"'"'"']+)+$/
} else {
f = a; sub(/[ \t]+["'"'"'].*$/, " ", f)
ok = p ~ /(^|[ \t;&|(){`"'"'"'])([^ \t;&|(){`"'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?([ \t]+[^ \t;&|(){`"'"'"']+)*[ \t]+$/
}
if (!ok) { out = p substr(a, 1, 1); s = substr(a, 2) s; continue }
v = a; sub(/^[^"'"'"']*["'"'"']/, "", v); v = substr(v, 1, length(v) - 1); gsub(/[ \t;&|()`]/, "\001", v)
out = p f v
}
s = out s
# A quoted WORD with nothing inside it a shell would split on (a
# quoted branch name, a quoted refspec) is unquoted the same way
# rather than read as a delimiter -- a bare `"` or `'"'"'` used to
# split the line here, dropping the quoted text (and everything
# after it on that line) from what the classifier below ever saw.
# A quoted string that DOES contain such characters is left alone:
# it may be a whole command (`bash -c "git commit"`) meant to fall
# on its own line for re-inspection, or a commit message, and either
# reading is unchanged from before.
out = ""
while (match(s, /"[^" \t;&|()`]*"|'"'"'[^'"'"' \t;&|()`]*'"'"'/)) {
p = out substr(s, 1, RSTART - 1); a = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
out = p substr(a, 2, length(a) - 2)
}
s = out s
gsub(/&&|\|\|/, "\n", s); gsub(/[;&|()`"'"'"']/, "\n", s); print s
}' \
| grep -E "^[[:space:]]*((if|then|else|elif|do|while|until|time|exec|command|nohup|!|[{])[[:space:]]+|(env|sudo|doas|xargs|nice|timeout|stdbuf|winpty|command)([[:space:]]+-[A-Za-z0-9-]+(=[^[:space:]]*)?([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+([0-9][0-9.]*[smhd]?[[:space:]]+)?|[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*([^[:space:]]*[/\\])?[Gg][Ii][Tt](\.exe)?([[:space:]]+(-[Cc][[:space:]]+[^[:space:]]+|--(git-dir|work-tree|namespace|super-prefix|config-env|attr-source)[[:space:]]+[^[:space:]]+|-[A-Za-z]|--[A-Za-z-]+(=[^[:space:]]+)?))*[[:space:]]+$1([[:space:]]|\$)"
}
# Only process git commit commands
if ! echo "$COMMAND" | grep -qE '^git[[:space:]]+commit'; then
exit 0
COMMIT_SEG=$(git_segment commit "$COMMAND") || exit 0
# `git -C <dir> commit` commits whatever repository <dir> is in. Only this
# project's staged files are ours to check; another repository is not. Leave
# only when <dir> PROVABLY is another repository: a <dir> rev-parse cannot
# resolve here -- `"$CLAUDE_PROJECT_DIR"`, `~/x`, `"$(pwd)"`, all unexpanded in
# the command text -- is this project as often as not, and skipping it let the
# commit through unchecked.
#
# COMMIT_SEG can hold more than one line -- a chained `git -C other commit &&
# git commit` -- and this early exit must not fire on the strength of the
# FIRST one alone: that let a real commit of THIS repo, chained after a
# provably-elsewhere one, through unchecked. So it only applies when exactly
# one commit invocation was found; a chain of several falls through to the
# normal staged-file checks below, which are already scoped to this repo.
if [ "$(printf '%s\n' "$COMMIT_SEG" | grep -c .)" = 1 ]; then
GIT_OPTS=${COMMIT_SEG%%[[:space:]]commit*}
case "$GIT_OPTS" in
*-C*)
C_DIR=$(printf '%s' "$GIT_OPTS" | sed -nE 's/.*[[:space:]]-C[[:space:]]+([^[:space:]]+).*/\1/p' | tr '\001' ' ')
if [ -n "$C_DIR" ] && _C_TOP=$(git -C "$C_DIR" rev-parse --show-toplevel 2>/dev/null) && [ -n "$_C_TOP" ]; then
_OUR_TOP=$(git rev-parse --show-toplevel 2>/dev/null)
[ "$_C_TOP" != "$_OUR_TOP" ] && [ ! "$_C_TOP" -ef "$_OUR_TOP" ] && exit 0
fi ;;
esac
fi
# Resolve a Python interpreter ONCE for every batched check below. Both the
# blocking JSON scan and the advisory design scan must not spawn one process per
# item; they spawn one each, and share this lookup.
# Run it, do not just find it: the Windows Store `python` placeholder passes
# `command -v` and then prints an install hint instead of running anything.
# And ask for Python 3: a Python 2 first on PATH runs `import sys` fine, then
# fails every check below (they read sys.stdin.buffer), and valid data blocked.
_VC_PY=""
for _c in python python3 py; do
if command -v "$_c" >/dev/null 2>&1; then _VC_PY="$_c"; break; fi
if command -v "$_c" >/dev/null 2>&1 && "$_c" -c 'import sys; sys.exit(sys.version_info[0] < 3)' >/dev/null 2>&1; then
_VC_PY="$_c"; break
fi
done
# Get staged files
STAGED=$(git diff --cached --name-only 2>/dev/null)
# Get the files this commit will record, and where each committed copy lives.
# A plain commit records the INDEX. `-a` / `--all` (or a cluster such as -am)
# also commits every modified tracked file, and `git commit <path>` commits the
# named paths' WORKING copies, staged or not -- both read from the working tree
# (WT_FILES). core.quotePath=false: without it a non-ASCII path comes back
# quoted ("assets/data/caf\303\251.json") and no pattern below matched it.
_GIT="git -c core.quotePath=false"
COMMIT_ARGS=" ${COMMIT_SEG#*[[:space:]]commit}"
INDEX_FILES=$($_GIT diff --cached --name-only 2>/dev/null)
WT_FILES=""
if printf '%s' "$COMMIT_ARGS" | grep -qE '[[:space:]](-[ABDEGHIJKLMNOPQRTUVWXYZbdefghijklnopqrsvwxyz]*a[A-Za-z]*|--all)([[:space:]]|$)'; then
WT_FILES=$($_GIT diff --name-only 2>/dev/null)
fi
# Pathspecs after the `commit` word. ANY pathspec takes working copies -- `.`,
# `-- .`, a folder, a glob, `:/` -- not only a literal assets/ path, and a
# message or an -F file is not one. So Python tokenises the command as a shell
# would (shlex; COMMIT_SEG was split at quotes and cannot say where `-m "a b"`
# ends) and git expands the pathspecs. Without Python only literal data paths
# are found, and the data check below blocks on them anyway.
#
# The tokeniser also reads an -a the grep above cannot see: COMMIT_ARGS stops
# at the first quote, so `git commit -m "x" -a` and `--all` committed every
# modified tracked file unchecked. `-a` counts in a short-option cluster only
# before a letter that takes a value (`-am x` is -a; `-ma` is the message "a").
#
# A path-limited commit (`git commit -m x src/player.gd`, no -i/--include)
# records ONLY those paths -- `git commit --dry-run` lists nothing else -- so a
# broken file staged for later must not block it. The tokeniser says so on its
# first line (CCGS-ONLY), and only when it read every commit in the command:
# one inside `bash -c "..."` or a here-doc is a single word or plain text to it.
#
# A relative pathspec resolves where the commit runs, and this hook runs at the
# project root: after a `cd`, under -C, or in a session sitting in a subfolder,
# `cd assets && git commit -m x data/item.json` named nothing here and the file
# went unchecked. There, and whenever a pathspec names no tracked file, every
# modified tracked file is checked (`:/`), and the index with it.
if [ -n "$_VC_PY" ]; then
if command -v jq >/dev/null 2>&1; then
_VC_CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
else
_VC_CWD=$(echo "$INPUT" | LC_ALL=C grep -oE '"cwd"[[:space:]]*:[[:space:]]*"(\\.|[^"\\])*"' | head -1 \
| LC_ALL=C sed 's/^"cwd"[[:space:]]*:[[:space:]]*"//; s/"$//; s/\\\\/\\/g')
fi
_VC_AWAY=0
[ -n "$_VC_CWD" ] && [ ! "$_VC_CWD" -ef . ] && _VC_AWAY=1
_VC_TOK=$(printf '%s\n' "$COMMAND" | "$_VC_PY" -c '
import re, shlex, subprocess, sys
OPS = set(";&|()<>")
GIT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--attr-source"}
REDIR = re.compile(r"^[<>]+&?$|^&>+$") # >, >>, >&, <, <<, &>: a redirection operator
COMMIT_ARG = {"--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash",
"--author", "--date", "--template", "--cleanup", "--trailer", "--pathspec-from-file"}
def words(line):
try:
lx = shlex.shlex(line, posix=True, punctuation_chars=True)
lx.whitespace_split = True
lx.commenters = ""
return list(lx)
except ValueError: # an unclosed quote: the line continues a string
return line.split()
def git(*args):
return subprocess.run(["git", "-c", "core.quotePath=false"] + list(args),
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
text = sys.stdin.buffer.read().decode("utf-8", "replace")
away = sys.argv[1] == "1" or re.search(r"(^|[^\w.-])(cd|pushd|popd)(\s|[;&|)]|$)", text, re.M) is not None
specs, commits, only = [], 0, True
for line in text.splitlines():
w, i = words(line), 0
while i < len(w):
if w[i].replace("\\", "/").rsplit("/", 1)[-1].lower() not in ("git", "git.exe"):
i += 1
continue
j = i + 1
while j < len(w) and w[j].startswith("-"):
away = away or w[j] in ("-C", "--git-dir", "--work-tree") or w[j].startswith(("--git-dir=", "--work-tree="))
j += 2 if w[j] in GIT_ARG else 1
if j >= len(w) or w[j] != "commit":
i = j
continue
commits += 1
k, rest, mine, index = j + 1, False, [], False
while k < len(w):
a = w[k]
if a.isdigit() and k + 1 < len(w) and w[k + 1][:1] in "<>":
k += 1 # 2>&1, 2> err.txt: a file descriptor, not a pathspec
continue
if REDIR.match(a):
k += 2 # the redirection and its target; the commit goes on after it
continue
if a and set(a) <= OPS:
break # the next command
if rest or not a.startswith("-") or a == "-":
mine.append(a)
elif a == "--":
rest = True
elif a.startswith("--"):
if a == "--all" or a.startswith("--pathspec-from-file"):
mine.append(":/") # every tracked change, or paths only git reads
index = index or a in ("--all", "--include", "--patch", "--interactive") \
or a.startswith("--pathspec-from-file")
if a in COMMIT_ARG:
k += 1 # --message <msg>: the value is not a pathspec
else:
for n, ch in enumerate(a[1:], 1): # -am: -a, then -m <msg>
if ch in "mFCct":
if n == len(a) - 1:
k += 1
break
if ch in "uS":
break # -uno, -S<key>: the rest is its value
if ch == "a":
mine.append(":/")
index = index or ch in "aip"
k += 1
specs += mine
only = only and bool(mine) and not index
i = k
if specs and not away:
away = git("ls-files", "--error-unmatch", "--", *specs).returncode != 0
if specs and away:
specs.append(":/")
only = False
only = only and commits > 0 and len(re.findall(r"(?<![\w.-])commit(?![\w-])", text)) == commits
out = git("diff", "--name-only", "HEAD", "--", *specs) if only else None
if out is None or out.returncode != 0: # not path-limited, or no HEAD yet
only = False
out = git("diff", "--name-only", "--", *specs) if specs else None
sys.stdout.buffer.write((b"CCGS-ONLY\n" if only else b"CCGS-INDEX\n") + (out.stdout if out else b""))
' "$_VC_AWAY" 2>/dev/null)
case "$_VC_TOK" in CCGS-ONLY*) INDEX_FILES="" ;; esac
WT_FILES=$(printf '%s\n%s' "$WT_FILES" "$(printf '%s\n' "$_VC_TOK" | tail -n +2)")
else
_AFTER=$(printf '%s' "$COMMAND" | LC_ALL=C awk '
!f && match($0, /(^|[^A-Za-z0-9_.-])commit([ \t]|$)/) { f = 1; print substr($0, RSTART + RLENGTH); next }
f { print }')
for _p in $(printf '%s' "$_AFTER" | LC_ALL=C grep -oE '(assets|Assets|Content)/[^[:space:]"'"'"';|&)]*'); do
WT_FILES=$(printf '%s\n%s' "$WT_FILES" "$($_GIT diff --name-only -- "$_p" 2>/dev/null)")
done
fi
WT_FILES=$(printf '%s\n' "$WT_FILES" | grep -v '^$' | sort -u)
STAGED=$(printf '%s\n%s\n' "$INDEX_FILES" "$WT_FILES" | grep -v '^$' | sort -u)
if [ -z "$STAGED" ]; then
exit 0
fi
@@ -110,31 +410,73 @@ trap '[ -n "$TMP_DESIGN" ] && rm -f "$TMP_DESIGN"' EXIT
#
# Making it merely faster is not enough -- the cost stays linear and the hook
# starves again at a larger file count. Batch it, or it returns.
#
# WHERE DATA LIVES is engine-specific, like the code root: Godot keeps it in
# assets/data/, Unity under Assets/ (Assets/Data/, Assets/Game/Data/), Unreal
# under Content/. Anchored to ^assets/data/ alone, this -- the hook's only
# blocking check -- never fired on Unity or Unreal. It stays inside the asset
# roots so an editor's JSON-with-comments (.vscode/settings.json) never blocks.
# ---------------------------------------------------------------------------
DATA_FILES=$(echo "$STAGED" | grep -E '^assets/data/.*\.json$')
DATA_FILES=$(echo "$STAGED" | grep -E '^(assets|Assets|Content)/(.*/)?[Dd]ata/.*\.[Jj][Ss][Oo][Nn]$')
if [ -n "$DATA_FILES" ]; then
PYTHON_CMD="$_VC_PY"
if [ -n "$PYTHON_CMD" ]; then
# One spawn, all files. The file list arrives on stdin so an unbounded
# number of paths cannot overflow the argument limit.
BAD_JSON=$(printf '%s\n' "$DATA_FILES" | "$PYTHON_CMD" -c '
import json, sys, os
# The first output line is a sentinel. Without it the validator did not
# run -- a crash, or an interpreter that is not Python -- and an empty
# list of bad files would read as "all valid".
# Each line is "W<tab>path" (commit takes the working copy) or
# "I<tab>path" (commit takes the staged copy). Index copies are read
# through ONE `git cat-file --batch`, not a `git show` per file.
JSON_OUT=$(LC_ALL=C awk -v OFS='\t' 'NR == FNR { if ($0 != "") wt[$0] = 1; next }
$0 != "" { print (($0 in wt) ? "W" : "I"), $0 }' \
<(printf '%s\n' "$WT_FILES") <(printf '%s\n' "$DATA_FILES") \
| "$PYTHON_CMD" -c '
import json, sys, os, subprocess
bad = []
for line in sys.stdin.read().splitlines():
path = line.strip()
if not path or not os.path.isfile(path):
continue
# Bytes in, decoded as UTF-8: a Windows console code page would mangle a
# non-ASCII path before it reached os.path or git.
items = [l.split("\t", 1) for l in sys.stdin.buffer.read().decode("utf-8").splitlines() if "\t" in l]
staged = [p for mode, p in items if mode == "I"]
blobs = {}
if staged:
out = subprocess.run(["git", "cat-file", "--batch"], stdout=subprocess.PIPE,
input="".join(":" + p + "\n" for p in staged).encode("utf-8")).stdout
pos = 0
for p in staged:
end = out.index(b"\n", pos)
head = out[pos:end].split()
pos = end + 1
if len(head) == 3 and head[1] == b"blob":
size = int(head[2])
blobs[p] = out[pos:pos + size]
pos += size + 1
for mode, p in items:
try:
with open(path, "r", encoding="utf-8-sig") as fh:
json.load(fh)
if mode == "I":
if p not in blobs:
continue # removed from the index: nothing of it is committed
data = blobs[p]
else:
if not os.path.isfile(p):
continue
with open(p, "rb") as fh:
data = fh.read()
json.loads(data.decode("utf-8-sig"))
except Exception:
bad.append(path)
bad.append(p)
# Bytes, not text. Python text-mode stdout on Windows rewrites \n as \r\n,
# putting a CR inside every path but the last. Invisible in a terminal and in
# a line-by-line diff -- only a byte comparison against the pre-batch
# implementation surfaced it. get_yaml_key writes bytes for the same reason.
sys.stdout.buffer.write("\n".join(bad).encode("utf-8"))
sys.stdout.buffer.write(("CCGS-JSON-CHECKED\n" + "\n".join(bad)).encode("utf-8"))
' 2>/dev/null)
if [ "$(printf '%s\n' "$JSON_OUT" | head -1)" != "CCGS-JSON-CHECKED" ]; then
echo "BLOCKED: $(printf '%s\n' "$DATA_FILES" | grep -c .) staged data file(s) could not be validated -- the JSON check did not run under '$PYTHON_CMD'. Check that it is a working Python 3 and commit again." >&2
exit 2
fi
BAD_JSON=$(printf '%s\n' "$JSON_OUT" | tail -n +2)
if [ -n "$BAD_JSON" ]; then
# Every offender at once. The per-file loop reported only the first
# and exited, so a commit with several bad files took several
@@ -145,7 +487,11 @@ sys.stdout.buffer.write("\n".join(bad).encode("utf-8"))
exit 2
fi
else
echo "WARNING: Cannot validate JSON (python not found)" >&2
# Block, do not warn. Stderr from a hook that exits 0 goes to the debug
# log only -- Claude never sees it -- so the old warning let unvalidated
# data through in silence. A check that cannot run must not pass.
echo "BLOCKED: $(printf '%s\n' "$DATA_FILES" | grep -c .) staged data file(s) could not be validated -- no Python 3 interpreter found (tried python, python3, py). CCGS needs Python for its config reads too; install it and commit again." >&2
exit 2
fi
fi
@@ -165,7 +511,7 @@ fi
# status. A sourced file returns the status of its last statement, which here is
# incidental -- gating on it left _VC_HELPER at 0 on a perfectly good source, the
# workflow tier silently stayed at its "standard" fallback, and the GDD section
# check stopped scaling with the tier (caught by test LL.9).
# check stopped scaling with the tier.
_VC_HELPER=0
if [ -f .claude/hooks/yaml-helper.sh ]; then
. .claude/hooks/yaml-helper.sh 2>/dev/null || true
@@ -189,7 +535,12 @@ if [ -n "$DESIGN_FILES" ]; then
*) REQUIRED="Overview|Detailed|Edge Cases|Dependencies|Acceptance Criteria" ;;
esac
if [ -n "$REQUIRED" ]; then
if [ -n "$REQUIRED" ] && [ -z "$_VC_PY" ]; then
# A skipped step announces itself (obligation 3, as below). Handing the
# scan to a `python` that is not there printed nothing, which read as a
# GDD with every section its tier requires.
WARNINGS="$WARNINGS\nSKIPPED: no Python 3 interpreter found (tried python, python3, py), so the GDD section check did NOT run on the staged design doc(s)."
elif [ -n "$REQUIRED" ]; then
# One pass for every file x section pair.
#
# A nested shell loop -- for each staged design doc, `echo |
@@ -206,7 +557,7 @@ if [ -n "$DESIGN_FILES" ]; then
# documents warn -- a behaviour change smuggled inside a performance
# fix. If that wants tightening it should be its own change with its
# own test.
printf '%s\n' "$DESIGN_FILES" | "${_VC_PY:-python}" -c '
printf '%s\n' "$DESIGN_FILES" | "$_VC_PY" -c '
import sys, os
argv = sys.argv
required = [x for x in argv[1].split("|") if x]
@@ -326,9 +677,16 @@ if [ -n "$SRC_FILES" ]; then
fi
fi
# Print warnings (non-blocking) and allow commit
# Warnings allow the commit. They go out as hook JSON: stderr on exit 0 reaches
# the debug log only, so neither the user nor Claude ever saw them. stderr stays
# as the fallback for a tree whose helper did not load.
if [ -n "$WARNINGS" ]; then
echo -e "=== Commit Validation Warnings ===$WARNINGS\n================================" >&2
_VC_MSG=$(printf '%b' "=== Commit Validation Warnings ===$WARNINGS\n================================")
if command -v hook_warn >/dev/null 2>&1; then
hook_warn PreToolUse "$_VC_MSG"
else
printf '%s\n' "$_VC_MSG" >&2
fi
fi
exit 0