@@ -127,6 +127,107 @@ import { isEntrypoint } from './invoked-as.mjs';
127127const HERE = dirname ( fileURLToPath ( import . meta. url ) ) ;
128128const REPO_ROOT = resolve ( HERE , '..' ) ;
129129
130+ const EXAMPLES_ROOT = 'examples' ;
131+ const PACKAGES_ROOT = 'packages' ;
132+
133+ /**
134+ * The two trees this gate walks, and WHICH PART of each is its population.
135+ *
136+ * The asymmetry is the whole content of this table, and it is why the roots are
137+ * written as records rather than as a flat list of directory names:
138+ *
139+ * `subtree` -- read WHOLESALE. Every file under an example app is something
140+ * a coupling can target, and `couplingTarget()` resolves to
141+ * arbitrary paths beneath it, so an edit anywhere in the tree
142+ * can move this gate's verdict. 239 tracked files, effectively
143+ * all of them in the population.
144+ *
145+ * `filtered` -- walked, then narrowed twice: to test files
146+ * (`TEST_FILE_RE` / `TEST_DIR_RE`), and then to the handful of
147+ * those that actually live-import an example app. Measured on
148+ * this tree: 76 coupled files across 3 packages
149+ * (`@objectstack/dogfood` 72, `@objectstack/cli` 2,
150+ * `@objectstack/lint` 2) out of 4861 tracked files under
151+ * `packages/`, of which 2507 are test files.
152+ *
153+ * `population` is not decoration: `--self-test` reads it to decide which roots
154+ * MUST appear in ROOT_DIR_WATCH_HINTS below and which must NOT. Both directions
155+ * are derived from this table rather than re-spelled, so renaming a root or
156+ * re-scoping what it contributes cannot leave the declaration describing a
157+ * population the gate stopped walking.
158+ */
159+ const SCAN_ROOTS = [
160+ { dir : EXAMPLES_ROOT , population : 'subtree' } ,
161+ { dir : PACKAGES_ROOT , population : 'filtered' } ,
162+ ] ;
163+
164+ /**
165+ * The half of SCAN_ROOTS that `scripts/pm/dispatch-gates.mjs` cannot see,
166+ * written in the syntax that derivation CAN read.
167+ *
168+ * ── The defect this repairs ─────────────────────────────────────────────────
169+ *
170+ * The dispatch derivation scans a gate's module body for path-ish string
171+ * literals. The only literal describing this gate's example-app population was
172+ * the bare single-segment word `examples` (from `'examples/'`, whose trailing
173+ * slash the extractor trims), and `hintCovers` refuses a separator-less literal
174+ * as too generic. So this gate scored, for EVERY card in the tree:
175+ *
176+ * pnpm check:examples-live-imports [lint.yml] dead: 'examples' -- the tree
177+ * HAS it; the covering rule refuses the literal as too generic (no path
178+ * separator)
179+ *
180+ * A gate in that state is named by no dispatch brief -- including a brief for
181+ * the one edit most likely to break it. That is the exact cost shape #8754
182+ * records: an examples-only diff runs neither of CI's scoping layers, and the
183+ * shared merge queue is the first signal.
184+ *
185+ * ── Why the subtree spelling, and not a wider extractor ─────────────────────
186+ *
187+ * The refusal is measured, not incidental, and it is not this file's to relax:
188+ * `hintCovers`' docblock prices teaching the extractor to accept bare top-level
189+ * directory words at +139084 fabricated (gate, file) pairs, precisely because
190+ * `packages`, `apps` and `examples` are path COMPONENTS in dozens of gates that
191+ * never read those roots. A declared subtree is a different claim -- an author
192+ * stating what this gate reads -- and the glob collapse reduces `examples/**`
193+ * back to this root and to nothing else. One gate pays for its own precision
194+ * instead of every gate paying for one gate's.
195+ *
196+ * ── Why `packages/**` is deliberately NOT declared ──────────────────────────
197+ *
198+ * The `packages/**` test-inventory side is unreachable too, and by a wider
199+ * margin: `'packages'` carries no separator either, so `extractWatchHints`
200+ * drops it BEFORE `hintCovers` is ever consulted -- it is not even a dead hint.
201+ * It is also not reached by the test-file convention trigger
202+ * (`CHANGE_KIND_GATES`), which this gate is not listed in. Measured: a
203+ * dispatch derivation for `packages/cli/test/i18n-section-coverage.test.ts`
204+ * -- the very file #8754 went red on -- names this gate nowhere.
205+ *
206+ * It stays undeclared anyway, because the instrument cannot express this side's
207+ * population. A root hint covers a whole subtree, so `packages/**` would name
208+ * this gate for all 4861 tracked files under `packages/` in order to reach the
209+ * 76 that carry a coupling: 1.6% precision, pasted into every `packages/**`
210+ * dispatch prompt. That is the "22 leads is the same as none" failure the
211+ * derivation's header prices a fabricated lead against, bought at a worse ratio
212+ * than the wholesale admission it already refuses. The two sides are also not
213+ * symmetric in what they cost a dev: a `packages/**` test that couples to an
214+ * example app spells `examples/` in its own diff and gets a failure text naming
215+ * itself and what to write, while an example-app edit gets no signal at all.
216+ *
217+ * The refusal is pinned below rather than left in this paragraph, so a later
218+ * author who adds `packages/**` meets an assertion instead of prose.
219+ *
220+ * ── Provenance, never a lookup key ──────────────────────────────────────────
221+ *
222+ * Nothing in this gate reads this array. The glob form appearing in SCAN_ROOTS
223+ * would send `walk()` at a directory that does not exist -- and both walks here
224+ * fail SOFT (`existsSync` guard in `exampleApps`/`testFiles`, a swallowed
225+ * `readdirSync` throw in `walk`), so the mistake would quietly shrink the
226+ * population to nothing while every gate stayed green. The self-test pins that
227+ * apart too.
228+ */
229+ const ROOT_DIR_WATCH_HINTS = [ 'examples/**' ] ;
230+
130231/**
131232 * Every `packages/**` test coupling to `examples/**` that CI's scoping layers
132233 * CANNOT see -- the #8754 population.
@@ -252,7 +353,7 @@ function walk(dir, out = []) {
252353
253354/** Test files (and the helpers under a test dir that carry couplings into them). */
254355function testFiles ( ) {
255- const pkgRoot = join ( REPO_ROOT , 'packages' ) ;
356+ const pkgRoot = join ( REPO_ROOT , PACKAGES_ROOT ) ;
256357 if ( ! existsSync ( pkgRoot ) ) return [ ] ;
257358 return walk ( pkgRoot ) . filter ( ( abs ) => {
258359 if ( ! / \. [ m c ] ? [ j t ] s x ? $ / . test ( abs ) ) return false ;
@@ -263,7 +364,7 @@ function testFiles() {
263364
264365/** The example apps, by directory and by published package name. */
265366function exampleApps ( ) {
266- const root = join ( REPO_ROOT , 'examples' ) ;
367+ const root = join ( REPO_ROOT , EXAMPLES_ROOT ) ;
267368 const apps = [ ] ;
268369 if ( ! existsSync ( root ) ) return apps ;
269370 for ( const e of readdirSync ( root , { withFileTypes : true } ) ) {
@@ -741,6 +842,36 @@ function selfTest() {
741842 '* does not span segments' ,
742843 ! globCoversTarget ( 'examples/app-showcase/src/*.ts' , 'examples/app-showcase/src/ui/x.ts' , false ) ,
743844 ] ,
845+
846+ // ── the dispatch-gates declaration (#9964's pattern, #10114's coupling) ──
847+ //
848+ // Enforcement cannot hold any of these: ROOT_DIR_WATCH_HINTS is read by
849+ // another tool entirely, so a wrong or stale one runs green here forever and
850+ // pays itself out as a dev dispatched on an examples card with this gate
851+ // missing from the brief. Both directions are derived from SCAN_ROOTS rather
852+ // than re-spelled, so widening or renaming a root cannot leave the
853+ // declaration describing the old population.
854+ [
855+ 'every WHOLESALE root is declared (a root with no path separator is refused as too generic, so it needs the subtree spelling)' ,
856+ SCAN_ROOTS . filter ( ( r ) => r . population === 'subtree' && ! r . dir . includes ( '/' ) ) . every ( ( r ) =>
857+ ROOT_DIR_WATCH_HINTS . includes ( `${ r . dir } /**` ) ,
858+ ) ,
859+ ] ,
860+ [
861+ '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)' ,
862+ ROOT_DIR_WATCH_HINTS . every ( ( h ) => SCAN_ROOTS . some ( ( r ) => r . dir === h . replace ( / \/ \* + $ / , '' ) ) ) ,
863+ ] ,
864+ [
865+ 'a FILTERED root stays undeclared (packages/** would name this gate for 4861 files to reach 76; the measurement is in the docblock)' ,
866+ SCAN_ROOTS . filter ( ( r ) => r . population !== 'subtree' ) . every (
867+ ( r ) => ! ROOT_DIR_WATCH_HINTS . some ( ( h ) => h . replace ( / \/ \* + $ / , '' ) === r . dir ) ,
868+ ) ,
869+ ] ,
870+ [ 'examples is the root it declares (the #8754 population)' , ROOT_DIR_WATCH_HINTS . includes ( 'examples/**' ) ] ,
871+ [
872+ 'the declared form is NOT a SCAN_ROOTS dir (provenance, never a lookup key: the glob form would send the walk at a directory that does not exist)' ,
873+ ! SCAN_ROOTS . some ( ( r ) => ROOT_DIR_WATCH_HINTS . includes ( r . dir ) ) ,
874+ ] ,
744875 ] ;
745876
746877 let failed = 0 ;
0 commit comments