mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
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:
co-authored by
Claude Opus 5.5
parent
7ed2c3e9c4
commit
b21fa0f7f2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user