mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
Git Bash hands native Bun C:/ paths while gstack-hook-check looked them up as /c/, so every TypeScript hook was refused and team-mode auto-update held every release since 1.91.31. The checker maps Bun's paths back to the shell spelling, the auto-updater lets the incoming release's own checker decide (installed checker only as a fallback, hook-check-unavailable when neither verifies), and docs/troubleshooting.md gives stuck installs one recovery command. /gstack-upgrade and /spec guards use the --show-prefix root check, gstack-wtree treats drive-letter --git-path answers as absolute, and setup refuses to replace a runtime root that is or contains its own checkout, Kiro's in-place root included (#3084), with --status suggesting the move. New sourced helper bin/gstack-path-spelling.sh and TS twin lib/path-spelling.ts. Co-authored-by: yuriysan <63948747+yuriysan@users.noreply.github.com> Co-authored-by: y$un_ <3579304+szsunyuan@users.noreply.github.com> Co-authored-by: Frederik Reventlow <267160539+dmfrede@users.noreply.github.com> Co-authored-by: skleinbe-ny <226563731+skleinbe-ny@users.noreply.github.com> Co-authored-by: bkermen <184220642+bkermen@users.noreply.github.com> Co-authored-by: Drew Jankowski <3200063+andrewkjankowski@users.noreply.github.com>
256 lines
11 KiB
Bash
Executable File
256 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
|
|
|
|
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>".
|
|
ENTRIES=""
|
|
for hook in $HOOKS; do
|
|
shim="$ROOT/$hook"
|
|
[ -f "$shim" ] || continue
|
|
for entry in $(ts_entries "$shim"); 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" -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
|
|
|
|
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
|
|
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_entries "$shim"); 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)
|
|
for found in $(grep -HnE -m1 '^(<<<<<<<|=======|>>>>>>>)( |$)' $(printf '%s\n' "$scan" | awk '!seen[$0]++') 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"
|