fix(hooks): guard-governed-enqueue's sibling-checkout lookup is injectable, so its self-test is hermetic on a box that carries the sibling - #18365
Conversation
…ectable so its self-test is hermetic The self-test case "an exception-row path in a repo this container cannot resolve" rested on a premise about the BOX, not about the hook: that no objectstack-ai/cloud checkout sits beside this one. On a container that does carry a sibling `cloud` checkout the guard resolved it, recomputed the register predicate on it with `--root`, got the governed answer and BLOCKED — the matrix read `54 passed, 1 failed` there and was green in CI only because the runner mounts no sibling. lint.yml's step comment calls these matrices hermetic; this case was not. The hook now reads OS_GOVERNED_ENQUEUE_SIBLING_ROOT for the directory a sibling is resolved UNDER. It moves WHERE the search looks and nothing else: the origin-slug comparison stays the whole admission rule, so a sibling that resolves is audited exactly as before, and a root holding no matching checkout resolves nothing and falls through to the fail-open a box without the sibling already takes. Unset or empty means the parent of this checkout — today's behaviour to the byte, proved by re-running the untouched matrix against the changed hook for output identical to the baseline. The matrix now owns its own premise: the "cannot resolve" case points the lookup at a directory it created and knows is empty, and a new case pins the other half — a sibling that DOES resolve is audited on its own tree, asserted as agreement with the register rather than as a copied verdict, beside a structural assertion that the no-checkout fail-open did not fire. Two things measured on the way and corrected rather than restated: that case never reached the "no checkout … is available" fail-open it claimed to pin (with nothing resolved the register is asked without `--root`, answers about this tree, lifts the path, and the hook leaves at the cleared-predicate exit with empty stderr), and the slug reader keeps a `.git` suffix, so a sibling cloned with the conventional URL does not resolve at all. The first is corrected in the case comment; the second is reported for its own card. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
…rtion Two repairs to the case added in the previous commit, both found by running its firing control rather than by reading it. The companion assertion that the "no checkout … is available" text is ABSENT was a phantom: it stayed green under BOTH controls (a not-governed fixture, and a throwaway sibling whose origin names a different repo). When the sibling does not resolve the hook does not print that warning either — it leaves at the cleared-predicate exit with empty stderr — so the assertion held in both worlds and discriminated nothing. The verdict row is the discriminator, and it does go red under both controls; the phantom is removed and the reason recorded so it is not written again. The register leg also spelled the path a second time, so changing the fixture alone would have left it agreeing about a question nobody asked. One CROSS_REPO_PATH now feeds both. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
|
CI reading — skills seat, session Generated by Claude Code |
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读(终稿)— PR #18365 / #18321改了什么: 验证:本席在带真 要你做的:审 PR #18365,同意即 APPROVE(只能请 hotlong;os-zhuang 是 PR 作者)。本席本班收班,批准后的裁定 C 落地由届时在席者做或你自行 ready + 入队。 顺带发现(已立卡):① Generated by Claude Code |
|
Ruling-C landing provenance — skills seat (signed off, 留守 on this PR), session Generated by Claude Code |
Fixes #18321
The defect
.claude/hooks/guard-governed-enqueue.selftest.sh's case "an exception-row path in a repothis container cannot resolve" rested on a premise about the box, not about the hook:
that no
objectstack-ai/cloudcheckout sits beside this one. The hook resolves a siblingcheckout by comparing origin slugs under the parent directory of its own repo root, so on a
container that does carry a sibling
cloudcheckout the guard resolved it, recomputed theregister predicate on that tree with
--root, got the governed answer and blocked. Thematrix read
54 passed, 1 failedthere, and was green in CI only because the runner mountsno sibling.
lint.yml's step comment calls these matrices hermetic; this case was not.What changed
The hook now reads
OS_GOVERNED_ENQUEUE_SIBLING_ROOTfor the directory a sibling isresolved under. It moves where the search looks and nothing else — the origin-slug
comparison remains the entire admission rule, so a sibling that resolves is audited exactly
as before, and a root holding no matching checkout resolves nothing. No fail-open path was
added or widened.
The matrix now owns its own premise: the "cannot resolve" case points the lookup at a
directory it creates and knows is empty, and one new case pins the other half — a sibling
that does resolve is audited on its own tree. The throwaway sibling is built in the
self-test (
git initplus anoriginnaming the target repo is the whole admissionrequirement) and removed by the existing
trapon the matrix's own temp root, so no newcleanup path was needed. Both temp directories live under that root by construction.
The new case is written as agreement with the register, not as a copied verdict — the
shape this file's own header says it learned the hard way, where a hard-coded
expect allowwent red over an upstream register change the hook had nothing to do with.
Reverse verification
BEFORE, on
origin/main1411cf2c, this container,/home/user/cloudpresent:AFTER, at
5391e5c0, same container,/home/user/cloudstill present — four injectionstates, all
exit 0:"Unset changes nothing" is proved directly, not inferred: the untouched matrix was run
against the changed hook, and its output was byte-identical to the baseline log
(
diffempty — still54 passed, 1 failed, still the same one case).The variable's semantics were also measured against the hook directly, with the real
sibling present, on a payload targeting
objectstack-ai/cloud:Firing control. Two were run, each mutating the committed file, proving the mutation
landed on disk by hash, and restoring with
git checkout HEAD --under atrap(
git diff HEADempty afterwards, blob hash back to the HEAD blob):stays green,
56 passed, 0 failed. It cannot fire, and that is a property of theassertion rather than a gap: an agreement assertion flips the register leg and the hook
together, so they still agree. Reported rather than papered over.
originnamesa different repo, so it is no longer admitted: red,
55 passed, 1 failed,FAIL want=block got=allow a sibling checkout that resolves is audited, never waved through.This is the mutation that corresponds to "the hook stopped consulting the sibling tree",
which is what the case exists to catch.
Diff confined to the two files in the declared surface:
Gates
dispatch-gates.mjsderives 13 families from the real change set (2 paths, three-dot vsmerge base
1411cf2c6); all 13 ran and all recordedexit 0. Reconciliation:check-doc-formula-expressionsfirst returned exit 3 (PREREQUISITE NOT MET — nothingmeasured) because two workspace packages were unbuilt; it was re-run to
exit 0after atargeted build, and only that second reading is recorded. The path face
check-governed-merges.mjs --testanswers GOVERNED (.claude/**), as expected.shellcheckis not measured: the tree wires no shellcheck step (lint.ymlruns the hookmatrices, it does not lint them) and the binary is absent from this container.
bash -nparses both files and
check:bash32-floorpasses.Acceptance notes
Two things measured on the way, both out of scope for this PR and neither fixed here:
on it described the "no checkout of the target repo is available" branch. It does not
reach that branch, in any environment including CI: with nothing resolved the register is
asked without
--root, answers about this tree, finds the path byte-exact against itsown generator and lifts it, so the hook leaves at the cleared-predicate
exit 0withempty stderr. That branch is reachable — a path hitting the exception row but absent
from the generator's declared output set returns governed with a non-empty
exceptionslist — but no case in the matrix reaches it today. The stale comment is corrected here
because it sits on the case being re-keyed; pinning the branch is separate work.
.gitsuffix. Its path character class owns the dot and isgreedy, so an origin of
https://github.com/objectstack-ai/cloud.gityieldsobjectstack-ai/cloud.gitand matches nothing. A sibling cloned with the conventional URLtherefore does not resolve. The same expression also derives the slug for a bare
gh pr merge NUMBER, where a non-matching slug makes the API read 404 and the guard failopen. Measured here; reported for its own card. The new case uses the bare URL form on
purpose and says so in place, so nobody "tidies" a
.gitonto it.Also noted, not filed:
lint.yml's step is named for the two matrices that existed when itwas written, while discovery now picks up more. The step comment is explicit that discovery
is the contract and that a hard-coded list would be the defect, so the name is illustrative
rather than a ledger — nothing to reconcile. Whoever adds the next hook matrix is the one who
reads it.
lint.ymlis outside this PR's declared surface and was read, not edited; itshermetic claim becomes true rather than edited.
Serial context: PR #18317 touched
.claude/settings.jsonand landed before this branch wascut; it is unrelated to either hook file and nothing here depends on it.
维护者速读(草稿)
改了什么 — 给这个 PreToolUse 守卫加了一个环境变量,用来指定「到哪个目录下去找兄弟仓
checkout」。默认不设时行为与今天逐字节一致。自测脚本据此改写了一个用例,并新增一个用例。
为什么改 — 这个自测用例原本依赖「这台机器上没有 cloud 仓的 checkout」这个环境事实,而不
是依赖守卫本身的行为。凡是挂了
/home/user/cloud的机器上它就红,CI 绿只是因为 runner 上恰好没有。测试的结论必须由被测代码决定,不能由机器上还挂了什么决定。
风险与代价(含回滚) — 风险低。变量只改变「去哪里找」,不改变「找到了算不算数」——
判定仍然是比对 origin slug,所以误设一个值只会让它找不到(退回到本来就存在的放行分支),
不会让它放过本该拦截的东西。已逐项实测 unset / 空串 / 空目录 / 不存在目录四种取值。回滚就是
还原这两个文件,无数据迁移、无发布物、无下游依赖。
席位意见 — (待席位填写)
你要做的 —
.claude/**属受管面,按 Prime Directive #14 需要一次授权的 APPROVED 审核;本 PR 保持 draft,未请求任何 reviewer,未触碰 auto-merge。除批准外无需其他动作。
🤖 Generated with Claude Code
https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
Generated by Claude Code