Repository navigation
Expand file tree
/
Copy pathcheck-stack-collection-maps.mjs
More file actions
1233 lines (1185 loc) · 62.1 KB
/
Copy pathcheck-stack-collection-maps.mjs
File metadata and controls
1233 lines (1185 loc) · 62.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// check-stack-collection-maps -- every enumeration of the stack-collection set
// must be ANSWERABLE to `ObjectStackDefinitionSchema` (#6242).
//
// node scripts/check-stack-collection-maps.mjs # run the gate
// node scripts/check-stack-collection-maps.mjs --list # print every reconciliation
// node scripts/check-stack-collection-maps.mjs --self-test # verify the checker itself
//
// ## The hole this closes (#6242)
//
// `ObjectStackDefinitionSchema` (`packages/spec/src/stack.zod.ts`) decides which
// collections a stack may declare. EIGHT other places re-enumerate that same set
// by hand: the map-format field list, the plural->singular map, the artifact
// category enum, the ObjectQL registration seam, the artifact-ingest field map,
// the runtime's app-payload probe, AppPlugin's ADR-0057 security surface and the
// showcase coverage manifest -- and until this gate NOTHING compared any of them
// to the schema or to each other. They drifted independently and invisibly:
//
// (Counted differently when this gate was written: ObjectQL declared its list
// TWICE, once per registration seam, and the two copies had drifted four
// collections apart -- recorded here as a waiver row until #7049 hoisted the
// single `METADATA_ARRAY_KEYS` both seams now read. A divergence between two
// copies is the one deviation no reading of either copy alone produces, which is
// the argument for this gate in one line.)
//
// `SECURITY_FIELDS` joined as the eighth at #12894, and how it was missing is
// the point rather than a footnote. ⚠️ It is ALSO the sentence this comment
// first got wrong, and the correction is the more useful half. The original
// wording said the flat extractor "could not read it and the site was skipped
// instead of failing"; #13014 re-measured that on `8cb96ec41b` and it is FALSE.
// This gate has refused an empty extraction since #7032 -- an extract that
// returns nothing is a FAILURE in the runner below, never a pass -- so a tuple
// site read with the flat extractor goes RED, measured by swapping this site's
// extractor back to `stringArrayItems`: exit 1, "SECURITY_FIELDS -- could not
// extract the enumeration from packages/runtime/src/app-plugin.ts".
//
// The site was skipped because it was never a SITE. `SECURITY_FIELDS` occurred
// ZERO times in this file before #13009 (positive control in the same probe:
// `ARTIFACT_FIELD_TO_TYPE`, 5 occurrences), so no extractor ever ran on it and
// there was nothing to come back empty. The hole was the hand-written SITES
// population -- which is answerable to nothing and cannot be derived -- not the
// extractor set. Worth keeping straight in both directions: a reader who
// believes the original wording concludes the empty-extraction floor is missing
// and adds a second one, and a reader who trusts the SITES list to be complete
// repeats #12894. It is the eighth site because someone went looking, and that
// is still the only way a ninth gets found. (#13014 measured the mechanical
// alternative too: sweeping the tree for literals naming 8+ stack collections
// returns 45 files, 38 of them not sites -- an allowlist that relocates the
// hand-written list rather than deleting it.)
//
// The tuple shape is still why the site needed a THIRD extractor rather than
// reusing one: it is the ONLY one of the eight that pairs its keys as
// `[collection, kind]` TUPLES. It carried a `policies` dead pointer -- the
// same retired kind waived on two other sites -- and removing that entry from
// the artifact door while leaving this one unpinned would have left exactly one
// place where the fourth instance of a twice-retired pattern could land back
// unnoticed.
//
// - `PLURAL_TO_SINGULAR` carries `ragPipelines`, which the schema does not
// declare.
// - `metadataArrayKeys` still lists `workflows` / `approvals` / `roles` /
// `profiles` / `policies` -- kinds retired across ADR-0019, ADR-0020,
// ADR-0088 and ADR-0090.
// - `ARTIFACT_FIELD_TO_TYPE` mapped the SEED collection (`data:`) onto the
// ANALYTICS kind name (`dataset`) -- the exact collision the registry entry
// warns about in prose -- and omits five live collections.
// - `MetadataCategoryEnum` lists retired `triggers`/`workflows` and omits most
// of the live set.
//
// Every row above looks like a one-line typo in isolation, and each HAS been
// fixed one line at a time before (`docs` in `ARTIFACT_FIELD_TO_TYPE`, `roles`
// -> `positions` in the same map, `capabilities` in `metadataArrayKeys`), each
// carrying a "this key was missing and it silently dropped X" comment today. The
// cause is structural: `KIND_COVERAGE` is answerable to the metadata-type
// registry and fails CI when a kind is added without an entry, and the liveness
// ledger is answerable to the same registry. The collection-key maps had no such
// gate, so the same defect kept being re-filed one instance at a time.
//
// ## What "answerable" means here
//
// DERIVING all seven from the schema is not possible today: they disagree on
// purpose as often as by accident (`views` has no `name`, `data` seeds key by
// `object`, `translations` is a record, an artifact category is a directory
// layout rather than a registration). So each site is PINNED instead -- the gate
// reconciles it against the schema in BOTH directions and every deviation must
// be a `WAIVERS` row carrying a reason. Same shape as the liveness ledger's
// undrilled baseline: a deviation that is deliberate is cheap to record, and one
// that is drift has nowhere to hide.
//
// The waiver list is a RATCHET. A waiver that no longer applies (the key came
// back, or the drift was fixed) FAILS, exactly like a stale ledger row -- an
// overstated debt misleads as much as an unrecorded one.
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { isEntrypoint } from './invoked-as.mjs';
import { maskCommentsAndLiterals } from './js-comment-mask.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..');
// ───────────────────────────────────────────────────────────────────────────
// The mask every scan below reads -- IMPORTED, not re-derived (#15776)
// ───────────────────────────────────────────────────────────────────────────
//
// `maskCommentsAndLiterals` is the tree's one comments+literals projection and
// `js-comment-mask.mjs` owns it; this gate used to spell a private `maskLiterals`
// that composed the same shared scanner by hand. What follows is the fact that
// belongs to THIS gate -- why a bracket counter here may read nothing else, and
// what the conversion onto the shared scanner measured when it happened.
//
// Blank out everything a bracket counter must not read: comment bodies, string
// and template contents, and regex literals. Returns a string of the SAME LENGTH
// as the input, so every index still addresses the original source — callers
// count structure on the mask and slice text from the original.
//
// Length-preserving masking rather than a `strip`: the first draft of this gate
// stripped comments and counted brackets over the rest, and one unbalanced paren
// inside PROSE — `.describe('Screen Flows (ADR-0019)')` — closed the
// `ObjectStackDefinitionSchema` literal 14 collections early. The gate then
// reconciled all seven sites against a truncated source of truth and reported
// 114 deviations, every one of them its own. A parser that fails toward "less
// schema" makes every consumer look wrong, which is the loudest possible way to
// be useless.
//
// String DELIMITERS survive (their contents do not), so a quoted object key and
// an array of string literals are both still locatable by index.
//
// ## CONVERTED onto the shared scanner (#13143)
//
// This body used to be a private left-to-right scanner written out here, and it
// was the one piece of comment-scanning code in this directory that no gate
// could see. `check-comment-mask-adoption.mjs` watches for exactly this shape
// and walks `packages` + `examples` only; `check-parse-guard.mjs` walks this
// directory for a different subject (the three TypeScript parser entry points).
// A private stripper here sits inside one gate's population and outside its
// subject, and inside the other's subject and outside its population, so
// nothing reds. Routing through the shared module is the half of that gap a
// caller can close on its own.
//
// A conversion is a MEASUREMENT rather than a mechanical edit, so here is the
// reading. The private scanner against `scanSource()`'s `comment | literal`
// projection, over THE POPULATION THIS GATE ACTUALLY READS (the seven SITE
// files plus `stack.zod.ts`, 1,028,984 chars): 3 of the 8 files disagree, 49
// spans, 247 characters. Two classes, and only one of them is a defect.
//
// - 18 spans are a PROJECTION difference and nothing else: the private copy
// blanked a regex literal's slash delimiters, the shared scanner keeps them
// as code. No bracket is a slash, so no caller here could ever see it.
// - 31 spans are the private scanner mis-reading a NESTED TEMPLATE. It closed
// an outer template at the first backtick inside a `${...}`, which flipped
// the parity of every backtick after it and handed the bracket counter 20
// bracket characters out of the interiors of string and template literals.
// Both files it happens in are live SITE files: `packages/objectql/src/
// engine.ts` and `packages/metadata/src/plugin.ts`. That is the same family
// as the `(ADR-0019)` incident above, arriving through a different door.
//
// Both directions of that defect are pinned in `--self-test` on synthetic
// bodies, because today's tree happens to punish neither: a nested template
// holding a `]` makes `stringArrayItems` DROP a real key, and one holding a
// quote makes it FABRICATE `${v}` as an enumerated key. The gate's verdict does
// NOT move on this tree -- `--list` is byte for byte identical before and after
// -- which is a fact about where this tree's nested templates sit, not a reason
// the private copy was safe.
//
// The instrument was shown able to fail before its empty results were read as
// agreement: the naive two-regex pair diffed against the shared scanner over
// the same eight files disagrees on 8 of 8, and the shared scanner diffed
// against itself returns nothing.
// ───────────────────────────────────────────────────────────────────────────
// Extraction -- pure, over source text
// ───────────────────────────────────────────────────────────────────────────
//
// These read SOURCE, not a build output, deliberately: this gate runs in the
// lint job, which does not build the workspace, and four of the seven sites live
// in packages `@objectstack/spec` must not import. Every extractor returns
// `null` when its anchor is gone, and the caller turns that into a FAILURE -- a
// rename that silently emptied a site would otherwise read as "no drift", which
// is the same silent-no-op shape this gate exists to catch.
/**
* Slice the balanced `{...}` / `[...]` body that follows `anchor` in `source`
* (the anchor's last character must be the opening bracket). Returns `null` when
* the anchor is absent. `from` lets a caller walk repeated occurrences of one
* anchor -- no SITE needs it since #7049 collapsed ObjectQL's two copies into
* one, but the next site that re-declares an anchor will, and the self-test
* keeps the behaviour pinned.
*/
export function sliceBody(source, anchor, from = 0) {
const at = source.indexOf(anchor, from);
if (at === -1) return null;
const mask = maskCommentsAndLiterals(source);
const openAt = at + anchor.length - 1;
const open = source[openAt];
const close = open === '{' ? '}' : ']';
let depth = 0;
for (let i = openAt; i < source.length; i++) {
const ch = mask[i];
if (ch === open) depth++;
else if (ch === close) {
depth--;
if (depth === 0) return { body: source.slice(openAt + 1, i), end: i };
}
}
return null;
}
/**
* Top-level keys of an object-literal body, each with its value's source text.
* Depth-aware: a nested literal never contributes its own keys.
*/
export function objectEntries(body) {
const mask = maskCommentsAndLiterals(body);
const out = [];
let depth = 0;
let i = 0;
while (i < body.length) {
const ch = mask[i];
if (ch === '{' || ch === '[' || ch === '(') { depth++; i++; continue; }
if (ch === '}' || ch === ']' || ch === ')') { depth--; i++; continue; }
if (depth === 0 && (i === 0 || /[\s,]/.test(mask[i - 1]))) {
// Match the key on the MASK, never on the original: a comment body is
// blanked there, and `/** ObjectQL: Data Layer */` reads as a perfectly
// good `key:` in the original text. That mis-read cost the walk every
// collection declared under a section banner — it consumed the fake entry
// and skipped forward past the real one.
const window = mask.slice(i, i + 80);
const bare = /^([A-Za-z_$][\w$]*)\s*:/.exec(window);
const quoted = /^(['"])\s*\1\s*:/.test(window)
? /^(['"])([A-Za-z_$][\w$]*)\1\s*:/.exec(body.slice(i, i + 80))
: null;
const m = bare ? { 0: bare[0], key: bare[1] } : quoted ? { 0: quoted[0], key: quoted[2] } : null;
if (m) {
const valueStart = i + m[0].length;
let j = valueStart;
let d = 0;
while (j < body.length) {
const c = mask[j];
if (c === '{' || c === '[' || c === '(') d++;
else if (c === '}' || c === ']' || c === ')') d--;
else if (c === ',' && d === 0) break;
j++;
}
out.push({ key: m.key, value: body.slice(valueStart, j).trim() });
i = j;
continue;
}
}
i++;
}
return out;
}
/** String literals at depth 0 of an array-literal body. */
export function stringArrayItems(body) {
const mask = maskCommentsAndLiterals(body);
const out = [];
let depth = 0;
for (let i = 0; i < body.length; i++) {
const ch = mask[i];
if (ch === '{' || ch === '[' || ch === '(') { depth++; continue; }
if (ch === '}' || ch === ']' || ch === ')') { depth--; continue; }
if (depth === 0 && (ch === "'" || ch === '"')) {
const end = mask.indexOf(ch, i + 1);
if (end === -1) break;
out.push(body.slice(i + 1, end));
i = end;
}
}
return out;
}
/**
* The FIRST string of each depth-1 tuple in an array-of-tuples body -- the
* `Array<[collectionKey, metadataType]>` shape `SECURITY_FIELDS` uses.
*
* A separate extractor rather than a flag on `stringArrayItems`, because the two
* disagree about what "the site enumerates" means: the flat form's strings ARE
* the keys, and here only the tuple's head is, with the tail naming the metadata
* kind. Reading a tuple site with the flat extractor returns an empty list at
* depth 0 -- and the caller REFUSES that, loudly, by name: an empty extraction
* is a failure in `run()`, never a reading. ⛔ Do not read this paragraph as
* "the flat extractor would have passed vacuously" -- it would not, and #13014
* measured it (exit 1). This extractor exists so the site has a non-empty
* answer at all, not to rescue the gate from a silent pass it never had.
*/
export function tupleFirstItems(body) {
const mask = maskCommentsAndLiterals(body);
const out = [];
let depth = 0;
let taken = false;
for (let i = 0; i < body.length; i++) {
const ch = mask[i];
if (ch === '{' || ch === '[' || ch === '(') {
depth++;
if (depth === 1) taken = false;
continue;
}
if (ch === '}' || ch === ']' || ch === ')') { depth--; continue; }
if (depth === 1 && !taken && (ch === "'" || ch === '"')) {
const end = mask.indexOf(ch, i + 1);
if (end === -1) break;
out.push(body.slice(i + 1, end));
taken = true;
i = end;
}
}
return out;
}
/**
* Top-level keys that MATCH the `z.array(<Something>Schema)` shape below and are
* nevertheless NOT metadata collections — so no site is expected to enumerate
* them, and their absence is not drift.
*
* ⚠️ Adding a name here is a claim, and the bar is high: it means the key does
* not name a metadata TYPE that the eight sites route, register, categorise or
* demonstrate. Get it wrong and a real collection silently leaves the reference
* set, which is this gate's own failure mode (an empty set reconciles against
* everything). The shape discriminator below stays the rule; this is the
* declared, reasoned exception list, and it is deliberately a list of names
* rather than a second heuristic.
*
* - `packages` (ADR-0130 D4) carries package MANIFESTS — the release artifact's
* co-owning packages — not authored metadata of some type. It has no singular
* metadata-type name (nothing in `PLURAL_TO_SINGULAR`), no artifact
* subdirectory, no `registerInMemory` kind and no map/record authoring form.
* Every one of the eight sites is correct to omit it, so counting it would
* manufacture eight simultaneous deviations and drive eight waiver rows that
* each assert the opposite of the truth.
* - `devLogins` (#17556) carries what the DEVELOPMENT BOOT BANNER prints — the
* first-run credentials an application contributes so an operator is pointed
* at an account that actually shows something. Its one reader is the CLI
* banner, off the top level of the definition it booted. It names no metadata
* type, registers nothing, persists nothing and creates no account: an entry
* is presentation about accounts the application seeds by other means. Like
* `packages` it has no singular name in `PLURAL_TO_SINGULAR`, no artifact
* subdirectory, no `registerInMemory` kind and no map form — and it is an
* artifact ENVELOPE key besides (`ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS`), so
* it is refused inside `packages[].manifest` exactly as `devPlugins` is. All
* eight sites are correct to omit it, so the same eight-contradictory-waivers
* outcome applies.
* - `demoStaff` (#25777) carries the DEMO ACCOUNTS a development boot creates
* for the application's seed data to be dealt to. Its one reader is
* `@objectstack/plugin-auth`, handed the list off the top level of the
* authored definition by `os serve`; `os build` leaves it out of the
* artifact. It names no metadata type and registers nothing: an entry becomes
* an account through the auth sign-up pipeline, never a metadata item. No
* singular name, no artifact subdirectory, no `registerInMemory` kind, no map
* form, and an artifact ENVELOPE key besides — the same eight correct
* omissions as `devLogins`.
*/
const NON_COLLECTION_ARRAY_KEYS = new Set(['packages', 'devLogins', 'demoStaff']);
/**
* The stack-collection set: top-level `ObjectStackDefinitionSchema` keys whose
* value is `z.array(<Something>Schema)`, minus the declared
* {@link NON_COLLECTION_ARRAY_KEYS}.
*
* That shape is the discriminator, and it is what keeps this mechanical instead
* of a second hand-maintained list: `plugins` (`z.array(z.unknown())`),
* `requires` / `tiers` (`z.array(z.string())`) and `devPlugins` (a union) are
* arrays but not metadata collections, and no site is expected to carry them.
*/
export function stackCollections(stackSource) {
// #8687 closed the top level with the shared `strictObject` template
// (`shared/strict-object.ts`): the FIRST argument is the authoring-surface
// options object (surface / history / guidance) and the SECOND is the shape
// this gate reads. Slice the options body to find where it ends, then slice
// the shape body that opens right after it.
const options = sliceBody(
stackSource,
'export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({',
);
if (!options) return null;
const shape = sliceBody(stackSource, '{', options.end + 1);
if (!shape) return null;
// [#14439] The shape may SPREAD a named const rather than writing every key
// inline: ADR-0130 D4's assembled package body is built from the same
// collections the stack declares, so those collections were lifted into
// `STACK_DEFINITION_COLLECTIONS_SHAPE` and both surfaces read that one const
// (a second transcription is the drift ADR-0116 exists about).
//
// Resolved by NAME, in the same source, and an unresolvable spread returns
// `null` rather than "no collections". That direction matters: this gate's
// caller treats an empty set as a hard failure precisely because an empty set
// reconciles perfectly against every site, so a spread this function could not
// follow must reach that refusal instead of silently shrinking the set.
const bodies = [shape.body];
for (const ident of spreadIdentifiers(shape.body)) {
const spread = sliceBody(stackSource, `const ${ident} = {`);
if (!spread) return null;
bodies.push(spread.body);
}
return bodies
.flatMap((body) => objectEntries(body))
.filter(({ value }) => /^z\.array\(\s*[A-Za-z_$][\w$]*Schema\s*\)/.test(value))
.map(({ key }) => key)
.filter((key) => !NON_COLLECTION_ARRAY_KEYS.has(key));
}
/**
* The identifiers an object literal body spreads (`...IDENT,`).
*
* Line-anchored on purpose: a `...` inside a nested value (a default object, a
* template) is not a shape spread, and matching one would send this gate
* looking for a const that does not exist and refusing a healthy tree.
*
* @param body - an object-literal body, as `sliceBody` returns it
* @returns the spread identifiers, in source order, without duplicates
*/
export function spreadIdentifiers(body) {
const found = [];
for (const line of body.split('\n')) {
const m = /^\s*\.\.\.([A-Za-z_$][\w$]*)\s*,?\s*$/.exec(line);
if (m && !found.includes(m[1])) found.push(m[1]);
}
return found;
}
// ───────────────────────────────────────────────────────────────────────────
// Reconciliation -- pure
// ───────────────────────────────────────────────────────────────────────────
/**
* Reconcile one site against the schema set.
* `extra` = enumerated here, not declared by the schema (retired or invented).
* `missing` = declared by the schema, not enumerated here.
*/
export function reconcileSite(schemaKeys, siteKeys) {
const schema = new Set(schemaKeys);
const site = new Set(siteKeys);
return {
extra: siteKeys.filter((k) => !schema.has(k)),
missing: schemaKeys.filter((k) => !site.has(k)),
};
}
/**
* Apply a site's waiver rows to its reconciliation. Returns the deviations that
* are NOT waived, plus the waivers that no longer apply (the ratchet -- a stale
* waiver is drift in the opposite direction).
*/
export function applyWaivers(recon, waivers) {
const waived = (direction) =>
new Set(waivers.filter((w) => w.direction === direction).flatMap((w) => w.keys));
const waivedExtra = waived('extra');
const waivedMissing = waived('missing');
const extraSet = new Set(recon.extra);
const missingSet = new Set(recon.missing);
return {
extra: recon.extra.filter((k) => !waivedExtra.has(k)),
missing: recon.missing.filter((k) => !waivedMissing.has(k)),
staleWaivers: [
...[...waivedExtra]
.filter((k) => !extraSet.has(k))
.map((k) => `${k} — waived as an EXTRA key, but the site no longer enumerates it`),
...[...waivedMissing]
.filter((k) => !missingSet.has(k))
.map((k) => `${k} — waived as MISSING, but the site now enumerates it`),
],
};
}
// ───────────────────────────────────────────────────────────────────────────
// The seven sites, and the waiver each deviation must carry
// ───────────────────────────────────────────────────────────────────────────
//
// A waiver row is `{ direction, keys, reason }`. `direction: 'extra'` waives a
// key the site enumerates and the schema does not; `direction: 'missing'` waives
// a schema collection the site deliberately (or, for recorded drift, knowingly)
// omits. The reason is the whole value of the row -- "these are the deliberate
// omissions" as a bare list is exactly the unfalsifiable claim this gate
// replaces.
//
// ⛔ A waiver is NOT permission. Rows that record DRIFT rather than design name
// the issue that tracks removing them; when that issue lands, the row must go or
// the ratchet fails.
const SITES = [
{
id: 'MAP_SUPPORTED_FIELDS',
file: 'packages/spec/src/shared/metadata-collection.zod.ts',
what: 'collections whose map/record authoring form is normalized into an array',
extract: (src) => {
const b = sliceBody(src, 'export const MAP_SUPPORTED_FIELDS = [');
return b && stringArrayItems(b.body);
},
waivers: [
{
direction: 'missing',
keys: ['views', 'objectExtensions', 'picklistExtensions', 'data', 'translations'],
reason:
'the five SHAPE exclusions, pinned here so the prose is checked: ViewSchema has no `name` (its '
+ 'identity is the target object), ObjectExtensionSchema and PicklistExtensionSchema key by '
+ '`extend`, SeedSchema keys by `object`, and TranslationBundleSchema is itself a record.',
},
{
direction: 'missing',
keys: ['datasourceMapping', 'capabilities', 'docs', 'books', 'tools', 'skills', 'picklists'],
reason:
'no map form has ever been offered for these. `datasourceMapping` is a rule LIST whose precedence is '
+ 'positional, so a map would lose the ordering that decides which rule wins; the other six are '
+ 'array-only authoring surfaces (`picklists` since it was declared). Offering a map form for any of them WIDENS what parses — an '
+ 'acceptance change, which belongs to its own reviewed diff rather than to this gate.',
},
],
},
{
id: 'PLURAL_TO_SINGULAR',
// [#11503] The declaration moved out of shared/metadata-collection.zod.ts
// to the schema-free /meta-spelling graph; /shared re-exports it.
file: 'packages/spec/src/meta-spelling/manifest-collection-spelling.ts',
what: 'plural stack key -> singular metadata type name',
extract: (src) => {
const b = sliceBody(src, 'export const PLURAL_TO_SINGULAR: Record<string, string> = {');
return b && objectEntries(b.body).map((e) => e.key);
},
waivers: [
{
direction: 'extra',
keys: ['ragPipelines'],
reason:
'DRIFT, recorded not blessed (#6242 row 2). `ragPipelines` is not declared by '
+ 'ObjectStackDefinitionSchema, so this mapping cannot be reached from a parsed stack: '
+ '`pluralToSingular` is called with keys that came OFF a stack. Inert, and removing it is a '
+ 'behavioural change to a published spec export — its own diff, tracked in #6242.',
},
{
direction: 'missing',
keys: ['datasourceMapping', 'translations', 'objectExtensions', 'picklistExtensions', 'data'],
reason:
'there is no singular metadata-type name to map to. `objectExtensions` and `picklistExtensions` '
+ 'merge into their target rather than registering as a type, `datasourceMapping` is stack-level '
+ 'routing configuration, `translations` is a bundle record consumed by the i18n resolvers, and '
+ '`data` seeds are applied by SeedLoaderService. `pluralToSingular` returns its input unchanged '
+ 'for all five, which is the correct answer rather than a gap.',
},
],
},
{
id: 'MetadataCategoryEnum',
file: 'packages/spec/src/kernel/package-artifact.zod.ts',
what: 'metadata categories an artifact may carry (one subdirectory each)',
extract: (src) => {
const b = sliceBody(src, 'export const MetadataCategoryEnum = z.enum([');
return b && stringArrayItems(b.body);
},
waivers: [
{
direction: 'extra',
keys: ['triggers', 'workflows', 'themes'],
reason:
'DRIFT, and NOT removable from here: this enum is an ACCEPTANCE face — dropping a member changes '
+ 'what a published artifact may declare, a protocol-breaking change owing an ADR-0087 conversion. '
+ 'Recorded so the enum stops reading as an answerable enumeration (#6242 row 5). `themes` joined '
+ 'the row at #10485 (carrier retired, ADR-0049): a previously published artifact may still '
+ 'declare the category, so the member leaves through its own reviewed diff, not this gate.',
},
{
direction: 'missing',
keys: [
'datasources', 'datasourceMapping', 'objectExtensions', 'apps', 'jobs', 'emailTemplates',
'docs', 'books', 'positions', 'capabilities', 'sharingRules', 'webhooks', 'tools', 'skills',
'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', 'picklists', 'picklistExtensions',
'queues', 'assignmentRules',
],
reason:
'DRIFT in the opposite direction, under the same acceptance constraint: ADDING a member widens '
+ 'what an artifact may declare and presumes a packaging layout (one subdirectory per category) '
+ 'that does not exist for these. The omissions are inert today because this enum is not the '
+ 'artifact ingest path — `ARTIFACT_FIELD_TO_TYPE` below is — but 23 of 35 collections absent is '
+ 'the measurement that says so out loud (#6242 row 5).',
},
],
},
{
// ONE site, not two, since #7049. `engine.ts` used to declare this list
// twice -- once inside the manifest registration loop and once inside
// `registerPlugin` -- and the two copies had drifted: `jobs`,
// `emailTemplates`, `tools` and `skills` registered from a manifest and NOT
// from a nested plugin, so a package shipping them from a nested plugin
// registered nothing and stamped no ADR-0010 provenance. That divergence
// was recorded here as a waiver row on the nested copy; #7049 closed it by
// hoisting ONE `METADATA_ARRAY_KEYS` both seams read, so the row is gone
// with the thing it recorded and the gate now pins the single enumeration
// that remains. (Hand-adding the four names to the second copy was the
// available alternative and is what #5870 did for `capabilities`; it is
// what left the other four undiffed.)
id: 'METADATA_ARRAY_KEYS',
file: 'packages/objectql/src/engine.ts',
what: 'collections the engine registers from a manifest AND from a nested plugin (the ADR-0010 provenance seam)',
extract: (src) => {
const b = sliceBody(src, 'const METADATA_ARRAY_KEYS = [');
return b && stringArrayItems(b.body);
},
waivers: [
{
direction: 'extra',
keys: ['workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines', 'themes'],
reason:
'DRIFT — kinds retired across ADR-0019 / ADR-0020 / ADR-0088 / ADR-0090 / ADR-0049 (#10485 for '
+ '`themes`) that this loop still '
+ 'iterates. Inert: `(manifest as any)[key]` is `undefined` for every one, because the schema '
+ 'rejects the keys long before the loop runs. Removing them is an `engine-core` source change '
+ '(#6242 row 3) and rides that lane, not this gate.',
},
{
direction: 'missing',
keys: ['objects', 'objectExtensions', 'apps', 'translations', 'datasourceMapping', 'datasources', 'data'],
reason:
'registered by a DIFFERENT seam rather than dropped: objects and objectExtensions go through the '
+ 'object-merge path (an extension overlays its target before registration), `apps` is registered '
+ 'by AppPlugin, `translations` and `datasourceMapping` are stack configuration, `datasources` are '
+ 'consumed as CONNECTIONS (driver handles built at boot), and `data` seeds are applied by '
+ 'SeedLoaderService. This row is why the gate reconciles both directions with reasons instead of '
+ 'demanding equality.',
},
// `positions` left this table at #22203: its waiver said positions reach "the registry" through the
// security bootstrap, but that is the metadata service's registry, not the SchemaRegistry this loop
// stamps provenance into — and the metadata save door reads the latter, so the absence WAS the gap.
],
},
{
id: 'ARTIFACT_FIELD_TO_TYPE',
file: 'packages/metadata/src/plugin.ts',
what: 'compiled-artifact field -> metadata type registered into MetadataManager',
extract: (src) => {
const b = sliceBody(src, 'const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {');
return b && objectEntries(b.body).map((e) => e.key);
},
waivers: [
{
direction: 'extra',
keys: ['workflows', 'ragPipelines'],
reason:
'DRIFT — retired kinds still mapped, inert for the same reason as the ObjectQL loops: a parsed '
+ 'artifact cannot carry the fields (#6242 row 4). `policies` left this row at #12894, removed '
+ 'from the map rather than re-waived: it was the THIRD entry retired here for one reason (after '
+ '`themes` and `roles`), and the map now carries the reason in place so a fourth reader does not '
+ 'have to rediscover it.',
},
{
direction: 'missing',
keys: ['data'],
reason:
'DELIBERATE, and the resolution of #6242 row 4(a). `data:` is the SEED collection, and this map '
+ 'used to send it to `dataset` — the ADR-0021 analytics kind, the exact collision '
+ '`metadata-plugin.zod.ts` warns about in prose. The entry was provably inert (SeedSchema declares '
+ 'no `name`; the ingest loop skips nameless items) so it registered nothing, but it was aimed at '
+ 'the wrong kind and would have begun mis-registering the day either side moved. REMOVED in the '
+ 'change that added this gate, rather than repointed at `seed`: seeds are applied by '
+ 'SeedLoaderService off the bundle, never registered as metadata items, so a `seed` mapping would '
+ 'be new behaviour needing its own verification.',
},
{
direction: 'missing',
keys: ['jobs', 'datasources', 'translations'],
reason:
'DRIFT with a real, bounded consequence — #6242 row 4(b). All three are consumed '
+ 'functionally by AppPlugin straight off the bundle, so boot is not broken; what they never do is '
+ 'register as METADATA ITEMS, so under `bootstrap: \'artifact-only\'` (edge / serverless / '
+ 'immutable image) `GET /meta/job`, `/meta/translation` and `/meta/datasource` answer empty for '
+ 'a package that ships them. Adding them changes what a sealed runtime serves and must be '
+ 'measured on a real artifact-only boot first — the filing card says so, and this gate does not '
+ 'smuggle it in. `datasets` left this row when the residual rule began walking it: it was never '
+ 'the same fact, since AppPlugin reads no dataset off the bundle (the ObjectQL registry takes a '
+ 'package body\'s datasets through its own collection loop), and with no entry here a '
+ '`packages[]` stack\'s top-level dataset that no body declared was neither registered nor '
+ 'refused. Measured on an artifact boot before the entry landed. `capabilities` was the FIFTH key '
+ 'on this row and left it at '
+ '#12892 step 1 (maintainer ruling, option 1: the door owns the registration route for the five '
+ 'security collections). It was never the same fact as the other three: AppPlugin registers it as '
+ 'a METADATA ITEM through `registerInMemory`, so `GET /meta/capability` was already non-empty on '
+ 'an artifact-only boot — what was missing from that answer was the strict-parsed shape, the '
+ '`scope` default and the ADR-0010 provenance stamp. Driven on a real artifact-only boot before '
+ 'the entry landed, per the sentence above.',
},
{
direction: 'missing',
keys: ['datasourceMapping'],
reason: 'stack-level routing configuration, never a registry item.',
},
{
direction: 'missing',
keys: ['picklistExtensions'],
reason:
'registered by a DIFFERENT seam rather than dropped: an extension is not an item of its own — it '
+ 'has no name, only the list it adds options to — so it never becomes a metadata item. The '
+ 'ObjectQL registry merges it into that list (additive only, a repeated value refused), which is '
+ 'where every field bound to the list reads its options; `picklists` itself IS mapped here.',
},
],
},
{
id: 'APP_CATEGORY_KEYS',
file: 'packages/runtime/src/app-plugin.ts',
what: 'keys whose presence means "this bundle was supposed to register an app"',
extract: (src) => {
const b = sliceBody(src, 'const APP_CATEGORY_KEYS = [');
return b && stringArrayItems(b.body);
},
waivers: [
{
direction: 'extra',
keys: ['workflows', 'triggers', 'ragPipelines'],
reason:
'DRIFT — three retired kinds in a probe that can never see them. This is the SEVENTH enumeration '
+ 'of the set, found by writing this gate rather than by hand: the card that catalogued the drift '
+ 'listed six sites and did not have this one.',
},
{
direction: 'missing',
keys: [
'objectExtensions', 'datasourceMapping', 'datasources', 'jobs', 'apis', 'webhooks',
'hooks', 'mappings', 'analyticsCubes', 'connectors', 'capabilities', 'datasets',
'picklists', 'picklistExtensions', 'queues', 'assignmentRules',
],
reason:
'DELIBERATE, and load-bearing. This is a HEURISTIC, not a registration list: it answers "did the '
+ 'author mean to ship an app here?" for a bundle with no app id, and its failure directions are '
+ 'asymmetric — a false positive throws on a brand-new empty environment, a false negative only '
+ 'degrades to a no-op plugin. So it lists the user-visible app payload, not every legal '
+ 'collection. Pinned rather than completed, so a NEW collection has to be considered here once.',
},
],
},
{
// The EIGHTH enumeration, added at #12894 with the `policies` dead pointer
// it carried. Unlike `APP_CATEGORY_KEYS` above -- same file, different
// question -- this list is a REGISTRATION list: every pair it names really
// does write into the metadata registry, so a key the schema does not
// declare is not a harmless extra word, it is a registration that silently
// never happens.
//
// Pinned in the `extra` direction above all: this is the second registrar
// of the artifact boot's security collections (`ARTIFACT_FIELD_TO_TYPE` is
// the first), and the two had drifted in MIRROR-IMAGE ways -- `policies`
// dead in both, `capabilities` live here and absent there. Reconciling both
// against one schema is what makes that pair of facts visible at all.
id: 'SECURITY_FIELDS',
file: 'packages/runtime/src/app-plugin.ts',
what: "AppPlugin's ADR-0057 stack-declared security metadata -> registerInMemory kind",
extract: (src) => {
const b = sliceBody(src, 'const SECURITY_FIELDS: Array<[string, string]> = [');
return b && tupleFirstItems(b.body);
},
waivers: [
{
direction: 'missing',
keys: [
'datasources', 'datasourceMapping', 'translations', 'objects', 'objectExtensions', 'apps',
'views', 'pages', 'dashboards', 'reports', 'datasets', 'actions', 'flows', 'jobs',
'emailTemplates', 'docs', 'books', 'apis', 'webhooks', 'agents', 'tools', 'skills',
'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', 'picklists', 'picklistExtensions',
'queues', 'assignmentRules',
],
reason:
'DELIBERATE — a four-collection SUBSET, not an enumeration of the collection set. This block '
+ 'exists so the boot seeders (plugin-security / plugin-sharing) and the runtime resolvers can '
+ 'read stack-declared SECURITY metadata through `list(...)` on a boot where the artifact door '
+ 'never runs; every other collection reaches the registry through the door or its own seam. '
+ 'Recorded as one row rather than left implicit so that a NEW security collection has to be '
+ 'considered here once — which is the direction this site was actually wrong in: `capabilities` '
+ 'was registered here and NOT by the door, making this block that collection\'s sole registrar '
+ 'on an artifact boot (#12894 half 2, carried to #12892 for the ownership decision). The door '
+ 'reaches it as of #12892 step 1, so this block is no longer that collection\'s sole registrar — '
+ 'it is the SECOND one, on the same boot path, which is the interim state the ruling permits '
+ 'while step 2 (this block stops registering the five on the ARTIFACT path only, after a census '
+ 'of the non-artifact boots that depend on it) lands. `queues` and `assignmentRules` (ADR-0142) '
+ 'were considered here and left out: nothing reads them through `list(...)` — the ownership-queue '
+ 'service and the assignment seam read the ObjectQL registry that `METADATA_ARRAY_KEYS` stamps, '
+ 'and the artifact door maps both — so a third registrar would only add a copy.',
},
],
},
{
id: 'STACK_COLLECTION_COVERAGE',
file: 'examples/app-showcase/src/coverage.ts',
what: 'stack collections the showcase demonstrates or waives (the non-registry-kind half)',
extract: (src) => {
const b = sliceBody(src, 'export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {');
return b && objectEntries(b.body).map((e) => e.key);
},
// The sibling `KIND_COVERAGE` manifest covers every collection that IS a
// registry kind, and its own test ratchets it against
// DEFAULT_METADATA_TYPE_REGISTRY. Reconciling this half against the whole
// schema set would report those as missing, so the gate subtracts them
// first — this manifest is answerable only for the remainder.
kindCovered: true,
waivers: [
{
direction: 'missing',
// `sharingRules` left with ADR-0131 D6: `sharing_rule` is a registry kind, covered by KIND_COVERAGE.
keys: ['datasourceMapping', 'translations', 'data', 'webhooks'],
reason:
'NOT DEMONSTRATED, and visible for the first time. This manifest tracked 3 of the non-kind '
+ 'collections and — unlike its ratcheted sibling `KIND_COVERAGE` — was answerable to nothing '
+ '(#6242 row 6). Demonstrating a collection is example-app work with its own review; this row is '
+ 'the ratchet that stops the list reading as complete in the meantime.',
},
],
},
];
/**
* Collections already covered by the showcase's ratcheted `KIND_COVERAGE`
* manifest, resolved through the spec's own plural->singular map so the two
* halves are compared in one vocabulary.
*/
export function kindCoveredCollections(coverageSource, pluralToSingular, schemaKeys) {
const b = sliceBody(coverageSource, 'export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {');
if (!b) return null;
const kinds = new Set(objectEntries(b.body).map((e) => e.key));
return schemaKeys.filter((k) => kinds.has(pluralToSingular[k] ?? k));
}
// ───────────────────────────────────────────────────────────────────────────
// Runner
// ───────────────────────────────────────────────────────────────────────────
function readSource(rel) {
const abs = join(repoRoot, rel);
return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
}
function pluralToSingularMap(specSource) {
const b = sliceBody(specSource, 'export const PLURAL_TO_SINGULAR: Record<string, string> = {');
if (!b) return null;
const out = {};
for (const { key, value } of objectEntries(b.body)) {
const m = /^['"]([\w-]+)['"]/.exec(value);
if (m) out[key] = m[1];
}
return out;
}
function run({ list = false } = {}) {
const failures = [];
const stackSource = readSource('packages/spec/src/stack.zod.ts');
if (!stackSource) {
console.error('✗ packages/spec/src/stack.zod.ts not found — the source of truth is gone, not clean.');
return 1;
}
const schemaKeys = stackCollections(stackSource);
if (!schemaKeys || schemaKeys.length === 0) {
console.error(
'✗ could not extract the stack-collection set from ObjectStackDefinitionSchema.\n'
+ ' The declaration was renamed or reshaped. Fix the anchor in this script — an empty set would\n'
+ ' make every site below reconcile perfectly against nothing.',
);
return 1;
}
const collectionSource = readSource('packages/spec/src/meta-spelling/manifest-collection-spelling.ts');
const singular = collectionSource ? pluralToSingularMap(collectionSource) : null;
console.log(
`stack-collection set: ${schemaKeys.length} collections declared by ObjectStackDefinitionSchema\n`,
);
for (const site of SITES) {
const src = readSource(site.file);
if (!src) {
failures.push(`${site.id} — ${site.file} does not exist`);
continue;
}
const siteKeys = site.extract(src);
if (!siteKeys || siteKeys.length === 0) {
failures.push(
`${site.id} — could not extract the enumeration from ${site.file}.\n`
+ ` The symbol was renamed, moved or reshaped. An unextractable site is a FAILURE, never a pass:\n`
+ ` an empty list reconciles against everything.`,
);
continue;
}
let expected = schemaKeys;
if (site.kindCovered) {
const covered = singular ? kindCoveredCollections(src, singular, schemaKeys) : null;
if (!covered) {
failures.push(`${site.id} — could not resolve the sibling KIND_COVERAGE manifest it splits with`);
continue;
}
const coveredSet = new Set(covered);
expected = schemaKeys.filter((k) => !coveredSet.has(k));
}
const recon = reconcileSite(expected, siteKeys);
const result = applyWaivers(recon, site.waivers ?? []);
if (list) {
console.log(`── ${site.id} (${site.file})`);
console.log(` ${site.what}`);
console.log(` ${siteKeys.length} enumerated · ${recon.extra.length} extra · ${recon.missing.length} missing · ${(site.waivers ?? []).length} waiver row(s)`);
if (recon.extra.length) console.log(` extra: ${recon.extra.join(', ')}`);
if (recon.missing.length) console.log(` missing: ${recon.missing.join(', ')}`);
console.log('');
}
for (const k of result.extra) {
failures.push(
`${site.id} enumerates \`${k}\`, which ObjectStackDefinitionSchema does not declare (${site.file}).\n`
+ ` Either remove it, or add a WAIVERS row with the reason it stays.`,
);
}
for (const k of result.missing) {
failures.push(
`${site.id} omits \`${k}\`, which ObjectStackDefinitionSchema declares (${site.file}).\n`
+ ` Either add it, or add a WAIVERS row with the reason it is deliberately absent.`,
);
}
for (const stale of result.staleWaivers) {
failures.push(
`${site.id} carries a STALE waiver: ${stale}.\n`
+ ` Delete the row — an overstated debt misleads as much as an unrecorded one.`,
);
}
}
if (failures.length) {
console.error(`✗ check-stack-collection-maps — ${failures.length} unreconciled deviation(s):\n`);
for (const f of failures) console.error(` • ${f}\n`);
console.error(
' Every enumeration of the stack-collection set must be answerable to stack.zod.ts (#6242).\n'
+ ' A deviation is legal only as a WAIVERS row carrying the reason, in scripts/check-stack-collection-maps.mjs.',
);
return 1;
}
const waiverRows = SITES.reduce((n, s) => n + (s.waivers ?? []).length, 0);
console.log(
`✓ check-stack-collection-maps: ${SITES.length} enumerations reconciled against `
+ `${schemaKeys.length} declared collections (${waiverRows} waiver rows, each with a reason).`,
);
return 0;
}
// ───────────────────────────────────────────────────────────────────────────
// Self-test -- the extractors and the ratchet, over synthetic sources
// ───────────────────────────────────────────────────────────────────────────
//
// The gate is green on `main` by construction (the waiver table was written from
// its own output), so a green run proves nothing about whether it CAN fire. The
// assertions below drive both failure directions on synthetic input.
// Set by `selfTest()` only after its verdict is printed, and read at the
// dispatch: a `return` that leaves the function above that line prints nothing
// and still exits 0 — a self-test that never finished, reported as one that
// passed (#13798). The self-test's own exit code stays load-bearing, so the
// handshake is a flag rather than a returned sentinel.
let selfTestReachedVerdict = false;
// ── The self-test's own battery roster and floor (#13489) ──────────────────
//
// The `asserted` counter below is COUNTED and PRINTED, but nothing ever
// compared it: if a case block stopped running the printed number shrank with it
// and the gate stayed green. ⚠️ A verdict line that prints a case count is
// evidence, NOT proof. Closed the way PR #13487 validated on
// check-doc-authoring: what is pinned is the registered NAMES, not a number, and
// the floor requires the OPENED set to equal the DECLARED set with each battery
// at or above its own count.
//
// This file declares ONE battery, opened at the top of the self-test body. It
// carries fewer than the two named section banners the sectioning criterion
// needs, and ⛔ a comment is NOT promoted to a section head — that is a
// judgement per comment this transplant does not make. The hoisted single
// battery is the shape PR #14896 and PR #15003 landed for exactly this case.
//
// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3
// keeps a total "right" the moment a sibling grows.
//
// The count is a FLOOR, not an equality — adding cases is ordinary work and must
// not red. A battery BELOW its floor means cases stopped running; the remedy is
// to find what stopped registering.
const SELF_TEST_BATTERIES = Object.freeze({
'check-stack-collection-maps self-test': 18,
});
// DELETING an entry silences that battery's floor exactly as effectively as
// zeroing it, so the roster's own size is pinned too.
const SELF_TEST_BATTERY_FLOOR = 1;
// The key an assertion is filed under when no battery is open. It is not a
// declared battery, so it reds by the same set difference rather than silently
// inflating whichever battery happened to run last.
const UNATTRIBUTED_BATTERY = '(no battery open)';
function selfTest() {
// The battery ledger this self-test's floor is evaluated against (#13489).
// `battery()` opens a battery; every assertion below is attributed to the one
// most recently opened, so a section that stops running stops registering and
// names ITSELF at the floor rather than going quiet.
const batterySeen = new Map();