From f10265c3833da9df7700c5e6b57f2f7cfd14e990 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 6 Oct 2026 20:28:26 +0000 Subject: [PATCH] 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 --- bin/gstack-hook-check | 117 +++++++++++ docs/troubleshooting.md | 23 +++ setup | 96 +++++---- test/setup-hook-syntax-gate.test.ts | 296 ++++++++++++++++++++++------ 4 files changed, 437 insertions(+), 95 deletions(-) create mode 100755 bin/gstack-hook-check diff --git a/bin/gstack-hook-check b/bin/gstack-hook-check new file mode 100755 index 000000000..3601e8d47 --- /dev/null +++ b/bin/gstack-hook-check @@ -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 ] --list print the checked hooks +# gstack-hook-check [--setup ] 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 ` argument in /setup (or the setup named by +# --setup, when another tree's setup registers '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 `.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 +# missing (no shim: setup skips registering it) +# fail :: +# 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 ] [--list] " >&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 — ":" from bun's "at f:l:c" +# or bash's "f: line N:" (falls back to ). +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" diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8d83d14db..6cecc0bd3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -518,6 +518,29 @@ Bun first. **Fix.** `cd && git pull --ff-only && ./setup`. + +### `gstack setup: refusing to register hooks that do not parse (Claude Code would block tool calls with them): :: ` + +**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 `:`. + +**Fix.** + +```bash +git -C ~/.claude/skills/gstack status # half-applied edits or merge conflicts? +git -C ~/.claude/skills/gstack checkout -- # or finish the merge +cd ~/.claude/skills/gstack && ./setup +``` + +**Expected result.** Setup finishes with exit 0 and no refusal line. + ### `CSO unavailable: its native helper was not built (windows-msvc-compile)` diff --git a/setup b/setup index 295d61ff7..216e0363b 100755 --- a/setup +++ b/setup @@ -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 diff --git a/test/setup-hook-syntax-gate.test.ts b/test/setup-hook-syntax-gate.test.ts index fb47aa1ab..4ab686509 100644 --- a/test/setup-hook-syntax-gate.test.ts +++ b/test/setup-hook-syntax-gate.test.ts @@ -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; + + 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 = { + 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); }); });