Repository navigation
Expand file tree
/
Copy pathcheck-required-contexts.mjs
More file actions
3503 lines (3378 loc) · 189 KB
/
Copy pathcheck-required-contexts.mjs
File metadata and controls
3503 lines (3378 loc) · 189 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.
/**
* Required-context name pin (#6865) — the job `name:` literals that branch
* protection references are CONTRACT, so the repo asserts them.
*
* node scripts/check-required-contexts.mjs # the pin (lint)
* node scripts/check-required-contexts.mjs --self-test # verify the checker itself
* node scripts/check-required-contexts.mjs --verify-required-set
* # report-only live ruleset diff (#9642)
* # ⛔ NOT runnable from a dev seat — see
* # "Where the live half can run" below
*
* ## The defect
*
* A GitHub required status check is matched BY THE CHECK-RUN NAME, and a job's
* check-run name is its `name:` value. Nothing in a workflow file says "this
* string is load-bearing". So renaming a job is a one-line edit with a silent,
* repo-wide consequence: the old name never reports again, and the required
* context sits permanently pending (equivalent to red — it wedges the PR and
* the merge queue), or, if the stale context is then quietly removed from the
* settings to unwedge things, every gate that job carries degrades to advisory
* with no signal anywhere. The second shape is #5617 verbatim: PR #5584 merged
* with its ESLint job red for 19 minutes, because the job carrying the whole
* `check:*` gate family was not in the required set at all. Four more merges
* repeated it that night (#6067/#6096/#6051/#6103) before the settings were
* fixed on 2026-08-07.
*
* ci.yml already writes this contract down in prose — at least eight places,
* e.g. "the NAME is the required check, so renaming it would silently drop the
* gate wherever branch protection lists it" — and `check-shard-attestation.mjs`
* asserts that the two aggregate gate JOB IDS (`test-gate`, `dogfood-gate`)
* still exist and still count attestations. Neither of those asserts a `name:`
* LITERAL, and lint.yml had no note and no assertion at all. This script is the
* missing half: the literals, in one registry, machine-checked.
*
* ## What this pin asserts (all of it repo-side, all of it checkable here)
*
* For every entry in REQUIRED_CONTEXTS:
*
* 1. the workflow file exists and parses;
* 2. the job id still exists in it;
* 3. its `name:` is EXACTLY the registered literal — the pin proper;
* 4. the job declares no `strategy.matrix`. A matrix job's check-run name is
* the name with the matrix values appended (`Test Core (1/3)`), so a bare
* registered name would never report. Worse, per #5617's audit a SKIPPED
* matrix job publishes the name UNEXPANDED, so the two spellings are not
* even stable across runs;
* 5. the job sets no truthy `continue-on-error`. Such a job cannot conclude
* `failure`, so it would publish a green required context over a failure —
* an advisory gate wearing a required gate's name;
* 6. its workflow carries a `merge_group:` trigger. Without it the queue
* build never produces the context and the whole queue stalls waiting for
* it (#5617's audit named `Console Pin Freshness` as exactly this shape at
* the time of the audit; that workflow has since been deleted outright —
* see the ⛔ exclusion note below, which outlives it);
* 7. its workflow's `pull_request:` trigger exists and carries no `paths:` /
* `paths-ignore:`, no `branches:` / `branches-ignore:`, and, if it names
* `types:` at all, that list is a superset of GitHub's default
* `[opened, synchronize, reopened]`. A path-filtered trigger produces NO
* check run on a PR that misses the glob; a BASE-filtered trigger
* produces none on a PR based on a branch outside the filter; and naming
* `types:` REPLACES (never extends) the default set, so dropping one of
* the three from a hand-written list produces NO check run on that
* activity — none of the three is a skip, all are an absence, which is
* permanent pending (the audit's `Spec property liveness` exclusion for
* the `paths:` half; #8304 for the `types:` half — no enrolled workflow
* names `types:` today, and this guard is what makes growing such a list
* safe; #16482 for the `branches:` half, where `branches: [main]` on both
* enrolled workflows meant all six required contexts reported NOTHING on
* every PR based on a feature branch).
*
* Plus two whole-registry properties:
*
* 8. no two entries register the same context name;
* 9. no UNREGISTERED job in a scanned workflow carries a registered name.
* Two jobs with one name publish one context whose conclusion is whichever
* finished last, so an unrelated job could satisfy a required gate. This
* is live ammunition here rather than a hypothetical: ci.yml's sharded
* `test` job is named `Test Core (${{ matrix.shard }}/3)` and its gate is
* named `Test Core`; deleting the suffix collides them.
*
* ## The required SET: readable, diffable, and deliberately not merge-blocking
*
* ⚠️ TWO DIFFERENT ENDPOINTS answer for "is this name actually required", and
* confusing them is how this header carried a false premise for weeks (#9642).
* Both are named here, with which one holds THIS repository's configuration, so
* a reader who doubts this paragraph tests the right URL:
*
* • the CLASSIC BRANCH-PROTECTION endpoint,
* GET /repos/objectstack-ai/objectstack/branches/main/protection,
* answers HTTP 403 `Resource not accessible by integration` to an ordinary
* agent seat. GitHub names its price in the response itself —
* `X-Accepted-GitHub-Permissions: administration=read` — and no Actions
* token can pay it either: `administration` is not among the 17 permissions
* a workflow may grant its GITHUB_TOKEN. It is ALSO not where this repo's
* configuration lives — `main` is governed by a repository RULESET, not by
* classic branch protection. Testing this URL and concluding "the required
* set is unreadable" is the exact error #9642 retired.
* • the RULESET endpoints, GET /repos/objectstack-ai/objectstack/rulesets and
* the same path plus /{id}, answer HTTP 200 to an ordinary agent seat.
* Their price is `X-Accepted-GitHub-Permissions: metadata=read` — the
* baseline every token carries, and not one of the 17 a workflow can toggle
* because it cannot be given up. The required SET is readable — by a token
* that reaches api.github.com at all, which a DEV SEAT does not (see
* "Where the live half can run" at `--verify-required-set` below; that is a
* transport fact about the container, not a permission this repo grants).
*
* Measured 2026-08-18 (#9642): one ruleset, `main`, id 12119582, enforcement
* `active`, repository-sourced — `includes_parents=true` returns that one and
* only that one, so no organization ruleset is being missed — carrying six
* `required_status_checks` contexts and `strict_required_status_checks_policy:
* false`.
*
* ⚠️ What is readable is the set as it is NOW, never how it got there. Both
* history routes are shut to a seat, re-measured 2026-08-18 (#9533) with the
* price GitHub names in each response:
*
* GET .../rulesets/12119582/history 403 administration=write
* GET .../rulesets/rule-suites 403 administration=read
*
* So "when did this context leave the set" and "was it ever in it" are NOT
* agent-answerable, and no amount of re-reading the live endpoint turns into
* an answer. A row's provenance therefore has to be carried in writing, here,
* by the PR that adds it — which is why `authorized` distinguishes a ruling
* that APPROVED a context from an application that was ATTESTED APPLIED. That
* distinction is not pedantry: #9533 turned on it (see the tombstone below).
*
* So the two lists are machine-comparable, and `--verify-required-set` below
* compares them in BOTH directions. What that mode is NOT is a merge-blocking
* gate, for a structural reason rather than a squeamish one: the settings half
* of any required-set change is maintainer-only and lands AFTER the merge (the
* #9325 two-step). A required check reddening on registry-vs-settings
* disagreement would be red on precisely the PR carrying the repo half and
* could not go green before merging — it would deadlock the sitting it claims
* to protect. It is report-only, in `check-governed-merges.mjs`'s posture, and
* the pin proper stays entirely network-free so the required `Lint & Repo
* Gates` context can never redden on a GitHub blip (self-test: 'the pin stays
* network-free').
*
* ⚠️ What has NOT changed: this registry is still the repo's DECLARATION of
* what the settings are believed to reference, sourced from the maintainer
* rulings on #5617. Adding a row here does not make a context required, and
* only a maintainer can change the settings. What #9642 changed is that a
* disagreement is now MEASURED instead of invisible — and the first run found
* one: `Build Docs` and `Console Pin Gate` were registered here and were NOT
* in the live required set. That first finding is closed (#9533, 2026-08-18
* ruling — the two rows dropped; the tombstone below carries the provenance),
* so the two lists agree in both directions today and the sweep prints its
* ✅ line. The mechanism stays for the next disagreement; it reports and
* decides nothing.
*
* ## Why `if:` is deliberately NOT asserted
*
* #6865's own body proposed asserting the enrolled jobs carry no `if:`. That is
* right for lint.yml's two and WRONG for the four jobs the 2026-08-09 ruling
* approved (approved — how much of that batch reached the settings is #9533's
* finding below): `build-core`, `build-docs`, `console-pin` and
* `temporal-conformance` each carry `if: ${{ !cancelled() && needs.filter…}}`
* BY DESIGN — THE FILTER CONTRACT (#4928). A job-level `if:` that skips still
* publishes a check run (conclusion `skipped`, which branch protection counts
* as passing); it is the workflow-level `paths:` filter that publishes nothing,
* which is why assertion 7 above judges the trigger and not the job. Asserting
* "no `if:`" uniformly would have made this gate red on `main` the day it
* landed, against four jobs that are correct.
*
* The two aggregate gates are a further special case — they need `if: always()`
* for a reason of their own (#3622: a gate that skips because a dependency
* failed publishes no context) — and `check-shard-attestation.mjs` already owns
* and asserts that. Re-litigating per-job `if:` shape here would duplicate that
* judgement in a second place with a different rationale, which is the one
* outcome #6865's dispatch explicitly warned against.
*
* ## The instruction-surface scan (#9491)
*
* Since #9491 this file also guards the INSTRUCTION FILES that state the
* required set as operative prose. The design — its split-PR sequencing
* constraint, the occurrence-budget grace, and its measured recognition
* limits — is documented at INSTRUCTION_SURFACES / RETIRED_CONTEXT_NAMES
* below, with the decision in judgeInstructionSurfaces.
*/
import { requireDependency } from './import-prerequisite.mjs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// Both layers of the shell lexer, from the one home that owns them. This file
// wrote `invokes` (and the `commandWords` under it) first (#10877), but they
// belong beside `shellCommands`, whose output they consume: the import edge
// between these two files runs THIS way only, so a helper kept here could never
// be reached from check-shard-attestation without a cycle — and that file needed
// the same narrow reading for the same defect one flag over (#10889).
import { invokes, shellCommands } from './check-shard-attestation.mjs';
import { isEntrypoint } from './invoked-as.mjs';
// ── The self-test's own battery roster and floor (#13489) ──────────────────
//
// `failures.length === 0` used to be this self-test's ONLY success condition, so
// "every case held" and "the cases never ran" printed the same line. Closed the
// way PR #13487 validated on check-doc-authoring: what is pinned is the
// registered NAMES, not a number. Every section opens with `battery('<name>')`,
// every assertion is attributed to the battery most recently opened, and the
// floor requires the OPENED set to equal the DECLARED set with each battery at
// or above its own count.
//
// ⛔ 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 counts are 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({
'the checked-in state is green': 2,
'(3) THE PIN: reverse verification, one per workflow file': 7,
'(2) the job disappearing entirely': 2,
'(4) growing a matrix on a registered job': 2,
'(5) continue-on-error': 4,
'(6) the merge_group trigger': 3,
'(7) a path-filtered pull_request trigger': 4,
'(7b) a `types:` list that drops a GitHub default': 6,
'(7c) a `branches:` filter on the pull_request trigger': 5,
'(9) the shadowing collision, on the live specimen': 3,
'(8) a registry that lists one context twice': 1,
'(10) a `carries` string that embeds a step count': 3,
'missing input is a failure, never a pass (#4690)': 6,
'the `on:` key under both YAML schemas': 3,
'instruction surfaces (#9491): the stale-name scan': 41,
'the dispatch-gates declaration (#9979)': 6,
'the wiring: this gate must actually run on every PR': 28,
'the live mode stays OFF the required path': 3,
'…and the standing caller it DOES have (#9678)': 7,
'the recognizer itself, in BOTH directions': 10,
'…and the NARROW recognizer beside it, in both directions': 6,
'is the pin WIRED into the required job?': 3,
'the wiring recognizer, in BOTH directions': 13,
});
// 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 = 23;
// 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)';
/**
* The branch-protection-required check contexts, as this repository declares
* them. Each entry is `<workflow file>` + `<job id>` → the exact check-run name.
*
* `authorized` names the ruling that put the context in the required set, so a
* maintainer visiting Settings can reconcile the two lists without archaeology.
* Adding a row here does NOT make a context required — only the settings do.
* Removing one from the settings without removing the row leaves a pin that is
* merely inert, never wrong: the assertion "this job still has this name" holds
* either way.
*
* ⛔ Names the #5617 audit ruled must STAY OUT of the required set, recorded so
* a later reader does not enroll them here by symmetry: `Console Pin Freshness`
* (the audit's stated reason was "no `merge_group` trigger — required-izing it
* deadlocks the queue, and that file's own comment invites it". ⚠️ Two things
* happened to that reason and neither reopens the question: #6121 added the
* trigger, which #6991 recorded as making the stated reason stale; then #10134
* DELETED the workflow, the script and the `package.json` entry outright, so no
* check-run of this name reports at all — the 2026-08-20 ruling is that which
* objectui revision we pin is a decision taken in an objectstack issue, never
* derived from objectui `main`, so there is no currency question for a gate to
* ask. This entry STAYS for a reason the deletion CREATES: it is now the only
* thing standing between a future author and rebuilding a same-named gate and
* enrolling it here), `Spec property liveness` (PR-side `paths:`,
* so PRs touching no spec/docs sit pending forever), `Validate Package
* Dependencies` (both faults), `Check PR Size` / `Auto Label` / `Check
* Changeset` (the `labeled` event republishes the same context as `skipped`,
* washing it green), the un-named `filter` job (its context name is the bare
* job id), and every matrix shard name. Assertions 4, 6 and 7 below are exactly
* the machine-readable form of the first three exclusions, so enrolling one of
* them by mistake fails here instead of in the merge queue.
*
* ⛔ And one name REMOVED rather than excluded, recorded so nobody re-enrolls
* it from git archaeology: `ADR maintainer approval` (adr-merge-approval.yml)
* was registered here on a 2026-08-10 screenshot of the ruleset, but the
* maintainer's own reading of the required set on 2026-08-18 listed exactly
* the entries above and not it — the screenshot had gone stale, and a PR
* carrying that check at `failure` merged through the queue the same day,
* confirming it empirically. The 2026-08-18 ruling then retired the check
* entirely (human merge IS the review record for governed surfaces; the
* post-merge audit is scripts/pm/check-governed-merges.mjs). This removal is
* the registry-follows half of the header's two-step; the Settings half is
* the maintainer attestation above.
*
* ⛔ TOMBSTONE — `Build Docs` (ci.yml:build-docs) and `Console Pin Gate`
* (ci.yml:console-pin), dropped 2026-08-18 by the #9533 ruling. Recorded at
* length because the shape here is NOT the one the ruling's wording assumed,
* and a future reader who re-derives it from git will otherwise reach the
* same wrong conclusion twice:
*
* • What the rows claimed. Both carried `authorized: '#5617 closing ruling
* 2026-08-09, second batch'` — and that ruling reads «Second batch
* approved: Build Core, Build Docs, Console Pin Gate, Temporal Conformance
* (live PG + MySQL) join the required set … The maintainer applies this in
* Settings alongside confirming item 2.» An APPROVAL, in the future tense,
* of a Settings action. Contrast the two 2026-08-07 rows, whose
* `authorized` says "applied to the settings the same day": those attest
* an accomplished state. Nothing anywhere ever attested this batch applied.
* • What the settings actually took. The one contemporaneous transcription
* of the ruleset — the maintainer's 2026-08-10 screenshot, quoted on
* #7022 — lists `ADR maintainer approval` "alongside TypeScript Type
* Check / ESLint / Test Core / Dogfood Regression Gate / Build Core".
* Of the four-name batch, only `Build Core` is there. `Temporal
* Conformance` arrived later; these two never appear in any reading.
* • So this is most likely a HALF-APPLIED approval, not a removal — the
* #6865 two-step's other failure mode, and one no one could see while the
* required set was believed unreadable. It cannot be proven: ruleset
* history is 403 to a seat (see the header). Stated as the likelihood it
* is, because the ruling's own veto clause ("if the removal was in fact
* accidental, restore the contexts in Settings instead") is aimed at a
* removal, and the maintainer re-reading this should know the choice on
* the table is really "apply the standing 2026-08-09 approval, or let it
* lapse". The ruling let it lapse; that is what these rows record.
*
* ⛔ And why this tombstone is PROSE and not a RETIRED_CONTEXT_NAMES row,
* against the #9523 precedent the ruling cited. That ledger bans a name from
* the instruction surfaces because the name is DEAD — no check-run reports it
* any more. Neither of these names is dead: both jobs still exist, still carry
* these `name:` literals, and still publish these check-runs on every PR that
* trips their filter. They lost REQUIRED status, which is a different fact.
* Ledgering them anyway was measured before it was rejected (2026-08-18):
* `docs/releases-maintenance.md` names `Console Pin Gate` in correct, current
* prose describing what still checks the pin, so
* the row reds the scan there and prints the diagnostic "a seat following this
* text looks for a check-run that no longer reports" — false, about prose that
* is right. Budgeting around it would only arm the trap for the next author
* who legitimately names the live job.
*
* `Console Pin Gate` IS dead since #25697 deleted its job with the objectui pin
* gates. It still takes no RETIRED_CONTEXT_NAMES row: it never sat in any
* reading of the required set, and the instruction files no longer name it as
* a live check (`docs/releases-maintenance.md` keeps it in the past tense).
*
* ⚠️ The cost of the drop, stated rather than discovered later: these two
* `name:` literals are now pinned by NOTHING, while prose elsewhere still refers
* to the jobs by name — `docs/releases-maintenance.md`,
* `packages/console/README.md` and lint.yml's cross-reference among them. This
* list read FIVE until #10134 deleted `scripts/check-objectui-pin-fresh.mjs` and
* `.github/workflows/objectui-pin-freshness.yml`, both of which named
* `Console Pin Gate` only to tell it apart from `Console Pin Freshness`; the
* recorded debt shrank by two and did not close. ⚠️ It was never asserted by
* anything either way, so read it as examples and never as a census — the same
* trap the `carries` note below is about. Renaming either job no longer detaches a
* required gate — that is the whole point — but it does silently falsify that
* prose. Filed as its own card rather than solved here, since a pin for
* "contract job names that are not required contexts" is a new mechanism and
* this card is ledger hygiene. Filed as #9793.
*
* ⛔ `carries` names the gate FAMILY and never a step count — assertion 10
* enforces that, because prose here reads as measurement while being asserted
* by nothing. Both counts this registry used to carry were wrong on the day
* they were WRITTEN, not merely outgrown: at the authoring commit (d3e53f2d8,
* 2026-08-09) the ESLint entry said "(25 steps)" against a job running 35
* `pnpm check:*` steps, and the typecheck entry said "(33 steps)" against 10
* (43 by the loosest caliber that counts its `--filter` spellings, 29 by
* non-setup steps — no caliber yields 33, so what was even measured is no
* longer recoverable). 25 was roughly the ESLint count at the #5617 INCIDENT
* (24 on 2026-08-06), i.e. a historical figure that had drifted into a
* present-tense field. Then it rotted at about three steps a day: 24 → 54
* between 2026-08-06 and 2026-08-16, gaining two more while #9103 — the card
* reporting the staleness — sat open, every gate green throughout. The count
* is a property of the workflow, so the workflow is where it is read from: the
* job's own step list, one grep away and never stale (#9103).
*
* ⛔ TOMBSTONE — `Dogfood Regression Gate` (ci.yml:dogfood-gate) and `Temporal
* Conformance (live PG + MySQL)` (ci.yml:temporal-conformance), dropped by the
* #22998 ruling (two-speed CI; ruling record 6120820653 on #22991): their legs
* run in the FULL BATTERY only — `main`'s push, hourly and dispatch runs — so a
* PR or queue build carries the fast set and these two contexts leave the
* required set. The order is the #9325 two-step: the registry and the
* instruction surfaces drop the rows in the PR, and the maintainer removes the
* two contexts from the ruleset in the same sitting as the merge; until then
* `--verify-required-set` reports them in direction B (required, pinned by no
* row), report-only, which is the sitting's own reminder. Prose and not a
* RETIRED_CONTEXT_NAMES row, for the reason the `Build Docs` / `Console Pin
* Gate` tombstone above gives: neither name is dead — both jobs still publish
* these check-runs on every full-battery run, and the instruction files name
* them correctly as full-battery jobs.
*/
export const REQUIRED_CONTEXTS = [
{
workflow: 'lint.yml',
job: 'lint',
// Renamed from 'ESLint' under the #9325 ruling: the old name described one
// of the job's steps and mis-routed the diagnosis of every other gate it
// carries. The rename lands in two halves that CANNOT be atomic — this
// registry + the workflow `name:` here, and the required-context entry in
// repository Settings → Rulesets — so it is done in one maintainer-present
// sitting: merge, then swap the Settings entry immediately. Either half
// alone is an outage (permanently-pending, or advisory-with-no-signal =
// #5617). If you are reading this while a required context named 'ESLint'
// is still live in Settings, that swap has not happened yet.
context: 'Lint & Repo Gates',
authorized:
'#5617 maintainer ruling 2026-08-07 (enrolment, applied to the settings the same day); ' +
'renamed from `ESLint` by the #9325 maintainer ruling 2026-08-17, swapped in the settings in the same sitting as the merge',
carries: 'the whole check:* gate family — the job whose red did not block #5584',
},
{
workflow: 'lint.yml',
job: 'typecheck',
context: 'TypeScript Type Check',
authorized: '#5617 maintainer ruling 2026-08-07 — applied to the settings the same day',
carries: 'the type-check + generated-artifact gate family',
},
{
workflow: 'ci.yml',
job: 'test-gate',
context: 'Test Core',
authorized: '#5617 closing ruling 2026-08-09, confirmation item 2 (the bare aggregate name)',
carries: 'the sharded Test Core matrix, counted via check:shard-attestation (#6082/#3622)',
},
{
workflow: 'ci.yml',
job: 'build-core',
context: 'Build Core',
authorized:
'#5617 closing ruling 2026-08-09, second batch — "the highest-value addition"; ' +
'the only member of that batch the 2026-08-10 ruleset screenshot (#7022) shows applied, and live in the 2026-08-18 read',
carries: 'the only compile-regression gate in the repo',
},
{
workflow: 'governed-surface-guard.yml',
job: 'governed-surface-guard',
// The one gate that governs the governed surface, and until this row the
// one live required context nothing pinned — `--verify-required-set` read
// it as `direction B` (required, pinned by NO registry row) from the day
// the enrolment landed until this row (#15233). Direction B is not
// direction A: #12427 fixed the guard running ADVISORY (not required at
// all); this pins the NAME it is required under. Neither implies the
// other, and the sweep counts them separately.
context: 'Governed Surface Queue Guard',
authorized:
'#12427 maintainer confirmation, closed `completed` 2026-08-27T07:51Z on the verbatim 「Governed Surface Queue Guard 已添加」 (comment 5436049459); ' +
'read back live in the ruleset sweep of 2026-09-10 that carries the director ruling enrolling this row (#15233)',
carries: 'the governed-surface refusal — the `merge_group` leg that refuses a governed diff carrying no ruled approval',
},
{
workflow: 'ci.yml',
job: 'lint-ui',
context: 'Lint UI',
authorized:
'#25752 step 6, maintainer ruling 2026-10-26T00:25Z in the director thread, verbatim 「加上了」 — ' +
'added to the `main` ruleset the same minute; this row is the repo half (#25853)',
carries: 'the ESLint + repo-gate family of the `ui/` workspace',
},
{
workflow: 'ci.yml',
job: 'build-e2e-ui',
context: 'Build & E2E UI',
authorized:
'#25752 step 6, maintainer ruling 2026-10-26T00:25Z in the director thread, verbatim 「加上了」 — ' +
'added to the `main` ruleset the same minute; this row is the repo half (#25853)',
carries: 'the Console build from `ui/apps/console` and its Playwright e2e',
},
{
workflow: 'ci.yml',
job: 'type-check-ui',
context: 'Type Check UI',
authorized:
'#25752 step 6, maintainer ruling 2026-10-26T02:56Z in the project thread, verbatim 「Type Check UI 加了」 — ' +
'after #25856 (fc2aaaec52) closed bill item 49 with the job green on the main push run 40253800516',
carries: 'the type-check + vitest-setup type-check family of the `ui/` workspace',
},
];
/**
* ── Instruction surfaces (#9491) ─────────────────────────────────────────────
*
* The same context literals are OPERATIVE PROSE outside the workflows: the
* instruction files that tell a review seat which check-runs to confirm before
* flipping a PR ready / arming auto-merge / enqueuing it. #9325's rename moved
* lint.yml and this registry in one PR (the pin above forces that), while
* AGENTS.md was carried along BY HAND and the pm-dispatch review checklist was
* caught by a dev seat READING it — no gate read the name at all (#9491's
* measurement: replacing it with `Nonexistent Job Name` turned nothing red
* across the file's whole derived gate union). A seat that looks for a
* check-run by a stale name and finds nothing either stalls or arms on an
* unverified gate family.
*
* ## The sequencing constraint this design is built around
*
* A rename CANNOT land atomically across all its surfaces: the Settings entry
* is maintainer-only, and the instruction files are split across seat tiers
* (`.claude/skills/pm-dispatch/**` is a maintainer-merge surface a dev seat
* may not edit — the reason the checklist half of #9325 was its own card). So
* between the rename PR and the instruction-fix PR(s), `main` legitimately
* holds stale names, and a gate that reds on them blocks the very sitting it
* protects. Not hypothetical: this scan landed WHILE the repo sat in that
* window — registry renamed, `review-checklist.md` still naming `ESLint`, the
* fix PR open. Hence:
*
* - RETIRED_CONTEXT_NAMES is a permanent, append-only rename ledger. The
* rename PR MUST add a row: without one, the scan below is red on every
* occurrence of the newly-old literal, and that red lands on the rename
* PR itself — where the registry edit is already in hand — so the ledger
* cannot be forgotten (self-test: 'the ledger cannot be forgotten').
* - `staleSites` is a per-file OCCURRENCE BUDGET, not a ban: the occurrence
* counts at rename time. Above budget = red (the dead name written
* FRESH); at or below = green with a notice. A ban would be wrong while
* the fix PRs are in flight — a fix may legitimately keep the dead name
* as a historical mention ("改名前的 PR 仍列旧名"), and history is
* legitimate prose everywhere. Budgets only ever ratchet down, by hand,
* notice-driven; a row whose budgets have been trimmed away is a standing
* ban on writing that dead name fresh anywhere in the scan set. The
* `ESLint` row reached that state on 2026-08-18: its checklist fix was
* expected to keep one historical mention and dropped the name outright,
* the trim notice fired, and the budget was deleted — measured, not
* assumed, which is the whole point of the notice being notice-driven.
* - `renameInFlight: true` lets the OLD literal satisfy a `mustName`
* requirement while the instruction fixes are in flight. Once every
* required-set surface names the new literal itself, a notice asks for
* the flip to false. Flipping early is safe by construction: the flip PR
* goes red while any surface still leans on the grace, so it cannot merge
* before the fixes do — it self-orders, it never races. (The pin runs in
* the `Lint & Repo Gates` job with no `if:`, asserted in the wiring block
* below, so "red" there means the required context is red — that is what
* makes the self-ordering hold rather than merely being claimed.)
*
* ## What this scan can and cannot recognise, measured before it was built
*
* Recognition is LEXICON-ONLY: current names (the registry) + former names
* (this ledger). Recognising an ARBITRARY check-run-shaped literal in
* bilingual prose — the stronger reading of #9491's direction 2 — needs
* markers in the instruction files, which are exactly the files split across
* seat tiers; every marker-free heuristic tried fires on ordinary English.
* The `mustName` guard closes most of that gap from the other side: a surface
* that states the required set must contain each required literal (current,
* or ledgered-former while in flight), so replacing a name with garbage or
* deleting the sentence is red even though the garbage itself is never
* recognised. That half is a FLOOR and says nothing about what a surface
* OMITS, which is its own defect shape: enrolling a context grew the registry
* while every surface's list stayed legal, so the prose kept calling the new
* context advisory-and-rides-through with every gate green (#9677 for the
* 2→6 instance, #17798 for the 6→7 one — twice is a mechanism, not an
* oversight). Hence `statesTheSet`: an entry that declares it must name the
* registry EXACTLY, so the NEXT enrolment cannot land without following the
* sentence. RESIDUALS, recorded rather than implied covered: paraphrase
* drift (naming a context loosely, e.g. "Lint & Type Check" in
* docs/launch-readiness.md) is invisible to both halves; and after a
* SHORTENING rename (new name a substring of the old), an old-literal mention
* satisfies the new name's `mustName` by substring — the budget half still
* tracks the old name, so staleness stays bounded; and the COUNT WORD that
* introduces such a list is prose like any other, so it is hand-followed and
* unpinned — measured, not assumed: reverting AGENTS.md's 「seven contexts
* block」 to 「six」 while all seven literals stay listed runs GREEN here.
* Bounded by the census rather than covered by it: every enrolled literal is
* forced into the prose, so a stale numeral sits next to a complete list, not
* a short one. Pinning the numeral needs the arbitrary-literal recognition
* this scan measured as out of reach above.
*
* ## The scan set, derived 2026-08-18, not assumed
*
* Every `*.md` under AGENTS.md, CLAUDE.md, `.claude/` and `docs/` naming any
* registered context, case-sensitive, with a positive control run on the zero
* hits. Excluded deliberately: `docs/adr/**` and package CHANGELOGs
* (point-in-time records — a rename must NOT retro-edit them, so scanning
* them would demand exactly that), and this script + `.github/workflows/**`
* (the ledger and the pinned half themselves — old names live here as
* history by design).
*
* Re-derived 2026-09-05 with the same query: the pm-dispatch readings ledger
* names all six and nothing pinned it, so it joins the set; every other `*.md`
* in that population was already enrolled, and `references/true-green.md`
* names no registered context at all, so it stays out — a surface enters this
* set by naming one, never by sitting next to one that does.
*/
export const INSTRUCTION_SURFACES = [
{
// The merge-queue rule ("the queue enforces only the required set"),
// naming every blocking context. States the required set ⇒ statesTheSet.
// Widened 2→6 by the #9677 ruling (2026-08-18): the sentence had named
// two and called the other four advisory-and-rides-through, which is the
// misclassification that puts a PR into the queue to be ejected. Then
// 6→7 by #17798 — the same misclassification, one enrolment later and
// with every gate green, which is what put the exclusivity rule below in
// the judge. Then 7→5 by #22998 (two-speed CI: the dogfood and temporal
// contexts moved to the full battery). Then 5→7 by #25853 (the `ui/`
// import's `Lint UI` and `Build & E2E UI`) and 7→8 with `Type Check UI`
// once bill item 49 closed (#25856). The full set is pinned here so the
// corrected sentence cannot rot back — a rename in ANY of the eight
// reddens this gate instead.
file: 'AGENTS.md',
statesTheSet: true,
mustName: [
'Lint & Repo Gates',
'TypeScript Type Check',
'Test Core',
'Build Core',
'Governed Surface Queue Guard',
'Lint UI',
'Build & E2E UI',
'Type Check UI',
],
},
{
// The review seat's gate-clearance step: confirm both jobs' `conclusion`
// before flipping ready / arming / enqueuing. It names these two as the
// required FLOOR a reviewer confirms by hand — its own next line sends the
// seat to `true-green.md` for the rest — so it does NOT state the set and
// carries no `statesTheSet`. The two literals stay a floor: a rename in
// either still reddens here, while the set growing does not falsify a
// sentence that never claimed to enumerate it.
file: '.claude/skills/pm-dispatch/references/review-checklist.md',
mustName: ['Lint & Repo Gates', 'TypeScript Type Check'],
},
{
// The seat's readings ledger states the required set it reads before a PR
// may be enqueued ⇒ statesTheSet, the whole registry.
file: '.claude/skills/pm-dispatch/references/platform-readings.md',
statesTheSet: true,
mustName: [
'Lint & Repo Gates',
'TypeScript Type Check',
'Test Core',
'Build Core',
// Enrolled 2026-08-27 (#12427) and pinned here by #15233. The ledger's
// own count line is hand-followed prose, so it is this array — held
// equal to the registry by the exclusivity rule in
// judgeInstructionSurfaces — that makes the seat's copy non-optional.
'Governed Surface Queue Guard',
'Lint UI',
'Build & E2E UI',
'Type Check UI',
],
},
{
// Names the typecheck context as the job two generated-artifact gates run
// in — operative, but does not state the required set ⇒ scan-only.
file: '.claude/skills/spec-property-retirement/SKILL.md',
mustName: [],
},
// Release/maintenance checklists that name individual contexts ⇒ scan-only.
{ file: 'docs/launch-readiness.md', mustName: [] },
{ file: 'docs/releases-maintenance.md', mustName: [] },
];
/**
* The repo-ROOT surface above, declared for `scripts/pm/dispatch-gates.mjs`
* (#9979, applying #9964's pattern).
*
* That tool derives a card's gate list from the path literals in each gate's
* own source, and "looks like a path" there means "carries a separator". Five
* of the six surfaces have one; `AGENTS.md` does not, because a repo-root FILE
* has no separator to be found by — so an AGENTS.md card derived this gate not
* at all, while that surface is the one carrying `mustName` for every
* required context (widened 2→6 by #9677, 6→7 by #17798, 7→5 by #22998, 5→7 by #25853, 7→8). Editing the merge-queue
* paragraph there is precisely how this gate goes red, and it was reachable
* only by judgment.
*
* `<file>/**` is the form that reaches a root file: the extractor accepts it,
* and `collapseHint` reduces it back to that one path and to nothing else.
*
* ⚠️ Provenance, NOT a lookup key. `scanInstructionSurfaces` opens every
* surface `file`; the glob spelling appearing there would send this gate
* reading a path that does not exist — and a surface it cannot read is a hard
* failure here by design (#4690). The self-test pins both halves.
*/
export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**'];
/**
* Former required-context names — permanent history, append-only. A row is
* added BY the rename PR (the scan is red on the old literal until it is) and
* never deleted: once its budgets are trimmed away, the row is a standing ban
* on writing the dead name fresh anywhere in the scan set.
*/
export const RETIRED_CONTEXT_NAMES = [
{
name: 'ESLint',
replacedBy: 'Lint & Repo Gates',
authorized: '#9325 maintainer ruling 2026-08-17; workflow + registry half landed via #9421 on 2026-08-18',
// NO budgets: the rename is complete on every surface. The checklist's
// gate-clearance line was the one budgeted site (1 occurrence, in flight
// as its own PR when this row was written) and its fix was expected to
// keep that mention as deliberate history; it dropped the name entirely
// instead, the trim notice fired, and the budget was deleted on
// 2026-08-18. This row is therefore now a standing ban: writing `ESLint`
// fresh into ANY scanned surface is red. A future deliberate historical
// mention is still legitimate prose — it just has to re-record a budget
// here, in the same PR, saying why.
// The grace is closed for the same reason: every required-set surface
// names 'Lint & Repo Gates' itself, so the retired literal no longer
// satisfies mustName (self-test: "the shipped ledger's grace is closed").
renameInFlight: false,
},
{
// Not a rename — the CHECK itself retired (2026-08-18 ruling: a human
// merge IS the review record for a governed surface; detection moved to
// the post-merge audit, scripts/pm/check-governed-merges.mjs — the
// registry's removal note above carries the two-step provenance). No
// budgets: no instruction surface named it when this row was written, so
// the row is purely the standing ban on writing the dead name fresh.
name: 'ADR maintainer approval',
replacedBy: null,
authorized:
'maintainer ruling 2026-08-18 (「同意。」 on the retirement package); registry row removed in the same PR, ' +
'Settings half attested by the maintainer the same day',
},
];
/** Repository root, resolved from this file rather than from the cwd. */
function scriptRepoRoot() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
}
/**
* GitHub's default `pull_request:` activity types. Naming any `types:` at
* all REPLACES this set rather than extending it, so a workflow that names
* `types:` must restate every one of these (or more) or it silently stops
* publishing a check run for the dropped activity — the same permanent-
* pending wedge assertion 7's `paths:` guard exists to catch, through a
* different key on the same trigger (#8304).
*/
const DEFAULT_PULL_REQUEST_TYPES = ['opened', 'synchronize', 'reopened'];
/**
* A workflow's trigger block.
*
* Read under both spellings on purpose. `on` is a YAML 1.1 boolean and a YAML
* 1.2 string, so the SAME document yields the key `'on'` under one schema and
* `true` under the other. That is a property of the parser, not of the
* document — GitHub's format has exactly one spelling — so accepting both is
* reading one contract, not tolerating two. Neither present is a problem, never
* a pass (#4690).
*/
function triggersOf(doc) {
if (!doc || typeof doc !== 'object') return undefined;
const block = doc.on ?? doc[true];
return block && typeof block === 'object' ? block : undefined;
}
/**
* The flag whose WIRING the pin below judges, as one constant rather than four
* regex literals: the recognizer, the two absence assertions and the caller
* sweep all have to be asking about the SAME string.
*/
const LIVE_READ_FLAG = '--verify-required-set';
/**
* The text of a workflow that a RUNNER would ACT ON — every comment gone.
*
* ## Why this is not one regex
*
* It was one, and the regex was wrong: `text.split('\n').filter((l) =>
* !/^\s*#/.test(l))` drops a line only when its FIRST non-space character is
* `#`. A workflow file stacks TWO comment grammars, and a line filter is wrong
* about both of them:
*
* - YAML's `#` opens a comment at line start OR after whitespace, and does
* NOT open one inside a quoted scalar — so a TRAILING `# ...` survived the
* filter whole, carrying its prose into the match;
* - a `run:` block scalar is not YAML at all, it is SHELL. There `#` opens a
* comment at a word boundary, `#` inside quotes is an argument, and a
* backslash-newline continues the command — none of which a per-line test
* can see. A trailing `# ...` on a live command line survived there too.
*
* So a comment WARNING people about `--verify-required-set` read as WIRING.
* That is not a hypothetical shape, it is the ordinary way anyone documents a
* trap, and the neighbouring `ci.yml` block already writes exactly such a
* comment for check-shard-attestation's sibling flag. Documenting the trap
* re-arms it — the #4890 shape, which is why check-shard-attestation's own
* `shellCommands()` drops comments and pins that it does.
*
* ## Each grammar goes to the thing that knows it
*
* `yaml.parse` answers the YAML layer: comments are not part of the parsed
* document, leading or trailing, while a `#` inside a quoted scalar stays in
* the string where it belongs. `shellCommands()` answers the shell layer inside
* each `run:` — check-shard-attestation's lexer, IMPORTED rather than re-typed.
* Two private comment strippers drifting apart in opposite directions is the
* exact history `js-comment-mask.mjs` exists to have ended, and a second
* hand-rolled one here would be the third family.
*
* ## Width is deliberate, and so is where it stops
*
* Outside a `run:` block this keeps the scalar WHOLE instead of judging what it
* would do, so the flag reaching a runner through an `env:` value, a `with:`
* input or a matrix entry still reads as wiring. That width is the safe error
* direction for an ABSENCE pin: a false red names a file and a line and is
* fixed in a minute, while a false green is a gate that quietly stopped
* guarding. It is also why this does NOT adopt check-shard-attestation's
* stricter `invokesScript()` adjacency test. That test earns its keep there
* because `--verify` and `--emit` are flags OTHER programs really take (`git
* rev-parse --verify` is what #6589 misread); `--verify-required-set` is spelled
* nowhere else in this tree, so adjacency would remove a false positive that
* cannot occur while introducing false negatives that can — the flag passed
* through a shell variable, or by a spelling of the invocation this file did not
* think to enumerate.
*
* @param {unknown} doc a parsed workflow document
* @returns {string} every scalar a runner acts on, joined, comments removed
*/
function liveWorkflowText(doc) {
const parts = [];
visitLive(doc, { onScalar: (scalar) => parts.push(scalar), onCommand: (command) => parts.push(command) });
return parts.join('\n');
}
/**
* Walk a workflow document the way a RUNNER reads it, once.
*
* Two questions in this file need this same walk and need DIFFERENT answers out
* of it (see `invokesLiveRead` for why the two must not be merged), so the
* traversal is shared and the reading is not:
*
* - `onScalar` receives every string a runner would act on — an `env:` value,
* a `with:` input, a matrix entry, a step `name:`;
* - `onCommand` receives every shell COMMAND inside a step's `run:`, lexed by
* `shellCommands()` so comments and separators are already gone.
*
* Sharing the walk is the point: a `run:` key that one reading understood and
* the other did not is precisely the drift that gave this file two `/^\s*#/`
* filters answering in opposite directions in the first place.
*
* @param {unknown} node a parsed workflow document, or any node of one
* @param {{ onScalar?: (scalar: string) => void, onCommand?: (command: string) => void }} visitors
*/
function visitLive(node, { onScalar = () => {}, onCommand = () => {} } = {}) {
if (typeof node === 'string') {
onScalar(node);
return;
}
if (Array.isArray(node)) {
for (const item of node) visitLive(item, { onScalar, onCommand });
return;
}
if (!node || typeof node !== 'object') return;
for (const [key, value] of Object.entries(node)) {
// Two different keys are spelled `run`. A step's is a COMMAND string and
// goes through the shell lexer; `defaults.run` is a mapping (`shell:`,
// `working-directory:`) and is ordinary structure. The typeof test is
// what tells them apart.
if (key === 'run' && typeof value === 'string') for (const command of shellCommands(value)) onCommand(command);
else visitLive(value, { onScalar, onCommand });
}
}
/**
* Every shell command the steps under `node` would actually EXECUTE.
*
* The `onCommand` half of the walk on its own — scalars deliberately dropped.
* A step `name:` that spells an invocation is a LABEL, and the whole point of
* the narrow readings below is that a label is not a command.
*
* @param {unknown} node a parsed workflow document, a job, or a step
* @returns {string[]} the commands, in order, comments already removed
*/
function runCommands(node) {
const commands = [];
visitLive(node, { onCommand: (command) => commands.push(command) });
return commands;
}
/**
* Does this workflow WIRE UP the live required-set read?
*
* The question the two absence assertions and the caller sweep in `--self-test`
* all ask. A mention in a comment — YAML or shell, leading or trailing — is
* prose and answers `false`; a mention anywhere a runner would act on answers
* `true`.
*
* @param {unknown} doc a parsed workflow document
*/
function wiresLiveRead(doc) {
return liveWorkflowText(doc).includes(LIVE_READ_FLAG);
}
/**
* The script this file is, as its own basename — the thing an invocation of the
* live read has to actually name.
*/
const LIVE_READ_SCRIPT = 'check-required-contexts.mjs';
/**
* The package script that carries the pin into `Lint & Repo Gates`.
*/
const PIN_SCRIPT = 'check:required-contexts';
/**
* Does this workflow actually INVOKE the live required-set read?
*
* ## ⭐ Why this is not `wiresLiveRead`, and must not become it
*
* These two ask about the same flag and are deliberately DIFFERENT WIDTHS,
* because they are consumed in opposite directions and a recognizer's error
* direction is only safe with respect to one of them:
*
* | question | recognizer | a mention in live prose costs |
* | --- | --- | --- |
* | may this workflow wire the read? (ABSENCE) | `wiresLiveRead`, WIDE | a false RED — loud, names a file, fixed in a minute |
* | does the patrol still run it? (PRESENCE) | `invokesLiveRead`, NARROW | a false GREEN — the patrol reads nothing and this gate says it does |
*
* So width is not a quality one of them has more of. For the absence half,
* reading an `echo` as wiring is the SAFE mistake, which is why
* `wiresLiveRead` keeps whole scalars (#9642's `env:`/`with:`/matrix routes)
* and why widening it further would still be defensible. For the presence
* half it is the ONLY mistake that matters: anything that widens what counts
* as "present" makes it easier for prose to satisfy the assertion, and the
* symptom is a green line over a patrol that stopped reading.
*
* ⛔ Do not tidy these onto one recognizer. One recognizer serving both
* directions is what this file already had — a `/^\s*#/` line filter, written
* twice, feeding an absence assertion and a presence assertion — and it was
* wrong in both directions at once for exactly this reason. Merging them again
* needs an argument that the merged width is correct for BOTH, not that it is
* tidier. The shared TRAVERSAL (`visitLive`) is the part that should be shared;
* the reading is the part that must not be.
*
* @param {unknown} node a parsed workflow document
*/
function invokesLiveRead(node) {
return runCommands(node).some((command) => invokes(command, LIVE_READ_SCRIPT, LIVE_READ_FLAG));
}
/**
* The steps of one job that INVOKE the required-context pin.
*
* Structure, not text shapes. What this replaced sliced lint.yml between
* `'\n lint:'` and `'\n typecheck:'` and matched `/run: pnpm
* check:required-contexts\b/` over the slice, which was wrong three ways at
* once and all three were false GREENS on the wiring of a required context:
*
* - it kept trailing comments (the `/^\s*#/` filter drops a line only when
* its FIRST non-space character is `#`), so `- uses: actions/checkout@v7 #
* run: pnpm check:required-contexts is below` satisfied it;
* - it matched any live text, so a step `name:` naming the invocation
* satisfied it too — prose that survives even a correct comment stripper;
* - `'\n typecheck:'` is not the job after `lint:`. Measured on the
* checked-in file: the slice ran from `lint` through `typecheck-consumers`,
* FIVE jobs, so the pin could migrate out of the required job entirely and
* this assertion would not notice.
*
* Reading `jobs.<id>.steps` closes all three, because none of them is a fact
* about the job at all.
*
* @param {unknown} job a parsed job
* @returns {unknown[]} the steps that run the pin
*/
function pinSteps(job) {
const steps = job && typeof job === 'object' && Array.isArray(job.steps) ? job.steps : [];
return steps.filter((step) => runCommands(step).some((command) => invokes(command, 'pnpm', PIN_SCRIPT)));
}
/**
* The jobs whose verdict the required `lint` context (`Lint & Repo Gates`) carries, as
* `[id, job]` pairs: the `lint` job itself and, since #22991 made it an aggregate, every lane
* it BOTH declares in `OS_AGGREGATOR_MEMBERS` AND lists in `needs:`.
*
* Both, not either. A lane that is only declared is not waited on, and a lane that is only in
* `needs:` is the roster drift `check:aggregator-roster` reds — so neither is a place the
* pin may live and still be called wired. Read from the parsed document, never from text, for
* the reasons `pinSteps` lists. An unreadable document or a missing `lint` job answers no
* jobs at all, so every presence assertion over it goes red rather than vacuously green.
*
* @param {unknown} doc a parsed lint.yml
* @returns {Array<[string, Record<string, unknown>]>}
*/
function jobsCarriedByLint(doc) {
const jobs = doc && typeof doc === 'object' ? doc.jobs : undefined;
if (!jobs || typeof jobs !== 'object') return [];
const lint = jobs.lint;
if (!lint || typeof lint !== 'object') return [];
const carried = [['lint', lint]];
const env = lint.env && typeof lint.env === 'object' ? lint.env : {};
const declared = String(env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean);
const needs = Array.isArray(lint.needs) ? lint.needs.map(String) : typeof lint.needs === 'string' ? [lint.needs] : [];
for (const id of declared) {
if (!needs.includes(id)) continue;
const job = jobs[id];
if (job && typeof job === 'object') carried.push([id, job]);
}
return carried;
}
/**
* Judge a registry against already-parsed workflows.
*
* Pure: every input is an argument, so `--self-test` exercises the real
* decision instead of a parallel imitation of it.
*
* @param {{
* registry: ReadonlyArray< { workflow: string, job: string, context: string } >,
* workflows: Map< string, { doc?: unknown, error?: string } >,
* }} input
* @returns {{ problems: string[], pinned: string[] }}
*/
export function judge({ registry, workflows }) {
const problems = [];
const pinned = [];
// A pin over an empty registry verifies nothing while printing a tick —
// the phantom check this whole family is written against (#4690).