Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

feat(hooks): guard the origin/main ENUMERATION half, and record the incident in AGENTS.md #10

feat(hooks): guard the origin/main ENUMERATION half, and record the incident in AGENTS.md

feat(hooks): guard the origin/main ENUMERATION half, and record the incident in AGENTS.md #10

Workflow file for this run

name: Hook Self-Tests
# The standing caller for `.claude/hooks/*.selftest.sh` — objectui#5754.
#
# ── The gap this closes ─────────────────────────────────────────────────────
#
# `.claude/hooks/guard-main-checkout-bash.sh` and `.claude/hooks/guard-shared-stash.sh`
# are the PreToolUse guards behind the rule both `CLAUDE.md` files state as binding —
# worktree-first, and never `git stash` (see those files' "⛔" sections, and
# AGENTS.md §9). Each ships a hermetic self-test matrix
# (`guard-main-checkout-bash.selftest.sh`, `guard-shared-stash.selftest.sh`) that
# needs only `jq` and `git` — no install, no build, no network — and both files'
# own headers say to run the matching self-test after touching the hook. Nothing
# ever did: `git grep -rn selftest .github/workflows/` returned no matches before
# this workflow existed. A hook is not imported by any package, so no unit test,
# type check, or lint reaches it either (`eslint.config.js` is scoped to
# `**/*.{ts,tsx}` throughout, and there is no `shellcheck` anywhere in this repo).
# The self-test matrix was therefore exercised only when a human — or an agent —
# remembered, in both repos, and a regression in either direction is invisible
# until it bites: fail-open silently stops guarding the shared checkout, fail-closed
# trains an operator onto the `OS_ALLOW_MAIN_EDITS=1` / `OS_ALLOW_STASH=1` escape
# hatch, switching the guard off for the whole command. Neither shows up in a PR.
#
# ── What this job is, and is not ────────────────────────────────────────────
#
# This is a RUNNER, not a rewrite of what it runs. It does not touch the hooks or
# their self-tests — `.claude/**` is governed surface, and the self-test matrices
# already assert their own pass/fail per case; this job only gives that matrix
# a caller that runs on every PR the matrix could catch a regression on, and
# turns "which self-tests failed" into a red check instead of scrollback nobody
# reads. If a self-test itself is ever found broken, that is a finding for a
# *separate* PR against the governed surface, not something to patch here.
#
# ── Why path-filtered, unlike `control-bytes.yml` ───────────────────────────
#
# `control-bytes.yml`'s header explains at length why ITS gate carries no `paths`
# filter: a markdown-only PR can trip a raw control byte just as easily as a
# TypeScript one, so an unfiltered gate is the only kind that can see it. That
# reasoning does not transfer here — both self-tests build their own hermetic
# git fixtures and assert the CURRENT hook script's behaviour; nothing about a
# docs-only or dependency-bump PR can move that needle. So this workflow mirrors
# `changeset-guard.yml`'s shape instead: it fires only on a PR (or push to `main`)
# that touches `.claude/hooks/**` or this workflow file itself.
#
# ── Cost — a dated measurement, not a live claim ─────────────────────────────
#
# Measured 2026-08-24 on `origin/main` @ 53dc89db8:
# `guard-main-checkout-bash.selftest.sh` (100 cases) ~3.2s wall,
# `guard-shared-stash.selftest.sh` (32 cases) ~0.6s wall. Both need only `jq`
# and `git`, both preinstalled on `ubuntu-latest` — no `setup-node`, no
# install, no build.
#
# Those counts stay, and the distinction is deliberate (objectui#6089): a
# measurement is HISTORY. It is read together with its date and its sha, and a
# matrix that grows afterwards does not make it false — it makes it older. A
# step NAME is a LIVE CLAIM about the run it labels, so it carries no count at
# all. See the next section.
#
# ── Why the step names carry no case counts ──────────────────────────────────
#
# Each `run:` step below is named for the self-test it calls and nothing else.
# Do not add "(N cases)" back to either name — this file used to do exactly
# that, and it was the declared≠actual shape:
#
# - Nothing derived those numbers and nothing re-checked them. A step name
# has no runtime behaviour, so a wrong count never turns a check red; it
# just shows a wrong number on the checks page, which is the one place a
# reviewer actually looks.
# - The number is already published by the only thing that cannot get it
# wrong: each run's own tail prints `N passed, N failed`, produced by the
# matrix being counted.
# - Where a count belongs beside prose, the hook header owns it WITH the
# recipe to re-derive it: `guard-shared-stash.sh` states its total, how to
# re-derive it (`grep -c '^expect ' <selftest>` plus the inline specials)
# and that it must equal the run's tail. `guard-main-checkout-bash.sh`
# describes its matrix with no count at all. A second copy here would be a
# second thing to keep in sync, and the one with no recipe behind it.
#
# The drift was structural, not carelessness: this file is a RUNNER, so the
# matrices it calls are meant to grow in `.claude/**` WITHOUT this file being
# touched (both #6042 / PR #6087 and #5789 / PR #6046 do exactly that, and are
# right to). Dropping the duplicate is what makes that independence safe.
#
# ── Fail-closed, deliberately ────────────────────────────────────────────────
#
# Each self-test script already exits non-zero on any failing case (its own
# `pass`/`fail` tally), so a plain `run:` step is enough to fail the job — and
# therefore the workflow — the moment either matrix regresses. A gate that ran
# these and reported without failing the build would reproduce exactly the defect
# this workflow exists to close.
on:
pull_request:
branches: [main, develop]
paths:
- '.claude/hooks/**'
- '.github/workflows/hook-selftests.yml'
push:
branches: [main]
paths:
- '.claude/hooks/**'
- '.github/workflows/hook-selftests.yml'
workflow_dispatch:
concurrency:
group: hook-selftests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
hook-selftests:
name: Hook Self-Tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v7
# Hermetic: builds its own throwaway git repo, worktree and non-repo
# directory under $TMPDIR. Exits non-zero on any failing case.
- name: guard-main-checkout-bash self-test
run: .claude/hooks/guard-main-checkout-bash.selftest.sh
# Hermetic in the same sense. Exits non-zero on any failing case.
- name: guard-shared-stash self-test
run: .claude/hooks/guard-shared-stash.selftest.sh
# Hermetic in the same sense — it feeds the hook JSON payloads and needs
# no git fixture at all. Exits non-zero on any failing case.
#
# ⚠️ This step had to be ADDED BY HAND, and that is the standing cost of
# the shape above: the steps here are a hand-kept ENUMERATION of
# `.claude/hooks/*.selftest.sh`, so a new matrix ships uncalled unless
# someone remembers this file — the workflow name claims to be the
# standing caller for all of them, and nothing makes the claim true.
# objectstack's twin does not have this failure mode: its collector runs
# `find .claude/hooks -type f -name '*.selftest.sh'`, so a new self-test is
# picked up with no workflow edit. Converting this job to that shape is
# worth doing and is deliberately NOT done here (it is a change to the
# runner's design, not to the matrix it runs); it is filed separately.
- name: guard-tree-enum self-test
run: .claude/hooks/guard-tree-enum.selftest.sh