Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion scripts/check-role-word.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,50 @@ const EXTENSIONS = new Set(['.mdx', '.md']);
const BASELINE_PATH = 'scripts/role-word-baseline.json';
const WORD = /\brole(?:s)?\b/gi;

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

const update = process.argv.includes('--update');

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

// ── The dispatch-gates declaration (#9964's pattern) ──────────────────────
//
// Enforcement cannot hold any of these: the declaration is read by another
// tool entirely, so a wrong or stale one runs green here forever and pays
// itself out as a dev dispatched on a skills card with this gate missing from
// the brief. The coupling is derived from ROOTS on both sides rather than
// re-spelled, so widening or renaming a root cannot leave the declaration
// describing the old population.
const separatorless = ROOTS.filter((r) => !r.includes('/'));
expect('the declaration exists for every ROOT the hint extractor cannot see (a root with no '
+ 'path separator is refused as too generic, so it needs the subtree spelling)',
separatorless.every((r) => ROOT_DIR_WATCH_HINTS.includes(`${r}/**`)));
expect('and it declares no root this gate does not walk (a declaration that can drift from the '
+ 'scan is worse than none — it replaces a silent gate with a lying one)',
ROOT_DIR_WATCH_HINTS.every((h) => ROOTS.includes(h.replace(/\/\*+$/, ''))));
expect('skills is the root it declares (the half PR #10038 met as red CI)',
ROOT_DIR_WATCH_HINTS.includes('skills/**'));
// Provenance, never a lookup key: `walk()` runs behind existsSync(root), so
// the glob form appearing in ROOTS would skip the root in silence — the exact
// failure the per-root green line above exists to make visible.
expect('the declared form is NOT a ROOTS entry',
!ROOTS.some((r) => ROOT_DIR_WATCH_HINTS.includes(r)));

if (failures.length) {
for (const f of failures) console.error(` x self-test: ${f}`);
console.error(`\ncheck-role-word --self-test: ${failures.length} failure(s).\n`);
Expand All@@ -350,7 +417,8 @@ function selfTest() {
'OK self-test: the NEW-use remedy marks baseline expansion as maintainer-only, the predicate '
+ 'rejects an unmarked offer, the ratchet-DOWN remedy stays the author\'s own, and both '
+ 'success texts report what was READ \u2014 so a scanned tree and an unscanned one cannot print '
+ 'the same result once the ledger is empty.',
+ 'the same result once the ledger is empty. Every separator-less ROOT also declares the '
+ 'subtree spelling dispatch-gates derives from, and declares nothing this gate does not walk.',
);
process.exit(0);
}
Expand Down
31 changes: 31 additions & 0 deletions scripts/pm/dispatch-gates.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2726,6 +2726,37 @@ function selfTest() {
const anchorRootHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-doc-anchors.mjs'), 'utf8'));
t('and the doc-anchors pair claims neither instruction file', !anchorRootHints.some((h) => hintCovers(h, 'AGENTS.md') || hintCovers(h, 'CLAUDE.md')));

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

// ── A trailing sentence period is not part of the path (#8534, half two) ──
//
// Coupled to the rule above: the raw-prefix comparison reached the real file
Expand Down
Loading