fix(gates): refuse a configured ROOT that does not exist in check-role-word - #10308
Merged
Conversation
…e-word `walk()` ran behind `existsSync(root)`, so a directory named in ROOTS but absent from the tree was skipped and the gate reached a verdict — including OK — over whatever the surviving roots contributed. #9910 made that legible (the per-root `Scanned:` line) and deliberately stopped short of refusing, because refusing is a verdict change. This takes that change. Measured on the parent commit, all three exit 0: * no root present (reachable with no source edit, by running the gate from any directory that is not the repo root): `OK, no new occurrences` over a zero-file scan, with the cwd-relative ledger reading `{}` so nothing contradicted the green; * one root missing with the ledger fully populated: named in the Scanned line with its zero, still OK — the card's headline case, which the ratchet-DOWN branch never protected because a root whose ledger share is already zero takes nothing with it when it goes; * `--update` with no root present: overwrote a populated ledger with `{}` and exited 0. Per-root rather than the suggested "refuse only when EVERY root is missing". That middle route's whole justification is keeping the gate runnable in a partial checkout, and no such caller exists: the gate has two executing callers (the root `check:role-word` script and the `Lint & Repo Gates` step that runs it), that job checks out with no `sparse-checkout` filter, nothing in the repo configures one, `fetch-depth: 0` and the `--depth` clones elsewhere truncate history rather than the working tree, and both roots are tracked directories. The middle route would protect nobody while leaving the headline case green. A healthy tree's verdict and output are byte-identical. A root that EXISTS and contributes nothing stays green — that is the zero-volume Scanned line's subject, and it is what the per-root #9910 assertions now test. The self-test pins the shape in two layers: pure legs prove the probe discriminates (including the partial tree, the leg the middle route fails), and spawned legs run the gate inside built trees so a probe nothing calls cannot pass. The `--update` leg asserts the ledger is byte-identical afterwards, not merely that the exit code is 1. Fixes #9932 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
os-zhuang
marked this pull request as ready for review
August 20, 2026 15:45
os-zhuang
enabled auto-merge
August 20, 2026 15:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #9932
scripts/check-role-word.mjswalked its roots behindexistsSync(root). A directory named inROOTSbut absent from the tree was skipped, and the gate went on to reach a verdict — including OK — over whatever the surviving roots contributed. #9910 made that condition legible (the per-rootScanned:line) and deliberately stopped short of refusing, because refusing is a verdict change. This is that change.Premise: still valid on current
mainThe card predates PR #9931 (#9910) and PR #10044 (#9947), both of which touched this file. Neither of them took this half. Measured on
bf7cc130, the merge base — all three exit 0:EXIT=0,check-role-word: OK, no new occurrences of the reserved word.over0 .md/.mdx file(s) read across 2 root(s) — content/docs 0, skills 0.ROOTpointed at a non-existenthandbook/)EXIT=0,OK, withScanned: 216 … content/docs 180, skills 36, handbook 0.--updatewith no root present, over a populated ledgerEXIT=0, ledger overwritten with{}The second row is the card's headline case. The incidental protection people reach for — baselined files dropping out of
currentand tripping the ratchet-DOWN branch — never covered it: a root whose ledger share is already zero takes nothing with it when it goes, and every root added toROOTSstarts there by definition.Shape: per-root, not the middle route
Triage suggested the middle route (hard-error only when every configured root is missing) and asked the dev to confirm by measurement, with per-root available if no partial-checkout caller exists. None exists.
check:role-wordpackage script, and theReserved-word ("role") docs ratchetstep in theLint & Repo Gatesjob that runs it. Every other repo-wide hit is a comment, a doc, orscripts/pm/dispatch-gates.mjsreading this file's source for watch hints.actions/checkout@v7and nosparse-checkoutfilter.grep -rn 'sparse-checkout|sparse_checkout|sparseCheckout'over the whole repo exits 1 with 0 hits; the same invocation foractions/checkoutexits 0 with 48 hits, so the search does reach the corpus.fetch-depth: 0there, and the--depthclones elsewhere, truncate history, not the working tree. Both roots are tracked directories, so every checkout of every ref materialises both.So the middle route protects nobody, and it leaves green precisely the case the card calls not-hypothetical-in-shape. The guard's own origin story is not the basis for the decision and could not be checked here anyway:
git log -Sin this depth-50 clone reaches only as far as the declaration commit, so "it was written for partial checkouts" stays an unverified reading, while the caller inventory is a fact about today.A root that exists and contributes nothing is a different condition and stays green on purpose — that is the zero-volume
Scanned:line's subject, and it is what the per-root #9910 assertions now test.After
Same three conditions on this branch:
EXIT=1each,REFUSING to reach a verdict, naming only the roots that did not resolve (handbook, not the two that did). The--updatecase leaves the populated ledger byte-identical (cmpclean) — the refusal precedes the write.A healthy tree is untouched: the gate's output on this tree
diffs identical to the pre-fix run, and--updateon the real tree still writes a byte-identicalscripts/role-word-baseline.json.Pinned in the self-test, in two layers
The pure legs prove the probe discriminates (all absent, partially absent, none absent); the spawned legs run this gate inside built temp trees, so a predicate no caller reaches cannot pass. The
--updateleg asserts ledger byte-identity, not merely an exit code.Both layers were ablated, mutation confirmed on disk by anchor hit-counts each time:
if (existsSync(root)) walk(...), refusal removed) → 3 failures: the all-missing leg, the partial leg, and the--updateleg. The discrimination control (a whole tree is not refused) stayed green.absentRoots.length === ROOTS.length) → exactly 2 failures: the partial leg and the--updateleg. That is the self-test discriminating between the two candidate shapes, not merely asserting that something refuses.Gates
Derived with
node scripts/pm/dispatch-gates.mjs(no path args — it takes the change set from the merge base itself), re-run on the final head80fc64c3with a clean worktree. All exit 0, verdict lines as printed by each gate:pnpm check:role-word—check-role-word: OK, no new occurrences of the reserved word.pnpm check:cross-package-test-inputs—OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.pnpm check:nul-bytes—check-nul-bytes: OK (scanned 6090 text file(s) … no raw ASCII control bytes).pnpm check:ratchet-remedy-authority—98 scripts swept …; 6 mark the expanding remedy ⛔ MAINTAINER-ONLY …(this file is in its control registry and gained a message; its classification is unchanged)pnpm check:pm-dispatch-gates—✓ dispatch-gates self-test: 388 cases pass.(this file's source is parsed by that tool)Exit codes were captured before any pipe throughout.
Declared narrowing: ESLint was not executed locally (it needs a full
pnpm install). Derived fromeslint.config.mjs: the only flat-config entry matching a root.mjsis the first, whose sole rule isno-restricted-importson@objectstack/specsubpaths; the imports added here arenode:builtins, so it cannot fire. CI's lint job runs it regardless.Clause ② : not claimed. Its path limb is
packages/spec/src/**, andscripts/check-role-word.mjsis not that. On the content limb — this does change accept/reject behaviour, but of a repo gate, not of a published contract: nothing outside this repo consumes the verdict, no published artifact changes, and rootscripts/is not a publish surface (which is also why this takesskip-changeset). Recorded rather than left unanswered; the card was in fact worked atopus.Changeset: none. Root
scripts/publishes nothing, so this carriesskip-changeset.Generated by Claude Code
Generated by Claude Code