4848// merely unimplemented.
4949//
5050// Scope: content/docs (hand-written; its `references/` tree is generated from
51- // spec and excluded BY PATH — the spec source is the fix site there) and
51+ // spec and its `protocol-upgrade/` tree from the ADR-0087 registries, both
52+ // excluded BY PATH — the generator's source is the fix site there) and
5253// skills/, walked whole, `references/` included: that tree is published catalog
5354// content, not a generated one. See the SKIP_SUBTREES docblock below for why the
5455// exclusion is a path and not a directory name. File and directory NAMES count
@@ -63,6 +64,7 @@ import {
6364import { tmpdir } from 'node:os' ;
6465import { join , relative , dirname } from 'node:path' ;
6566import { fileURLToPath } from 'node:url' ;
67+ import { NOT_DRIVER_MANAGED } from './regen-artifacts.mjs' ;
6668
6769const ROOTS = [ 'content/docs' , 'skills' ] ;
6870
@@ -122,15 +124,37 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']);
122124// justifies the entry above is what decides it rather than weighing against it:
123125// the reserved word emitted into a page by its generator is a REAL finding whose
124126// remedy is that generator. Naming the generated tree is how the gate is told
125- // where to send such a finding, and `content/docs/references/` is the only tree
126- // under these roots whose fix site this gate cannot otherwise reach.
127+ // where to send such a finding, and exactly two trees under these roots have a
128+ // fix site this gate cannot otherwise reach:
129+ //
130+ // content/docs/references/ generated from spec by
131+ // packages/spec/scripts/build-docs.ts; the
132+ // fix site is the Zod source
133+ // content/docs/protocol-upgrade/ the protocol upgrade guide, one page per
134+ // protocol major plus an index, written by
135+ // `gen:upgrade-guide` from the ADR-0087
136+ // conversion and migration registries; the
137+ // fix site is the registry `.ts`
138+ //
139+ // The second tree is also UNTRACKED: gitignored, written on demand by the
140+ // `@objectstack/docs` build, never committed, so CI's checkout never holds it.
141+ // Read, it made a local run judge a tree CI never sees — a docs build reddened
142+ // this gate with findings whose remedy lives in a `.ts` file this markdown gate
143+ // does not read, while CI stayed green over the same commit. Skipping it is the
144+ // fix-site argument above, applied unchanged, and it makes a local verdict equal
145+ // CI's. Whether the published guide's prose should itself meet ADR-0090 D3 is a
146+ // question for the registry, not one this exclusion settles.
127147//
128148// The self-test pins both directions from the WALK's own output rather than as a
129149// file count. A count has to be re-typed whenever a page is added, and neither
130150// failure it must catch is a statement a count can make: "the scan reaches no
131- // published reference page at all", and "the scan descended into the generated
132- // tree".
133- const SKIP_SUBTREES = new Set ( [ 'content/docs/references' ] ) ;
151+ // published reference page at all", and "the scan descended into a generated
152+ // tree". An untracked tree leaves that walk pin vacuous on a fresh checkout, so
153+ // the self-test also holds this set against `NOT_DRIVER_MANAGED` in
154+ // scripts/regen-artifacts.mjs — the one register of untracked generator output —
155+ // and runs this gate as a child process over a fixture page inside each skipped
156+ // tree, with the same page outside it as the control.
157+ const SKIP_SUBTREES = new Set ( [ 'content/docs/references' , 'content/docs/protocol-upgrade' ] ) ;
134158const EXTENSIONS = new Set ( [ '.mdx' , '.md' ] ) ;
135159const BASELINE_PATH = 'scripts/role-word-baseline.json' ;
136160const WORD = / \b r o l e (?: s ) ? \b / gi;
@@ -1258,11 +1282,12 @@ let selfTestReachedVerdict = false;
12581282// must not red. A battery BELOW its floor means cases stopped running; the
12591283// remedy is to find what stopped registering.
12601284const SELF_TEST_BATTERIES = Object . freeze ( {
1261- 'The scan population: which `references/` the skip means (#15061)' : 5 ,
1285+ 'The scan population: which `references/` the skip means (#15061)' : 7 ,
12621286 'The ratchet-remedy authority convention (#8435)' : 4 ,
12631287 'The NEW-use message names the relocation case (#14659)' : 7 ,
12641288 'The green body reports what was READ (#9910)' : 5 ,
12651289 'A missing ROOT is REFUSED, per root (#9932)' : 8 ,
1290+ 'Every skipped subtree, at the PROGRAM level' : 7 ,
12661291 'The dispatch-gates declaration (#9964\'s pattern)' : 4 ,
12671292 'The vendor-wire fence exemption (#10533)' : 6 ,
12681293 'Every way the MARKING could stop bounding the exemption' : 15 ,
@@ -1275,7 +1300,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
12751300
12761301// DELETING an entry silences that battery's floor exactly as effectively as
12771302// zeroing it, so the roster's own size is pinned too.
1278- const SELF_TEST_BATTERY_FLOOR = 13 ;
1303+ const SELF_TEST_BATTERY_FLOOR = 14 ;
12791304
12801305// The key an assertion is filed under when no battery is open. It is not a
12811306// declared battery, so it reds by the same set difference rather than silently
@@ -1345,12 +1370,38 @@ function selfTest() {
13451370 + 'the three surviving entries meet at every depth and under every root, and `references` '
13461371 + 'never did. A fourth entry has to be stated rather than appended' ,
13471372 [ ...SKIP_DIRS ] . every ( ( d ) => INSTALLED_OR_GENERATED . has ( d ) ) ) ;
1348- expect ( '#15061 — every SKIP_SUBTREES entry lies under a configured ROOT and EXISTS: a path '
1349- + 'excluding a tree no root reaches, or one that has since moved, is dead configuration that '
1350- + 'reads as coverage' ,
1373+ // The UNTRACKED half of SKIP_SUBTREES, read from the register rather than
1374+ // re-spelled here: `NOT_DRIVER_MANAGED`'s `untracked: true` entries, which
1375+ // `check:merge-driver` holds against git and against their generator's name,
1376+ // narrowed to the ones under a ROOT. Such a tree exists only after its
1377+ // generator ran, so existence cannot be what proves the exclusion live; the
1378+ // register entry naming its generator is.
1379+ const underRoot = ( p ) => ROOTS . some ( ( r ) => p === r || p . startsWith ( `${ r } /` ) ) ;
1380+ const untrackedUnderRoots = NOT_DRIVER_MANAGED
1381+ . filter ( ( e ) => e . untracked && e . path . endsWith ( '/**' ) )
1382+ . map ( ( e ) => e . path . slice ( 0 , - '/**' . length ) )
1383+ . filter ( underRoot ) ;
1384+ const skippedReached = walkedRel . filter (
1385+ ( f ) => [ ...SKIP_SUBTREES ] . some ( ( p ) => f . startsWith ( `${ p } /` ) ) ,
1386+ ) ;
1387+ expect ( 'the walk reaches NO page under ANY SKIP_SUBTREES entry, content/docs/protocol-upgrade/ '
1388+ + 'included whenever a local docs build has written it. On a fresh checkout that tree is '
1389+ + 'absent and this case cannot fail for it, which is why the register case below and the '
1390+ + 'program-level battery exist' ,
1391+ skippedReached . length === 0 ) ;
1392+ expect ( '#15061 — every SKIP_SUBTREES entry lies under a configured ROOT and EXISTS, or is an '
1393+ + 'UNTRACKED generated tree the register declares (written on demand, so absent from a fresh '
1394+ + 'checkout): a path excluding a tree no root reaches, or one that has since moved, is dead '
1395+ + 'configuration that reads as coverage' ,
13511396 [ ...SKIP_SUBTREES ] . every (
1352- ( p ) => ROOTS . some ( ( r ) => p === r || p . startsWith ( ` ${ r } /` ) ) && existsSync ( p ) ,
1397+ ( p ) => underRoot ( p ) && ( existsSync ( p ) || untrackedUnderRoots . includes ( p ) ) ,
13531398 ) ) ;
1399+ expect ( 'every untracked generated tree the register places under a ROOT is in SKIP_SUBTREES, '
1400+ + 'and the register names at least one, so a renamed flag cannot pass this by matching '
1401+ + 'nothing. CI never generates such a tree, so reading it makes a local run judge pages CI '
1402+ + 'never sees, with findings whose fix site is their generator. Dropping the '
1403+ + 'content/docs/protocol-upgrade entry reds HERE on every checkout, pages on disk or not' ,
1404+ untrackedUnderRoots . length > 0 && untrackedUnderRoots . every ( ( p ) => SKIP_SUBTREES . has ( p ) ) ) ;
13541405
13551406 // ── The ratchet-remedy authority convention (#8435) ────────────────────────
13561407 //
@@ -1625,6 +1676,65 @@ function selfTest() {
16251676 rmSync ( sandbox , { recursive : true , force : true } ) ;
16261677 }
16271678
1679+ // ── Every skipped subtree, at the PROGRAM level ────────────────────────────
1680+ //
1681+ // The scan-population cases prove the SET says the right thing; these prove
1682+ // the program honours it, by building a tree and running this file in it as a
1683+ // child, the discipline of the missing-ROOT legs above. It is also the only
1684+ // evidence an untracked tree leaves on a fresh checkout, where the walk pin
1685+ // has none of its pages to see.
1686+ //
1687+ // The trees are built from SKIP_SUBTREES and the register's untracked trees
1688+ // together, never re-spelled: an entry dropped from the set is still built
1689+ // here, so its page is COUNTED and the skip leg reds by exit status.
1690+ //
1691+ // Per tree, three legs that differ only in where the page sits:
1692+ // inside the page inside the skipped tree: exit 0 with zero files READ,
1693+ // so the green came from the walk never opening it, not from an
1694+ // exemption or a baseline (no baseline exists in these trees);
1695+ // outside the same page one level up, in the hand-written tree: a NEW use,
1696+ // exit 1, because a gate that skipped everything would pass `inside`;
1697+ // sibling the same page in a sibling directory whose name merely STARTS
1698+ // with the skipped one's: a NEW use too, because the exclusion is
1699+ // a path and never a string prefix.
1700+ battery ( 'Every skipped subtree, at the PROGRAM level' ) ;
1701+ const SKIP_FIXTURE_PAGE = 'fixture-page.md' ;
1702+ const SKIP_FIXTURE = '# Approvals\n\nEvery approver holds a role, said the prose.\n' ;
1703+ expect ( 'the skipped-subtree fixture CARRIES the reserved word, so a green inside a skipped tree '
1704+ + 'is the skip working and not an empty page' ,
1705+ countMatches ( SKIP_FIXTURE ) === 1 ) ;
1706+ const skipSandbox = mkdtempSync ( join ( tmpdir ( ) , 'check-role-word-skipped-' ) ) ;
1707+ try {
1708+ const nothingRead = scanClause ( ROOTS . map ( ( root ) => ( { root, files : 0 } ) ) ) ;
1709+ const skipTrees = [ ...new Set ( [ ...SKIP_SUBTREES , ...untrackedUnderRoots ] ) ] ;
1710+ skipTrees . forEach ( ( tree , i ) => {
1711+ const buildSkipTree = ( leg , pageDir ) => {
1712+ const dir = join ( skipSandbox , `${ i } -${ leg } ` ) ;
1713+ for ( const r of ROOTS ) mkdirSync ( join ( dir , r ) , { recursive : true } ) ;
1714+ mkdirSync ( join ( dir , pageDir ) , { recursive : true } ) ;
1715+ writeFileSync ( join ( dir , pageDir , SKIP_FIXTURE_PAGE ) , SKIP_FIXTURE ) ;
1716+ return dir ;
1717+ } ;
1718+ const inside = runIn ( buildSkipTree ( 'inside' , tree ) ) ;
1719+ expect ( `${ tree } / — a page inside it is SKIPPED: exit 0 with nothing read, where the same `
1720+ + 'page counted would be a NEW use and exit 1' ,
1721+ inside . status === 0 && inside . out . includes ( nothingRead ) ) ;
1722+ const outsidePage = `${ dirname ( tree ) } /${ SKIP_FIXTURE_PAGE } ` ;
1723+ const outside = runIn ( buildSkipTree ( 'outside' , dirname ( tree ) ) ) ;
1724+ expect ( `${ tree } / — the same page one level up, outside it, still FIRES as a NEW use `
1725+ + '(exit 1), so the green above came from this exclusion and not from a gate that skips '
1726+ + 'everything' ,
1727+ outside . status === 1 && outside . out . includes ( `${ outsidePage } : NEW use of the reserved word` ) ) ;
1728+ const siblingPage = `${ tree } -draft/${ SKIP_FIXTURE_PAGE } ` ;
1729+ const sibling = runIn ( buildSkipTree ( 'sibling' , `${ tree } -draft` ) ) ;
1730+ expect ( `${ tree } / — the same page in a sibling directory whose name only STARTS with it still `
1731+ + 'FIRES as a NEW use (exit 1): the exclusion is a path, never a string prefix' ,
1732+ sibling . status === 1 && sibling . out . includes ( `${ siblingPage } : NEW use of the reserved word` ) ) ;
1733+ } ) ;
1734+ } finally {
1735+ rmSync ( skipSandbox , { recursive : true , force : true } ) ;
1736+ }
1737+
16281738 // ── The dispatch-gates declaration (#9964's pattern) ──────────────────────
16291739 //
16301740 // Enforcement cannot hold any of these: the declaration is read by another
@@ -2260,9 +2370,12 @@ function selfTest() {
22602370 console . log (
22612371 `OK self-test: the walk reaches ${ walkedRel . length } markdown file(s) across the roots, `
22622372 + `${ publishedRefs . length } of them published reference pages under skills/, and `
2263- + `${ generatedRefs . length } under the generated content/docs/references/ tree — both `
2373+ + `${ skippedReached . length } under the generated subtrees ( ${ [ ... SKIP_SUBTREES ] . join ( ', ' ) } ) — both `
22642374 + 'directions pinned from the WALK, never from a typed count, so a skip that empties the '
2265- + 'published half and one that swallows the generated half each name themselves. '
2375+ + 'published half and one that swallows the generated half each name themselves. Every '
2376+ + 'skipped subtree is also held against the register of untracked generator output, and '
2377+ + 'driven through a real child process: a page inside it is never read, while the same page '
2378+ + 'outside it, or in a sibling directory sharing its prefix, still counts. '
22662379 + 'The NEW-use remedy marks baseline expansion as maintainer-only, the predicate '
22672380 + 'rejects an unmarked offer, the ratchet-DOWN remedy stays the author\'s own, and that '
22682381 + 'same remedy NAMES the pure-relocation case, quotes a ratchet-DOWN row the gate really '
0 commit comments