Skip to content

fix(gates): refuse a configured ROOT that does not exist in check-role-word - #10308

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9932-role-word-missing-root
Aug 20, 2026
Merged

fix(gates): refuse a configured ROOT that does not exist in check-role-word#10308
os-zhuang merged 1 commit into
mainfrom
claude/issue-9932-role-word-missing-root

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #9932

scripts/check-role-word.mjs walked its roots behind existsSync(root). A directory named in ROOTS but 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-root Scanned: line) and deliberately stopped short of refusing, because refusing is a verdict change. This is that change.

Premise: still valid on current main

The 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:

condition before
no root exists (run the gate from any cwd that is not the repo root — no source edit needed) EXIT=0, check-role-word: OK, no new occurrences of the reserved word. over 0 .md/.mdx file(s) read across 2 root(s) — content/docs 0, skills 0.
one root missing, ledger fully populated (a third ROOT pointed at a non-existent handbook/) EXIT=0, OK, with Scanned: 216 … content/docs 180, skills 36, handbook 0.
--update with no root present, over a populated ledger EXIT=0, ledger overwritten with {}

The second row is the card's headline case. The incidental protection people reach for — baselined files dropping out of current and 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 to ROOTS starts 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.

  • The gate has exactly two executing callers: the root check:role-word package script, and the Reserved-word ("role") docs ratchet step in the Lint & Repo Gates job that runs it. Every other repo-wide hit is a comment, a doc, or scripts/pm/dispatch-gates.mjs reading this file's source for watch hints.
  • That job checks out with actions/checkout@v7 and no sparse-checkout filter. grep -rn 'sparse-checkout|sparse_checkout|sparseCheckout' over the whole repo exits 1 with 0 hits; the same invocation for actions/checkout exits 0 with 48 hits, so the search does reach the corpus.
  • fetch-depth: 0 there, and the --depth clones 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 -S in 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=1 each, REFUSING to reach a verdict, naming only the roots that did not resolve (handbook, not the two that did). The --update case leaves the populated ledger byte-identical (cmp clean) — 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 --update on the real tree still writes a byte-identical scripts/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 --update leg asserts ledger byte-identity, not merely an exit code.

Both layers were ablated, mutation confirmed on disk by anchor hit-counts each time:

  • Pre-fix behaviour restored (if (existsSync(root)) walk(...), refusal removed) → 3 failures: the all-missing leg, the partial leg, and the --update leg. The discrimination control (a whole tree is not refused) stayed green.
  • The middle route (absentRoots.length === ROOTS.length) → exactly 2 failures: the partial leg and the --update leg. 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 head 80fc64c3 with a clean worktree. All exit 0, verdict lines as printed by each gate:

  • pnpm check:role-wordcheck-role-word: OK, no new occurrences of the reserved word.
  • pnpm check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
  • pnpm check:nul-bytescheck-nul-bytes: OK (scanned 6090 text file(s) … no raw ASCII control bytes).
  • pnpm check:ratchet-remedy-authority98 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 from eslint.config.mjs: the only flat-config entry matching a root .mjs is the first, whose sole rule is no-restricted-imports on @objectstack/spec subpaths; the imports added here are node: builtins, so it cannot fire. CI's lint job runs it regardless.

Clause ② : not claimed. Its path limb is packages/spec/src/**, and scripts/check-role-word.mjs is 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 root scripts/ is not a publish surface (which is also why this takes skip-changeset). Recorded rather than left unanswered; the card was in fact worked at opus.

Changeset: none. Root scripts/ publishes nothing, so this carries skip-changeset.

Generated by Claude Code


Generated by Claude Code

…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 os-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 20, 2026 — with Claude
@os-zhuang
os-zhuang marked this pull request as ready for review August 20, 2026 15:45
@os-zhuang
os-zhuang enabled auto-merge August 20, 2026 15:45
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 20, 2026
Merged via the queue into main with commit 2d624eb Aug 20, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9932-role-word-missing-root branch August 20, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: check-role-word skips a configured ROOT that does not exist, silently — the verdict half that #9910 deliberately left alone

2 participants