Skip to content

Commit 4fb58de

Browse files
committed
fix(pm): declare check-role-word's skills root for dispatch-gates derivation
check-role-word walks ROOTS = ['content/docs', 'skills'], but dispatch-gates builds its watch hints from path literals carrying a separator, so the bare word `skills` produced no hint at all: a skills-only card derived the content half and scored this gate `silent`. PR #10038 paid one repair round for it — a green local union, then `role-word count grew 2 to 3` in CI. Adds the provenance-only subtree declaration (`skills/**`) in the gate's own source, the same pattern root-file populations use, with the coupling to ROOTS pinned in both self-tests. The extractor and hintCovers are untouched: that refusal of bare single-segment literals is measured and deliberate. No scanning behaviour changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeA3nU1B5Q2pgxqxgUrexd
1 parent 1800ffa commit 4fb58de

2 files changed

Lines changed: 100 additions & 1 deletion

File tree

‎scripts/check-role-word.mjs‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,50 @@ const EXTENSIONS = new Set(['.mdx', '.md']);
4848
const BASELINE_PATH = 'scripts/role-word-baseline.json';
4949
const WORD = /\brole(?:s)?\b/gi;
5050

51+
/**
52+
* The half of ROOTS that `scripts/pm/dispatch-gates.mjs` cannot see, written in
53+
* the subtree spelling that tool compares in. Provenance ONLY: nothing in this
54+
* gate reads this list, and the scan above behaves exactly as it did without it.
55+
*
56+
* ## The gap this closes (#9964's declaration pattern, one class over)
57+
*
58+
* That tool builds every dispatch's gate list by scanning each gate's own source
59+
* for the path literals it operates on, and "looks like a path" there means
60+
* "carries a separator". `content/docs` has one; `skills` does not, so no hint
61+
* was ever built for it — this gate's population reached the derivation as its
62+
* content half plus its baseline artifact, and a card touching only the skills
63+
* tree scored `silent`: not "irrelevant", but "its sources name paths, none of
64+
* which cover yours", which that tool's residue summary calls its weakest claim
65+
* and explicitly not a clearance.
66+
*
67+
* Not hypothetical. PR #10038 — a skills-only docs fix — derived a green local
68+
* union and met this gate as red CI (`role-word count grew 2 → 3`), costing one
69+
* repair round. CI enforces either way (lint.yml carries no path filter); what
70+
* was missing is discoverability, and this restores it.
71+
*
72+
* ## Why the subtree spelling, and not a wider extractor
73+
*
74+
* `hintCovers` refuses a bare single-segment literal (`skills`) as too generic
75+
* BY DESIGN, and that refusal is measured, not incidental: teaching the
76+
* extractor to accept bare top-level directory words was priced at +139084
77+
* fabricated (gate, file) pairs, because `packages`, `apps` and `examples` are
78+
* path COMPONENTS in dozens of gates that never read those roots. A declared
79+
* subtree is a different claim from a bare word — an author stating what the
80+
* gate reads, in the syntax the repo uses for that everywhere else — and the
81+
* glob collapse reduces this one back to this gate's second root and to nothing
82+
* else. `.claude/skills/...` is NOT under it, which is correct: ROOTS does not
83+
* reach there, so the tool must not name this gate for a card that edits it.
84+
*
85+
* ## Provenance, never a lookup key
86+
*
87+
* `walk()` runs over ROOTS behind `existsSync`, so the glob form appearing there
88+
* would send the scan at a directory that does not exist — skipped in exactly
89+
* the silence the green line below is built to expose. The self-test pins both
90+
* halves of the coupling: every separator-less ROOT is declared here, and
91+
* nothing declared here is itself a ROOTS entry.
92+
*/
93+
const ROOT_DIR_WATCH_HINTS = ['skills/**'];
94+
5195
const update = process.argv.includes('--update');
5296

5397
function walk(dir, out) {
@@ -341,6 +385,29 @@ function selfTest() {
341385
+ 'over a dead scan cannot read like a debt fully paid',
342386
updateSummary(DEAD_SCAN, PAID_OFF) !== updateSummary(SCANNED, PAID_OFF));
343387

388+
// ── The dispatch-gates declaration (#9964's pattern) ──────────────────────
389+
//
390+
// Enforcement cannot hold any of these: the declaration is read by another
391+
// tool entirely, so a wrong or stale one runs green here forever and pays
392+
// itself out as a dev dispatched on a skills card with this gate missing from
393+
// the brief. The coupling is derived from ROOTS on both sides rather than
394+
// re-spelled, so widening or renaming a root cannot leave the declaration
395+
// describing the old population.
396+
const separatorless = ROOTS.filter((r) => !r.includes('/'));
397+
expect('the declaration exists for every ROOT the hint extractor cannot see (a root with no '
398+
+ 'path separator is refused as too generic, so it needs the subtree spelling)',
399+
separatorless.every((r) => ROOT_DIR_WATCH_HINTS.includes(`${r}/**`)));
400+
expect('and it declares no root this gate does not walk (a declaration that can drift from the '
401+
+ 'scan is worse than none — it replaces a silent gate with a lying one)',
402+
ROOT_DIR_WATCH_HINTS.every((h) => ROOTS.includes(h.replace(/\/\*+$/, ''))));
403+
expect('skills is the root it declares (the half PR #10038 met as red CI)',
404+
ROOT_DIR_WATCH_HINTS.includes('skills/**'));
405+
// Provenance, never a lookup key: `walk()` runs behind existsSync(root), so
406+
// the glob form appearing in ROOTS would skip the root in silence — the exact
407+
// failure the per-root green line above exists to make visible.
408+
expect('the declared form is NOT a ROOTS entry',
409+
!ROOTS.some((r) => ROOT_DIR_WATCH_HINTS.includes(r)));
410+
344411
if (failures.length) {
345412
for (const f of failures) console.error(` x self-test: ${f}`);
346413
console.error(`\ncheck-role-word --self-test: ${failures.length} failure(s).\n`);
@@ -350,7 +417,8 @@ function selfTest() {
350417
'OK self-test: the NEW-use remedy marks baseline expansion as maintainer-only, the predicate '
351418
+ 'rejects an unmarked offer, the ratchet-DOWN remedy stays the author\'s own, and both '
352419
+ 'success texts report what was READ \u2014 so a scanned tree and an unscanned one cannot print '
353-
+ 'the same result once the ledger is empty.',
420+
+ 'the same result once the ledger is empty. Every separator-less ROOT also declares the '
421+
+ 'subtree spelling dispatch-gates derives from, and declares nothing this gate does not walk.',
354422
);
355423
process.exit(0);
356424
}

‎scripts/pm/dispatch-gates.mjs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,6 +2726,37 @@ function selfTest() {
27262726
const anchorRootHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-doc-anchors.mjs'), 'utf8'));
27272727
t('and the doc-anchors pair claims neither instruction file', !anchorRootHints.some((h) => hintCovers(h, 'AGENTS.md') || hintCovers(h, 'CLAUDE.md')));
27282728

2729+
// The DIRECTORY half of the same class (#10107). A gate whose population is a
2730+
// top-level DIRECTORY spelled as a bare word is invisible for the same reason
2731+
// a root file is — `looksPathy` finds no separator, so the extractor builds no
2732+
// hint at all — and it is the more expensive half, because the word names a
2733+
// whole subtree rather than one file. `check:role-word` walks
2734+
// `['content/docs', 'skills']`: the first is a hint, the second was nothing,
2735+
// so a skills-only card derived the content half and scored this gate
2736+
// `silent`. PR #10038 paid for it — a green local union, then
2737+
// `role-word count grew 2 → 3` in CI. It declares the subtree spelling now.
2738+
//
2739+
// Read from the real gate, not a fixture: what is pinned is that the tree
2740+
// still HAS the declaration. If this gate stops walking that root, delete the
2741+
// declaration and these cases together — never keep them green by re-pointing
2742+
// at a gate that never read it.
2743+
const roleWordHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-role-word.mjs'), 'utf8'));
2744+
t('the role-word ratchet reaches the published skills catalog it declares', roleWordHints.some((h) => hintCovers(h, 'skills/objectstack-platform/SKILL.md')));
2745+
t('and still reaches the content half it always named', roleWordHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx')));
2746+
// The negative halves, and the reason this is a DECLARATION and not an
2747+
// extractor change. `.claude/skills/` is the live specimen: a real tracked
2748+
// tree whose last segment IS the declared root, which this gate does not walk
2749+
// — a widened extractor accepting the bare word `skills` would not tell them
2750+
// apart, and the collapsed subtree does.
2751+
t('and claims nothing under the internal .claude skills tree it never walks', !roleWordHints.some((h) => hintCovers(h, '.claude/skills/pm-dispatch/SKILL.md')));
2752+
t('nor a package source file', !roleWordHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts')));
2753+
t('nor the sibling FILE beside the content root', !roleWordHints.some((h) => hintCovers(h, 'content/docs.site.json')));
2754+
// The pair that makes the declaration worth having: the bare word this gate
2755+
// actually spells in its ROOTS array stays refused, so the coverage above is
2756+
// bought by the declaration and by nothing else.
2757+
t('the bare root word the gate spells in ROOTS is still refused as too generic', !hintCovers('skills', 'skills/objectstack-platform/SKILL.md'));
2758+
t('while the declared subtree covers that same path', hintCovers('skills/**', 'skills/objectstack-platform/SKILL.md'));
2759+
27292760
// ── A trailing sentence period is not part of the path (#8534, half two) ──
27302761
//
27312762
// Coupled to the rule above: the raw-prefix comparison reached the real file

0 commit comments

Comments
 (0)