Files
gstack/bin/gstack-hook-check
T
garrytanandBenjaminDSmithy f10265c383 fix(setup): register the hooks that parse, refuse the rest with file:line (#3032)
The landed gate stopped setup before any registration, so one unparseable
hook left every other hook unregistered. On the Claude install the canonical
root is the checkout Claude Code runs hooks from, so there is no staged swap
to abort: setup now registers the hooks that parse, refuses the ones that do
not with file and line, finishes the install and exits non-zero, saying it
is a gstack bug and that a skipped hook keeps running from the checkout.

bin/gstack-hook-check derives its list from setup's _hook_command_path calls
plus the autoplan phase-publication hook (a test keeps them equal), parses
each shim with its shebang's interpreter, and bundles each TypeScript entry
with its local imports (packages external) into a temp dir, so a broken or
missing import, or a missing entry, fails.

Co-authored-by: BenjaminDSmithy <BenjaminDSmithy@users.noreply.github.com>
2026-10-06 20:28:26 +00:00

118 lines
4.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-hook-check — parse-check every hook Claude Code runs from a gstack tree.
#
# Usage:
# gstack-hook-check [--setup <file>] --list <root> print the checked hooks
# gstack-hook-check [--setup <file>] <root> check them
#
# Claude Code runs gstack's hook shims through /bin/sh; a shim that does not
# parse exits 2, which for a PreToolUse hook blocks the tool call in every
# session. setup runs this before it registers hooks, and the auto-updater
# (bin/gstack-session-update) runs it on the incoming revision before the
# live checkout moves.
#
# The hook list comes from the registration code: every
# `_hook_command_path <path>` argument in <root>/setup (or the setup named by
# --setup, when another tree's setup registers <root>'s hooks), plus the autoplan
# phase-publication hook (registered by its skill frontmatter).
# test/setup-hook-syntax-gate.test.ts keeps the two equal.
#
# Each shim is parsed by the interpreter its shebang names (bash -n, sh -n).
# Each TypeScript entry the shim runs (a `<name>.ts` beside it) is bundled with
# its local imports into a temp directory, packages external, so a broken or
# missing local import fails; a missing entry fails.
#
# Output, one line per hook:
# ok <hook>
# missing <hook> (no shim: setup skips registering it)
# fail <hook> <file>:<line>: <message>
# Exit: 0 when nothing failed, 1 when a hook failed, 2 on usage, 3 when bun
# (needed for the TypeScript check) is not found. Set GSTACK_HOOK_CHECK_BUN to
# the bun to run (setup passes its own).
set -u
LIST_ONLY=0 SETUP_FILE=""
while [ $# -gt 1 ]; do
case "$1" in
--list) LIST_ONLY=1; shift ;;
--setup) SETUP_FILE="$2"; shift 2 ;;
*) break ;;
esac
done
ROOT="${1:-}"
if [ -z "$ROOT" ] || [ ! -d "$ROOT" ]; then
echo "usage: gstack-hook-check [--setup <file>] [--list] <gstack root>" >&2
exit 2
fi
SETUP_FILE="${SETUP_FILE:-$ROOT/setup}"
hook_list() {
sed -n 's/.*\$(_hook_command_path \([A-Za-z0-9._/-]*\).*/\1/p' "$SETUP_FILE" 2>/dev/null
echo autoplan/bin/phase-publication-hook
}
HOOKS=$(hook_list | awk '!seen[$0]++')
if [ "$LIST_ONLY" -eq 1 ]; then
printf '%s\n' "$HOOKS"
exit 0
fi
BUN="${GSTACK_HOOK_CHECK_BUN:-bun}"
if ! command -v "$BUN" >/dev/null 2>&1; then
echo "gstack-hook-check: bun not found, so TypeScript hook entries cannot be checked" >&2
exit 3
fi
TMP_OUT=$(mktemp -d "${TMPDIR:-/tmp}/gstack-hook-check.XXXXXX") || { echo "gstack-hook-check: cannot create a temp directory" >&2; exit 1; }
trap 'rm -rf "$TMP_OUT"' EXIT
# first_location <file> <tool output> — "<file>:<line>" from bun's "at f:l:c"
# or bash's "f: line N:" (falls back to <file>).
first_location() {
_loc=$(printf '%s\n' "$2" | sed -n 's/^[[:space:]]*at \(.*\):\([0-9][0-9]*\):[0-9][0-9]*$/\1:\2/p' | head -1)
[ -n "$_loc" ] || _loc=$(printf '%s\n' "$2" | sed -n 's/^\(.*\): line \([0-9][0-9]*\):.*/\1:\2/p' | head -1)
printf '%s\n' "${_loc:-$1}"
}
first_message() {
_msg=$(printf '%s\n' "$1" | sed -n 's/^error: //p' | head -1)
[ -n "$_msg" ] || _msg=$(printf '%s\n' "$1" | sed -n 's/^.*: line [0-9][0-9]*: //p' | head -1)
[ -n "$_msg" ] || _msg=$(printf '%s\n' "$1" | grep -v '^[[:space:]]*$' | head -1)
printf '%s\n' "$_msg"
}
FAILED=0
fail() { echo "fail $1 $2: $3"; FAILED=1; }
for hook in $HOOKS; do
shim="$ROOT/$hook"
if [ ! -f "$shim" ]; then echo "missing $hook"; continue; fi
hook_ok=1
shebang=$(head -1 "$shim")
case "$shebang" in
'#!'*bash*) interp=bash ;;
'#!'*/sh|'#!'*/sh' '*|'#!'*' sh'|'#!'*' sh '*) interp=sh ;;
*) interp="" ;;
esac
if [ -z "$interp" ]; then
fail "$hook" "$shim:1" "unsupported shebang '$shebang' (expected bash or sh)"
continue
fi
if ! err=$("$interp" -n "$shim" 2>&1); then
fail "$hook" "$(first_location "$shim" "$err")" "$(first_message "$err")"
hook_ok=0
fi
dir=$(dirname "$shim")
for entry in $(grep -v '^[[:space:]]*#' "$shim" | grep -oE '[A-Za-z0-9._-]+\.ts([^A-Za-z0-9_]|$)' | sed -E 's/\.ts.*$/.ts/' | awk '!seen[$0]++'); do
if [ ! -f "$dir/$entry" ]; then
fail "$hook" "$dir/$entry:1" "missing TypeScript entry (the hook runs it)"
hook_ok=0
continue
fi
if ! err=$("$BUN" build --target=bun --packages=external --outdir "$TMP_OUT/$(basename "$hook")" "$dir/$entry" 2>&1 >/dev/null); then
fail "$hook" "$(first_location "$dir/$entry" "$err")" "$(first_message "$err")"
hook_ok=0
fi
done
[ "$hook_ok" -eq 1 ] && echo "ok $hook"
done
exit "$FAILED"