#!/bin/bash # --- work from the project root ---------------------------------------------- # Every path below is repo-relative, so a hook invoked with a working directory # that is not the repo root would silently read and write the WRONG TREE -- # returning a near-empty result instead of the session-recovery block, and # creating stray trees such as docs/production/session-logs/ on write. # # PRECEDENCE IS LOAD-BEARING. A cwd that IS a project root carries real # information and must win: a caller sitting inside another project means that # project, not this one. Resolving to the script's own location first would # override them. So, in order: # 1. cwd holds project.yaml -> cwd (a project root) # 2. cwd holds .claude/ -> cwd (a project root not yet configured) # 3. CLAUDE_PROJECT_DIR -> that (populated in the hook environment) # 4. this script's location -> /.claude/hooks/../.. by construction # Rule 4 always works and needs no environment at all; rules 1-2 stop it from # overriding a caller that legitimately means somewhere else. # # NOT an upward search: that resolves a nested project to its parent's config. if [ -f "project.yaml" ] || [ -d ".claude" ]; then CCGS_ROOT="$PWD" elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then CCGS_ROOT="$CLAUDE_PROJECT_DIR" else CCGS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)" fi [ -n "$CCGS_ROOT" ] && cd "$CCGS_ROOT" 2>/dev/null || true # Claude Code PreToolUse hook: Validates git commit commands # Receives JSON on stdin with tool_input.command # Exit 0 = allow, Exit 2 = block (stderr shown to Claude) # # Input schema (PreToolUse for Bash): # { "tool_name": "Bash", "tool_input": { "command": "git commit -m ..." } } INPUT=$(cat) # 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 # 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 , -c , --git-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< notes.md < 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 COMMIT_SEG=$(git_segment commit "$COMMAND") || exit 0 # `git -C commit` commits whatever repository is in. Only this # project's staged files are ours to check; another repository is not. Leave # only when PROVABLY is another repository: a 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 && "$_c" -c 'import sys; sys.exit(sys.version_info[0] < 3)' >/dev/null 2>&1; then _VC_PY="$_c"; break fi done # 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 ` 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 : the value is not a pathspec else: for n, ch in enumerate(a[1:], 1): # -am: -a, then -m if ch in "mFCct": if n == len(a) - 1: k += 1 break if ch in "uS": break # -uno, -S: 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"(?/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 WARNINGS="" # Section findings are collected through a file, not a variable: the loops below # run inside pipelines, and a subshell cannot assign to WARNINGS in the parent. # The scratch file must actually be WRITABLE, and that has to be tested rather # than assumed. `mktemp` and the `${TMPDIR:-/tmp}` fallback beside it both aim # at /tmp, which does not exist or is not writable in several environments this # hook runs in -- notably sandboxed Windows shells. When the redirect failed, # `[ -s "$TMP_DESIGN" ]` was false, the design-section scan emitted nothing, and # the hook exited 0: a check that could not run, reporting clean. # # So: try mktemp, then TMPDIR, then the repo own .git directory (writable by # definition -- `git diff --cached` has already succeeded against it), and PROVE # each candidate by writing to it. If none works, TMP_DESIGN stays empty and the # scan announces that it did not run instead of skipping in silence. TMP_DESIGN="" for _cand in "$(mktemp 2>/dev/null)" "${TMPDIR:+$TMPDIR/ccgs-commit-$$}" \ "$(git rev-parse --git-dir 2>/dev/null)/ccgs-commit-$$"; do [ -z "$_cand" ] && continue if : > "$_cand" 2>/dev/null; then TMP_DESIGN="$_cand"; break; fi done trap '[ -n "$TMP_DESIGN" ] && rm -f "$TMP_DESIGN"' EXIT # --------------------------------------------------------------------------- # BLOCKING CHECK FIRST. Everything below this block is advisory: # it appends to $WARNINGS and the hook still exits 0. This block is the only one # that can exit 2, and exit 2 is the entire reason the hook is registered. # # Running it THIRD, after the design-section scan, and validating one file # per `python -m json.tool` invocation -- one interpreter spawn each, ~110ms on # Windows -- gets the hook killed by its 15s timeout on a large commit before # it reaches the corrupt file, so whether a bad file is caught depends on where # its NAME SORTED. Measured with 201 staged files and one corrupt: # # corrupt sorts FIRST -> 730ms, rc=2, BLOCKED emitted # corrupt sorts LAST -> 15217ms, rc=124, nothing emitted, commit not blocked # # Two independent fixes, because either alone is a mitigation rather than a # guarantee: # (a) ORDER. The blocking check runs before any advisory work, so a starved # hook has already done the part that matters. # (b) COST. One interpreter validates every staged JSON file, so the check is # constant in file count instead of linear. The one-subprocess-per-item # shape has caused this same starvation more than once; batching is the # only fix that holds. # # 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|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. # 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 "Wpath" (commit takes the working copy) or # "Ipath" (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 = [] # 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: 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(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(("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 # round-trips to clean up. echo "$BAD_JSON" | while IFS= read -r bad; do [ -n "$bad" ] && echo "BLOCKED: $bad is not valid JSON" >&2 done exit 2 fi else # 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 # Check design documents for the sections REQUIRED AT THIS PROJECT'S TIER. # # Demanding all 8 sections unconditionally would contradict # .claude/docs/coding-standards.md and workflow-modes.md: the required count is # a function of `modes.workflow` -- 8 at `full`, 5 (+ conditional Formulas) at # `standard`, and no GDD requirement at all at `minimal`. A jam project on the # recommended `minimal` tier was being warned about six sections its own # configuration says it does not need, which trains the user to ignore the hook. # Source the config helper ONCE, ahead of every consumer below. It was # previously sourced inside the design-doc branch, so the code scans further # down -- which now need resolve_code_root -- could not reach it on a commit # that staged no GDD. # Detect success by asking whether the function EXISTS, not by the source's exit # 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. _VC_HELPER=0 if [ -f .claude/hooks/yaml-helper.sh ]; then . .claude/hooks/yaml-helper.sh 2>/dev/null || true command -v resolve_setting >/dev/null 2>&1 && _VC_HELPER=1 fi DESIGN_FILES=$(echo "$STAGED" | grep -E '^design/gdd/') if [ -n "$DESIGN_FILES" ]; then WORKFLOW="standard" if [ "$_VC_HELPER" = 1 ]; then W=$(resolve_setting modes.workflow 2>/dev/null | cut -f1) [ -n "$W" ] && WORKFLOW="$W" fi # "Detailed" (not "Detailed Rules") because the canonical heading has a # documented alias, "Detailed Design" -- workflow-modes.md says not to treat # one as missing when the other is present. case "$WORKFLOW" in minimal) REQUIRED="" ;; full) REQUIRED="Overview|Player Fantasy|Detailed|Formulas|Edge Cases|Dependencies|Tuning Knobs|Acceptance Criteria" ;; *) REQUIRED="Overview|Detailed|Edge Cases|Dependencies|Acceptance Criteria" ;; esac 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 | # tr | while read` then one `grep -qi` per required section -- does not # scale. At # workflow=full that is 8 greps per file, and each is a process. 50 # design docs measured 14644ms -- 97% of this hook's 15s budget, from # the ADVISORY half alone. 50 GDDs is an ordinary project, not a # stress case. # # Matching is unchanged on purpose: case-insensitive SUBSTRING anywhere # in the file, exactly what `grep -qi "$section"` did. It is looser than # a heading check, and tightening it here would silently change which # 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" -c ' import sys, os argv = sys.argv required = [x for x in argv[1].split("|") if x] workflow = argv[2] out = [] for line in sys.stdin.read().splitlines(): path = line.strip() if not path or not path.endswith(".md") or not os.path.isfile(path): continue try: with open(path, "r", encoding="utf-8", errors="replace") as fh: body = fh.read().lower() except OSError: continue for section in required: if section.lower() not in body: out.append("DESIGN: %s missing section required at workflow=%s: %s" % (path, workflow, section)) if out: # Bytes, not text -- see the CRLF note in the JSON scan above. sys.stdout.buffer.write(("\n".join(out) + "\n").encode("utf-8")) ' "$REQUIRED" "$WORKFLOW" > "$TMP_DESIGN" 2>/dev/null if [ -z "$TMP_DESIGN" ]; then # Obligation 3 of .claude/rules/skill-authoring.md: a skipped step # announces itself. Silence here is indistinguishable from a GDD # that already has every section its tier requires. WARNINGS="$WARNINGS\nSKIPPED: no writable scratch location, so the GDD section check did NOT run on the staged design doc(s)." elif [ -s "$TMP_DESIGN" ]; then WARNINGS="$WARNINGS\n$(cat "$TMP_DESIGN")" fi fi fi # Check for hardcoded gameplay values in gameplay code # Uses grep -E (POSIX extended) instead of grep -P (Perl) for cross-platform compatibility # # Batched through xargs. Run one `grep` per staged file -- a process each, # ~60ms on Windows -- and 400 staged gameplay files blow a 15s budget on their # own. `xargs` packs as many # paths into each grep as ARG_MAX allows, so the spawn count is bounded by total # path length rather than by file count. # # `-l` not `-q`: with many files per invocation the exit status can no longer # say WHICH matched, and the warning names the file. It stays a test, not a # report -- `-l` prints only names, never matched lines, which is what the -q # comment here was guarding against. # # NUL-delimited, NOT `-d '\n'`: both `-d` and `-r` are GNU-only extensions. # BSD xargs (macOS) rejects `-d`, and because this pipeline ends in # `2>/dev/null || true` the rejection is SILENT -- the check returns empty and # reports nothing found, which is indistinguishable from a clean scan. README # claims these hooks run on macOS, so a silent no-op there is a false pass. # `-0` exists in both GNU and BSD xargs. `-r` is dropped, not replaced: both # callers below reach xargs only inside `if [ -n ... ]`, so the empty-input case # it guarded cannot occur. `|| true` stays -- grep exits 1 on no match, which is # the normal case and not an error. # --- resolve the code root before either scan below ------------------------ # # Both scans used to be hardcoded to `^src/gameplay/` and `^src/`. Per # .claude/docs/directory-structure.md, `src/` is the GODOT row of the code-root # table -- Unity compiles only `Assets/`, Unreal builds only from `Source/`. So # on two of the three supported engines both greps matched nothing on every # commit, both checks silently did not run, and the hook exited 0. A scan that # cannot run and a scan that found nothing produced the identical artifact. # # The `/gameplay/` narrowing is dropped rather than translated: it describes a # Godot source layout and has no Unity or Unreal equivalent, so keeping it would # leave the check Godot-only by a second route. The hardcoded-value scan instead # widens to the whole code root and is bounded by source-code EXTENSION, which # is what actually keeps it precise -- without it, a Unity commit would drag # `.meta`, `.asset` and `.prefab` YAML (all full of `speed: 5`) into a scan # meant for code, and a hook that cries wolf gets muted rather than fixed. CODE_ROOT=""; CODE_ROOT_SRC="unset" if [ "$_VC_HELPER" = 1 ]; then _CR=$(resolve_code_root 2>/dev/null) CODE_ROOT=$(printf '%s' "$_CR" | cut -f1) CODE_ROOT_SRC=$(printf '%s' "$_CR" | cut -f2) fi # Obligation 3 of .claude/rules/skill-authoring.md -- a skipped step announces # itself. Only fires when source code is actually being committed, so a # docs-only or config-only commit stays quiet and the notice keeps its meaning. if [ -z "$CODE_ROOT" ]; then if echo "$STAGED" | grep -qiE '\.(gd|cs|cpp|cc|hpp|h|c|py|js|ts|rs|java|kt|lua|gdshader|shader|hlsl)$'; then WARNINGS="$WARNINGS\nSKIPPED: source files are staged but no code root could be resolved (engine.name unset, and the tree does not name one unambiguously). The hardcoded-value and TODO-owner scans did NOT run. Run /setup-engine, or set engine.name in project.yaml." fi fi CODE_FILES="" [ -n "$CODE_ROOT" ] && CODE_FILES=$(echo "$STAGED" \ | grep -E "^${CODE_ROOT}/" \ | grep -iE '\.(gd|cs|cpp|cc|hpp|h|c|py|js|ts|rs|java|kt|lua)$') if [ -n "$CODE_FILES" ]; then HARDCODED=$(printf '%s\n' "$CODE_FILES" | tr '\n' '\0' \ | xargs -0 grep -lE '(damage|health|speed|rate|chance|cost|duration)[[:space:]]*[:=][[:space:]]*[0-9]+' 2>/dev/null || true) if [ -n "$HARDCODED" ]; then while IFS= read -r file; do [ -n "$file" ] && WARNINGS="$WARNINGS\nCODE: $file may contain hardcoded gameplay values. Use data files." done <<< "$HARDCODED" fi fi # Check for TODO/FIXME without assignee -- uses grep -E instead of grep -P # Same code root as the scan above. Not extension-bounded: an unowned TODO is # worth flagging in a shader or a build script too, and this scan greps for a # literal marker rather than a numeric pattern, so it does not misfire on # serialized asset YAML the way the hardcoded-value scan would. SRC_FILES="" [ -n "$CODE_ROOT" ] && SRC_FILES=$(echo "$STAGED" | grep -E "^${CODE_ROOT}/") if [ -n "$SRC_FILES" ]; then UNOWNED=$(printf '%s\n' "$SRC_FILES" | tr '\n' '\0' \ | xargs -0 grep -lE '(TODO|FIXME|HACK)[^(]' 2>/dev/null || true) if [ -n "$UNOWNED" ]; then while IFS= read -r file; do [ -n "$file" ] && WARNINGS="$WARNINGS\nSTYLE: $file has TODO/FIXME without owner tag. Use TODO(name) format." done <<< "$UNOWNED" fi fi # 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 _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