mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
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>
118 lines
4.4 KiB
Bash
Executable File
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"
|