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); }); });