diff --git a/.claude/hooks/guard-governed-enqueue.selftest.sh b/.claude/hooks/guard-governed-enqueue.selftest.sh new file mode 100755 index 0000000000..c0621ea866 --- /dev/null +++ b/.claude/hooks/guard-governed-enqueue.selftest.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +# Self-test for guard-governed-enqueue.sh — run it after touching that hook: +# +# .claude/hooks/guard-governed-enqueue.selftest.sh +# +# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse +# and asserts the block/allow verdict per case, plus the load-bearing sentences +# of the refusal. Modelled on guard-shared-stash.selftest.sh; the two matrices +# are kept in the same shape so neither drifts into its own idiom. +# +# NO NETWORK. The three GitHub reads come from `OS_GOVERNED_ENQUEUE_FIXTURE` +# (documented in the hook's header as test-only injection): a directory holding +# `pull.json` / `files.json` / `reviews.json`. What is NOT stubbed is the part +# that matters — both predicates run for real, so this matrix fails if the hook +# ever stops asking the register and the queue guard and starts deciding for +# itself. +# +# Needs `jq` (to build fixtures) and `node` (the two real predicates run). No +# pnpm install, no build: measured against a worktree with no `node_modules`. +# +# ⚠️ THE PURE-REGENERATION CASE IS AN AGREEMENT ASSERTION, NOT A FIXED VERDICT, +# and that is a repair rather than a preference: the first revision of this file +# hard-coded `expect allow` against the one exception row that was cheap to +# lift, and that row was retired upstream hours later — the case then went red +# over a register change the hook had nothing to do with. Copying a verdict out +# of the register makes this matrix a second register. It now ASKS the register +# and requires the hook to answer the same way; see that block for the detail. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/guard-governed-enqueue.sh" +repo_root="$(cd "$here/../.." && pwd)" +pass=0 +fail=0 + +command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; } +command -v node >/dev/null 2>&1 || { echo "selftest needs node: both predicates run for real" >&2; exit 1; } +[ -x "$hook" ] || { echo "hook is not executable: $hook" >&2; exit 1; } + +HEAD_SHA=b25f061c6a1d4e2f3c9b8a7d6e5f4a3b2c1d0e9f +OLD_SHA=0f9e8d7c6b5a4938271605f4e3d2c1b0a98877665 + +root="$(mktemp -d)" +trap 'rm -rf "$root"' EXIT INT TERM + +# fixture -> prints the directory +fixture() { + local dir="$root/$1" + mkdir -p "$dir" + jq -nc --arg s "$HEAD_SHA" '{head:{sha:$s}}' > "$dir/pull.json" + printf '%s' "$2" > "$dir/files.json" + printf '%s' "$3" > "$dir/reviews.json" + printf '%s' "$dir" +} + +files_of() { # files_of path... -> the /pulls/{n}/files body shape + local out="[]" p + for p in "$@"; do out="$(printf '%s' "$out" | jq -c --arg f "$p" '. + [{filename:$f}]')"; done + printf '%s' "$out" +} + +approved_at() { # approved_at + jq -nc --arg l "$1" --arg c "$2" '[{state:"APPROVED",user:{login:$l},commit_id:$c}]' +} + +NO_REVIEWS='[]' +GOVERNED_FILES="$(files_of AGENTS.md packages/spec/src/index.ts)" +CLEAR_FILES="$(files_of packages/spec/src/index.ts README.md)" +# The incident's own file class, and four of them, the way it actually happened: +# `skills/*/references/_index.md` is a governed `skills/**` path whose generator +# (`gen:skill-refs`) owns it, so a byte-exact regeneration is lifted and needs no +# approval at all. Every path here must be one the generator DECLARES — a skill +# absent from its map is hand-authored content that stays governed, which is the +# ruling's own limit and not a bug to route around. +REGEN_PATHS="skills/objectstack-data/references/_index.md skills/objectstack-query/references/_index.md skills/objectstack-ui/references/_index.md skills/objectstack-api/references/_index.md" +# shellcheck disable=SC2086 +REGEN_FILES="$(files_of $REGEN_PATHS)" + +F_UNAPPROVED="$(fixture governed-unapproved "$GOVERNED_FILES" "$NO_REVIEWS")" +F_PINNED="$(fixture governed-pinned "$GOVERNED_FILES" "$(approved_at os-zhuang "$HEAD_SHA")")" +F_STALE="$(fixture governed-stale "$GOVERNED_FILES" "$(approved_at os-zhuang "$OLD_SHA")")" +F_OUTSIDER="$(fixture governed-outsider "$GOVERNED_FILES" "$(approved_at os-warren "$HEAD_SHA")")" +F_DISMISSED="$(fixture governed-dismissed "$GOVERNED_FILES" \ + "$(jq -nc --arg c "$HEAD_SHA" '[{state:"APPROVED",user:{login:"os-zhuang"},commit_id:$c},{state:"DISMISSED",user:{login:"os-zhuang"},commit_id:$c}]')")" +F_CLEAR="$(fixture not-governed "$CLEAR_FILES" "$NO_REVIEWS")" +F_REGEN="$(fixture pure-regeneration "$REGEN_FILES" "$NO_REVIEWS")" +F_EMPTY="$(fixture empty-diff '[]' "$NO_REVIEWS")" + +mcp() { # mcp [owner] [repo] + jq -nc --arg t "$1" --argjson n "$2" --arg o "${3:-objectstack-ai}" --arg r "${4:-objectstack}" \ + '{tool_name:$t,tool_input:{owner:$o,repo:$r,pullNumber:$n}}' +} +bash_call() { jq -nc --arg c "$1" '{tool_name:"Bash",tool_input:{command:$c}}'; } + +AUTO=mcp__github__enable_pr_auto_merge +MERGE=mcp__github__merge_pull_request + +# The hook is the LAST element of the pipeline, so `$?` here is the HOOK's exit +# status and not some downstream reader's. That is the only shape in which +# reading a status after a pipe is safe, and it is why nothing is piped past it. +run() { # run [env assignments…] -> allow | block | exitN + local payload="$1"; shift + local rc + printf '%s' "$payload" | env "$@" "$hook" >/dev/null 2>&1 + rc=$? + case "$rc" in + 0) printf 'allow' ;; + 2) printf 'block' ;; + *) printf 'exit%s' "$rc" ;; + esac +} + +stderr_of() { # stderr_of [env…] + local payload="$1"; shift + printf '%s' "$payload" | env "$@" "$hook" 2>&1 >/dev/null +} + +expect() { # expect