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>
This commit is contained in:
garrytanandBenjaminDSmithy committed 2026-10-06 20:28:26 +00:00
1 parent a08cb03782
commit f10265c383
4 files changed
+437 -95

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
#!/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"
+23
View File
@@ -518,6 +518,29 @@ Bun first.
**Fix.** `cd <gstack checkout> && git pull --ff-only && ./setup`.
<a id="setup-hook-does-not-parse"></a>
### `gstack setup: refusing to register hooks that do not parse (Claude Code would block tool calls with them): <file>:<line>: <error>`
**Meaning.** Claude Code runs gstack's hook shims through `/bin/sh`, and a hook
that does not parse exits 2, which blocks the tool call it guards in every
session. Setup parse-checks every hook it registers (the shim, and the
TypeScript it runs with its local imports). It registers the hooks that parse,
skips the ones listed, finishes the rest of the install, and exits non-zero.
Claude Code runs hooks straight from `~/.claude/skills/gstack`, so a skipped
hook that an earlier setup registered keeps running the broken file until it
is fixed. This is a gstack bug, or a half-applied edit or merge in your
checkout: report the printed `<file>:<line>`.
**Fix.**
```bash
git -C ~/.claude/skills/gstack status # half-applied edits or merge conflicts?
git -C ~/.claude/skills/gstack checkout -- <file> # or finish the merge
cd ~/.claude/skills/gstack && ./setup
```
**Expected result.** Setup finishes with exit 0 and no refusal line.
<a id="cso-windows-msvc-compile"></a>
### `CSO unavailable: its native helper was not built (windows-msvc-compile)`
+61 -35
View File
@@ -3706,7 +3706,14 @@ fi
# Echo the canonical path for a hook (repo-relative arg); fails when the hook
# is not executable at the canonical install — callers must skip + log.
# A hook the parse gate below refused is never registered.
_HOOK_REFUSED=""
_hook_refused() {
case " $_HOOK_REFUSED " in *" $1 "*) return 0 ;; esac
return 1
}
_hook_command_path() {
_hook_refused "$1" && return 1
if [ -x "$CANONICAL_GSTACK_ROOT/$1" ]; then
printf '%s\n' "$CANONICAL_GSTACK_ROOT/$1"
return 0
@@ -3714,31 +3721,35 @@ _hook_command_path() {
return 1
}
# Hook syntax gate (#3032): Claude Code runs these shims through /bin/sh, and a
# shell file that does not parse exits 2, which for a PreToolUse hook blocks the
# tool call in every session on the machine. A half-edited or half-merged hook
# must stop setup with its file and line before any registration is written or
# healed, not install silently.
_HOOK_SYNTAX_PROBLEMS=""
for _hook in bin/gstack-session-update hosts/claude/hooks/question-log-hook \
hosts/claude/hooks/question-preference-hook hosts/claude/hooks/auq-error-fallback-hook \
hosts/claude/hooks/timeline-stop-hook autoplan/bin/phase-publication-hook; do
_hook_file="$CANONICAL_GSTACK_ROOT/$_hook"
[ -f "$_hook_file" ] || continue
if ! _hook_err=$(bash -n "$_hook_file" 2>&1); then
_HOOK_SYNTAX_PROBLEMS="${_HOOK_SYNTAX_PROBLEMS}
$_hook_err"
# Hook parse gate: Claude Code runs these shims through /bin/sh, and a shell
# file that does not parse exits 2, which for a PreToolUse hook blocks the tool
# call in every session on the machine. bin/gstack-hook-check parses every hook
# path this file resolves for registration, and the TypeScript each one runs.
# The canonical root is the checkout Claude Code runs hooks from, so its files
# are already live: there is no staged swap to abort here. Setup registers the
# hooks that parse, refuses the ones that do not (the resolver skips them), and
# exits non-zero at the end with the file and line. The auto-updater runs the
# same check before the checkout moves.
if [ -d "$CANONICAL_GSTACK_ROOT" ]; then
_HOOK_CHECK_RC=0
_HOOK_CHECK_OUT=$(GSTACK_HOOK_CHECK_BUN="$BUN_CMD" "$SOURCE_GSTACK_DIR/bin/gstack-hook-check" \
--setup "$SOURCE_GSTACK_DIR/setup" "$CANONICAL_GSTACK_ROOT") || _HOOK_CHECK_RC=$?
_HOOK_REFUSED=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail \([^ ]*\) .*/\1/p' | awk '!seen[$0]++' | tr '\n' ' ')
_HOOK_PASSED=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^ok //p' | tr '\n' ' ')
_HOOK_REFUSED_AT=$(printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail [^ ]* \([^ ]*\): .*/\1/p' | head -1)
if [ -n "$_HOOK_REFUSED" ]; then
{
echo "gstack setup: refusing to register hooks that do not parse (Claude Code would block tool calls with them):"
printf '%s\n' "$_HOOK_CHECK_OUT" | sed -n 's/^fail [^ ]* / /p'
echo " Hooks that parse register as usual: ${_HOOK_PASSED:-none}"
echo " Skipped: $_HOOK_REFUSED"
echo " This is a gstack bug; report $_HOOK_REFUSED_AT at https://github.com/garrytan/gstack/issues"
echo " Claude Code runs hooks straight from $CANONICAL_GSTACK_ROOT, so a skipped hook that an earlier setup registered, or that a skill file runs, keeps running the broken file until it is fixed."
echo " Fix: repair the file (git -C \"$CANONICAL_GSTACK_ROOT\" status shows half-applied edits or merge conflicts), then re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse"
} >&2
elif [ "$_HOOK_CHECK_RC" -ne 0 ]; then
echo "gstack setup: warning: the hook parse check could not run (exit $_HOOK_CHECK_RC); hooks are registered unchecked." >&2
fi
if [ -f "$_hook_file.ts" ] && ! _hook_err=$(bun_cmd build --no-bundle --target=bun "$_hook_file.ts" 2>&1 >/dev/null); then
_HOOK_SYNTAX_PROBLEMS="${_HOOK_SYNTAX_PROBLEMS}
$_hook_file.ts:
$(printf '%s\n' "$_hook_err" | sed 's/^/ /')"
fi
done
if [ -n "$_HOOK_SYNTAX_PROBLEMS" ]; then
echo "gstack setup: refusing to register hooks: a hook file does not parse, and Claude Code would block tool calls with it:$_HOOK_SYNTAX_PROBLEMS" >&2
echo "Fix: repair the file above (git -C \"$SOURCE_GSTACK_DIR\" status shows half-applied edits or merge conflicts), then re-run ./setup." >&2
exit 1
fi
# Heal-first: prune dead gstack hook entries and re-point survivors at the
@@ -3802,7 +3813,11 @@ if [ "$TEAM_MODE" -eq 1 ]; then
if [ -x "$SETTINGS_HOOK" ] && [ -n "$HOOK_CMD" ]; then
"$SETTINGS_HOOK" add-event --event SessionStart --command "$HOOK_CMD" --source gstack-session-update >/dev/null || true
elif [ -z "$HOOK_CMD" ]; then
log " SessionStart hook not registered: bin/gstack-session-update missing at $CANONICAL_GSTACK_ROOT (no stable install)"
if _hook_refused bin/gstack-session-update; then
log " SessionStart hook not registered: bin/gstack-session-update does not parse (see above)"
else
log " SessionStart hook not registered: bin/gstack-session-update missing at $CANONICAL_GSTACK_ROOT (no stable install)"
fi
fi
log ""
@@ -4002,14 +4017,18 @@ PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted"
# Canonical-only: an ephemeral tree with no stable install gets a visible skip,
# never a baked worktree path.
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] \
&& { [ -z "$PLAN_TUNE_LOG_HOOK" ] || [ -z "$PLAN_TUNE_PREF_HOOK" ]; }; then
&& { { [ -z "$PLAN_TUNE_LOG_HOOK" ] && ! _hook_refused hosts/claude/hooks/question-log-hook; } \
|| { [ -z "$PLAN_TUNE_PREF_HOOK" ] && ! _hook_refused hosts/claude/hooks/question-preference-hook; }; }; then
log " AskUserQuestion hooks not registered: hooks missing at $CANONICAL_GSTACK_ROOT (no stable install)"
fi
# A hook refused by the parse gate leaves its variable empty; the others in
# this block still register.
if [ "$NO_TEAM_MODE" -ne 1 ] \
&& [ -x "$SETTINGS_HOOK" ] \
&& [ -n "$PLAN_TUNE_LOG_HOOK" ] \
&& [ -n "$PLAN_TUNE_PREF_HOOK" ]; then
&& { [ -n "$PLAN_TUNE_LOG_HOOK" ] || _hook_refused hosts/claude/hooks/question-log-hook; } \
&& { [ -n "$PLAN_TUNE_PREF_HOOK" ] || _hook_refused hosts/claude/hooks/question-preference-hook; } \
&& [ -n "$PLAN_TUNE_LOG_HOOK$PLAN_TUNE_PREF_HOOK$AUQ_ERROR_FALLBACK_HOOK" ]; then
# Already installed? Require BOTH the plan-tune source AND the AUQ-error-fallback
# source — so an existing install that predates the fallback hook re-runs the
@@ -4084,13 +4103,15 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
# fail-closed settings error) so callers log honestly instead of claiming
# success for a mutation that never happened.
local _pt_install_rc=0
"$SETTINGS_HOOK" ensure-event \
--event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_LOG_HOOK" \
--source plan-tune-cathedral \
--timeout 5 || _pt_install_rc=1
if [ "$_PT_SKIP_PREF" -eq 0 ]; then
if [ -n "$PLAN_TUNE_LOG_HOOK" ]; then
"$SETTINGS_HOOK" ensure-event \
--event PostToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
--command "$PLAN_TUNE_LOG_HOOK" \
--source plan-tune-cathedral \
--timeout 5 || _pt_install_rc=1
fi
if [ "$_PT_SKIP_PREF" -eq 0 ] && [ -n "$PLAN_TUNE_PREF_HOOK" ]; then
"$SETTINGS_HOOK" ensure-event \
--event PreToolUse \
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
@@ -4428,3 +4449,8 @@ if [ ${#_BACKED_UP_SKILL_MDS[@]} -gt 0 ]; then
log "Moved ${#_BACKED_UP_SKILL_MDS[@]} customized SKILL.md file(s) to $_SKILL_BACKUP_ROOT before installing gstack's: ${_BACKED_UP_SKILL_MDS[*]}"
log " Those were gstack-generated files you had edited; restore anything you meant to keep under a different skill name."
fi
if [ -n "${_HOOK_REFUSED:-}" ]; then
echo "" >&2
echo "gstack setup: everything else is installed, but these hooks were not registered because they do not parse: $_HOOK_REFUSED(first: $_HOOK_REFUSED_AT). This is a gstack bug; report $_HOOK_REFUSED_AT. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse" >&2
exit 1
fi
+236 -60
View File
@@ -1,85 +1,261 @@
/**
* #3032: 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's gate parses every registered hook (bash -n on the
* shim, a Bun parse of its TypeScript entry) and stops with the file and line
* before any registration is written or healed.
* 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. bin/gstack-hook-check parses every hook setup registers (the shim
* with the interpreter its shebang names, and each TypeScript entry it runs
* bundled with its local imports), and setup registers the hooks that parse,
* refuses the rest with file and line, and exits non-zero.
*
* Runs the real gate block extracted from setup against a fixture install;
* driving all of ./setup here is disproportionate.
* The setup tests run the real hook sections of setup (canonical root, gate,
* heal, SessionStart, plan-tune and Stop registration, and the final refusal)
* against a fixture install and a temp settings.json; driving all of ./setup
* (builds, browser install) here is disproportionate.
*/
import { describe, expect, test } from 'bun:test';
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const CHECK = path.join(ROOT, 'bin', 'gstack-hook-check');
const SETUP = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const START = SETUP.indexOf('# Hook syntax gate (#3032)');
const END = SETUP.indexOf('# Heal-first: prune dead gstack hook entries');
const GATE = SETUP.slice(START, END);
const HOOKS = ['bin/gstack-session-update', 'hosts/claude/hooks/question-log-hook', 'hosts/claude/hooks/question-preference-hook',
'hosts/claude/hooks/auq-error-fallback-hook', 'hosts/claude/hooks/timeline-stop-hook', 'autoplan/bin/phase-publication-hook'];
function fixture(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-syntax-gate-'));
function slice(from: string, to: string | null): string {
const start = SETUP.indexOf(from);
expect(start).toBeGreaterThan(-1);
const end = to === null ? SETUP.length : SETUP.indexOf(to, start);
expect(end).toBeGreaterThan(start);
return SETUP.slice(start, end);
}
const HOOK_SECTIONS = [
slice('CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR', '# ─── GBrain detection'),
slice('# 11. Plan-tune cathedral hook install', '# ─── Redact pre-push guard consent'),
slice('if [ -n "${_HOOK_REFUSED:-}" ]; then', null),
].join('\n');
function listHooks(root: string, setupFile?: string): string[] {
const args = setupFile ? ['--setup', setupFile, '--list', root] : ['--list', root];
const r = spawnSync('bash', [CHECK, ...args], { encoding: 'utf8', timeout: 15_000 });
expect(r.status).toBe(0);
return r.stdout.trim().split('\n');
}
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
const HOOKS = listHooks(ROOT);
/** A gstack tree with the real hook shims and trivial TypeScript entries. */
function fixtureTree(root: string) {
for (const hook of HOOKS) {
fs.mkdirSync(path.dirname(path.join(root, hook)), { recursive: true });
fs.copyFileSync(path.join(ROOT, hook), path.join(root, hook));
fs.chmodSync(path.join(root, hook), 0o755);
if (fs.existsSync(path.join(ROOT, `${hook}.ts`))) fs.writeFileSync(path.join(root, `${hook}.ts`), 'export const ok = 1;\n');
}
return root;
fs.copyFileSync(path.join(ROOT, 'setup'), path.join(root, 'setup'));
}
function runGate(root: string) {
const script = `bun_cmd() { bun "$@"; }\nCANONICAL_GSTACK_ROOT='${root}'\nSOURCE_GSTACK_DIR='${root}'\n${GATE}\necho GATE_PASSED`;
const r = spawnSync('bash', ['-c', script], { encoding: 'utf8', timeout: 30_000 });
function tmpBase(): string {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-gate-'));
bases.push(base);
return base;
}
function check(root: string) {
const r = spawnSync('bash', [CHECK, root], { encoding: 'utf8', timeout: 30_000 });
return { code: r.status, out: `${r.stdout}${r.stderr}` };
}
describe('#3032: setup refuses to register a hook that does not parse', () => {
test('the gate runs before any hook registration is healed or written', () => {
expect(START).toBeGreaterThan(-1);
expect(END).toBeGreaterThan(START);
const writes = [...SETUP.matchAll(/"\$SETTINGS_HOOK" (?:ensure-event|add-event|prune-stale|remove-source)/g)].map(m => m.index!);
expect(writes.length).toBeGreaterThan(0);
expect(Math.min(...writes)).toBeGreaterThan(END);
describe('gstack-hook-check: the hook list is the registration code', () => {
test('equals every hook setup resolves for registration plus the frontmatter-registered autoplan hook', () => {
const registered = [...SETUP.matchAll(/_hook_command_path (\S+)/g)].map(m => m[1]!.replace(/\W+$/, ''));
const frontmatter = [...fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md'), 'utf8')
.matchAll(/skills\/gstack\/(autoplan\/bin\/[a-z-]+-hook)\b/g)].map(m => m[1]!);
const expected = [...new Set([...registered, ...frontmatter])].sort();
expect(registered.length).toBeGreaterThanOrEqual(5);
expect(frontmatter.length).toBeGreaterThan(0);
expect([...HOOKS].sort()).toEqual(expected);
});
test('healthy hooks pass', () => {
const root = fixture();
try { expect(runGate(root)).toEqual({ code: 0, out: 'GATE_PASSED\n' }); }
finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('a half-merged shell shim stops setup with its file and line', () => {
const root = fixture();
try {
const shim = path.join(root, 'hosts/claude/hooks/question-preference-hook');
fs.appendFileSync(shim, '<<<<<<< HEAD\nif [ -n "$x" ]; then\n');
const r = runGate(root);
expect(r.code).toBe(1);
expect(r.out).toContain('refusing to register hooks');
expect(r.out).toContain(shim);
expect(r.out).not.toContain('GATE_PASSED');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('a TypeScript entry with a syntax error stops setup with its file', () => {
const root = fixture();
try {
const ts = path.join(root, 'hosts/claude/hooks/timeline-stop-hook.ts');
fs.writeFileSync(ts, 'export const x = {;\n');
const r = runGate(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`${ts}:`);
expect(r.out).toContain('Expected identifier');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test("every shipped hook in this checkout parses", () => {
const r = runGate(ROOT);
expect(r.out).toContain('GATE_PASSED');
test('every shipped hook in this checkout passes', () => {
const r = check(ROOT);
expect(r.code).toBe(0);
expect(r.out.trim().split('\n')).toEqual(HOOKS.map(h => `ok ${h}`));
});
});
describe('gstack-hook-check: what fails', () => {
test('healthy fixture passes', () => {
const root = tmpBase();
fixtureTree(root);
expect(check(root).code).toBe(0);
});
test('a half-merged shell shim fails with its file and line', () => {
const root = tmpBase();
fixtureTree(root);
const shim = path.join(root, 'hosts/claude/hooks/question-preference-hook');
const lines = fs.readFileSync(shim, 'utf8').split('\n').length;
fs.appendFileSync(shim, '<<<<<<< HEAD\nif [ -n "$x" ]; then\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toMatch(new RegExp(`^fail hosts/claude/hooks/question-preference-hook ${shim}:${lines}: syntax error`, 'm'));
});
test('a TypeScript entry with a syntax error fails with its file and line', () => {
const root = tmpBase();
fixtureTree(root);
const ts = path.join(root, 'hosts/claude/hooks/timeline-stop-hook.ts');
fs.writeFileSync(ts, 'export const ok = 1;\nexport const x = {;\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail hosts/claude/hooks/timeline-stop-hook ${ts}:2: Expected identifier`);
});
test('a broken module the entry imports fails at the imported file', () => {
const root = tmpBase();
fixtureTree(root);
const dir = path.join(root, 'hosts/claude/hooks');
fs.writeFileSync(path.join(dir, 'question-log-hook.ts'), "import { log } from './hook-log';\nimport { chromium } from 'playwright';\nlog(chromium);\n");
fs.writeFileSync(path.join(dir, 'hook-log.ts'), 'export function log(x: unknown) {\n return x +;\n}\n');
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail hosts/claude/hooks/question-log-hook ${path.join(dir, 'hook-log.ts')}:2:`);
expect(r.out).not.toContain('playwright');
});
test('a missing local import fails', () => {
const root = tmpBase();
fixtureTree(root);
const entry = path.join(root, 'autoplan/bin/phase-publication-hook.ts');
fs.writeFileSync(entry, "import { x } from '../../lib/gone';\nconsole.log(x);\n");
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail autoplan/bin/phase-publication-hook ${entry}:1: Could not resolve: "../../lib/gone"`);
});
test('a missing entry fails instead of being skipped', () => {
const root = tmpBase();
fixtureTree(root);
const entry = path.join(root, 'hosts/claude/hooks/auq-error-fallback-hook.ts');
fs.rmSync(entry);
const r = check(root);
expect(r.code).toBe(1);
expect(r.out).toContain(`fail hosts/claude/hooks/auq-error-fallback-hook ${entry}:1: missing TypeScript entry`);
});
test('a missing shim is reported as missing, not failed (setup already skips registering it)', () => {
const root = tmpBase();
fixtureTree(root);
fs.rmSync(path.join(root, 'hosts/claude/hooks/timeline-stop-hook'));
const r = check(root);
expect(r.code).toBe(0);
expect(r.out).toContain('missing hosts/claude/hooks/timeline-stop-hook');
});
});
describe('setup: registers the hooks that parse, refuses the rest, exits non-zero', () => {
test('the gate runs before any hook registration is healed or written', () => {
const gate = SETUP.indexOf('# Hook parse gate');
const heal = SETUP.indexOf('# Heal-first: prune dead gstack hook entries');
expect(gate).toBeGreaterThan(-1);
expect(heal).toBeGreaterThan(gate);
const writes = [...SETUP.matchAll(/"\$SETTINGS_HOOK" (?:ensure-event|add-event|prune-stale|remove-source)/g)].map(m => m.index!);
expect(writes.length).toBeGreaterThan(0);
expect(Math.min(...writes)).toBeGreaterThan(heal);
expect(SETUP).not.toContain('refusing to register hooks: a hook file does not parse');
});
function setupFixture() {
const base = tmpBase();
const home = path.join(base, 'home');
const canonical = path.join(home, '.claude', 'skills', 'gstack');
fs.mkdirSync(canonical, { recursive: true });
fs.mkdirSync(path.join(home, '.gstack'), { recursive: true });
fixtureTree(canonical);
return { base, home, canonical, settings: path.join(home, '.claude', 'settings.json') };
}
type Fx = ReturnType<typeof setupFixture>;
function runSetupHooks(fx: Fx) {
const script = [
'set -e',
"log() { printf '%s\\n' \"$*\"; }",
`BUN_CMD='${process.execPath}'`,
`SOURCE_GSTACK_DIR='${ROOT}'`,
`SETTINGS_HOOK='${ROOT}/bin/gstack-settings-hook'`,
`GSTACK_CONFIG='${ROOT}/bin/gstack-config'`,
'TEAM_MODE=1 NO_TEAM_MODE=0 IS_WINDOWS=0 QUIET=1 PLAN_TUNE_HOOKS_MODE="" TIMELINE_STOP_HOOK_MODE=""',
HOOK_SECTIONS,
'echo SETUP_HOOKS_DONE',
].join('\n');
const env: Record<string, string> = {
PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`,
HOME: fx.home,
GSTACK_STATE_ROOT: path.join(fx.home, '.gstack'),
GSTACK_SETTINGS_FILE: fx.settings,
GSTACK_PLAN_TUNE_HOOKS: 'yes',
TMPDIR: fx.base,
};
const r = spawnSync('bash', ['-c', script], { encoding: 'utf8', env, timeout: 60_000 });
return { code: r.status, out: `${r.stdout}${r.stderr}` };
}
function registeredCommands(fx: Fx): string[] {
const settings = JSON.parse(fs.readFileSync(fx.settings, 'utf8'));
return Object.values(settings.hooks ?? {}).flatMap((entries: any) =>
entries.flatMap((e: any) => (e.hooks ?? []).map((h: any) => String(h.command)))).sort();
}
const shim = (fx: Fx, rel: string) => path.join(fx.canonical, rel);
const ALL = ['bin/gstack-session-update', 'hosts/claude/hooks/question-log-hook', 'hosts/claude/hooks/question-preference-hook',
'hosts/claude/hooks/auq-error-fallback-hook', 'hosts/claude/hooks/timeline-stop-hook'];
test('healthy install registers every hook and finishes', () => {
const fx = setupFixture();
const r = runSetupHooks(fx);
expect(r.out).toContain('SETUP_HOOKS_DONE');
expect(r.code).toBe(0);
expect(r.out).not.toContain('refusing to register');
expect(registeredCommands(fx)).toEqual(ALL.map(rel => shim(fx, rel)).sort());
});
test('fresh install: the unparseable hook is not registered, the rest are, and setup exits 1 with file:line', () => {
const fx = setupFixture();
const broken = shim(fx, 'hosts/claude/hooks/question-log-hook');
const lines = fs.readFileSync(broken, 'utf8').split('\n').length;
fs.appendFileSync(broken, 'if then\n');
const r = runSetupHooks(fx);
expect(r.code).toBe(1);
expect(r.out).not.toContain('SETUP_HOOKS_DONE');
expect(r.out).toContain('gstack setup: refusing to register hooks that do not parse');
expect(r.out).toContain(` ${broken}:${lines}: syntax error`);
expect(r.out).toContain('Skipped: hosts/claude/hooks/question-log-hook');
expect(r.out).toContain(`This is a gstack bug; report ${broken}:${lines}`);
expect(r.out).toContain('Hooks that parse register as usual: bin/gstack-session-update hosts/claude/hooks/question-preference-hook');
expect(r.out).toContain('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse');
expect(r.out).not.toContain('previous install is still active');
expect(r.out).not.toContain('no stable install');
expect(registeredCommands(fx)).toEqual(ALL.filter(rel => rel !== 'hosts/claude/hooks/question-log-hook').map(rel => shim(fx, rel)).sort());
});
test('upgrade over live registrations: the others stay registered, the broken one is not re-registered, exit 1', () => {
const fx = setupFixture();
expect(runSetupHooks(fx).code).toBe(0);
const before = registeredCommands(fx);
expect(before).toHaveLength(ALL.length);
// The new revision's Stop hook entry does not parse.
fs.writeFileSync(shim(fx, 'hosts/claude/hooks/timeline-stop-hook.ts'), 'export const x = {;\n');
const r = runSetupHooks(fx);
expect(r.code).toBe(1);
expect(r.out).toContain(`${shim(fx, 'hosts/claude/hooks/timeline-stop-hook.ts')}:1: Expected identifier`);
expect(r.out).toContain('Skipped: hosts/claude/hooks/timeline-stop-hook');
expect(r.out).toContain('keeps running the broken file until it is fixed');
expect(r.out).not.toContain('previous install is still active');
expect(registeredCommands(fx)).toEqual(before);
});
});