Files
gstack/bin/gstack-hook-check
T
Garry Tanandcapy-ai[bot] 4b8c03bea9 v1.91.294.0 fix(doctor): cut Git Bash process starts so the doctor tests fit Windows CI (GSTA-138) (#3540)
* perf(doctor): cut Git Bash process starts in doctor, hook-check and launch probe (GSTA-138)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

The doctor's in-process config read reselected the state root after the
doctor set GSTACK_STATE_ROOT, so --check and --json printed 'selected by
GSTACK_STATE_ROOT' whatever selected the root. cfg now keeps _gstack_sr_var.
Adds the regression test, a CRLF/blank/200-byte launch-probe detail test,
and corrects the clamp comment.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: check the doctor cores row's test_backend by behavior, not the reader's spelling

test/owner-brief-contributor-banned.test.ts pinned the literal
'gstack-config" get test_backend' in bin/gstack-doctor-check.sh, which the
in-process cfg read removed (CI: quick-bun-default, free-suite 14). It now
runs gstack-doctor --check --require cores against a state root holding
test_backend: ubicloud and expects test_backend=ubicloud, with an empty
state root as the negative control (test_backend=local). The under-8-cores
wording pin stays.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: CHANGELOG [Unreleased] entry for the doctor's Windows process cuts

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.294.0 for #3540

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-28 13:05:02 +00:00

270 lines
11 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 every
# gstack path on a `command:` line in a skill's frontmatter (<root>/*/SKILL.md:
# /autoplan, /careful, /freeze, /guard, /investigate, /plan-ceo-review). Claude
# Code runs those while the skill is active, and a broken PreToolUse one blocks
# the tool call. test/setup-hook-syntax-gate.test.ts keeps the lists equal.
#
# Each shim is parsed by the interpreter its shebang names (bash -n, sh -n),
# and so is every gstack shell file it sources, transitively. 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. Bundles run from <root>, so the
# caller's tsconfig.json and bunfig.toml are never read. The shim, its sourced
# helpers, each entry and every local module the entry bundles are scanned for
# merge conflict marker lines, which still parse inside a heredoc, a string, a
# comment or a template literal.
#
# 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 when a hook runs TypeScript) is not found. Set GSTACK_HOOK_CHECK_BUN to
# the bun to run (setup passes its own).
set -u
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
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
case "$ROOT" in /*|[A-Za-z]:[\\/]*) ;; *) ROOT="$PWD/$ROOT" ;; esac
# Git Bash hands native Bun its drive paths as C:/x, and spells the native temp
# directory /tmp: one spelling (/c/x) on both sides of every built.tsv lookup
# (#3088, #3100). The checker is self-contained on purpose (it runs on an
# extracted archive and must not fail for a missing helper); the same mapping
# is gstack_path_shell in bin/gstack-path-spelling.sh, and
# test/path-spelling.test.ts keeps the two equal.
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*)
_native=$(cygpath -am "$ROOT" 2>/dev/null) || _native=""
case "$_native" in
[A-Za-z]:/*) ROOT="/$(printf '%s' "${_native%%:*}" | tr '[:upper:]' '[:lower:]')${_native#?:}" ;;
*) case "$ROOT" in [A-Za-z]:[\\/]*) command -v cygpath >/dev/null 2>&1 && ROOT=$(cygpath -u "$ROOT") ;; esac ;;
esac ;;
CYGWIN*) case "$ROOT" in [A-Za-z]:[\\/]*) ROOT=$(cygpath -u "$ROOT") ;; esac ;;
esac
[ "$ROOT" = / ] || ROOT=${ROOT%/}
SETUP_FILE="${SETUP_FILE:-$ROOT/setup}"
# Frontmatter is the block between a SKILL.md's first two `---` lines.
hook_list() {
sed -n 's/.*\$(_hook_command_path \([A-Za-z0-9._/-]*\).*/\1/p' "$SETUP_FILE" 2>/dev/null
# One awk over every skill (a process start is slow on Windows): only lines
# inside each file's leading frontmatter block count.
set -- "$ROOT"/*/SKILL.md
[ -f "$1" ] || return 0
awk 'FNR == 1 { fm = /^---/; next } { sub(/\r$/, "") } fm && /^---/ { fm = 0 } fm && /^[[:space:]]*command:/' "$@" \
| grep -oE 'skills/gstack/[A-Za-z0-9._/-]+' | sed 's|^skills/gstack/||'
}
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}"
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"
}
# Lists below are newline-separated, so a root with spaces stays one word.
NL='
'
IFS=$NL
FAILED=0
fail() { echo "fail $1 $2: $3"; FAILED=1; }
# sourced_by <file> — the gstack files <file> sources: each `*.sh` path on a
# `.`/`source` line, in a variable assignment, or in a `# shellcheck source=`
# directive. A path resolves from <root>, then beside <file>, then beside
# <file> without its first segment (a `$DIR/` prefix); only files inside
# <root> count.
sourced_by() {
_sdir=${1%/*}
grep -E '^[[:space:]]*#[[:space:]]*shellcheck[[:space:]].*source=|(^|[;&|({[:space:]])(\.|source)[[:space:]]|^[[:space:]]*(local |export |readonly )?[A-Za-z_][A-Za-z0-9_]*=' "$1" \
| grep -oE '[A-Za-z0-9._/-]*\.sh([^A-Za-z0-9_]|$)' | sed -E 's/\.sh.*$/.sh/' | awk '!seen[$0]++' | while IFS= read -r _tok; do
for _cand in "$ROOT/$_tok" "$_sdir/$_tok" "$_sdir/${_tok#*/}"; do
[ -f "$_cand" ] || continue
_cand="$(cd "${_cand%/*}" && pwd)/${_cand##*/}"
case "$_cand" in "$ROOT"/*) [ "$_cand" != "$1" ] && echo "$_cand"; break ;; esac
done
done
}
# helpers_of <shim> — sourced_by, followed transitively.
helpers_of() {
_seen="$NL$1$NL" _queue=$1
while [ -n "$_queue" ]; do
_next=""
for _f in $_queue; do
for _c in $(sourced_by "$_f"); do
case "$_seen" in *"$NL$_c$NL"*) continue ;; esac
_seen="$_seen$_c$NL"
echo "$_c"
_next="$_next$_c$NL"
done
done
_queue=$_next
done
}
# ts_entries <shim> — the TypeScript files a shim runs, beside it.
ts_entries() {
grep -v '^[[:space:]]*#' "$1" | grep -oE '[A-Za-z0-9._-]+\.ts([^A-Za-z0-9_]|$)' | sed -E 's/\.ts.*$/.ts/' | awk '!seen[$0]++'
}
# One bun process bundles every hook's TypeScript entries (a bun start costs
# seconds on Windows). Each line is "<entry>\tok\t<input>" per bundled input,
# or "<entry>\tfail\t<file:line>\t<message>".
# TS_OF[i] keeps hook i's entries for the check loop below, so each shim is
# scanned once (four process starts, ~80 ms on Windows).
ENTRIES=""
i=0
for hook in $HOOKS; do
shim="$ROOT/$hook"
TS_OF[i]=""
[ -f "$shim" ] && TS_OF[i]=$(ts_entries "$shim")
i=$((i + 1))
[ -f "$shim" ] || continue
for entry in ${TS_OF[i - 1]}; do
[ -f "${shim%/*}/$entry" ] && ENTRIES="$ENTRIES${shim%/*}/$entry$NL"
done
done
BUILT="$TMP_OUT/built.tsv"
: > "$BUILT"
if [ -n "$ENTRIES" ] && ! 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
if [ -n "$ENTRIES" ]; then
# Bun reports native spellings; shellPath maps them back to the keys looked
# up below (lib/path-spelling.ts shellPath; test/path-spelling.test.ts keeps
# the two equal).
# shellcheck disable=SC2086 # newline-split list (IFS is set to a newline below)
(cd "$ROOT" && IFS=$NL && "$BUN" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
const shellPath = (value) => process.platform === "win32"
? String(value).replace(/\\/g, "/").replace(/^([A-Za-z]):(?=\/|$)/, (_, drive) => `/${drive.toLowerCase()}`)
: String(value);
for (const entry of process.argv.slice(1)) {
const key = shellPath(entry);
const r = await Bun.build({ entrypoints: [entry], target: "bun", packages: "external", metafile: true, throw: false }).catch(e => ({ success: false, logs: [e] }));
if (r.success) for (const input of Object.keys(r.metafile?.inputs ?? {})) console.log(`${key}\tok\t${shellPath(input)}`);
else {
const log = (r.logs ?? []).find(l => l?.level === "error") ?? r.logs?.[0] ?? {};
const at = log.position ? `${shellPath(log.position.file)}:${log.position.line}` : key;
console.log(`${key}\tfail\t${at}\t${String(log.message ?? log).split("\n")[0]}`);
}
}' $ENTRIES) > "$BUILT" 2>/dev/null || true
fi
i=0
for hook in $HOOKS; do
shim="$ROOT/$hook"
i=$((i + 1))
if [ ! -f "$shim" ]; then echo "missing $hook"; continue; fi
hook_ok=1
shebang=""
IFS= read -r shebang < "$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
scan="$shim"
for helper in $(helpers_of "$shim"); do
if ! err=$("$interp" -n "$helper" 2>&1); then
fail "$hook" "$(first_location "$helper" "$err")" "$(first_message "$err")"
hook_ok=0
fi
scan="$scan$NL$helper"
done
dir=${shim%/*}
for entry in ${TS_OF[i - 1]}; do
if [ ! -f "$dir/$entry" ]; then
fail "$hook" "$dir/$entry:1" "missing TypeScript entry (the hook runs it)"
hook_ok=0
continue
fi
scan="$scan$NL$dir/$entry"
result=$(awk -F '\t' -v e="$dir/$entry" '$1 == e' "$BUILT")
if [ -z "$result" ]; then
fail "$hook" "$dir/$entry:1" "bun could not bundle the TypeScript entry"
hook_ok=0
continue
fi
failed=$(printf '%s\n' "$result" | awk -F '\t' '$2 == "fail" { print $3 "\t" $4; exit }')
if [ -n "$failed" ]; then
fail "$hook" "${failed%% *}" "${failed#* }"
hook_ok=0
continue
fi
for rel in $(printf '%s\n' "$result" | awk -F '\t' '$2 == "ok" { print $3 }'); do
case "$rel" in /*|../*) ;; *) scan="$scan$NL$ROOT/$rel" ;; esac
done
done
# One grep for every scanned file: -m1 stops at each file's first marker,
# and -H prefixes "<file>:<line>:" (the line is the last colon field before
# the marker text, so paths containing colons still split right).
# shellcheck disable=SC2046 # newline-split list (IFS is a newline)
files=""
for f in $scan; do
case "$NL$files" in *"$NL$f$NL"*) ;; *) files="$files$f$NL" ;; esac
done
for found in $(grep -HnE -m1 '^(<<<<<<<|=======|>>>>>>>)( |$)' $files 2>/dev/null); do
marker_file=${found%%:[0-9]*:[<=>]*}
marker_line=${found#"$marker_file":}
marker_line=${marker_line%%:*}
fail "$hook" "$marker_file:$marker_line" "unresolved merge conflict marker"
hook_ok=0
done
[ "$hook_ok" -eq 1 ] && echo "ok $hook"
done
exit "$FAILED"