184184// here is one sentence in that remedy, not a mechanism.
185185import { execFileSync } from 'node:child_process' ;
186186import { readdirSync , readFileSync , writeFileSync , existsSync , openSync , closeSync , unlinkSync } from 'node:fs' ;
187- import { dirname , join , resolve } from 'node:path' ;
187+ import { dirname , join , posix , relative , resolve } from 'node:path' ;
188188import { fileURLToPath } from 'node:url' ;
189189import { tmpdir } from 'node:os' ;
190190import { randomUUID } from 'node:crypto' ;
@@ -221,11 +221,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
221221 'Root anchoring and the population classifier (#10907). These are the only' : 4 ,
222222 'The build-prerequisite CLOSURE (#12564). What makes the remedy worth naming' : 15 ,
223223 'The prerequisite refusal CLASS, and the advisory that names it (#14857).' : 23 ,
224+ 'The dispatch-gates declaration (#21011): the example configs and the trees they import' : 10 ,
224225} ) ;
225226
226227// DELETING an entry silences that battery's floor exactly as effectively as
227228// zeroing it, so the roster's own size is pinned too.
228- const SELF_TEST_BATTERY_FLOOR = 5 ;
229+ const SELF_TEST_BATTERY_FLOOR = 6 ;
229230
230231// The key an assertion is filed under when no battery is open. It is not a
231232// declared battery, so it reds by the same set difference rather than silently
@@ -246,16 +247,52 @@ const EXAMPLES_DIR = 'examples';
246247const PACKAGES_DIR = 'packages' ;
247248const BASELINE_PATH = 'scripts/i18n-coverage-baseline.json' ;
248249
249- // ⛔ Neither root above is declared to the dispatch derivation, and that is a
250- // recorded REFUSAL rather than an omission. Both populations are FILENAME
251- // filters — one `objectstack.config.ts` per example directory (3 of 240), and
252- // files named `i18n-extract.config.ts` beneath a `scripts` segment (9 of 5035).
253- // The `ROOT_DIR_WATCH_HINTS` idiom can only name a whole subtree, so the only
254- // spellable claim here would name this gate for 5035 files to reach 9 — the
255- // costlier error, per `hintCovers`. Both verdicts are recorded as
256- // REFUSE-UNSPELLABLE in the triage that `scripts/pm/bare-root-worklist.mjs`
257- // self-tests on every PR; giving `PACKAGES_DIR` a population-constant name is
258- // what made this root visible to that sweep at all.
250+ // ── What the dispatch derivation is told this gate reads (#21011) ──────────
251+ //
252+ // `scripts/pm/dispatch-gates.mjs` names a gate for a card by reading path
253+ // literals out of the gate's own source, and it refuses a bare single-segment
254+ // word like the two constants above as too generic. So with nothing else
255+ // declared, an edit under `examples/` derived nothing for this gate. Measured on
256+ // a real producer (#21011): a showcase diff derived 116 families locally, none
257+ // of them this one, and CI then failed it here (`app-showcase` 414 -> 416).
258+ //
259+ // The EXAMPLES population is not the filename filter `discoverExamples` applies.
260+ // This gate spawns `os lint` on each config, and `os lint` loads the config
261+ // through bundle-require — esbuild bundling every relative import, with
262+ // `@objectstack/*` kept external — so the count moves with any file the
263+ // config's import graph reaches: objects, datasets, dashboards, translation
264+ // bundles. Measured with that bundler and those externals at `30c530e5`: the 4
265+ // configs' import closures are 153 tracked files, each one either the config
266+ // itself or a file under that example's own `src/` tree.
267+ //
268+ // So the declaration below names exactly those two shapes, and nothing wider:
269+ // - `examples/**` would also name this gate for the test suites, e2e specs,
270+ // READMEs, changelogs and manifests — 103 of the 256 tracked files under the
271+ // root, none of which any config imports (60% precise);
272+ // - the two hints below cover 170: the whole closure (complete), plus 17 files
273+ // no config imports — 11 `src/docs/*.md` pages (`os lint` reads them, but
274+ // its docs rules are not `i18n/` rules, so they never reach this count), two
275+ // `*.test.ts` and three other unimported files under `src/`, and the `src/`
276+ // of an example that has no config (90% precise).
277+ // `--self-test` holds this against the live tree from both sides: every config
278+ // the population finds, and the files a real showcase edit touched, are covered
279+ // as the derivation itself reads this source; a test file and a `packages/` path
280+ // are not; and no relative import in a config or under its `src/` leaves that
281+ // example's `src/` — the one edit that would make this declaration under-name.
282+ //
283+ // ⛔ The PACKAGES half stays undeclared, on its own recorded verdict:
284+ // `scripts/pm/bare-root-worklist.mjs` holds it SPELLABLE-UNDECLARED (nine extract
285+ // configs, one spelling shared with two sibling i18n gates, deferred with them).
286+ // That map's EXAMPLES row is DECLARED-NARROWER and holds its recorded spelling
287+ // SET-EQUAL to the array below, so the record and the declaration cannot drift
288+ // apart silently. Nor does this declaration chase the workspace packages a
289+ // config imports by name: a spec edit can move this count too, but that closure
290+ // is the `packages/` root wholesale — the REFUSE-WIDE shape — and its CLI half
291+ // already reaches this gate through `cli-build-prerequisite.mjs`.
292+ //
293+ // Provenance, never a lookup key: the measurement never reads the array, and
294+ // only `--self-test` does — to hold it against what the gate really loads.
295+ const ROOT_DIR_WATCH_HINTS = [ 'examples/*/objectstack.config.ts' , 'examples/*/src/**' ] ;
259296
260297/** A repo-relative path, resolved against the module-derived root. */
261298const at = ( rel ) => join ( REPO_ROOT , rel ) ;
@@ -709,7 +746,7 @@ function measureAllConfigs(configPaths, measure) {
709746// as one that passed (#13798).
710747const SELF_TEST_VERDICT = 'check-i18n-coverage self-test reached its verdict' ;
711748
712- function selfTest ( ) {
749+ async function selfTest ( ) {
713750 // The battery ledger this self-test's floor is evaluated against (#13489).
714751 // `battery()` opens a battery; every assertion below is attributed to the one
715752 // most recently opened, so a section that stops running stops registering and
@@ -1275,6 +1312,141 @@ function selfTest() {
12751312 'the stale-spelling predicate no longer sees `Exit code 1`' ,
12761313 ) ;
12771314
1315+ // -------------------------------------------------------------------------
1316+ // The dispatch-gates declaration (#21011). `ROOT_DIR_WATCH_HINTS` is read by
1317+ // ANOTHER tool, so nothing in this gate's own run can go red when it is wrong:
1318+ // a missing hint is a gate the derivation never names for an example edit
1319+ // (the defect this card was filed on), a surplus one names it for files it
1320+ // never opens. So both directions are asked here, of the live tree, through
1321+ // the derivation's OWN extractor and covering rule — imported inside this
1322+ // body, where the extractor's self-test mask blanks the specifier, so the
1323+ // import adds nothing to the population this file declares.
1324+ // -------------------------------------------------------------------------
1325+ battery ( 'The dispatch-gates declaration (#21011): the example configs and the trees they import' ) ;
1326+ const { extractWatchHints, hintCovers } = await import ( './pm/dispatch-gates.mjs' ) ;
1327+ const ownPath = relative ( REPO_ROOT , fileURLToPath ( import . meta. url ) ) . split ( '\\' ) . join ( '/' ) ;
1328+ const derivedHints = extractWatchHints ( readFileSync ( fileURLToPath ( import . meta. url ) , 'utf8' ) , ownPath ) ;
1329+ const derives = ( path , hints = derivedHints ) => hints . some ( ( h ) => hintCovers ( h , path ) ) ;
1330+ expect (
1331+ '#21011 the derivation reads every declared hint off this source' ,
1332+ ROOT_DIR_WATCH_HINTS . length > 0 && ROOT_DIR_WATCH_HINTS . every ( ( h ) => derivedHints . includes ( h ) ) ,
1333+ `declared ${ JSON . stringify ( ROOT_DIR_WATCH_HINTS ) } , extracted ${ JSON . stringify ( derivedHints ) } — a declaration ` +
1334+ 'the extractor cannot read (a computed spelling, a leading glob) names this gate for nothing' ,
1335+ ) ;
1336+ // The positive case the card asked for, spelled as the paths of the real diff
1337+ // that went red in CI (#20998's three example source files). Strings, not
1338+ // files: `hintCovers` judges a path, so a later rename cannot empty this case.
1339+ const SHOWCASE_EDIT = [
1340+ 'examples/app-showcase/src/data/analytics/showcase.cube.ts' ,
1341+ 'examples/app-showcase/src/system/translations/index.ts' ,
1342+ 'examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts' ,
1343+ ] ;
1344+ expect (
1345+ '#21011 an edit to the showcase files a real red diff touched derives this gate' ,
1346+ SHOWCASE_EDIT . every ( ( p ) => derives ( p ) ) ,
1347+ `not derived: ${ SHOWCASE_EDIT . filter ( ( p ) => ! derives ( p ) ) . join ( ', ' ) } ` ,
1348+ ) ;
1349+ const liveConfigs = discoverExamples ( ) ;
1350+ expect (
1351+ '#21011 every example config the population finds derives this gate' ,
1352+ liveConfigs . length > 0 && liveConfigs . every ( ( p ) => derives ( p ) ) ,
1353+ `${ liveConfigs . length } config(s); not derived: ${ liveConfigs . filter ( ( p ) => ! derives ( p ) ) . join ( ', ' ) || 'none' } ` ,
1354+ ) ;
1355+
1356+ // COMPLETENESS against what `os lint` loads. Its bundler follows relative
1357+ // specifiers and keeps every `@objectstack/*` import external, so the only way
1358+ // a config's import graph can reach an example file outside the declaration is
1359+ // a relative specifier — in the config or in a file under its `src/` — that
1360+ // leaves that example's `src/`. Asked of every such file, not only the ones
1361+ // the graph reaches: a superset, so a miss cannot hide behind a guess about
1362+ // which files are imported. Test files are skipped, being what no config loads.
1363+ const RELATIVE_SPECIFIER = / (?: \b f r o m | \b i m p o r t | \b r e q u i r e ) \s * \( ? \s * ( [ ' " ] ) ( \. { 1 , 2 } \/ [ ^ ' " \n ] * ) \1/ g;
1364+ const escapesOf = ( file , text , srcRoot ) => {
1365+ const out = [ ] ;
1366+ for ( const m of String ( text ) . matchAll ( RELATIVE_SPECIFIER ) ) {
1367+ const target = posix . normalize ( posix . join ( posix . dirname ( file ) , m [ 2 ] ) ) ;
1368+ out . push ( { target, escapes : target !== srcRoot && ! target . startsWith ( `${ srcRoot } /` ) } ) ;
1369+ }
1370+ return out ;
1371+ } ;
1372+ const sourcesUnder = ( rel , out = [ ] ) => {
1373+ if ( ! existsSync ( at ( rel ) ) ) return out ;
1374+ for ( const e of readdirSync ( at ( rel ) , { withFileTypes : true } ) ) {
1375+ const p = posix . join ( rel , e . name ) ;
1376+ if ( e . isDirectory ( ) ) sourcesUnder ( p , out ) ;
1377+ else if ( / \. (?: [ c m ] ? [ j t ] s x ? ) $ / . test ( e . name ) && ! / \. (?: t e s t | s p e c ) \. / . test ( e . name ) ) out . push ( p ) ;
1378+ }
1379+ return out ;
1380+ } ;
1381+ const edges = liveConfigs . flatMap ( ( config ) => {
1382+ const srcRoot = posix . join ( posix . dirname ( config ) , 'src' ) ;
1383+ return [ config , ...sourcesUnder ( srcRoot ) ] . flatMap ( ( file ) =>
1384+ escapesOf ( file , readFileSync ( at ( file ) , 'utf8' ) , srcRoot ) . map ( ( edge ) => ( { file, ...edge } ) ) ) ;
1385+ } ) ;
1386+ const escaped = edges . filter ( ( e ) => e . escapes ) ;
1387+ expect (
1388+ '#21011 the import scan sees real edges — the configs reach their sources by relative specifiers' ,
1389+ edges . length > 0 && edges . some ( ( e ) => liveConfigs . includes ( e . file ) ) ,
1390+ `${ edges . length } edge(s) scanned; an empty scan would pass the next case over nothing` ,
1391+ ) ;
1392+ expect (
1393+ '#21011 no relative import in a config or under its `src/` leaves that example\'s `src/` — the ' +
1394+ 'declaration names the whole graph `os lint` loads' ,
1395+ escaped . length === 0 ,
1396+ `${ escaped . map ( ( e ) => `${ e . file } -> ${ e . target } ` ) . slice ( 0 , 4 ) . join ( ' · ' ) } — this import reaches a ` +
1397+ 'file the declaration does not name, so an edit there derives nothing for this gate. Move the file under ' +
1398+ '`src/`, or widen ROOT_DIR_WATCH_HINTS to the root it really reads and re-measure its precision' ,
1399+ ) ;
1400+ expect (
1401+ '#21011 …and every target the scan resolved is a path the derivation names' ,
1402+ edges . every ( ( e ) => e . escapes || derives ( e . target ) ) ,
1403+ edges . filter ( ( e ) => ! e . escapes && ! derives ( e . target ) ) . map ( ( e ) => e . target ) . slice ( 0 , 4 ) . join ( ' · ' ) ,
1404+ ) ;
1405+ expect (
1406+ 'NEGATIVE CONTROL: the escape scan can still see an escape, and passes an in-tree import' ,
1407+ escapesOf ( 'examples/app-x/src/a.ts' , "import s from '../../shared/s.js';" , 'examples/app-x/src' )
1408+ . every ( ( e ) => e . escapes )
1409+ && escapesOf ( 'examples/app-x/objectstack.config.ts' , "import a from './src/a.js';" , 'examples/app-x/src' )
1410+ . every ( ( e ) => ! e . escapes )
1411+ && escapesOf ( 'examples/app-x/src/a.ts' , "export { b } from './b.js'; const c = await import('../c.js');" ,
1412+ 'examples/app-x/src' ) . filter ( ( e ) => e . escapes ) . length === 1 ,
1413+ 'the relative-specifier scan stopped discriminating, so the completeness case above cannot fail' ,
1414+ ) ;
1415+
1416+ // PRECISION: what the derivation must NOT name. A test file under an example
1417+ // is exactly what `examples/**` would add and no config imports; the two
1418+ // `packages/` paths are the same red diff's non-example files, which the
1419+ // card's other pin says must not derive through THIS declaration (the CLI half
1420+ // of this gate's reach is inherited from `cli-build-prerequisite.mjs`, and
1421+ // judged there).
1422+ const NOT_THIS_DECLARATION = [
1423+ 'examples/app-showcase/test/gap-fill.test.ts' ,
1424+ 'examples/README.md' ,
1425+ 'packages/spec/src/data/analytics.zod.ts' ,
1426+ 'packages/services/service-analytics/README.md' ,
1427+ ] ;
1428+ expect (
1429+ '#21011 a test file, a root README and a packages/ path that touches no declared root do NOT derive it' ,
1430+ NOT_THIS_DECLARATION . every ( ( p ) => ! derives ( p ) ) ,
1431+ `derived: ${ NOT_THIS_DECLARATION . filter ( ( p ) => derives ( p ) ) . join ( ', ' ) } ` ,
1432+ ) ;
1433+ // The controls that keep the cases above measurements: the extractor really
1434+ // reads a literal fixture and really drops the computed spelling, so the first
1435+ // case can fail; and `examples/**` really would name the test file, so the
1436+ // precision case is asked of a declaration that could have been wider.
1437+ const literalFixture = extractWatchHints ( "const HINTS = ['examples/*/src/**'];\n" , ownPath ) ;
1438+ const computedFixture = extractWatchHints ( "const HINTS = ['examples'].map((r) => r + '/*/src/**');\n" , ownPath ) ;
1439+ expect (
1440+ 'NEGATIVE CONTROL: the extractor reads a literal declaration and drops a computed one' ,
1441+ literalFixture . includes ( 'examples/*/src/**' ) && ! computedFixture . includes ( 'examples/*/src/**' ) ,
1442+ `literal -> ${ JSON . stringify ( literalFixture ) } , computed -> ${ JSON . stringify ( computedFixture ) } ` ,
1443+ ) ;
1444+ expect (
1445+ 'NEGATIVE CONTROL: the wholesale spelling WOULD name the test file this declaration leaves out' ,
1446+ derives ( NOT_THIS_DECLARATION [ 0 ] , [ 'examples/**' ] ) && ! derives ( NOT_THIS_DECLARATION [ 2 ] , [ 'examples/**' ] ) ,
1447+ 'hintCovers no longer separates a subtree from its root, so the precision case is not asking anything' ,
1448+ ) ;
1449+
12781450 // ── The floor: every declared battery RAN, and ran its cases (#13489) ────
12791451 //
12801452 // Evaluated after every battery has had its chance and BEFORE the verdict, so
@@ -1331,14 +1503,16 @@ function selfTest() {
13311503 `from outside the repo root as well as inside it, and an empty one is refused rather than reported OK; ` +
13321504 `the build-prerequisite closure names all ${ onRoot . length } of them plus the CLI, and refuses whole rather ` +
13331505 `than naming some; and all three refusal paths exit ${ EXIT_PREREQUISITE_NOT_MET } — distinct from a finding's ` +
1334- `${ EXIT_FINDINGS } — with an advisory that names the number it claims (#14857).` ,
1506+ `${ EXIT_FINDINGS } — with an advisory that names the number it claims (#14857); and the dispatch derivation, ` +
1507+ `reading this source, names this gate for all ${ liveConfigs . length } example config(s) and every file their ` +
1508+ `${ edges . length } relative import(s) reach, and for no test file or undeclared packages/ path (#21011).` ,
13351509 ) ;
13361510
13371511 return SELF_TEST_VERDICT ;
13381512}
13391513
13401514if ( process . argv . includes ( '--self-test' ) ) {
1341- if ( selfTest ( ) !== SELF_TEST_VERDICT ) {
1515+ if ( ( await selfTest ( ) ) !== SELF_TEST_VERDICT ) {
13421516 console . error (
13431517 '\n✗ check-i18n-coverage self-test: selfTest() returned without reaching its verdict,\n'
13441518 + 'so no success line was printed. Exiting 0 here would report a self-test\n'
0 commit comments