Skip to content

Commit b9598e9

Browse files
os-elon-muskclaude
andauthored
test(hooks): pin guard-governed-enqueue's "no checkout available" fail-open (#18423)
Fixes #18367 ## 维护者速读(草稿) **改了什么** 只改一个文件:`.claude/hooks/guard-governed-enqueue.selftest.sh`,新增一个自检用例(4 条断言)。守卫脚本 `guard-governed-enqueue.sh` 一个字节未动。 **为什么改** 该守卫有一条「放行」分支:它拿到一条生成物例外行,却找不到目标仓的任何检出来重算这行的来源,于是打印警告并放行。这条分支在任何环境下都没有用例钉住——原先声称覆盖它的那个用例其实到不了:它用的路径与本树自己的生成器逐字节相等,注册表直接豁免,守卫在更上游的「谓词已清」出口就退出了,stderr 全空。也就是说,这条放行分支可以被悄悄删掉或写错而全绿。新用例把它钉住:同一个注入的空目录,换成一条任何生成器都不声明的 `skills/**` 路径,注册表在哪里问都答「受管 + 一条例外行」,守卫因此进入放行分支并打印那句话。 **风险与代价(含回滚)** 风险低:纯测试面,不改任何运行时判定;守卫的行为、放行/拦截口径完全不变。用例是自封闭的(自己造空目录、自己造 fixture),在装了 `node_modules` 的工作树和本文件自称运行的裸树上都成立——两边注册表给的理由不同,但前提(exit 3 带一条例外行)相同,已分别实测。代价是自检从 62 条变 66 条,整轮约多跑 5 秒。回滚 = revert 本 PR 的唯一 commit,不留任何残留(无生成物、无配置、无 changeset)。 **席位意见** (留空) **你要做的** 一个动作:确认这条放行分支值得被钉住,然后批准(governed surface,本 PR 保持 draft,不由任何 AI 席位落地)。 ## What the case pins The hook's fail-open sentence is `PR_REF may hit a generated-surface exception row, and no checkout of OWNER/REPO is available to recompute its provenance on` (warn + allow). Reaching it needs two things at once: no checkout of the target repo resolves, AND the register still answers governed (exit 3) carrying an exception row. The existing cross-repo case supplies the first and is lifted out of the second, which is why its own comment says, correctly, that it does not reach this branch. The new case keeps the injected empty root the case above already builds and changes only the path shape: ``` skills/zz-no-such-skill/references/_index.md ``` Measured in this installed worktree: ``` $ node scripts/pm/check-governed-merges.mjs --test --json skills/zz-no-such-skill/references/_index.md ; echo EXIT=$? EXIT=3 exceptions: 1 reason: "`pnpm --filter @objectstack/spec gen:skill-refs` does not write skills/zz-no-such-skill/references/_index.md — it is not among the 9 file(s) that generator declared on this tree ... fail closed: the path stays governed" ``` and in a checkout with no `node_modules` (the shape this self-test documents itself as running under): ``` EXIT=3 exceptions: 1 reason: "the generator declared no output set (the generator's own --check exited 254) ... fail closed: the path stays governed" ``` Two reasons, one precondition — exit 3 carrying an exception row — and that precondition is all the branch reads, so the case is hermetic in both directions. The hook, fed that payload with the empty root injected, answers: ``` HOOK_EXIT=0 ⚠️ guard-governed-enqueue: objectstack-ai/cloud#999 may hit a generated-surface exception row, and no checkout of objectstack-ai/cloud is available to recompute its provenance on — ALLOWING (the merge-queue guard remains the hard line). ``` ## The text assertions are red under a firing control The card asks for exactly this, and it is the file's own phantom-check lesson: a `says` row that nothing can make fail pins nothing. The control is the same payload with the sibling RESOLVED (the throwaway `git init` + `origin` the case above already builds), where the hook recomputes on that tree and never reaches the branch. **The negated expectation.** From the committed implementation, one line was mutated on disk — the `says` row that names the repo was pointed at the RESOLVED root instead of the empty one: ``` mutated line 517: "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$NO_SIBLING_ROOT" -> "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$SIBLING_ROOT" ``` On-disk proof of the mutation, before reading any verdict (an editor's exit code is not evidence): ``` before: NO_SIBLING_ROOT occurrences = 4 · SIBLING_ROOT (resolved) occurrences = 2 after: NO_SIBLING_ROOT occurrences = 3 · SIBLING_ROOT (resolved) occurrences = 3 HEAD blob: 2d9f332 working blob: 9b6f2bc7c26375f36d18aac97ba0a0d61ad9c515 (differs — the mutation reached disk) ``` **Red:** ``` $ bash .claude/hooks/guard-governed-enqueue.selftest.sh FAIL missing "no checkout of objectstack-ai/cloud" the fail-open names the repo it could not resolve 65 passed, 1 failed ``` **Restored, then green.** Restore was `git checkout HEAD -- PATH` (never the bare form, which takes the mutation back out of the index), proven by bytes rather than by an exit code: `git diff HEAD --stat` empty, and `git hash-object` on the file equals `2d9f332ea518826251a8f760d8d3e9bed72a2dde`, the HEAD blob above. The real run: ``` == an exception row with NO tree to recompute it on takes the fail-open == .. register verdict on THIS tree for the ungenerated path: exit 3 with 1 exception row(s) ok allow an exception row with no checkout to recompute it on is waved through, not refused ok says the fail-open names the repo it could not resolve ok says the fail-open names the reading that was missing ok lacks the same payload with the sibling RESOLVED never claims a missing checkout 66 passed, 0 failed ``` **62 / 62 on the base, 66 / 66 here** — k = 4 (one verdict row, two text rows, one control row). The register verdict line is printed, never asserted: it pins the hook's branch, not the register's answer, and it is there so a dead premise reads as one line instead of as two mysteriously red text rows. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 14 families for this path; all 14 ran in the foreground with exit codes captured before any pipe, plus `pnpm check:pm-settings-deny-roster` (the roster the derivation marks as living under `.claude`, where silence is evidence in neither direction). Reconciliation: `14 derived, 14 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero. ``` node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/pm/check-harness-current.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:pm-settings-deny-roster :: exit 0 ``` `check:doc-formula-expressions` first exited 3 — PREREQUISITE NOT MET, an unbuilt `@objectstack/lint` / `@objectstack/formula`, which measures nothing. It was re-run to exit 0 after `pnpm exec turbo run build --concurrency=2 --filter=@objectstack/formula --filter=@objectstack/lint`; the 3 is recorded here so the re-run is not read as the only attempt. ## Changeset None, and that is the rule's own arm. AGENTS.md, Post-Task Checklist step 3, verbatim: 「⛔ never `skip-changeset`: that label is for a diff that publishes nothing from any released package.」 This diff is one file under `.claude/`, which no package's `files[]` ships, so nothing publishes and the label is exactly what the sentence describes. Applied additively on the PR and read back. ## Acceptance notes - The path in the fixture is deliberately one that no generator declares, and the case comment says so in place. A declared `skills/*/references/_index.md` is byte-exact against its own output on this tree and would be lifted, which would turn the case into a silent second copy of the cannot-resolve one — the failure this file has already been repaired for once. - The control root is the resolved sibling the case above builds, reused rather than re-created, so the two cases cannot drift into asking about different emptiness. If that block is ever deleted, this case fails loudly on the unbound name rather than degrading quietly. - The hook is unchanged. Nothing in writing this case suggested a defect in it: the branch is reachable, its guard condition is the one its comment describes, and its warning names both the repo it could not resolve and the reading that was missing. --- _Generated by [Claude Code](https://claude.ai/code/session_01HPfcjvF23QBoBj7P47DDxs)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55fd5ee commit b9598e9

1 file changed

Lines changed: 61 additions & 0 deletions

File tree

‎.claude/hooks/guard-governed-enqueue.selftest.sh‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,67 @@ expect "$sibling_want" 'a sibling checkout that resolves is audited, never waved
470470
# worlds and discriminated nothing. The verdict row above is the discriminator,
471471
# and it is the one that goes red when the sibling stops being resolved.
472472

473+
echo "== an exception row with NO tree to recompute it on takes the fail-open =="
474+
# The branch neither case above reaches, and the reason it needs a third fixture
475+
# rather than a third root: with nothing resolved the register is asked WITHOUT
476+
# `--root`, so it answers about THIS tree, and a path this tree's generator owns
477+
# is LIFTED there — the cleared-predicate `exit 0`, several branches ABOVE the
478+
# fail-open. To reach it the payload must carry a path that stays GOVERNED with
479+
# an exception row wherever the register is asked: a `skills/**` path that no
480+
# generator declares. The hook then holds an exception row and no right tree to
481+
# recompute its provenance on, which is the whole of this branch's premise.
482+
#
483+
# The injected root is the EMPTY one the case above already built — same
484+
# injection, and the path shape is the only thing separating the cleared exit
485+
# from this warning. Reused rather than re-created so the two cases cannot drift
486+
# into asking about different emptiness.
487+
#
488+
# ⛔ Do not "fix" the path to a real skill. A declared path is byte-exact
489+
# against its own output here and is lifted, which would turn this case into a
490+
# silent second copy of the one above — green forever over a branch nothing
491+
# reaches, the exact failure this file was repaired for once already.
492+
#
493+
# Hermetic in both directions and both were measured: with `node_modules`
494+
# present the register answers "does not write … not among the 9 file(s) that
495+
# generator declared on this tree"; without it, "the generator declared no
496+
# output set (the generator's own --check exited 254)". Two reasons, one
497+
# precondition — exit 3 carrying an exception row — and that precondition is all
498+
# this branch reads, so the case holds on an installed worktree and on the bare
499+
# one this file documents itself as running under.
500+
UNGENERATED_PATH=skills/zz-no-such-skill/references/_index.md
501+
F_UNGENERATED="$(fixture ungenerated-exception-row "$(files_of "$UNGENERATED_PATH")" "$NO_REVIEWS")"
502+
# Printed, never asserted: the rows below pin the HOOK's branch, not a register
503+
# verdict, so copying one in would make this matrix a second register again.
504+
# It is here so a dead premise — the register no longer answering governed with
505+
# an exception row for this shape — reads as one line instead of as two
506+
# mysteriously red text assertions.
507+
node "$repo_root/scripts/pm/check-governed-merges.mjs" --test --json "$UNGENERATED_PATH" \
508+
> "$root/ungenerated-verdict.json" 2>/dev/null
509+
ungenerated_rc=$?
510+
printf ' .. register verdict on THIS tree for the ungenerated path: exit %s with %s exception row(s)\n' \
511+
"$ungenerated_rc" "$(jq '.exceptions | length' < "$root/ungenerated-verdict.json" 2>/dev/null || printf '?')"
512+
expect allow 'an exception row with no checkout to recompute it on is waved through, not refused' \
513+
"$(mcp $AUTO 999 objectstack-ai cloud)" \
514+
"OS_GOVERNED_ENQUEUE_FIXTURE=$F_UNGENERATED" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$NO_SIBLING_ROOT"
515+
expect_says 'no checkout of objectstack-ai/cloud' 'the fail-open names the repo it could not resolve' \
516+
"$(mcp $AUTO 999 objectstack-ai cloud)" \
517+
"OS_GOVERNED_ENQUEUE_FIXTURE=$F_UNGENERATED" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$NO_SIBLING_ROOT"
518+
expect_says 'recompute its provenance' 'the fail-open names the reading that was missing' \
519+
"$(mcp $AUTO 999 objectstack-ai cloud)" \
520+
"OS_GOVERNED_ENQUEUE_FIXTURE=$F_UNGENERATED" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$NO_SIBLING_ROOT"
521+
# THE FIRING CONTROL, and it is what keeps the two rows above from being the
522+
# phantom this file has already paid for once: the same payload with the sibling
523+
# RESOLVED (the throwaway built above — one, not two) recomputes on that tree
524+
# and never reaches the branch, so the sentence must be ABSENT there. Measured
525+
# in both directions before it was written: unresolved prints it and allows,
526+
# resolved prints the ordinary governed refusal and blocks. Point either `says`
527+
# row at this root and it goes red — which is the proof a text assertion owes,
528+
# and the reading the companion assertion on the resolved-sibling case could not
529+
# give, since nothing prints that sentence in EITHER of the two worlds it saw.
530+
expect_lacks 'no checkout of' 'the same payload with the sibling RESOLVED never claims a missing checkout' \
531+
"$(mcp $AUTO 999 objectstack-ai cloud)" \
532+
"OS_GOVERNED_ENQUEUE_FIXTURE=$F_UNGENERATED" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$SIBLING_ROOT"
533+
473534
echo "== every spelling of that same origin URL resolves to that same sibling =="
474535
# The other half of this card's defect. `git clone` hands out
475536
# `https://github.com/objectstack-ai/cloud.git`, and until `slug_of` stripped that

0 commit comments

Comments
 (0)