#!/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"
