Skip to content

Commit de1e75a

Browse files
claude[bot]claude
andauthored
fix(pm): declare check:doc-authoring's real population to dispatch-gates (#10662)
* fix(pm): declare check:doc-authoring's real population to dispatch-gates 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --------- Co-authored-by: claude <noreply@anthropic.com>
1 parent 1768a8f commit de1e75a

2 files changed

Lines changed: 160 additions & 1 deletion

File tree

scripts/check-doc-authoring.mjs

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,98 @@ const SKIP_PATHS = new Set([
129129
// Generated from spec/frontmatter — not hand-authored, don't police.
130130
const SKIP_FILES = new Set(['content/docs/ai/skills-reference.mdx']);
131131

132+
/**
133+
* ROOTS above, written in the subtree spelling `scripts/pm/dispatch-gates.mjs`
134+
* compares in. Provenance ONLY: nothing in this gate reads this list, and the
135+
* scan behaves exactly as it did without it.
136+
*
137+
* ## The gap this closes (#9964's declaration pattern, sixth instance)
138+
*
139+
* That tool builds every dispatch's gate list by scanning each gate's own source
140+
* for the path literals it operates on, and "looks like a path" there means
141+
* "carries a separator" — or names a top-level DOTTED directory, which is the
142+
* one arm that saved `.claude`. So three of the four ROOTS were bare words that
143+
* never became a hint, while `SKIP_PATHS` below spells its entries with
144+
* separators, and those DID.
145+
*
146+
* The result was a declaration almost exactly inverted. Measured on `main` at
147+
* 9dd192d48b, this gate's whole hint set was:
148+
*
149+
* .claude the one live root the dotted-dir
150+
* arm admitted — 6 of 389 files
151+
* .claude/worktrees, docs/audits, the exemptions, i.e. subtrees it
152+
* docs/handoff, docs/plans, deliberately does NOT read
153+
* content/docs/ai/skills-reference.mdx
154+
*
155+
* — five of its six declared paths were exclusions, and 383 of its 389 walked
156+
* files (98.5%) were declared by nothing at all. `docs/**`, `skills/**` and
157+
* `content/**` below are what close that; `.claude/**` is redundant with the
158+
* bare `.claude` the extractor already takes, and is kept so the declaration is
159+
* uniform across ROOTS rather than depending on which arm happened to admit
160+
* which root.
161+
*
162+
* That is worse than declaring nothing, and worse in the direction that hides
163+
* it: the residue line still PRINTED gate names, so the row read as "declared,
164+
* just not relevant to you". A card editing `docs/qa/platform-checklist/` — a
165+
* file this gate does read — derived an EMPTY union and met this REQUIRED gate
166+
* (lint.yml, `Doc/skill authoring guard`) as red CI instead of as a local
167+
* command. That is the cost this file's own header opens with, one level up:
168+
* a check that reported on a corpus nobody could see it was reading.
169+
*
170+
* ## Why the subtree spelling, and not a wider extractor
171+
*
172+
* `hintCovers` refuses a bare single-segment literal (`docs`) as too generic BY
173+
* DESIGN, and that refusal is measured rather than incidental: teaching the
174+
* extractor to accept bare top-level directory words was priced at +139084
175+
* fabricated (gate, file) pairs, because `packages`, `apps` and `examples` are
176+
* path COMPONENTS in dozens of gates that never read those roots. A declared
177+
* subtree is a different claim from a bare word — an author stating what the
178+
* gate reads, in the syntax the repo uses for that everywhere else — and the
179+
* glob collapse reduces each of these back to one ROOTS entry and to nothing
180+
* else.
181+
*
182+
* ## Why the ROOT, and not the live subtrees under it (the SKIP_PATHS question)
183+
*
184+
* `hintCovers` has no way to SUBTRACT: hints are positive containment, so
185+
* "`docs/**` except `docs/plans`" is not expressible. The exempt subtrees are
186+
* therefore claimed by this declaration, and that is a DELIBERATE, bounded
187+
* residual rather than an oversight — pinned as such in the self-test, so it
188+
* cannot silently grow past the exemptions it is accounted for.
189+
*
190+
* Declaring the live subtrees instead was considered and refused on three
191+
* grounds. It does not remove the residual (`SKIP_PATHS` spells those paths as
192+
* module-body literals, so they stay hints whatever this list says — only
193+
* unquoting them the way `DEFAULT_BASE_REF` is assembled would, at the cost of
194+
* obscuring this file's most safety-critical constant). It contradicts the
195+
* reason the ROOT is `docs` and not its three live subdirectories, argued at
196+
* the top of this file: a new subdirectory is covered on arrival instead of
197+
* being missed the same way twice — and a declaration that has to be extended
198+
* by hand is the same silent narrowing, one tool over. And it strands the
199+
* twelve hand-written top-level guides (`docs/protocol-upgrade-guide.md`,
200+
* `docs/upgrading-to-11.md`, …), which are files rather than a subtree and
201+
* would have to be enumerated one literal each.
202+
*
203+
* The residual is also not new: those four subtrees derive this gate TODAY, via
204+
* the `SKIP_PATHS` literals. This declaration subsumes those hints and adds
205+
* nothing to that side while closing all 389 files of the missing side.
206+
*
207+
* What the precedent does draw a line at is claiming a tree the ROOTS do not
208+
* reach at all, and the self-test in `scripts/pm/dispatch-gates.mjs` pins that
209+
* negative half against the real extractor — the load-bearing direction for a
210+
* declaration this broad, since a gate named on EVERY card is the louder
211+
* version of naming none. Carve-outs INSIDE a walked root are the tolerated
212+
* case there: `check:role-word` declares `skills/**` while skipping every
213+
* `references/` directory under it, and `check:slot-lookup-ratchet` declares
214+
* the whole of `packages/**`.
215+
*
216+
* ## Provenance, never a lookup key
217+
*
218+
* The glob form appearing in ROOTS would send `walk()` at a directory that does
219+
* not exist — since #4916 a hard refusal rather than a silent skip, but one
220+
* that fails naming the wrong problem. The self-test pins both halves.
221+
*/
222+
const ROOT_WATCH_HINTS = ['.claude/**', 'docs/**', 'skills/**', 'content/**'];
223+
132224
const DOMAINS = [
133225
'Datasource', 'Connector', 'Policy', 'SharingRule', 'Position', 'PermissionSet',
134226
'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube',
@@ -421,11 +513,45 @@ function selfTest() {
421513
rmSync(dir, { recursive: true, force: true });
422514
}
423515

516+
// ── The dispatch-gates declaration (#9964's pattern, sixth instance) ───────
517+
//
518+
// Enforcement cannot hold any of these: the declaration is read by another
519+
// tool entirely, so a wrong or stale one runs green here forever and pays
520+
// itself out as a dev dispatched on a docs card with this REQUIRED gate
521+
// missing from the brief — which is exactly how it stood before this block.
522+
// Both sides are derived from ROOTS rather than re-spelled, so renaming or
523+
// widening a root cannot leave the declaration describing the old population.
524+
const separatorless = ROOTS.filter((r) => !r.includes('/'));
525+
expect('the declaration exists for every ROOT the hint extractor cannot see (a root with no '
526+
+ 'path separator is refused as too generic, so it needs the subtree spelling)',
527+
separatorless.every((r) => ROOT_WATCH_HINTS.includes(`${r}/**`)), true);
528+
expect('and it declares no root this gate does not walk (a declaration that can drift from the '
529+
+ 'scan is worse than none — it replaces a silent gate with a lying one)',
530+
ROOT_WATCH_HINTS.every((h) => ROOTS.includes(h.replace(/\/\*+$/, ''))), true);
531+
// Provenance, never a lookup key: the glob form appearing in ROOTS would send
532+
// `walk()` at a directory that does not exist. Since #4916 that is a hard
533+
// refusal rather than a silent skip, but it fails naming the wrong problem.
534+
expect('the declared form is NOT a ROOTS entry',
535+
ROOT_WATCH_HINTS.some((h) => ROOTS.includes(h)), false);
536+
// The residual, pinned rather than hidden. `hintCovers` is positive
537+
// containment with no way to subtract, so declaring a ROOT necessarily claims
538+
// the exempt subtrees carved out of it. That is accounted for — but only for
539+
// the exemptions themselves: every SKIP_PATHS entry must sit UNDER a declared
540+
// root, so a future exemption somewhere this declaration does not reach fails
541+
// here instead of quietly widening the over-claim.
542+
expect('every skipped subtree is one this declaration knowingly over-claims, and none is a '
543+
+ 'surprise from outside the declared roots',
544+
[...SKIP_PATHS].every((p) => ROOTS.some((r) => p.startsWith(`${r}/`))), true);
545+
// The exemptions must stay a strict SUBSET of the walked roots: an entry that
546+
// WAS a whole root would mean the gate declares a population it never reads.
547+
expect('no exemption swallows a declared root whole',
548+
[...SKIP_PATHS].some((p) => ROOTS.includes(p)), false);
549+
424550
if (failures.length) {
425551
console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`);
426552
process.exit(1);
427553
}
428-
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.');
554+
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.');
429555
}
430556

431557
function main() {

scripts/pm/dispatch-gates.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3148,6 +3148,39 @@ function selfTest() {
31483148
t('the doc-anchors gate reaches the content page population it declares', anchorHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx')));
31493149
t('and does not thereby claim a path outside that population', !anchorHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts')));
31503150

3151+
// The sixth instance of the class (#10648), and the worst-shaped one: three
3152+
// of check-doc-authoring's four roots were bare words (`.claude` survived on
3153+
// the dotted-dir arm alone), while its SKIP_PATHS carried separators and were
3154+
// taken. Five of the six paths it declared were therefore EXCLUSIONS, and 383
3155+
// of its 389 walked files were declared by nothing. The failure printed as a
3156+
// populated `names:` column, which reads as "declared, just not relevant to
3157+
// you" rather than as a blind spot — the reason it survived five same-class
3158+
// fixes without being noticed.
3159+
const docAuthoringHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-doc-authoring.mjs'), 'utf8'));
3160+
// One case per declared root, because a single one passes for a declaration
3161+
// that dropped the other three — which is the exact shape being fixed. Each
3162+
// path is reachable ONLY through its root's subtree spelling, never through a
3163+
// SKIP_PATHS literal.
3164+
t('the doc-authoring gate reaches the live docs corpus it declares', docAuthoringHints.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md')));
3165+
t('and the top-level docs guides, which are files rather than a subtree', docAuthoringHints.some((h) => hintCovers(h, 'docs/protocol-upgrade-guide.md')));
3166+
// ⚠️ This one case does NOT pin the declaration, and says so rather than
3167+
// reading as though it does: `.claude` is a top-level DOTTED dir, which
3168+
// `looksPathy` admits and `hintCovers` does not refuse, so the bare ROOTS
3169+
// entry reaches this path on its own. Measured — deleting `.claude/**` from
3170+
// the gate leaves this case green, exactly the way check-nul-bytes survives
3171+
// the ablation above. What it pins is that `.claude` stays reachable AT ALL;
3172+
// the declaration itself is pinned in the gate's own self-test, which
3173+
// requires a subtree spelling for every separator-less ROOT.
3174+
t('and the agent operating manual it took in for the same reason', docAuthoringHints.some((h) => hintCovers(h, '.claude/agents/os-dev.md')));
3175+
t('and the published skills catalog', docAuthoringHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md')));
3176+
t('and the content tree', docAuthoringHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx')));
3177+
// The negative half, load-bearing for a declaration spanning four roots: a
3178+
// gate named on EVERY card is the louder version of naming none. These are
3179+
// the three biggest trees in the repo and none of them is corpus.
3180+
t('and claims nothing under packages/', !docAuthoringHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts')));
3181+
t('nor under apps/', !docAuthoringHints.some((h) => hintCovers(h, 'apps/console/src/main.tsx')));
3182+
t('nor under examples/', !docAuthoringHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts')));
3183+
31513184
// The second gate of that class (#9700): a whole-tree ESLint ratchet whose
31523185
// only literals were its own baseline artifact and the ref it diffs against,
31533186
// so it scored `silent` for every card in the tree while being REQUIRED in

0 commit comments

Comments
 (0)