From a05a5f961c0d864e2fb156ebe08c0e1ba7cca9ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:54:46 +0000 Subject: [PATCH] ci(lint): run the .claude/hooks self-test matrices in the required job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/hooks/` holds the enforcement behind worktree-first and the stash ban, and each guard ships a hermetic self-test matrix whose header says to re-run it after touching the hook. Nothing ran them — a grep for `selftest` over `.github/workflows/` matched nothing — so a guard that had silently stopped guarding landed green. Adds one step to the `lint` job (`Lint & Repo Gates`, a required status context) that DISCOVERS every `.claude/hooks/**/*.selftest.sh` at run time and runs it. No hook is modified: `.claude/**` is governed surface and this workflow is not. - discovered, never listed, so a matrix added tomorrow is picked up with no edit here - an empty discovery is red, not green (#4690) - tolerate-and-collect (#10814): a bare loop under `bash -e` would abort at the first red matrix and leave the rest unrun, neither green nor red - a step of the required job rather than a job of its own, so a red guard stops the merge queue instead of publishing an advisory context (#5617) Baseline measured on main before wiring: guard-main-checkout-bash 121/0, guard-shared-stash 32/0. Non-vacuity: with the bash guard's sole BLOCK verdict mutated to `exit 0` (scratch, restored byte-identical), the step exits 1, names the failing matrix, and the second matrix still runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- .github/workflows/lint.yml | 100 +++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a701b057e0..91f77b6c47 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -784,6 +784,106 @@ jobs: - name: PM ci-failure self-test run: node scripts/pm/ci-failure.mjs --self-test + # Claude hook guard self-tests (#11514, objectstack half of + # objectstack-ai/objectui#5754). `.claude/hooks/` holds the enforcement + # behind the two rules whose violation is most expensive in this repo — + # worktree-first and the stash ban — and each guard ships a hermetic + # matrix asserting its block/allow verdict case by case, with a header + # telling you to re-run it after touching the hook. Nothing ran them: + # `grep -rn 'selftest' .github/workflows/` returned no match when this + # card was filed, so those headers WERE the enforcement. A guard that has + # silently stopped guarding is worse than no guard, because everyone keeps + # behaving as though it works — the stash ban's own receipt is two + # parallel agents losing their in-flight changes to a shared LIFO stack. + # + # Baseline on `main` before this step existed, measured rather than + # assumed: `guard-main-checkout-bash` 121 cases pass, `guard-shared-stash` + # 32 cases pass. Both guards were healthy; this wires the alarm, it does + # not fix an outage. + # + # ⭐ DISCOVERED at run time, never listed. A hard-coded list is this + # card's own defect one level up: add a hook with a matrix tomorrow and it + # silently is not run, and nothing goes red. The glob is the contract, so + # a new `.claude/hooks/**/*.selftest.sh` is picked up with no edit here. + # `find`, not a flat glob, so a matrix in a subdirectory is not a silent + # miss either. + # + # ...and an EMPTY discovery is RED, not green (#4690). A renamed or moved + # directory would otherwise make this step pass by running nothing, which + # is the identical green line the whole self-test family above exists to + # distrust. The count is printed on success so a SHRINKING set is visible + # in the log rather than inferred. + # + # ⭐ Collected rather than sequenced, for the reason spelled out at the + # `Shallow-history guard self-tests` step above (#10814): a `run:` block + # is executed as `bash -e`, so a bare loop would abort at the FIRST red + # matrix and leave the rest unrun — neither green nor red, and nothing in + # the log tells those apart. The matrices are independent by construction + # (each builds its own throwaway fixture), so collecting loses nothing. + # ⚠️ `check:step-collectors` CANNOT see this block — its population is + # `scripts/`|`packages/` paths carrying `--self-test`, and these are + # `.claude/hooks/*.selftest.sh` — so the collector shape here is held by + # review, not by that gate. Do not "simplify" it into a bare loop. + # + # ⛔ This step RUNS the matrices; it does not modify any hook. `.claude/**` + # is governed surface and this workflow is not — that separation is what + # keeps the wiring on the ordinary merge path. + # + # Home: a STEP of this job rather than a job of its own, deliberately. + # `Lint & Repo Gates` is a required status context (pinned by + # `check:required-contexts`); a new job would publish a context that is in + # no ruleset, so a red guard would be advisory and the merge queue would + # not stop for it — #5617 verbatim, which is the exact shape this card + # exists to close. + # + # Hermetic by construction, and measured that way: each matrix builds its + # own git repo and linked worktree under $TMPDIR, and both were re-run + # from a primary checkout, a detached HEAD and a non-repo cwd with + # identical results, so neither this checkout's depth nor its branch is an + # input. Needs `jq` and `git` and nothing else — no pnpm, no node, no + # build, no network (`jq` is in the runner image and is already relied on + # bare by cut-rc.yml and release.yml). ~2 s measured. + - name: Claude hook guard self-tests (worktree-first · stash ban) + run: | + # Tolerate-and-collect (#10814) — see the note above this step. Each + # matrix runs unconditionally and prints its own verdict; the step + # still FAILS when any of them does, naming every one that failed. + # ⛔ Never let the collector swallow the exit code — a green step over + # a red self-test looks identical to success. + mapfile -t selftests < <(find .claude/hooks -type f -name '*.selftest.sh' | sort) + if [ "${#selftests[@]}" -eq 0 ]; then + echo "Claude hook guard self-tests — DISCOVERED NOTHING under .claude/hooks/." + echo "This step verified nothing, which is a failure and not a pass (#4690):" + echo "the matrices were moved, renamed or deleted. Re-point the search above" + echo "rather than deleting the step." + exit 1 + fi + echo "discovered ${#selftests[@]} hook self-test(s):" + printf ' %s\n' "${selftests[@]}" + echo "" + failed="" + run_self_test() { + echo "-- $*" + if "$@"; then + echo "PASS $*" + else + echo "FAIL $*" + failed="${failed} $*"$'\n' + fi + return 0 + } + for selftest in "${selftests[@]}"; do + run_self_test "$selftest" + done + if [ -n "$failed" ]; then + echo "" + echo "Claude hook guard self-tests — the following FAILED:" + printf "%s" "$failed" + exit 1 + fi + echo "" + echo "Claude hook guard self-tests — all ${#selftests[@]} ran and passed" + # Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in # Markdown/MDX are not type-checked or ESLinted, so skills/ and # content/docs/ can drift back to teaching the bare `: Page = {}` literal