@@ -65,6 +65,12 @@ type AnyRec = Record<string, unknown>;
6565 * Only the types some rule declares in `runtimeTypes` need an entry; the guard
6666 * in `authoring-rule-wiring.test.ts` fails if a declared type is missing one,
6767 * so widening the gate cannot half-land.
68+ *
69+ * [#13390] The VALUES here are also one of the two inputs
70+ * {@link NAME_KEYED_STACK_KEYS} is derived from — a stack key that some write
71+ * type maps into is a key whose top-level index the caller cannot resolve. Adding
72+ * a mapping onto a context collection therefore name-keys it by construction; it
73+ * is no longer a second edit that nothing checks.
6874 */
6975const TYPE_TO_STACK_KEY : Readonly < Record < string , string > > = {
7076 flow : 'flows' ,
@@ -413,30 +419,118 @@ export function buildRuntimeWriteSnapshots(args: {
413419 return { baseline, candidate } ;
414420}
415421
422+ /**
423+ * The name-keyed stack keys implied by a context shape and a write-type table:
424+ * the context collections that some write type ALSO lands an item inside.
425+ *
426+ * Exported (#13390) as a pure function of its two inputs so the derivation can
427+ * be exercised on SYNTHETIC sets. The real inputs are four keys that agree with
428+ * the list they replaced, which shows the answer is right today and cannot show
429+ * that the DERIVATION is the reason — the property this card buys is about the
430+ * next widening, so it has to be measured on inputs that widen.
431+ *
432+ * Order follows `contextStackKeys`, deliberately: it keeps the derived value
433+ * comparable to the hand list it replaced position for position, and it keeps
434+ * the pattern built from it byte-identical to the literal it replaced.
435+ */
436+ export function deriveNameKeyedStackKeys (
437+ contextStackKeys : readonly string [ ] ,
438+ writtenStackKeys : Iterable < string > ,
439+ ) : readonly string [ ] {
440+ const written = new Set ( writtenStackKeys ) ;
441+ return contextStackKeys . filter ( ( key ) => written . has ( key ) ) ;
442+ }
443+
444+ /**
445+ * `['objects', 'pages']` → `/^(objects|pages)\[(\d+)\](.*)$/` — the top-level
446+ * index matcher, BUILT from the name-keyed set instead of restating it (#13390).
447+ *
448+ * A derived alternation has two hazards a hand-written literal did not, and both
449+ * are decided here rather than left implicit:
450+ *
451+ * - **Escaping.** Every member today is `[a-z]+`, so nothing needs escaping and
452+ * nothing would notice if it were skipped. But a stack key is a
453+ * {@link RuntimeStackContext} property name, and a quoted one may hold a `.`
454+ * or a `-`; an unescaped `.` matches ANY character, which is the silent-failure
455+ * direction. Members are escaped rather than trusted — one `replace`.
456+ * - **Prefix ordering.** Alternation is ordered, so `page|pages` reads as though
457+ * the short branch shadows the long one. It does not in THIS pattern: the group
458+ * is anchored by `\[`, which fails the short branch and forces the engine to
459+ * backtrack into the long one. That is a property of the anchor, not of
460+ * alternation — so it is pinned by test with a synthetic `page` / `pages` pair
461+ * in BOTH orders, rather than papered over with a longest-first sort that would
462+ * silently stop being exercised and would leave the claim untested either way.
463+ *
464+ * An empty set yields a pattern matching nothing. Interpolating it would produce
465+ * `^()\[(\d+)\](.*)$`, which name-keys EVERY top-level index — the failure
466+ * direction that widens the rewrite instead of narrowing it.
467+ */
468+ export function buildTopLevelIndexPattern ( stackKeys : readonly string [ ] ) : RegExp {
469+ if ( stackKeys . length === 0 ) return / (? ! ) / ;
470+ const alternation = stackKeys . map ( ( key ) => key . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ) . join ( '|' ) ;
471+ return new RegExp ( `^(${ alternation } )\\[(\\d+)\\](.*)$` ) ;
472+ }
473+
474+ /**
475+ * The stack keys some write type lands an item INSIDE — the VALUES of
476+ * {@link TYPE_TO_STACK_KEY}, read off the table rather than restated, so a new
477+ * `type → key` mapping cannot arrive without this set seeing it.
478+ *
479+ * Exported for the pin in `runtime-gate.derived-name-keys.test.ts` and for that
480+ * only — it is not on either package entry. The pin asks, per context
481+ * collection, whether a top-level index is name-keyed, and it must ask that
482+ * against the SAME table the gate uses; a test that restated the answer would
483+ * be a sixth hand-written spelling of the very set this card removed.
484+ */
485+ export const WRITTEN_STACK_KEYS : ReadonlySet < string > = new Set ( Object . values ( TYPE_TO_STACK_KEY ) ) ;
486+
416487/**
417488 * The collection-resident stack keys whose TOP-LEVEL index the gate rewrites
418- * to a name key before findings leave it (#10064).
489+ * to a name key before findings leave it (#10064) — DERIVED, not listed (#13390) .
419490 *
420491 * These are the collections a written item lands INSIDE **and** that the
421- * context also fills (`TYPE_TO_STACK_KEY` routes `object` / `permission` /
422- * `book` / `page` writes into them) — so a finding's `objects[417]` is an
423- * offset into this gate's per-write snapshot, an in-memory array the caller has
424- * never seen and cannot enumerate. Every other write type is the sole member of
425- * its own collection (`flows[0]` IS this write, trivially stable), and
426- * `datasets` is context-only — no write type maps into it — so both keep their
427- * positional spelling.
428- *
429- * [#13216] `pages` JOINED this list in the same change that made `pages` a
430- * context collection, and the pairing is the rule rather than a coincidence:
431- * before that, a `page` write's snapshot held exactly one page, so `pages[0]`
432- * was this write, trivially stable, and name-keying it would have been
433- * pointless. The moment the live universe joins the snapshot, the index stops
434- * meaning anything to the caller — `validatePresetComparands` already runs on
435- * `page` writes and emits paths into this collection. So: adding a key to
436- * {@link CONTEXT_STACK_KEYS} that some write type ALSO maps into means adding
437- * it here too.
492+ * context also fills — so a finding's `objects[417]` is an offset into this
493+ * gate's per-write snapshot, an in-memory array the caller has never seen and
494+ * cannot enumerate. Every other write type is the sole member of its own
495+ * collection (`flows[0]` IS this write, trivially stable), and a context-only
496+ * collection holds no write at all — so both keep their positional spelling.
497+ *
498+ * ## Why it is derived
499+ *
500+ * That paragraph is not a judgement call, it is two conditions intersected, and
501+ * both are already written down: the context fills the collection
502+ * ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
503+ * ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
504+ * set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
505+ * is validity, not completeness, and the compiler holds nothing else. Omitting a
506+ * member here did not fail to build, fail a test, or fail a gate; it emitted
507+ * findings that LOOK correct whose `path` the caller cannot resolve, which is
508+ * the #10064 defect re-created silently.
509+ *
510+ * [#13216] `pages` is the measurement that made the case: adding it touched
511+ * FIVE spellings of this one set and only the fifth announced itself — the one
512+ * the compiler could see, and only after that accumulator was retyped as a
513+ * mapped type. The pairing is the rule rather than a coincidence. Before the
514+ * live page universe joined the snapshot, a `page` write's snapshot held exactly
515+ * one page, so `pages[0]` WAS this write and name-keying it would have been
516+ * pointless; the moment the universe joins, the index stops meaning anything to
517+ * the caller (`validatePresetComparands` already runs on `page` writes and emits
518+ * paths into this collection). Derived, the two move together by construction
519+ * and the next widening is a one-key edit again.
520+ *
521+ * ## Measured against the list it replaces (#13390)
522+ *
523+ * Same four members in the same order — `objects`, `permissions`, `books`,
524+ * `pages`. `datasets` falls out on its own, for exactly the reason the old
525+ * comment had to state by hand: it is context-only, no write type maps into it.
526+ * So **no member needed a hand-written exception** and none is kept. If a future
527+ * member ever does need one, state it here WITH its reason — quietly
528+ * re-introducing a literal is the thing this constant now exists to prevent.
438529 */
439- const NAME_KEYED_STACK_KEYS = [ 'objects' , 'permissions' , 'books' , 'pages' ] as const ;
530+ const NAME_KEYED_STACK_KEYS : readonly string [ ] = deriveNameKeyedStackKeys (
531+ CONTEXT_STACK_KEYS ,
532+ WRITTEN_STACK_KEYS ,
533+ ) ;
440534
441535/**
442536 * Machine names safe to splice into a dotted path. Matches the spec's
@@ -446,7 +540,7 @@ const NAME_KEYED_STACK_KEYS = ['objects', 'permissions', 'books', 'pages'] as co
446540 */
447541const PATH_SAFE_NAME = / ^ [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * $ / ;
448542
449- const TOP_LEVEL_INDEX = / ^ ( o b j e c t s | p e r m i s s i o n s | b o o k s | p a g e s ) \[ ( \d + ) \] ( . * ) $ / ;
543+ const TOP_LEVEL_INDEX = buildTopLevelIndexPattern ( NAME_KEYED_STACK_KEYS ) ;
450544
451545/**
452546 * `objects[417].sharingModel` → `objects.acme_invoice.sharingModel` (#10064).
@@ -458,6 +552,13 @@ const TOP_LEVEL_INDEX = /^(objects|permissions|books|pages)\[(\d+)\](.*)$/;
458552 * purpose — within one named item they index the author's own document, which
459553 * the receiver holds and can resolve.
460554 *
555+ * [#13390] Exported for the pin, not for callers (it is on neither package
556+ * entry). It is the one place the derived set and the derived pattern MEET, so
557+ * it is where the invariant is observable end to end: for each context
558+ * collection, is the top-level index rewritten exactly when a write type maps
559+ * into that collection? Asked here, the answer cannot be produced by a list
560+ * that agrees with the derivation by luck.
561+ *
461562 * Fallback is the positional spelling, never a hole: an entry that is missing,
462563 * unnamed, or whose name will not splice into a dotted path keeps the index.
463564 *
@@ -466,11 +567,11 @@ const TOP_LEVEL_INDEX = /^(objects|permissions|books|pages)\[(\d+)\](.*)$/;
466567 * stored items that (illegitimately) share a name must not have their distinct
467568 * findings merged or cancelled by the rewrite.
468569 */
469- function nameKeyFindingPath ( path : string , candidate : AnyRec ) : string {
570+ export function nameKeyFindingPath ( path : string , candidate : AnyRec ) : string {
470571 const m = TOP_LEVEL_INDEX . exec ( path ) ;
471572 if ( ! m ) return path ;
472573 const [ , stackKey , index , rest ] = m ;
473- if ( ! ( NAME_KEYED_STACK_KEYS as readonly string [ ] ) . includes ( stackKey ! ) ) return path ;
574+ if ( ! NAME_KEYED_STACK_KEYS . includes ( stackKey ! ) ) return path ;
474575 const collection = candidate [ stackKey ! ] as readonly unknown [ ] | undefined ;
475576 const entry = collection ?. [ Number ( index ) ] ;
476577 const name = entry && typeof entry === 'object' ? ( entry as AnyRec ) . name : undefined ;
0 commit comments