From a55ce0e358721aba6df0c90cbc779f9fca4774c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 13:59:33 +0000 Subject: [PATCH 1/2] fix(pm): dispatch-gates names check:doc-anchors for a content card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hintCovers` applied its "a single segment is too generic" refusal to the GLOB-COLLAPSED copy of a watch hint, so `content/**` and `skills/**` were refused as though the author had written the bare word `content`. Read the hint as written instead: a word with no separator is still refused, a declared subtree is honoured. `check-doc-anchors.mjs` spelled its population `'content'` — a word, so it contributed no hint at all and `check:doc-anchors` scored `silent` for every card under `content/**`, while being REQUIRED in lint.yml and the only fragment coverage the repo has. It now declares the subtree it reads and derives the join root from it, so the two cannot drift. Measured over 107 families x 6181 tracked files: 19024 -> 19834 watch-hint pairs (+810, none lost), via exactly three live declarations. The rejected alternative (accept any bare literal naming a real top-level dir) measures 158108 pairs (+139084) and takes one packages/spec card from 7 matched families to 34. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- scripts/check-doc-anchors.mjs | 32 +++++++++- scripts/pm/dispatch-gates.mjs | 117 ++++++++++++++++++++++++++++++---- 2 files changed, 136 insertions(+), 13 deletions(-) diff --git a/scripts/check-doc-anchors.mjs b/scripts/check-doc-anchors.mjs index 32be68aac8..60c80f2ab1 100644 --- a/scripts/check-doc-anchors.mjs +++ b/scripts/check-doc-anchors.mjs @@ -124,9 +124,39 @@ import Slugger from 'github-slugger'; import { stripCodeSpans, stripFencedBlocks } from './check-adr-links.mjs'; +/** + * The page population this gate sweeps, as the repo-relative glob it really + * reads — every `.md`/`.mdx` under the Fumadocs content root, `content/blog` + * and `content/docs` alike. + * + * ## Why the glob is the constant and the root is derived from it (#9626) + * + * `scripts/pm/dispatch-gates.mjs` builds every dispatch's gate list by scanning + * each gate's own source for the path literals it operates on, and a literal + * with no path separator is a WORD, not a path — it is refused as too generic, + * because `packages` and `apps` are path components dozens of gates join with + * something else. Spelled `'content'`, this gate's population therefore + * contributed no hint at all: `check:doc-anchors` scored `silent` for every + * card under `content/**`, while being REQUIRED in lint.yml and the only + * fragment coverage this repo has (`check-links.yml` sets + * `include_fragments = "none"` and says so in its own header — a link to a + * heading that does not exist is reported `[200] OK` there). + * + * Declaring the SUBTREE is what makes the declaration readable. The root is one + * substring of it rather than a second constant, so the two cannot drift apart + * — the failure that would otherwise replace a silent gate with a lying one. + * + * What this does NOT reach: `EXTRA_SOURCES` below. A top-level FILE name + * carries no separator either, and teaching the scanner to accept one would + * admit every `package.json` basename in the tree. A card editing only + * `README.md` or `ARCHITECTURE.md` still has to reach this gate by judgment; + * that residue is recorded in `hintCovers`' docblock as a decided loss. + */ +const CONTENT_GLOB = 'content/**'; + /** The Fumadocs content root — what `/` means in a site route, and what the * lychee lane passes as `--root-dir`. */ -const CONTENT_ROOT = 'content'; +const CONTENT_ROOT = CONTENT_GLOB.slice(0, CONTENT_GLOB.indexOf('/')); /** Link sources outside `content/`, matching the lychee globs. */ const EXTRA_SOURCES = ['README.md', 'ARCHITECTURE.md']; diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 219a9b0483..f45dc6138c 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -668,11 +668,59 @@ export function runnableInvocation({ check, filter, direct }) { /** * Does a watch hint cover an input path? Containment either way, compared on - * PATH SEGMENT boundaries, with globs collapsed. A hint that collapses to a - * bare top-level directory name (`packages`, `scripts` — no slash, not a dotted - * dir) is rejected as too generic: it would match every file under the tree's - * biggest directories and drown the signal the matched-via column exists to - * carry. + * PATH SEGMENT boundaries, with globs collapsed. A hint that names a bare + * top-level directory (`packages`, `scripts` — a WORD, with no separator + * anywhere in it, and not a dotted dir) is rejected as too generic: it would + * match every file under the tree's biggest directories and drown the signal + * the matched-via column exists to carry. + * + * ## Why the refusal reads the hint AS WRITTEN, not the collapsed copy (#9626) + * + * That refusal used to be applied to `plain` — the hint AFTER globs were + * collapsed and trailing separators stripped. Collapsing is lossy in exactly + * the way the refusal is deciding on: `content/**` and `skills/**` collapse to + * `content` and `skills`, so a gate that declared a whole SUBTREE as its + * population was refused as though it had written a bare word. The two are not + * the same claim. A bare `packages` is a path component a script joins with + * something else; `packages/**` is an author stating what the gate reads, in + * the syntax the repo uses for exactly that everywhere else (`paths:` filters, + * turbo inputs, the `files` field). + * + * The blind spot was total for the class and it hid REQUIRED coverage. Measured + * on this tree, three live hints collapse to a bare root, and all three are + * genuine population declarations that reached nothing at all: + * + * `scripts/**`, `content/**` check-cross-package-test-inputs' declaration + * table, whose own header calls its entries "the + * repo-relative globs they really read" + * `skills/**` check-governed-merges' GOVERNED_SURFACES row + * for the published skills catalog + * + * `check:doc-anchors` was the specimen that surfaced it: it spelled its root + * `'content'`, contributed no hint at all, and so scored `silent` for every + * card under `content/**` — while being the ONLY fragment coverage this repo + * has (`check-links.yml` sets `include_fragments = "none"`, and says so). + * + * Reading the hint as written closes the class without widening the scan. + * Measured over 107 discovered families against all 6181 tracked files: + * + * watch-hint (gate, file) pairs 19024 -> 19834 (+810, and ZERO lost) + * families gaining coverage 3, via those three hints and nothing else + * + * The alternative — teaching `extractWatchHints` to accept a bare single-segment + * literal that happens to name a real top-level directory — was measured on the + * same corpus and REFUSED: it takes those pairs to 158108 (+139084), because + * `packages`, `apps`, `examples` and `package.json` are path COMPONENTS in + * dozens of gates that never read the root. One card + * (`packages/spec/src/index.ts`) goes from 7 matched families to 34. That is + * the "22 leads is the same as none" failure in the header, bought wholesale. + * + * What stays out of reach, deliberately: a population that is a top-level FILE + * (`README.md`, `ARCHITECTURE.md` — the rest of `check-doc-anchors`' corpus). + * A bare filename carries no separator either, and accepting one would admit + * every `package.json` / `turbo.json` / `tsconfig.json` basename a gate joins + * with a package directory — the same explosion, one class over. A miss there + * costs one card one CI round; that is the side this file errs on. * * ## Why a segment boundary and not a raw string prefix (#8534) * @@ -730,7 +778,9 @@ export function runnableInvocation({ check, filter, direct }) { export function hintCovers(hint, inputPath) { const plain = hint.replace(/\*\*?/g, '').replace(/\/+$/, '').replace(/\/$/, ''); if (plain.length < 2) return false; - if (!plain.includes('/') && !plain.startsWith('.')) return false; + // `hint`, not `plain`: glob collapse destroys the separator this refusal is + // deciding on, and a declared subtree is not a bare word. See the docblock. + if (!hint.includes('/') && !plain.startsWith('.')) return false; return ( inputPath === plain || inputPath.startsWith(`${plain}/`) || @@ -1246,10 +1296,17 @@ export function reachesMetadataFormModule(path, modulePaths) { * like any other literal — comment masking cannot reach it. The ratchet * entry's remedy command therefore spells its `--filter` values unquoted (and * says to quote them for the shell): measured, the shell-quoted spelling adds - * both of its glob filter values to THIS file's own hint set as hints, inert - * only because `hintCovers` rejects one that collapses to a bare top-level - * directory. A gate list that fabricates hints out of its own explanations is - * the failure this whole script is written against. + * both of its glob filter values (the two `./packages` globs, one flat and + * one nested) to THIS file's own hint set as hints. That spelling is now + * LOAD-BEARING rather than + * merely tidy: it used to be inert as well, because `hintCovers` refused a + * hint that COLLAPSED to a bare top-level directory, and since #9626 that + * refusal reads the hint as written — `packages/*` carries a separator, so + * quoting it here would make this file's own prose match every card under + * `packages/`. Leave the filter values unquoted. A gate list that fabricates + * hints out of its own explanations is the failure this whole script is + * written against, and this is the one place in the tree where the trade is + * live rather than hypothetical. * - Every `name` here is checked against the LIVE workflows by the self-test, * not only by the run that happens to print it. The STALE branch reports rot * to whoever is looking at the output; the self-test case makes the same rot @@ -1430,8 +1487,10 @@ export function residueLines({ discovered, matched, undetermined, silent, unfilt `Residue — all ${discovered} discovered famil(ies) placed, derived at runtime:`, ` ${matched} matched above · ${undetermined} undetermined (their sources name no path at all — NOT known irrelevant)` + ` · ${silent} silent (their sources name paths, none of which cover yours).`, - ' A `silent` verdict is this derivation\'s weakest claim, not a clearance: a gate that computes its own population and' + - ' names only its baseline artifact scores silent for every card in the tree.', + ' A `silent` verdict is this derivation\'s weakest claim, not a clearance, and there are two ways to earn it that have' + + ' nothing to do with your paths: a gate that computes its own population and names only its baseline artifact scores' + + ' silent for every card in the tree, and so does one whose population is a top-level FILE (`README.md`) — a literal' + + ' with no path separator is refused as too generic, so the gate reads your file while naming nothing that can match it.', ` ${unfiltered} of the ${discovered} sit only in workflows that declare no pull_request path filter — CI schedules those on` + ' EVERY pull request, so no path derivation can narrow them and their verdict above is about relevance, never schedule.', ` Convention-triggered gates cut ACROSS all three and are printed above when a kind hits (${kinds.map((k) => k.kind).join('; ')}).`, @@ -2209,6 +2268,40 @@ function selfTest() { t('the same glob still covers the package it names', hintCovers('packages/client*', 'packages/client/src/index.ts')); t('a segment-boundary glob is untouched by the trade', hintCovers('packages/client/**', 'packages/client/src/index.ts')); + // ── A declared SUBTREE is not a bare word (#9626) ───────────────────────── + // + // The genericity refusal reads the hint as the author wrote it. Both + // directions, because the whole value of the rule is the pair: the word is + // still refused, the declaration is now honoured. Collapsing `content/**` + // yields the same `content` the bare word yields, which is precisely why the + // refusal cannot be decided on the collapsed copy. + t('a single-segment root declared as a subtree covers the tree it names', hintCovers('content/**', 'content/docs/any-page.mdx')); + t('the same declaration covers the OTHER subtree under that root', hintCovers('content/**', 'content/blog/a-post.mdx')); + t('a bare top-level directory WORD is still refused as too generic', !hintCovers('packages', 'packages/spec/src/index.ts')); + t('a bare root that lost its separator to the trailing trim is still refused', !hintCovers(extractWatchHints("const D = 'examples/';")[0] ?? 'examples', 'examples/app-showcase/src/x.ts')); + t('a declared subtree does not reach a sibling root', !hintCovers('content/**', 'contentious/x.md')); + // A top-level FILE stays out of reach on purpose: accepting a bare filename + // would admit every `package.json` basename a gate joins with a package dir. + // Pinned so the loss reads as a decision, not an oversight — it is the rest + // of check-doc-anchors' corpus (README.md, ARCHITECTURE.md). + t('a bare top-level FILE name is refused, the decided loss', !hintCovers('README.md', 'README.md')); + + // The three live declarations the refusal used to swallow, read from the real + // gates rather than fixtures — a fixture cannot show that the tree still has + // the shape. If one of these gates stops declaring its root, re-point the + // case at whatever gate then does; deleting one deletes the evidence. + const crossPkgHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-cross-package-test-inputs.mjs'), 'utf8')); + t('the cross-package gate reaches the root scripts dir it declares', crossPkgHints.some((h) => hintCovers(h, 'scripts/check-nul-bytes.mjs'))); + t('and the content tree it declares', crossPkgHints.some((h) => hintCovers(h, 'content/docs/getting-started/index.mdx'))); + const governedHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/pm/check-governed-merges.mjs'), 'utf8')); + t('the governed-merge gate reaches the published skills catalog it declares', governedHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md'))); + + // The card this landed for: the ONLY fragment coverage in the repo, which + // scored `silent` for every content card while being REQUIRED in lint.yml. + const anchorHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-doc-anchors.mjs'), 'utf8')); + 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'))); + // ── 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 From 11aed652e956b720ce5eb9d2bd26ff7c94d970bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 14:03:13 +0000 Subject: [PATCH 2/2] test(pm): point the cross-package case at a path only the declaration reaches Measured under ablation: the case passed with the fix reverted, because check-cross-package-test-inputs names scripts/check-nul-bytes.mjs explicitly as well as declaring scripts/**. A fixture that survives the ablation pins nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- scripts/pm/dispatch-gates.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index f45dc6138c..40630d37bd 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2291,7 +2291,11 @@ function selfTest() { // the shape. If one of these gates stops declaring its root, re-point the // case at whatever gate then does; deleting one deletes the evidence. const crossPkgHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-cross-package-test-inputs.mjs'), 'utf8')); - t('the cross-package gate reaches the root scripts dir it declares', crossPkgHints.some((h) => hintCovers(h, 'scripts/check-nul-bytes.mjs'))); + // NOT `scripts/check-nul-bytes.mjs`: that gate names that file explicitly + // too, so the case would pass with the declaration still refused — measured, + // it survived the ablation. Pick a scripts path reachable ONLY through the + // declared subtree, or the case pins nothing. + t('the cross-package gate reaches the root scripts dir it declares', crossPkgHints.some((h) => hintCovers(h, 'scripts/pm/dispatch-gates.mjs'))); t('and the content tree it declares', crossPkgHints.some((h) => hintCovers(h, 'content/docs/getting-started/index.mdx'))); const governedHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/pm/check-governed-merges.mjs'), 'utf8')); t('the governed-merge gate reaches the published skills catalog it declares', governedHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md')));