From 247fedc3a1655d2c2fa7094626b7d2151ef2e5c9 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 21 Aug 2026 07:53:37 +0000 Subject: [PATCH 1/2] fix(pm): declare check:doc-authoring's real population to dispatch-gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the gate's four ROOTS were bare words the watch-hint extractor refuses as too generic, while its SKIP_PATHS carry separators and were taken. Five of the six paths it declared were therefore exclusions, and 383 of its 389 walked files were declared by nothing — so a card editing the live corpus derived an empty union and met this REQUIRED gate as red CI instead of as a local command. Declares each ROOT in the subtree spelling the extractor compares in, with both halves pinned: the gate's own self-test couples the declaration to ROOTS and bounds its over-claim to SKIP_PATHS, and the extractor's self-test pins that it reaches all four roots and claims nothing under packages/, apps/ or examples/. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- scripts/check-doc-authoring.mjs | 128 +++++++++++++++++++++++++++++++- scripts/pm/dispatch-gates.mjs | 25 +++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index 40b0d4a95f..34cece5c0e 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -129,6 +129,98 @@ const SKIP_PATHS = new Set([ // Generated from spec/frontmatter — not hand-authored, don't police. const SKIP_FILES = new Set(['content/docs/ai/skills-reference.mdx']); +/** + * ROOTS above, written in the subtree spelling `scripts/pm/dispatch-gates.mjs` + * compares in. Provenance ONLY: nothing in this gate reads this list, and the + * scan behaves exactly as it did without it. + * + * ## The gap this closes (#9964's declaration pattern, sixth instance) + * + * 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" — or names a top-level DOTTED directory, which is the + * one arm that saved `.claude`. So three of the four ROOTS were bare words that + * never became a hint, while `SKIP_PATHS` below spells its entries with + * separators, and those DID. + * + * The result was a declaration almost exactly inverted. Measured on `main` at + * 9dd192d48b, this gate's whole hint set was: + * + * .claude the one live root the dotted-dir + * arm admitted — 6 of 389 files + * .claude/worktrees, docs/audits, the exemptions, i.e. subtrees it + * docs/handoff, docs/plans, deliberately does NOT read + * content/docs/ai/skills-reference.mdx + * + * — five of its six declared paths were exclusions, and 383 of its 389 walked + * files (98.5%) were declared by nothing at all. `docs/**`, `skills/**` and + * `content/**` below are what close that; `.claude/**` is redundant with the + * bare `.claude` the extractor already takes, and is kept so the declaration is + * uniform across ROOTS rather than depending on which arm happened to admit + * which root. + * + * That is worse than declaring nothing, and worse in the direction that hides + * it: the residue line still PRINTED gate names, so the row read as "declared, + * just not relevant to you". A card editing `docs/qa/platform-checklist/` — a + * file this gate does read — derived an EMPTY union and met this REQUIRED gate + * (lint.yml, `Doc/skill authoring guard`) as red CI instead of as a local + * command. That is the cost this file's own header opens with, one level up: + * a check that reported on a corpus nobody could see it was reading. + * + * ## Why the subtree spelling, and not a wider extractor + * + * `hintCovers` refuses a bare single-segment literal (`docs`) as too generic BY + * DESIGN, and that refusal is measured rather than 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 each of these back to one ROOTS entry and to nothing + * else. + * + * ## Why the ROOT, and not the live subtrees under it (the SKIP_PATHS question) + * + * `hintCovers` has no way to SUBTRACT: hints are positive containment, so + * "`docs/**` except `docs/plans`" is not expressible. The exempt subtrees are + * therefore claimed by this declaration, and that is a DELIBERATE, bounded + * residual rather than an oversight — pinned as such in the self-test, so it + * cannot silently grow past the exemptions it is accounted for. + * + * Declaring the live subtrees instead was considered and refused on three + * grounds. It does not remove the residual (`SKIP_PATHS` spells those paths as + * module-body literals, so they stay hints whatever this list says — only + * unquoting them the way `DEFAULT_BASE_REF` is assembled would, at the cost of + * obscuring this file's most safety-critical constant). It contradicts the + * reason the ROOT is `docs` and not its three live subdirectories, argued at + * the top of this file: a new subdirectory is covered on arrival instead of + * being missed the same way twice — and a declaration that has to be extended + * by hand is the same silent narrowing, one tool over. And it strands the + * twelve hand-written top-level guides (`docs/protocol-upgrade-guide.md`, + * `docs/upgrading-to-11.md`, …), which are files rather than a subtree and + * would have to be enumerated one literal each. + * + * The residual is also not new: those four subtrees derive this gate TODAY, via + * the `SKIP_PATHS` literals. This declaration subsumes those hints and adds + * nothing to that side while closing all 389 files of the missing side. + * + * What the precedent does draw a line at is claiming a tree the ROOTS do not + * reach at all, and the self-test in `scripts/pm/dispatch-gates.mjs` pins that + * negative half against the real extractor — the load-bearing direction for a + * declaration this broad, since a gate named on EVERY card is the louder + * version of naming none. Carve-outs INSIDE a walked root are the tolerated + * case there: `check:role-word` declares `skills/**` while skipping every + * `references/` directory under it, and `check:slot-lookup-ratchet` declares + * the whole of `packages/**`. + * + * ## Provenance, never a lookup key + * + * The glob form appearing in ROOTS would send `walk()` at a directory that does + * not exist — since #4916 a hard refusal rather than a silent skip, but one + * that fails naming the wrong problem. The self-test pins both halves. + */ +const ROOT_WATCH_HINTS = ['.claude/**', 'docs/**', 'skills/**', 'content/**']; + const DOMAINS = [ 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Position', 'PermissionSet', 'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube', @@ -421,11 +513,45 @@ function selfTest() { rmSync(dir, { recursive: true, force: true }); } + // ── The dispatch-gates declaration (#9964's pattern, sixth instance) ─────── + // + // 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 docs card with this REQUIRED gate + // missing from the brief — which is exactly how it stood before this block. + // Both sides are derived from ROOTS rather than re-spelled, so renaming or + // widening 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_WATCH_HINTS.includes(`${r}/**`)), true); + 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_WATCH_HINTS.every((h) => ROOTS.includes(h.replace(/\/\*+$/, ''))), true); + // Provenance, never a lookup key: the glob form appearing in ROOTS would send + // `walk()` at a directory that does not exist. Since #4916 that is a hard + // refusal rather than a silent skip, but it fails naming the wrong problem. + expect('the declared form is NOT a ROOTS entry', + ROOT_WATCH_HINTS.some((h) => ROOTS.includes(h)), false); + // The residual, pinned rather than hidden. `hintCovers` is positive + // containment with no way to subtract, so declaring a ROOT necessarily claims + // the exempt subtrees carved out of it. That is accounted for — but only for + // the exemptions themselves: every SKIP_PATHS entry must sit UNDER a declared + // root, so a future exemption somewhere this declaration does not reach fails + // here instead of quietly widening the over-claim. + expect('every skipped subtree is one this declaration knowingly over-claims, and none is a ' + + 'surprise from outside the declared roots', + [...SKIP_PATHS].every((p) => ROOTS.some((r) => p.startsWith(`${r}/`))), true); + // The exemptions must stay a strict SUBSET of the walked roots: an entry that + // WAS a whole root would mean the gate declares a population it never reads. + expect('no exemption swallows a declared root whole', + [...SKIP_PATHS].some((p) => ROOTS.includes(p)), false); + if (failures.length) { console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`); process.exit(1); } - console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored) and the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored) all hold.'); + console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); } function main() { diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 756286626b..0bfe0e5737 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3148,6 +3148,31 @@ function selfTest() { t('the doc-anchors gate reaches the content page population it declares', anchorHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); t('and does not thereby claim a path outside that population', !anchorHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts'))); + // The sixth instance of the class (#10648), and the worst-shaped one: three + // of check-doc-authoring's four roots were bare words (`.claude` survived on + // the dotted-dir arm alone), while its SKIP_PATHS carried separators and were + // taken. Five of the six paths it declared were therefore EXCLUSIONS, and 383 + // of its 389 walked files were declared by nothing. The failure printed as a + // populated `names:` column, which reads as "declared, just not relevant to + // you" rather than as a blind spot — the reason it survived five same-class + // fixes without being noticed. + const docAuthoringHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-doc-authoring.mjs'), 'utf8')); + // One case per declared root, because a single one passes for a declaration + // that dropped the other three — which is the exact shape being fixed. Each + // path is reachable ONLY through its root's subtree spelling, never through a + // SKIP_PATHS literal. + t('the doc-authoring gate reaches the live docs corpus it declares', docAuthoringHints.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md'))); + t('and the top-level docs guides, which are files rather than a subtree', docAuthoringHints.some((h) => hintCovers(h, 'docs/protocol-upgrade-guide.md'))); + t('and the agent operating manual it took in for the same reason', docAuthoringHints.some((h) => hintCovers(h, '.claude/agents/os-dev.md'))); + t('and the published skills catalog', docAuthoringHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md'))); + t('and the content tree', docAuthoringHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); + // The negative half, load-bearing for a declaration spanning four roots: a + // gate named on EVERY card is the louder version of naming none. These are + // the three biggest trees in the repo and none of them is corpus. + t('and claims nothing under packages/', !docAuthoringHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts'))); + t('nor under apps/', !docAuthoringHints.some((h) => hintCovers(h, 'apps/console/src/main.tsx'))); + t('nor under examples/', !docAuthoringHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts'))); + // The second gate of that class (#9700): a whole-tree ESLint ratchet whose // only literals were its own baseline artifact and the ref it diffs against, // so it scored `silent` for every card in the tree while being REQUIRED in From c239b3560a2e9526225e387134564f1730f89fec Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 21 Aug 2026 08:02:00 +0000 Subject: [PATCH 2/2] docs(pm): mark the .claude reach case as not pinning the declaration Measured: deleting `.claude/**` from the gate leaves that case green, because `.claude` is a dotted top-level dir the extractor admits bare. The case still pins that the root stays reachable; the declaration is pinned in the gate's own self-test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- scripts/pm/dispatch-gates.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 0bfe0e5737..3e8cd0d97f 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3163,6 +3163,14 @@ function selfTest() { // SKIP_PATHS literal. t('the doc-authoring gate reaches the live docs corpus it declares', docAuthoringHints.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md'))); t('and the top-level docs guides, which are files rather than a subtree', docAuthoringHints.some((h) => hintCovers(h, 'docs/protocol-upgrade-guide.md'))); + // ⚠️ This one case does NOT pin the declaration, and says so rather than + // reading as though it does: `.claude` is a top-level DOTTED dir, which + // `looksPathy` admits and `hintCovers` does not refuse, so the bare ROOTS + // entry reaches this path on its own. Measured — deleting `.claude/**` from + // the gate leaves this case green, exactly the way check-nul-bytes survives + // the ablation above. What it pins is that `.claude` stays reachable AT ALL; + // the declaration itself is pinned in the gate's own self-test, which + // requires a subtree spelling for every separator-less ROOT. t('and the agent operating manual it took in for the same reason', docAuthoringHints.some((h) => hintCovers(h, '.claude/agents/os-dev.md'))); t('and the published skills catalog', docAuthoringHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md'))); t('and the content tree', docAuthoringHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx')));