diff --git a/scripts/check-role-word.mjs b/scripts/check-role-word.mjs index c42f5b70c1..ad9a3c4885 100644 --- a/scripts/check-role-word.mjs +++ b/scripts/check-role-word.mjs @@ -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) { @@ -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`); @@ -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); } diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 82389aecbc..8bc3951aed 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -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