@@ -243,6 +243,18 @@ const POPULATION_CONSTANT = /^(?:[A-Z0-9_]*_ROOTS?|[A-Z0-9_]*_DIRS?|POPULATION|[
243243 * this file's own hint set and hand a reporting tool a population it does not
244244 * read, which the self-test refuses in as many words.
245245 *
246+ * An entry's `segments` records either ONE hint (a flat array of path
247+ * segments, as above) or SEVERAL (a LIST of segment arrays) — #14233's
248+ * addition, for a gate whose declared population is spelled as more than one
249+ * glob (one hint per extension, or one per file kind at the same root). A
250+ * multi-hint entry's `hintCovers` reach is the UNION of its members, asked of
251+ * every hint in turn; its `holds` is written to accept whatever any member
252+ * covers, since the two-sided pin below (LIVE/PRECISE/COMPLETE/NARROWING)
253+ * still runs over that union. Every hint of a multi-hint entry is required to
254+ * share one root — the sweep below has exactly one denominator per entry, not
255+ * one per hint — and that is verified per entry when the row is written, not
256+ * pinned mechanically here (see the five rows #14233 added).
257+ *
246258 * `holds` is the spelling's claim written INDEPENDENTLY of `hintCovers` — a
247259 * plain segment test a reader can check by eye. It is not a copy of the matcher:
248260 * `hintCovers` reaches its answer through `globInNonFinalSegment`,
@@ -305,12 +317,66 @@ const SPELLINGS = new Map([
305317 claim : "every file inside a skill's references folder, at any depth" ,
306318 holds : ( s ) => s [ 0 ] === 'skills' && s . length >= 4 && s [ 2 ] === 'references' ,
307319 } ] ,
320+ // ── Multi-hint entries (#14233) — a gate whose declared population is more
321+ // than one glob at the same root. `holds` accepts whatever any member hint
322+ // covers; see `hintsOf` and the pin loop below for how the union is asked.
323+ [ 'packages TypeScript source' , {
324+ segments : [
325+ [ 'packages' , '**' , '*.ts' ] ,
326+ [ 'packages' , '**' , '*.tsx' ] ,
327+ [ 'packages' , '**' , '*.mts' ] ,
328+ ] ,
329+ claim : 'every `.ts`, `.tsx` or `.mts` file at any depth under the packages root' ,
330+ holds : ( s ) => s [ 0 ] === 'packages' && s . length >= 2
331+ && [ '.ts' , '.tsx' , '.mts' ] . some ( ( ext ) => s [ s . length - 1 ] . endsWith ( ext ) ) ,
332+ } ] ,
333+ [ 'examples TypeScript source' , {
334+ segments : [ 'examples' , '**' , '*.ts' ] ,
335+ claim : 'every `.ts` file at any depth under the examples root' ,
336+ holds : ( s ) => s [ 0 ] === 'examples' && s . length >= 2 && s [ s . length - 1 ] . endsWith ( '.ts' ) ,
337+ } ] ,
338+ [ 'apps TypeScript source' , {
339+ segments : [
340+ [ 'apps' , '**' , '*.ts' ] ,
341+ [ 'apps' , '**' , '*.tsx' ] ,
342+ ] ,
343+ claim : 'every `.ts` or `.tsx` file at any depth under the apps root' ,
344+ holds : ( s ) => s [ 0 ] === 'apps' && s . length >= 2
345+ && [ '.ts' , '.tsx' ] . some ( ( ext ) => s [ s . length - 1 ] . endsWith ( ext ) ) ,
346+ } ] ,
347+ [ 'dual-build manifests and configs' , {
348+ segments : [
349+ [ 'packages' , '**' , 'package.json' ] ,
350+ [ 'packages' , '**' , 'tsup.config.ts' ] ,
351+ ] ,
352+ claim : 'every workspace manifest or tsup config at any depth under the packages root' ,
353+ holds : ( s ) => s [ 0 ] === 'packages' && s . length >= 2
354+ && ( s [ s . length - 1 ] === 'package.json' || s [ s . length - 1 ] === 'tsup.config.ts' ) ,
355+ } ] ,
356+ [ 'scripts top-level script files' , {
357+ segments : [
358+ [ 'scripts' , '*.mjs' ] ,
359+ [ 'scripts' , '*.mts' ] ,
360+ ] ,
361+ claim : 'every `.mjs` or `.mts` file directly under the scripts root (non-recursive)' ,
362+ holds : ( s ) => s . length === 2 && s [ 0 ] === 'scripts'
363+ && ( s [ 1 ] . endsWith ( '.mjs' ) || s [ 1 ] . endsWith ( '.mts' ) ) ,
364+ } ] ,
308365] ) ;
309366
310- /** The recorded spelling, assembled. Never a literal — see `SPELLINGS`. */
367+ /**
368+ * Normalises a `SPELLINGS` entry's `segments` to a LIST of segment arrays,
369+ * whether it records one hint (a flat array) or several (already a list) —
370+ * distinguished by whether the first element is itself an array. #14233.
371+ */
372+ function hintsOf ( segments ) {
373+ return Array . isArray ( segments [ 0 ] ) ? segments : [ segments ] ;
374+ }
375+
376+ /** The recorded spelling(s), assembled. Never a literal — see `SPELLINGS`. */
311377export function spellingOf ( name ) {
312378 const s = SPELLINGS . get ( name ) ;
313- return s ? s . segments . join ( '/' ) : null ;
379+ return s ? hintsOf ( s . segments ) . map ( ( seg ) => seg . join ( '/' ) ) . join ( ' | ') : null ;
314380}
315381
316382const TRIAGE = new Map ( [
@@ -322,22 +388,36 @@ const TRIAGE = new Map([
322388 } ] ,
323389 [ 'check:logger-receiver-detach SCAN_ROOTS packages' , {
324390 verdict : 'DECLARED-NARROWER' ,
391+ spelling : 'packages TypeScript source' ,
325392 why : 'the population is the non-test TypeScript source under the root, declared beside the '
326393 + 'constant at the three live extensions (4968 of 5485 tracked files, 90.6%) instead of '
327394 + 'the bare root. The remainder is manifests, JSON, markdown and fixtures the gate never '
328- + 'opens, and the test files it deliberately does not read' ,
395+ + 'opens, and the test files it deliberately does not read. #14233: the declared population '
396+ + 'needs three hints (`.ts`/`.tsx`/`.mts`), which the SPELLINGS harness could not hold before '
397+ + 'it grew a multi-hint entry — the union now pins the three declared hints LIVE, PRECISE and '
398+ + 'COMPLETE against a plain "ends with one of these extensions" claim (5240 of 5796 tracked '
399+ + 'files under the bare root, re-measured 2026-09-01 — a different, wider count than the '
400+ + "gate's own non-test figure above, since the pin holds the raw hints' literal reach and "
401+ + "the gate's own test-file exclusion is not something a hint can spell)" ,
329402 } ] ,
330403 [ 'check:logger-receiver-detach SCAN_ROOTS examples' , {
331404 verdict : 'DECLARED-NARROWER' ,
405+ spelling : 'examples TypeScript source' ,
332406 why : 'same declaration, same gate: the TypeScript source under the examples root, 204 of 241 '
333407 + 'tracked files (84.6%), rather than the bare word. The uncovered remainder carries no '
334- + 'TypeScript for this gate to read' ,
408+ + 'TypeScript for this gate to read. #14233: this root needs only the one hint the gate '
409+ + 'declares (`.ts`), now pinned LIVE, PRECISE and COMPLETE (206 of 243 tracked files under '
410+ + 'the bare root, re-measured 2026-09-01)' ,
335411 } ] ,
336412 [ 'check:logger-receiver-detach SCAN_ROOTS apps' , {
337413 verdict : 'DECLARED-NARROWER' ,
414+ spelling : 'apps TypeScript source' ,
338415 why : 'same declaration, same gate: 28 of 40 tracked files (70.0%) under the apps root, at the '
339416 + 'two extensions that exist there. The lowest ratio of the three and still a real '
340- + 'narrowing — the uncovered dozen are config and content files, none of them TypeScript' ,
417+ + 'narrowing — the uncovered dozen are config and content files, none of them TypeScript. '
418+ + '#14233: the two declared hints (`.ts`/`.tsx`) are now pinned as one multi-hint spelling, '
419+ + 'LIVE, PRECISE and COMPLETE (29 of 41 tracked files under the bare root, re-measured '
420+ + '2026-09-01)' ,
341421 } ] ,
342422 [ 'check:objectql-double-limit SCAN_ROOT packages' , {
343423 verdict : 'DECLARED-NARROWER' ,
@@ -395,6 +475,7 @@ const TRIAGE = new Map([
395475 } ] ,
396476 [ 'check:dual-build-cjs-loads SCAN_ROOT packages' , {
397477 verdict : 'DECLARED-NARROWER' ,
478+ spelling : 'dual-build manifests and configs' ,
398479 why : 'the gate walks every publishable manifest under the root to find published `require` '
399480 + 'conditions, then reads only the dist/ those manifests point at — so the two literals it '
400481 + 'declares beside SCAN_ROOT under the ROOT_DIR_WATCH_HINTS idiom are the files whose '
@@ -407,10 +488,14 @@ const TRIAGE = new Map([
407488 + 'error this map names. The recall is not lost: the gate is a step in Build Core, a '
408489 + 'required context on every PR, so the omission costs one CI round trip rather than a '
409490 + 'missed defect. The row STAYS in the sweep because the bare root is still not covered — '
410- + 'no arbitrary file at the top of packages/ is reached — which is what this verdict says' ,
491+ + 'no arbitrary file at the top of packages/ is reached — which is what this verdict says. '
492+ + "#14233: the two declared hints could not be pinned before SPELLINGS held only one per "
493+ + 'entry; they are now one multi-hint spelling, LIVE, PRECISE and COMPLETE against the same '
494+ + '74/20 split, re-measured 2026-09-01 (94 of 5796 tracked files under the bare root)' ,
411495 } ] ,
412496 [ 'check:ratchet-remedy-authority SCRIPTS_DIR scripts' , {
413497 verdict : 'DECLARED-NARROWER' ,
498+ spelling : 'scripts top-level script files' ,
414499 why : 'RE-DECIDED 2026-09-01 (#13813) from REFUSE-UNSPELLABLE, whose stated reason — "the idiom '
415500 + 'has no non-recursive spelling" — was TRUE when written and is FALSE of this tree. It '
416501 + 'rested on the deletion-collapse: a glob carrying a literal SUFFIX in the final segment '
@@ -424,17 +509,22 @@ const TRIAGE = new Map([
424509 + 'The gate now declares ONE hint per admitted extension beside SCRIPTS_DIR under the '
425510 + 'ROOT_DIR_WATCH_HINTS idiom, and the pair is SET-EQUAL to that walk in both directions — '
426511 + '183 of 183, nothing read left uncovered, nothing covered left unread — so 100% precise '
427- + 'and complete. ⛔ NO `spelling` is recorded and that is deliberate, not an omission: '
428- + 'SPELLINGS holds ONE hint per entry and this population needs one per extension, the same '
429- + 'shape as the check:logger-receiver-detach and check:dual-build-cjs-loads rows above, so '
430- + 'the liveness-and-precision coupling is held in the gate own --self-test, which pins the '
431- + 'hints against SCRIPTS_DIR and CORPUS_EXTENSIONS and refuses both the subtree spelling and '
432- + 'the brace form its own messages print. The consumer is MEASURED, not argued: before this, '
433- + 'the derivation placed this family in the residue undetermined bucket, absent from the '
434- + 'matched list a brief prints, and a PR that ran its whole derived family green locally '
435- + 'lost a CI round to this gate. The row STAYS in the sweep because the bare root is still '
436- + 'not covered — no arbitrary file at the top of the root is reached, and no nested script '
437- + 'at any depth — which is what this verdict says and is correct, not outstanding debt' ,
512+ + 'and complete. The coupling described above was, at the time this row was decided, held '
513+ + "only in the gate's own --self-test (which pins the hints against SCRIPTS_DIR and "
514+ + 'CORPUS_EXTENSIONS and refuses both the subtree spelling and the brace form its own '
515+ + 'messages print), because SPELLINGS held ONE hint per entry and this population needs one '
516+ + 'per extension — the same shape as the check:logger-receiver-detach and '
517+ + 'check:dual-build-cjs-loads rows above, and the gap #14233 measured and closed: SPELLINGS '
518+ + 'now holds a LIST of segment arrays, so the pair is pinned HERE too, LIVE, PRECISE and '
519+ + 'COMPLETE (183 of 310 tracked files under the bare root, matching the gate own walk exactly, '
520+ + "re-measured 2026-09-01) — the gate's own pin and this one now independently corroborate "
521+ + 'the same declaration rather than only one of them re-measuring it. The consumer is '
522+ + 'MEASURED, not argued: before this, the derivation placed this family in the residue '
523+ + 'undetermined bucket, absent from the matched list a brief prints, and a PR that ran its '
524+ + 'whole derived family green locally lost a CI round to this gate. The row STAYS in the '
525+ + 'sweep because the bare root is still not covered — no arbitrary file at the top of the '
526+ + 'root is reached, and no nested script at any depth — which is what this verdict says and '
527+ + 'is correct, not outstanding debt' ,
438528 } ] ,
439529 // ── Refused: the population is the whole root, and the root is saturated ──
440530 [ 'check:skill-identifier-liveness IMPL_ROOTS packages' , {
@@ -1115,12 +1205,15 @@ function selfTest() {
11151205
11161206 for ( const name of [ ...usedSpellings ] . sort ( ) ) {
11171207 const { segments, claim, holds } = SPELLINGS . get ( name ) ;
1118- const hint = segments . join ( '/' ) ;
1119- const covered = files . filter ( ( f ) => hintCovers ( hint , f ) ) ;
1208+ // A multi-hint entry's `hintCovers` reach is the UNION of its members —
1209+ // asked of every hint in turn, never collapsed into one string first
1210+ // (there is no glob idiom that would spell "either of these two globs").
1211+ const hints = hintsOf ( segments ) . map ( ( s ) => s . join ( '/' ) ) ;
1212+ const covered = files . filter ( ( f ) => hints . some ( ( hint ) => hintCovers ( hint , f ) ) ) ;
11201213 const over = covered . filter ( ( f ) => ! holds ( seg ( f ) ) ) ;
11211214 const claimed = files . filter ( ( f ) => holds ( seg ( f ) ) ) ;
1122- const under = claimed . filter ( ( f ) => ! hintCovers ( hint , f ) ) ;
1123- const rootFiles = files . filter ( ( f ) => seg ( f ) [ 0 ] === segments [ 0 ] ) ;
1215+ const under = claimed . filter ( ( f ) => ! hints . some ( ( hint ) => hintCovers ( hint , f ) ) ) ;
1216+ const rootFiles = files . filter ( ( f ) => seg ( f ) [ 0 ] === hintsOf ( segments ) [ 0 ] [ 0 ] ) ;
11241217 t ( `the spelling recorded as "${ name } " is LIVE — hintCovers reaches ${ covered . length } tracked `
11251218 + `file(s) with it, so no record below names a hint that covers nothing` , covered . length > 0 ) ;
11261219 t ( `…and it is PRECISE: every file hintCovers admits for "${ name } " (${ claim } ) satisfies that `
0 commit comments