@@ -186,6 +186,105 @@ const SKIP_PATHS = new Set([
186186 'content/docs/references' ,
187187] ) ;
188188
189+ /**
190+ * ROOTS above, written in the subtree spelling `scripts/pm/dispatch-gates.mjs`
191+ * compares in. Provenance ONLY: nothing in this gate reads this list, and the
192+ * scan behaves exactly as it did without it.
193+ *
194+ * ## The gap this closes (#9964's declaration pattern, seventh instance)
195+ *
196+ * That tool builds every dispatch's gate list by scanning each gate's own
197+ * source for the path literals it operates on, and "looks like a path" there
198+ * means "carries a separator" — or names a top-level DOTTED directory, which is
199+ * the one arm that saved `.claude`. So three of the four ROOTS were bare words
200+ * that never became a hint, while `SKIP_PATHS` above spells its entries with
201+ * separators, and those DID.
202+ *
203+ * Measured on this tree, the gate's whole extracted hint set was:
204+ *
205+ * .claude the one md/mdx root the dotted-dir arm
206+ * admitted — 20 files
207+ * packages/spec/src SPEC_ROOT, surface 2 — already a path
208+ * literal, so already visible: 972 files
209+ * .claude/worktrees, docs/audits, the exemptions, i.e. the subtrees it
210+ * docs/handoff, docs/plans, deliberately does NOT read
211+ * content/docs/references
212+ * @objectstack /formula the import specifier, inert as a path
213+ *
214+ * Of the 1388 files this gate walks, 396 were declared by nothing at all
215+ * (28.5%) — every file under `docs` (156), `skills` (48) and `content` (192).
216+ * The three declarations below are what close that.
217+ *
218+ * Within the `docs` root the shape was inverted rather than merely absent: a
219+ * card touching `docs/plans/` DERIVED this gate (via the SKIP_PATHS literal —
220+ * a subtree the walk returns from immediately), while a card touching
221+ * `docs/qa/platform-checklist/` derived nothing. The exclusions were the
222+ * declaration and the population was not.
223+ *
224+ * That is worse than declaring nothing, and worse in the direction that hides
225+ * it: the residue line still PRINTED gate names, so the row read as "declared,
226+ * just not relevant to you" rather than as a blind spot. A card editing the
227+ * live docs corpus met this REQUIRED gate (lint.yml, `pnpm --filter
228+ * @objectstack /lint run check:doc-formula-expressions`) as red CI instead of as
229+ * a local command.
230+ *
231+ * `.claude/**` is redundant with the bare `.claude` the extractor already takes
232+ * on its dotted-dir arm, and is kept so the declaration is uniform across ROOTS
233+ * rather than depending on which arm happened to admit which root. SPEC_ROOT
234+ * needs no entry for the same reason `.claude` did not strictly need one — it
235+ * already carries a separator — and the self-test pins that it still does, so
236+ * renaming it to a bare word fails here instead of silently unhinting 972
237+ * files.
238+ *
239+ * ## Why the subtree spelling, and not a wider extractor
240+ *
241+ * `hintCovers` refuses a bare single-segment literal (`docs`) as too generic BY
242+ * DESIGN, and that refusal is measured rather than incidental: teaching the
243+ * extractor to accept bare top-level directory words was priced at +139084
244+ * fabricated (gate, file) pairs, because `packages`, `apps` and `examples` are
245+ * path COMPONENTS in dozens of gates that never read those roots. Nor can a
246+ * class-level guard author this for us — flagging any gate that names a bare
247+ * tracked directory none of its hints reach fires on 40 of 123 families, and
248+ * the majority are right as they stand. The distinction between "population
249+ * root" and "path component" is in the author's intent, not the source text,
250+ * which is why the declaration has to be authored, gate by gate.
251+ *
252+ * ## Why the ROOT, and not the live subtrees under it (the SKIP_PATHS question)
253+ *
254+ * `hintCovers` has no way to SUBTRACT: hints are positive containment, so
255+ * "`docs/**` except `docs/plans`" is not expressible. The exempt subtrees are
256+ * therefore claimed by this declaration, and that is a DELIBERATE, bounded
257+ * residual rather than an oversight — pinned as such in the self-test, so it
258+ * cannot silently grow past the exemptions it is accounted for.
259+ *
260+ * The same limit applies one level down, to the extension filter: `collectFiles`
261+ * keeps only `.md`/`.mdx` (and `.ts`/`.tsx` under SPEC_ROOT), which a subtree
262+ * hint cannot express either — so a card touching `content/docs.site.json`
263+ * derives this gate although the walk skips that file. Both residuals point the
264+ * same way: the declaration over-claims INSIDE what it walks, never outside,
265+ * and the negative half of the self-test is what holds that line.
266+ *
267+ * The residual is also not new, and this declaration does not widen it by one
268+ * path: those five subtrees derive this gate TODAY, via the `SKIP_PATHS`
269+ * literals themselves, which stay hints whatever this list says. Removing that
270+ * residual would mean unquoting the most safety-critical constant in this file.
271+ * The declaration subsumes those hints and adds nothing to that side while
272+ * closing all 396 files of the missing side.
273+ *
274+ * What the precedent does draw a line at is claiming a tree the ROOTS do not
275+ * reach at all, and the self-test in `scripts/pm/dispatch-gates.mjs` pins that
276+ * negative half against the real extractor — the load-bearing direction for a
277+ * declaration this broad, since a gate named on EVERY card is the louder
278+ * version of naming none.
279+ *
280+ * ## Provenance, never a lookup key
281+ *
282+ * The glob form appearing in ROOTS would send `walk()` at a directory that does
283+ * not exist — since #4916 a hard refusal rather than a silent skip, but one
284+ * that fails naming the wrong problem. The self-test pins both halves.
285+ */
286+ const ROOT_WATCH_HINTS = [ '.claude/**' , 'docs/**' , 'skills/**' , 'content/**' ] ;
287+
189288const posix = ( p ) => p . split ( sep ) . join ( '/' ) ;
190289
191290/**
@@ -918,6 +1017,66 @@ const EXEMPTION_SELF_TEST_CASES = [
9181017 } ,
9191018] ;
9201019
1020+ /**
1021+ * The `scripts/pm/dispatch-gates.mjs` declaration (#9964's pattern, seventh
1022+ * instance), pinned in both directions.
1023+ *
1024+ * Enforcement cannot hold any of these: the declaration is read by another tool
1025+ * entirely, so a wrong or stale one runs green here forever and pays itself out
1026+ * as a dev dispatched on a docs card with this REQUIRED gate missing from the
1027+ * brief — which is exactly how it stood before this block. Both sides are
1028+ * derived from ROOTS rather than re-spelled, so renaming or widening a root
1029+ * cannot leave the declaration describing the old population.
1030+ */
1031+ const DECLARATION_SELF_TEST_CASES = [
1032+ {
1033+ name : 'DECLARATION — every ROOT the hint extractor cannot see is declared as a subtree '
1034+ + '(a root with no path separator is refused as too generic)' ,
1035+ holds : ( ) => ROOTS . filter ( ( r ) => ! r . includes ( '/' ) ) . every ( ( r ) => ROOT_WATCH_HINTS . includes ( `${ r } /**` ) ) ,
1036+ } ,
1037+ {
1038+ name : 'DECLARATION — and it declares no root this gate does not walk (a declaration that '
1039+ + 'can drift from the scan is worse than none — it replaces a silent gate with a lying one)' ,
1040+ holds : ( ) => ROOT_WATCH_HINTS . every ( ( h ) => ROOTS . includes ( h . replace ( / \/ \* + $ / , '' ) ) ) ,
1041+ } ,
1042+ {
1043+ // Provenance, never a lookup key: the glob form appearing in ROOTS would
1044+ // send `walk()` at a directory that does not exist. Since #4916 that is a
1045+ // hard refusal rather than a silent skip, but it fails naming the wrong
1046+ // problem.
1047+ name : 'DECLARATION — the declared glob form is NOT a ROOTS entry' ,
1048+ holds : ( ) => ! ROOT_WATCH_HINTS . some ( ( h ) => ROOTS . includes ( h ) ) ,
1049+ } ,
1050+ {
1051+ // The residual, pinned rather than hidden. `hintCovers` is positive
1052+ // containment with no way to subtract, so declaring a ROOT necessarily
1053+ // claims the exempt subtrees carved out of it. That is accounted for — but
1054+ // only for the exemptions themselves: every SKIP_PATHS entry must sit UNDER
1055+ // a declared root, so a future exemption somewhere this declaration does not
1056+ // reach fails here instead of quietly widening the over-claim.
1057+ name : 'DECLARATION — every skipped subtree is one this declaration knowingly over-claims, '
1058+ + 'and none is a surprise from outside the declared roots' ,
1059+ holds : ( ) => [ ...SKIP_PATHS ] . every ( ( p ) => ROOTS . some ( ( r ) => p . startsWith ( `${ r } /` ) ) ) ,
1060+ } ,
1061+ {
1062+ // The exemptions must stay a strict SUBSET of the walked roots: an entry
1063+ // that WAS a whole root would mean the gate declares a population it never
1064+ // reads.
1065+ name : 'DECLARATION — no exemption swallows a declared root whole' ,
1066+ holds : ( ) => ! [ ...SKIP_PATHS ] . some ( ( p ) => ROOTS . includes ( p ) ) ,
1067+ } ,
1068+ {
1069+ // Surface 2 is declared by SPEC_ROOT itself, which the extractor takes
1070+ // because it carries a separator — 972 files riding on one property of one
1071+ // string. Renaming it to a bare word (`spec`, say) would unhint all of them
1072+ // exactly the way `docs` was unhinted, and this is the only place that
1073+ // would notice.
1074+ name : 'DECLARATION — SPEC_ROOT still carries a path separator, so surface 2 needs no '
1075+ + 'subtree spelling of its own' ,
1076+ holds : ( ) => SPEC_ROOT . includes ( '/' ) ,
1077+ } ,
1078+ ] ;
1079+
9211080function specSelfTest ( ) {
9221081 const problemsFor = ( c ) => {
9231082 const problems = [ ] ;
@@ -1004,7 +1163,12 @@ function selfTest() {
10041163 }
10051164 }
10061165 failed += specSelfTest ( ) ;
1007- const total = SELF_TEST_CASES . length + SPEC_SELF_TEST_CASES . length + EXEMPTION_SELF_TEST_CASES . length ;
1166+ for ( const c of DECLARATION_SELF_TEST_CASES ) {
1167+ if ( c . holds ( ) ) console . log ( ` ✓ ${ c . name } ` ) ;
1168+ else { failed ++ ; console . error ( ` ✗ ${ c . name } ` ) ; }
1169+ }
1170+ const total = SELF_TEST_CASES . length + SPEC_SELF_TEST_CASES . length + EXEMPTION_SELF_TEST_CASES . length
1171+ + DECLARATION_SELF_TEST_CASES . length ;
10081172 if ( failed > 0 ) {
10091173 console . error ( `\n✗ check:doc-formula-expressions self-test: ${ failed } case(s) failed` ) ;
10101174 process . exit ( 1 ) ;
0 commit comments