-
Notifications
You must be signed in to change notification settings - Fork 10
4663 lines (4447 loc) · 291 KB
/
Copy pathlint.yml
File metadata and controls
4663 lines (4447 loc) · 291 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
name: Lint & Type Check
on:
push:
branches:
- main
pull_request:
branches:
- main
# Merge queue (see ci.yml for the full note): required checks must report on
# queue builds or the queue stalls. This workflow has no PR-only steps, so
# the trigger alone is enough.
merge_group:
# ── MEASURED 2026-08-25 (#12211) — a NEGATIVE result, recorded so it is not
# re-measured. After the stale-ledger outage the queue's check set was measured
# rather than assumed, because two observations of that day read as
# contradictory: a PR landing an inconsistent tree at 11:50Z (which looked like
# "the consumer-gates lane does not run in the queue"), and five queue entries
# dequeued 12:26-13:05Z while main was red (which looked like the opposite).
#
# Both halves were read, and there is NO merge_group coverage gap to close:
#
# - STATIC. Every gate step in this workflow runs on merge_group; the claim
# three lines above is exact. The only event-conditioned step in the file is
# `Save Turbo cache (main only)`, which stores a cache and judges nothing.
# ci.yml is the same shape — its single merge_group exclusion sits on the
# paths filter, which on a queue build widens to "everything changed"
# rather than narrowing. All six required contexts live in these two
# merge_group-triggered workflows (scripts/check-required-contexts.mjs).
#
# - DYNAMIC. 180 merge_group runs of this workflow were read across
# 09:15-18:49Z. In run 32847794799 — a real queue build — the
# `Type Check · consumer gates` lane RAN and FAILED on
# `check:exported-any-returns`, and the required `TypeScript Type Check`
# aggregate carried that red into the queue. Tree-global ratchets do kick
# there; the 12:26-13:05Z dequeues are that gate, on innocent candidates,
# while main itself was red. CI was green on those same merge groups.
#
# What the 11:50Z landing actually was: that commit produced NO merge_group
# build at all — 4 of the 29 PRs landing 09:20-12:10Z produced none — and its
# PR-level required checks were green as measured at 07:20Z, ~3h before the
# gate that would have refused it existed. The residual is therefore not a
# trigger this file is missing. It is that a commit can reach main without a
# queue build, plus `strict_required_status_checks_policy: false` on the `main`
# ruleset (measured 2026-08-18, #9642). Both are repository SETTINGS and
# maintainer-only; neither is reachable from this file.
#
# ⛔ So do not "close the gap" by adding merge_group to the advisory workflows
# (check-links.yml, docs-drift-check.yml, validate-deps.yml, the patrols). A
# non-required check on a queue build costs runners and blocks nothing, and
# check-links.yml's own header already refuses it for that reason.
# Same policy as ci.yml: superseded runs on the same PR/branch waste runners
# and delay feedback; cancel them. Push runs to main group by commit ref, so an
# in-flight main run is cancelled only by a newer main push.
concurrency:
group: lint-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# ─────────────────────────────────────────────────────────────────────────────
# ⭐ GATE INVOCATION IDIOM — stated ONCE, here. The steps below point at it.
#
# Most gate steps in this file run their script directly:
#
# run: node scripts/check-<thing>.mjs
#
# rather than through a `pnpm check:*` alias. That is a deliberate in-repo
# idiom with its own justification, not a workaround:
#
# * Many gate steps in this file already use it — it is the local precedent.
# * `scripts/pm/dispatch-gates.mjs` derives gate families from EITHER
# spelling, so the direct form loses no discovery.
# * It adds no key to the root manifest.
# * It is pinned rather than incidental: check-aggregator-roster,
# check-ci-filter-parity and check-doc-frontmatter each assert in their own
# self-test that `node <self>` appears in THIS file, so silently rewiring
# one of them to an alias reddens its gate.
#
# ⚠️ It is NOT because root `package.json` is off limits. This file used to say
# that, in one shape or another, at nineteen separate sites, and it is FALSE.
# The declared territory of the #9465 @changesets/cli v3 lane inside that file
# is scoped by the lane's own parenthetical, verbatim from the issue body:
#
# root `package.json` (the `@changesets/cli` range and the `version` script)
#
# That parenthetical is SCOPING, not illustrative: one dependency range and one
# script key. It does not cover the file, and it does not cover a new `check:*`
# key — so adding `check:<thing>` to the root manifest would have been allowed
# all along. The steps below take the direct form on the merits listed above;
# they are not fenced out of the alternative.
#
# Stated once here, and POINTED AT rather than restated, because the over-broad
# reading propagates by copying — measured, not supposed. A dev agent wrote the
# same sentence into a new comment and a new script's docblock and said plainly:
# "I copied the phrasing from neighbouring comments instead of checking it."
# The count was 15 sites when #10894 was filed on 2026-08-21 and 19 by
# 2026-08-24, four of them added AFTER the card was filed and two of those
# within ninety minutes of it being picked up. Nineteen CORRECTED copies would
# re-arm the identical mechanism: a claim that reads as verified because it
# reads exactly like the verified ones beside it. So there is one statement of
# this idiom — this one — and every step refers to it.
#
# Changing this note? It is prose, and no gate can red on a wrong sentence
# here; that is precisely how nineteen wrong ones accumulated. Check any claim
# about the #9465 fence against #9465's own body before you write it down.
# ─────────────────────────────────────────────────────────────────────────────
jobs:
lint:
# ⚠️ This `name:` IS the required-status-check context in repository
# Settings → Rulesets (a job's check-run name is its `name:`), so it is
# pinned as contract by `scripts/check-required-contexts.mjs` and may only
# be changed together with that registry AND the Settings entry, in one
# maintainer-present sitting (#9325 ruling 2026-08-17). Either half alone
# is an outage: rename-first leaves the old context permanently pending,
# which wedges every open PR and the merge queue; settings-first drops the
# whole gate family to advisory with no signal anywhere, which is #5617
# verbatim.
#
# It is not called `ESLint` any more because it never was only that: the
# `pnpm lint` step below is one of ~70 sequential gate steps, so a red here
# was routinely read as "a lint problem" when it was a repo gate — three
# mis-routed diagnoses on 2026-08-17 alone (#9258, PRs #9256/#9291).
name: Lint & Repo Gates
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# The slot-lookup ratchet compares the baseline against its state at
# the merge base with main — the only way to see a file being ADDED
# to the grandfather list. A shallow clone has no merge base, and the
# check would degrade to "not verified" on every run.
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Enforces the no-restricted-imports guard against @objectstack/spec root
# namespace imports (the dormant rule was never run in CI). Syntactic
# only, so no build step needed.
- name: ESLint
run: pnpm lint
# Slot-lookup sweep ratchet (#4251). `pnpm lint` above bans erasing a
# service-lookup result to `any` across packages/, but the files still
# holding pre-existing sites are grandfathered by path — and an ignored
# file is ignored COMPLETELY, so new erasures added to one ride the old
# entry in silence. This re-measures those files with the grandfathering
# lifted and holds them to a per-file count, so the list can only shrink.
- name: Slot-lookup ratchet
run: pnpm check:slot-lookup
# Engine query-options erasure ratchet (#4918). The slot-lookup rule above
# protects the service LOOKUP; this one protects what you pass to the
# service you looked up. `IDataEngine.find/findOne/count/aggregate` declare
# their options as `EngineQueryOptions` & co., and for an internal caller
# `tsc` is the ONLY channel enforcing them — the protocol's ingress
# normalizer never sees a direct engine call, and the options schemas are
# not `.strict()`, so an unknown key is silently DROPPED. #4674 is the
# bill: two queries sorted by `direction` instead of `order`, both with a
# `limit`, so both returned the OLDEST rows — audit history that never
# showed recent changes, and a search that truncated away fresh records.
# #4720 restored those two sites and #4721 closed the external callers;
# this stops the shape regrowing internally. Same ratchet mechanism as
# slot-lookup for the non-test residual, plus one aggregate decrease-only
# number for test code (a test whose subject IS off-contract engine input
# must be able to build it). Runs its own --self-test first.
- name: Engine query-options erasure ratchet
run: pnpm check:query-options-erasure
# @objectstack/verify stand-in erasure guard (#6399). The third and
# narrowest member of the two above. `checkReadCoercion` /
# `checkDateBucketParity` take their driver STRUCTURALLY
# (`CoercibleDriver` / `BucketableDriver`) so an out-of-tree driver —
# cloud's driver-turso in remote mode — runs the identical contract
# without importing a concrete driver type; that parameter type IS the
# compile-time half of the conformance, and an assertion on the argument
# deletes it for that call site while looking like a call that has it.
# #6354 / PR #6396 is the bill: ten `as never` casts, every call site of
# both helpers, all of them dead, sitting over a check that was provably
# alive — and no gate rang for either fact.
#
# The ESLint rule (`verify-stand-in/no-asserted-driver-argument`) blocks
# the assertion. This step is the half that stops the rule going dead: it
# reconciles the guarded set against what packages/verify actually exports
# in BOTH directions — so a third stand-in check cannot arrive unguarded
# and a stale entry cannot rot — and COUNTS the call sites it reached, so
# a rename or a moved import surfaces as a census that fell instead of as
# a green run over nothing. `pnpm lint` alone cannot tell "clean tree"
# from "matched nothing". Runs its own --self-test first.
- name: "@objectstack/verify stand-in erasure guard"
run: pnpm check:verify-stand-in
# Raw control-byte guard (#3127 / #4890 / #5157 / #5460 / #6984). Scans
# every TEXT file git knows about for a raw ASCII control byte and fails on
# any hit. Since #6984 the scan set is the index PLUS untracked-but-not-
# ignored working-tree files, so a locally-authored file is covered before
# it is staged; here that widening is a no-op, because a workflow checks out
# a commit and has no untracked files at all (the step's summary line says
# so — it names both halves, and the untracked one reads 0 in CI).
# WHICH bytes are in the set and WHY each is rejected are stated and argued
# once, in the gate script's header — `scripts/check-nul-bytes.mjs`. That
# header is authoritative and this comment cites it rather than restating it
# (#5579 established the footing, #5681 applied it here). The one-line
# summary, for whoever is reading this because the step just went red: the
# set is every ASCII control character except tab/LF/CR, drawn by the
# ACCIDENT SOURCE — an editing tool materialising an escape into its byte —
# and not by byte semantics, so "mine is not a NUL" is never a reason to read
# a hit as a false positive.
# The command name stays `check:nul-bytes` for continuity — see the script's
# header for why. Authors must write the unicode escape instead of the byte.
- name: Raw control-byte guard
run: pnpm check:nul-bytes
# The three shared modules the two `scripts/**` routing gates below
# DELEGATE their design arguments to (#10608). Both of those gates are
# SPELLING gates by deliberate design — `check:entry-guard` enforces that
# only `scripts/invoked-as.mjs` may read `process.argv[1]`, and
# `check:parse-guard` that every TypeScript parse goes through
# `scripts/ts-parse.mjs` — and each answers "why is spelling enough?" the
# same way: the BEHAVIOUR is pinned once, at the module, by that module's
# own `--self-test`. Neither self-test ran anywhere. So CI enforced
# *"everybody routes through the module"* and never once checked that the
# module still refuses — a regression inside `ts-parse.mjs` (a
# `process.exit` downgraded to a `throw`, a dropped `parseDiagnostics`
# read) was caught by nothing, and its symptom is the same green line this
# whole family exists to distrust (#10574 / #10573 / #4690).
#
# `js-comment-mask.mjs` is here because BOTH gates ask it to tell code
# from prose before they judge anything, and its header records that it
# exists because two private `stripComments` families drifted apart in
# opposite directions. Its two failure directions are not symmetric: a
# mask that stops masking makes both gates fabricate findings out of their
# own documentation — loud — while a mask that starts OVER-masking makes
# both go quietly green over text they never read.
#
# What runs here is the self-tests, not the modules' callers: the callers
# are the two gate steps below, which already run on every PR.
# Invoked as `node` rather than through `pnpm check:*`: see the GATE
# INVOCATION IDIOM note at the top of this file.
# `ts-parse` spawns ~20 real node children (~10 s measured, and the spawns
# are the point — they pin that a caller's try/catch cannot swallow the
# refusal); the other two are in-process fixtures, ~0.5 s combined.
#
# ⭐ Collected rather than sequenced, for the reason spelled out at the
# `Shallow-history guard self-tests` step below (#10814): under `bash -e` a
# bare sequence stops at the first failure, so a red `ts-parse` would leave
# the entry-predicate and comment-mask self-tests UNRUN while the log shows
# only the one failure. `ts-parse` is both the slowest of the three and the
# one that spawns real children, so it is the likeliest to be red —
# precisely the masking direction. The three modules are independent of one
# another, so collecting loses nothing.
- name: scripts/ shared-module self-tests (parse · entry predicate · comment mask)
run: |
# Tolerate-and-collect (#10814) — see the note above this step. Each
# self-test runs unconditionally and prints its own verdict; the step
# still FAILS when any of them does, naming every one that failed.
# ⛔ Never let the collector swallow the exit code — a green step over
# a red self-test looks identical to success. Both directions are pinned
# by `node scripts/check-step-collectors.mjs --self-test`, which extracts
# THIS block from THIS file and drives it under `bash -e` with stubs.
failed=""
run_self_test() {
echo "-- $*"
if "$@"; then
echo "PASS $*"
else
echo "FAIL $*"
failed="${failed} $*"$'\n'
fi
return 0
}
run_self_test node scripts/ts-parse.mjs --self-test
run_self_test node scripts/invoked-as.mjs --self-test
run_self_test node scripts/js-comment-mask.mjs --self-test
if [ -n "$failed" ]; then
echo ""
echo "scripts/ shared-module self-tests — the following FAILED:"
printf "%s" "$failed"
exit 1
fi
echo "scripts/ shared-module self-tests — all three ran and passed"
# Every `scripts/**` entry guard goes through ONE predicate (#10086).
# The hand-typed forms of "did node run me, or did someone import me?"
# had drifted into ELEVEN spellings across 33 files, and NINE were wrong
# in the same invisible direction: node resolves symlinks for the module
# graph but leaves `process.argv[1]` as the caller typed it, so a script
# reached through a symlink compared two different paths, answered false,
# and did NOTHING — exit 0, no output. The CI wrappers here hold the
# child's exit STATUS only, so an inert child read as a green gate; the
# governed-surface register (`scripts/pm/check-governed-merges.mjs`) was
# among the affected, where the inert run and its "NOT governed, ordinary
# queue landing applies" verdict are the SAME exit code.
# The sweep alone would not have held — nothing stopped a twelfth
# spelling. This gate is the part that closes the class: only
# `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's
# own self-test drives a real probe through a real symlink. Rationale and
# the rejected behavioural-sweep alternative: the gate script's header.
# Scans ~115 files, no spawns; ~0.2s.
- name: scripts/ entry guards go through one predicate
run: pnpm check:entry-guard
# Every `scripts/**` TypeScript parse goes through ONE module (#10133 /
# #10573), and this is the half that keeps it that way (#10574).
# NONE of the three parser entry points throws on a source it cannot
# read: `ts.createSourceFile` returns a tree built by error recovery with
# the errors parked on `parseDiagnostics`; `ts.createProgram` parks them
# behind a second call, `getSyntacticDiagnostics()`; `ts.transpileModule`
# reports nothing at all without `reportDiagnostics: true` and still
# hands back an `outputText`. A gate then walks the wreckage, finds none
# of the shapes it is looking for, and scores the file CLEAN — so the
# SYMPTOM OF A MISSING REFUSAL IS A GREEN LINE, and an unguarded gate is
# indistinguishable from a guarded one by reading CI. That is not a
# theory: one gate here forced `ScriptKind.TSX` on 2504 test files, read
# 32 of them as wreckage, and printed `OK` while six pinned engine
# doubles went uncounted.
# The #10573 sweep converted 32 call sites across 15 gates; it could not
# stop the sixteenth being typed, and within the hour a new gate landed
# with two raw calls in it — caught by this step, which is the whole
# argument for having it. Same shape as `check:entry-guard` above.
# Also prints the parses OUTSIDE `scripts/**` (#10575) that it does not
# govern, so its green line is read as a claim about `scripts/` and not
# about the repository.
# Scans ~121 scripts/ files plus a read-only census of the rest, no
# spawns; ~0.6s.
- name: scripts/ TypeScript parses go through one module
run: pnpm check:parse-guard
# Nobody writes a NEW private comment-stripper (#12307).
# `scripts/js-comment-mask.mjs` exists because two private `stripComments`
# families drifted apart in opposite directions — one regex-based and
# blind to string literals, opening PHANTOM comments that delete real
# code; one string-aware but regex-blind, hiding real comments. What the
# tree had no instrument for was ADOPTION: `check:comment-mask-corpus`
# below verifies the shared mask against a real parser, which is a claim
# about the MODULE and says nothing about its callers. So the module
# landed, some callers were converted by hand, and the residue was found
# by hand three separate times — three cards for one conversion.
# This gate is the half that covers the caller nobody has written yet:
# a new private stripper reds on the PR that writes it, while the 23
# measured pre-existing ones sit in a shrink-only ledger and are NOT
# required to move first (a gate that reds on day one is a gate nobody
# can land). Converting them is per-row and deliberately not this gate's
# call.
# ⚠️ NOT a widening of `check:parse-guard` above: that gate governs the
# three TypeScript PARSER ENTRY POINTS and its own header refuses a root
# broader than `scripts/**` twice. Comment-stripping is a different
# subject, and widening that population would still catch no stripper.
# Its `--self-test` runs FIRST and is where the detectors are observed
# FIRING and, just as importantly, observed STAYING SILENT on an adopter
# and on prose — the live corpus is green by construction, so a passing
# production run alone cannot tell a working detector from a dead one.
# The ledger's stale-row equality is the other half: breaking a detector
# reddens the production run (measured: 3 stale rows) instead of going
# quietly green, which is what keeps this out of the double-green family
# `check:self-test-wired` names.
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
# GATE INVOCATION IDIOM note at the top of this file.
# Scans ~5k files under packages/** + examples/**, no spawns; ~2s.
- name: No new private comment-strippers (all route through js-comment-mask)
run: |
node scripts/check-comment-mask-adoption.mjs --self-test
node scripts/check-comment-mask-adoption.mjs
# The bash-3.2 floor, over every shell file the repo ships (#12221).
# `/usr/bin/env bash` is bash 3.2.57 on macOS -- Apple ships no bash 4+,
# for licensing reasons -- and THIS RUNNER IS BASH 5, where every construct
# the gate refuses works perfectly. So the class is invisible to a normal
# green run in both directions: the defect passes CI, and so does its
# repair. It is found by an operator on a Mac, at status 127, at the moment
# they most needed the script to work. Two incidents, four sites, both
# caught by hand and late; one of them turned the shared verify lock's
# bounded wait into an unbounded spin, because an unbound `EPOCHSECONDS`
# leaves the deadline EMPTY rather than erroring.
# What this step buys that a bash-5 runner cannot otherwise see is a static
# verdict: the gate reads the text, so it never needs the construct to be
# reachable, only written. Rationale, the construct table with a bash
# version per row, and the three exemption rules that tell a file HUNTING
# these tokens from a file USING one: the gate script's header. The short
# version, for whoever is reading this because the step just went red --
# there is deliberately NO filename allowlist, and no waiver comment; a
# legitimate mention is already exempt by being a full-line comment, a
# guarded `${VAR:-}` read, or a token outside command position.
# ⛔ This does not supersede the two file-scoped scans in
# `check:objectui-changeset` and the verify lock's own self-test. Those
# pair their scan with a SIMULATED 3.2 run (`enable -n mapfile readarray`
# via BASH_ENV, plus `unset` of the bash-5 variables), which reaches
# runtime-assembled commands a static scan cannot see and proves the real
# path COMPLETES without the builtins. Different instruments, one class.
# Scans 20 tracked shell files, no spawns in the production run; ~0.1s.
# The `--self-test` half spawns a handful of real bash children (the
# known-bad fixture tree, and the simulated-3.2 probe that proves the
# harness is not vacuous); ~1s.
- name: shell scripts hold the bash 3.2 floor
run: pnpm check:bash32-floor
# The comment-mask CORPUS sweep (#10640) — the other instrument for the
# module the two gates above delegate "is this span a comment, or code?"
# to. `js-comment-mask.mjs --self-test` pins the SHAPES someone wrote
# down; this parses every .{ts,tsx,mts,cts,js,mjs,cjs,jsx} file in the
# tree with @typescript-eslint/parser and diffs its comment ranges
# against the mask's, byte for byte. Neither subsumes the other, and that
# is measured in both directions on this tree rather than assumed:
# deleting the `{` counting inside `${...}` fails a pinned case and the
# sweep reads 0 disagreements over 4,741 files (the tree does not write
# that shape), while dropping `return` from REGEX_AFTER_KEYWORD passes all
# 23 pinned cases and the sweep names check-test-source-alias.mjs, where
# `return /(^|[^a-z])dist\//` is written today.
# The defect this would have caught shipped: 16 files disagreeing, 15 of
# them reading commented-out text as live code, 10,252 comment bytes in
# one file — and the instrument that found it lived in an agent's
# scratchpad, which is why re-deriving it was impossible until this file.
# WHY A HARD GATE ON EVERY PR, and not an on-demand flag: the sweep's
# verdict depends on the CORPUS as much as on the masker, so a PR that
# writes a shape the mask reads wrong turns ~20 source-scanning gates
# into liars on that file, on a PR that never touched the masker. That
# arrival is not detectable by any convention a human or an agent has to
# remember at the right moment — the failure mode this repo has already
# paid for at #4690 and again here.
# WHY A STEP AND NOT A JOB: a new job's `name:` is a required-context
# contract (scripts/check-required-contexts.mjs) that only a
# maintainer-present Settings change can enrol, and an unenrolled job is
# an advisory gate — #5617 verbatim. This job is already required.
# Invoked as `node` rather than `pnpm check:*`, same as the other
# `node scripts/…` steps in this lane: see the GATE INVOCATION IDIOM note
# at the top of this file.
# Runs its own comparator self-test first — 12 cases proving the
# comparison can still REPORT, because "0 files disagree" and "the
# comparison is broken" otherwise print the same line.
# Cost, measured on a contended 4-vCPU container: 4,741 files, 72 MB of
# source, 45–48 s, of which the parser is 45 s. The step prints CI's own
# number on every run.
- name: Comment mask agrees with a real parser over the whole corpus
run: node scripts/check-comment-mask-corpus.mjs
# Stack-collection enumerations vs the schema (#6242). `stack.zod.ts`
# decides which collections a stack may declare; eight other enumerations
# of that same set are hand-maintained (the map-format list, the
# plural→singular map, the artifact category enum, ObjectQL's two
# registration loops, the artifact-ingest field map, the runtime's
# app-payload probe, the showcase coverage manifest) and nothing compared
# any of them to the schema. Each drift instance has been fixed one line at
# a time before, always after a missing key silently dropped a collection.
# A deviation is legal here only as a waiver row carrying its reason.
- name: Stack-collection enumerations answerable to stack.zod.ts
run: pnpm check:stack-collection-maps
# pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill
# is read in full by every seat session and every Routine fire, so its
# size is a per-round tax on the whole fleet. The #7341 extraction moved
# long incident narratives to .claude/skills/pm-dispatch/references/
# (append-only case law, loaded on demand); this shrink-only ceiling
# keeps them from growing back. Ceiling discipline — lower freely, raise
# only with a maintainer ruling quoted in the raising PR — is stated in
# the script's header, which is authoritative.
- name: PM skill line ratchet
run: pnpm check:pm-skill-ratchet
# pm-skill issue-ID lint (maintainer ruling 2026-08-12): operative
# agent-protocol text (.claude/skills/pm-dispatch/** and
# .claude/agents/os-dev.md) carries lessons self-contained — no issue-ID
# citations; rulings keep date + verbatim quote. The script header is
# authoritative, including the one self-expiring legacy waiver.
- name: PM skill issue-ID lint
run: pnpm check:pm-skill-id-lint
# PM label description cap. GitHub hard-caps label descriptions at 100
# CHARACTERS and `gh label create` 422s above it; the `|| true` that makes
# scripts/pm/ensure-pm-labels.sh rerunnable swallows that 422, so an
# over-long -d means the label is never created on a repo that lacks it
# and a rerun can never repair it. Headroom is thin (one description sits
# at exactly 100), and the only previous enforcement was the author
# counting by hand. Measured in characters, never bytes: the live
# needs:contract-review description is 97 characters / 101 bytes, so a
# byte-length guard would red a label GitHub accepts. Unconditional, like
# the other self-tested gates above.
- name: PM label description cap
run: pnpm check:pm-label-desc-cap
# PM dispatch-gates self-test (#8162). `scripts/pm/dispatch-gates.mjs`
# derives the "local gates for this card" line of every dispatch prompt,
# and carried a 61-case --self-test that NO job ran: it executed only when
# a human or an agent typed it. A break in the extraction functions landed
# green and surfaced later as a prompt naming the wrong gate families —
# output that reads as correct, produced by the one tool whose purpose is
# to stop gate lists being memory-shaped. Same family as the
# `Changeset-family gate self-tests` step above, and like that one this
# step is deliberately UNCONDITIONAL: no `if:`, no label read, no paths
# filter. An exemption is what a self-test must not have, or the gap moves.
#
# The gate runs the SELF-TEST only. The live derivation
# (`node scripts/pm/dispatch-gates.mjs <path>`) answers a question about a
# card's file surface, is print-only, and exits 0 on any completed run —
# there is no verdict in it for CI to hold, only a slow read of the whole
# workflow tree. Why the gate is its own file rather than pointing
# `check:pm-dispatch-gates` straight at the tool is measured and argued in
# `scripts/pm/check-dispatch-gates.mjs`'s header (the tool's self-test
# fixtures are path strings, and they become watch hints that fabricate
# MATCHED leads across most of the tree). Reads two files and walks
# packages/; ~0.3s.
- name: PM dispatch-gates self-test
run: pnpm check:pm-dispatch-gates
# PM bare-root worklist self-test (#10840). The step above proves the
# dispatch derivation still WORKS; this one proves the recorded triage of
# the gates that derivation structurally cannot see is still true of the
# tree. A gate whose population is spelled as a bare single-segment word
# builds no watch hint at all — not a dead hint, not a silent verdict,
# nothing — so it is unnameable by any dispatch brief and leaves no residue
# saying so. The worklist enumerates that species and carries one recorded
# verdict per row; this step fails when a verdict outlives its row (the
# shrink) or when a new gate joins the species unjudged.
#
# ⛔ It runs the SELF-TEST only, never the sweep as a verdict: recognising
# this species needs a heuristic over constant NAMES, and #10705 refused to
# put one on the path that derives every PR's gate list. Nothing in
# `dispatch-gates.mjs` reads the worklist, and no verdict in it reaches a
# dispatch prompt.
#
# Unconditional, for the same reason as the step above: a self-test that
# can be skipped is the gap moving rather than closing. Reads the workflow
# tree and every gate source once; ~0.5s.
- name: PM bare-root worklist self-test
run: node scripts/pm/bare-root-worklist.mjs --self-test
# Part-of/closing-keyword guard self-test (#8476). The guard itself is a
# PR-scoped blocking check in its own workflow — it needs a pull request
# body to judge, which this job does not have — so what runs HERE is its
# self-test, which is the half with a verdict independent of any PR.
# Unconditional for the same reason as the two steps above: a self-test
# that runs only when someone remembers is a check whose coverage is a
# function of who remembered, and the failure it hides is quiet — a break
# in the verdict layer lands green and surfaces later as a card silently
# closed by the sentence written to keep it open.
#
# The self-test also pins the WIRING (the guard workflow still invokes
# the script, still subscribes to `edited`, still passes the body through
# `env:`), so unwiring the gate reddens here rather than going quiet.
# Imports one sibling module, reads two files; ~0.1s.
- name: Part-of closing-keyword guard self-test
run: pnpm check:partof-closing-keyword
# Publish-smoke tarball pin-set self-test. The assertion it pins lives on
# the RELEASE path (scripts/publish-smoke-pack.mjs runs only inside the
# packed-tarball smoke), so without this step a regression in it would be
# discovered by a release candidate — which is exactly how the unscoped
# `create-objectstack` hole surfaced: as a red on the operator's own
# release run, not on the PR that opened it. Pure functions, synthetic
# fixtures, no pnpm/workspace/network; ~0.05s.
- name: Publish-smoke pin-set self-test
run: pnpm check:publish-smoke-pin
# Single-claim path guard self-test (#9402). Same split as the step above
# and for the same reason: the guard is a PR-scoped blocking check in its
# own workflow, because its question is about OTHER open PRs and needs a
# pull request plus an API read that this job has neither of. What runs
# HERE is the half that needs no PR — the verdict layer, the exit-code
# contract, the short-circuit that makes the gate affordable, and the
# declared path list's own invariants.
#
# That last one is the reason this step is worth its second: the declared
# list IS the key, so the realistic way this gate turns into noise is a
# careless append to it. The self-test rejects an entry with no stated
# reason, rejects a duplicate, and pins that the three measured
# high-collision paths (the lock file, the root manifest, one plugin
# manifest — 33, 15 and 21 concurrent pairs in 300 PRs) stay OUT of it.
#
# It also pins the WIRING (the guard workflow still invokes the script,
# still subscribes to `synchronize` — without which a claim added in a
# second commit is never judged — and still passes the token), so
# unwiring the gate reddens here rather than going quiet.
# Dependency-free, reads two files; ~0.1s.
- name: Single-claim path guard self-test
run: pnpm check:single-claim-paths
# PM half-state sweeper self-test (#8528). `scripts/pm/check-half-states.mjs`
# carried a 79-case --self-test — the H1..H7 predicates, the seat-sticker
# parser, the transport classifier and its measured container classes —
# that NO job ran: it executed only when a human or an agent typed it.
# Fourth member of the same family as the three steps above, and the one
# with a blocking consumer: since #8527 the H7 predicate
# (`h7PartOfWithClosingKeyword` and `stripMarkdownCode` beneath it) is
# imported by the PR-scoped guard whose self-test runs in the step above,
# so a break there now reddens — or silently greens — every PR in the repo.
# That incidental coverage is real but bounded: it pins only the H7
# behaviours the guard depends on. H1..H6, the seat parser and the whole
# transport classifier had none. Unconditional for the family's reason: an
# exemption is what a self-test must not have, or the gap simply moves.
#
# The gate runs the SELF-TEST only. The live sweep
# (`node scripts/pm/check-half-states.mjs`) reads a shared board over the
# GitHub API, is report-only by design (a completed sweep exits 0 whether
# it found 0 or 40 half-states), and its non-zero exits classify the
# ENVIRONMENT — no token, exhausted quota, unreachable host — which is not
# a verdict about the PR running it. The script's own header argues both
# halves. The self-test is offline: no network, no token, ~0.05s.
#
# Pointed straight at the script, with no gate file in between, unlike
# `check:pm-dispatch-gates` next door — that one needs its own file because
# the tool's fixtures are path strings that become watch hints and
# fabricate MATCHED leads across the tree. Measured here on the current
# tree, under the module-body masking that now blanks comments and
# self-tests before the scan: this script yields exactly ONE hint, the repo
# slug in its API base, which is not a repo path and covers no input path.
# The fixtures are issue-shaped objects and prose, and they sit inside the
# masked self-test. So the pollution that forced a separate file there does
# not exist here, and the direct entry is the same shape as the step above.
- name: PM half-state sweeper self-test
run: pnpm check:pm-half-states
# Governed-merges audit self-test (#9495) — same family, same split as
# the half-state sweeper above: the live sweep enumerates the governed
# merges since a date/ref for the PM round report — report-only by
# design, the human-merge-is-the-review-record regime's detection half
# — while this step runs only the offline self-test (pure predicates +
# replay fixtures; no network, no token), so the predicates cannot rot
# unrun the way an uninvoked self-test does (#4690).
#
# ⛔ WHICH surfaces are governed is deliberately NOT restated here
# (#9840). The register is `GOVERNED_SURFACES` in
# `scripts/pm/check-governed-merges.mjs`, and this comment used to spell
# the five out by hand — a copy nothing read. The register grew three
# times in two days, and the two hand copies a seat DOES act on went
# stale twice (#9395, #9511); those two are pinned by the step below,
# over backtick spans inside an anchored region. A bare YAML comment
# cannot present that shape, so this one points at the register rather
# than restating it: the copy that does not exist cannot go stale.
# ⛔ Do not helpfully re-add the list here.
- name: Governed-merges audit self-test
run: pnpm check:pm-governed-merges
# Governed-surface PROSE pin (#9525) — the other half of the step above.
# `GOVERNED_SURFACES` is machine-read on every path decision, but the
# sentences that TELL a seat which surfaces are governed are prose,
# duplicated out of the register by hand, in AGENTS.md Prime Directive
# #14 and the PM skill's ACCEPT path-fork. That duplicate went stale
# twice in two days (#9395, #9511) and a human — not a gate — caught it
# both times: #9525 re-ran the whole derived gate union against the stale
# directive and every check came back green, with a positive control
# proving the file IS scanned. This step asserts both directions: the
# prose names every registered surface, and claims no surface the
# register lacks (the direction that manufactures enforcement nobody
# has). Unlike the audit next door it is a real gate, not only a
# self-test — it reads the shipped files — so it runs both, and it is
# deliberately UNCONDITIONAL: no `if:`, no paths filter, because the
# staleness it catches arrives via an edit to the REGISTER, in a PR that
# need not touch either prose file at all.
- name: Governed-surface prose pin
run: pnpm check:pm-governed-prose
# Release-rehearsal clone preflight self-test (#9555). A local
# `pnpm run version` rehearsal — the prescribed verification route for
# every release-machinery change — HANGS FOREVER in an agent container:
# every clone descends from a shallow checkout, so each `.changeset/*.md`
# resolves to the parentless shallow boundary, and @changesets/git answers
# a parentless add-commit by deepening and retrying in a loop with no
# attempt limit — against a remote that gains it nothing and exits 0 while
# doing so. Measured cost before diagnosis: ~2.5 h over two attempts,
# because full CPU with no output reads as slow progress, not as a hang.
# `scripts/pm/release-rehearsal-clone.mjs` is the refusal that turns that
# into a one-second diagnosis, plus the repair for a throwaway clone.
#
# What runs HERE is the self-test, not the live check: the live check
# judges whatever clone it is pointed at, and a CI checkout's depth is the
# workflow's business rather than a verdict this job can hold. The
# self-test pins BOTH directions on real git fixtures — the trapped shape
# refuses, while a healthy tree AND a merely-shallow one whose changesets
# sit after the boundary pass untouched (the predicate is the parentless
# add-commit, never `--is-shallow-repository` alone) — and it pins the
# WIRING, so unlinking the script from docs/releases-maintenance.md or
# from this step reddens instead of going quiet. Temp-dir fixtures, no
# network, ~1.7 s.
#
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
# GATE INVOCATION IDIOM note at the top of this file. Same shape as
# check-links.yml's ADR-link step.
- name: Release-rehearsal clone preflight self-test
run: node scripts/pm/release-rehearsal-clone.mjs --self-test
# Shallow-history guard self-tests (#9902). Three seat-run tools ask
# WINDOWED history questions (`--since` over a branch), and a shallow
# clone answers those from whatever part of the window is present, exits
# 0, and prints no warning. Agent containers clone shallow, and all three
# run from a seat rather than from CI — every workflow that reads history
# already checks out with `fetch-depth: 0`, which is precisely why the
# exposure survived: CI is not where these run.
#
# Measured in one container on 2026-08-21 (graft floor 2026-06-02):
# `check-governed-merges` rendered `✅ clean window` for a window in which
# GitHub lists 38 commits touching `docs/adr/**` and 13 touching
# `AGENTS.md`; `check-engine-split-ratio --days 90` — the workflow's own
# invocation — printed a 98.1% ADR trigger metric over a denominator 17
# commits short, one of whose members was the graft boundary commit
# itself (it carries the whole tree, so a truncated window does not just
# lose data points, it fabricates one).
#
# What runs here is the self-tests, not the live tools: the live tools
# judge whatever clone they are pointed at, and a CI checkout's depth is
# the workflow's business, not a verdict this job can hold. Each pins BOTH
# directions on real temp-git fixtures — a window crossing the floor is
# refused, and a still-shallow clone whose floor PREDATES the window is
# answered untouched, because the predicate is the floor rather than
# `--is-shallow-repository` and a guard that refused provably-correct
# answers would only train bypass.
#
# `git-history.mjs` is listed first and deliberately: it is the shared
# predicate all three call, it shipped for #9878 with a self-test that
# until now NOTHING in this repo ran, and an unrun self-test is a phantom
# check — the AGENTS.md rule about `@ts-expect-error` in an uncompiled
# file, wearing a different hat. Invoked as `node`/`bash` rather than
# through `pnpm check:*`: see the GATE INVOCATION IDIOM note at the top of
# this file. Temp-dir fixtures, no network, ~6 s in total.
#
# (`check-governed-merges.mjs`'s own cases run in the
# `pnpm check:pm-governed-merges` step above, which is already its
# self-test.)
#
# ⭐ The three run through a COLLECTOR rather than as a bare sequence
# (#10814). A `run:` block is executed by `bash -e`, so the first non-zero
# exit aborts the STEP and every command after it is never reached —
# neither green nor red, and nothing in the log tells those apart. Not
# hypothetical here: while `git-history.mjs --self-test` was red on `main`
# for ~10 h on 2026-08-21 (#10807), the two self-tests listed after it did
# not execute in CI once — on the step that gates every PR. Both were
# green, so that time the mask hid nothing; the compounding shape is that a
# SECOND regression can land unnoticed while the first is red, and then
# reads as though the fix broke it. #4690 one level up: a partial result
# that reads like a complete one.
#
# These three are INDEPENDENT — none is a precondition for reading the
# next — which is what makes collecting correct HERE and
# abort-on-first-failure correct in this job's many
# `<gate> --self-test` + `<gate>` steps, where the self-test IS the
# precondition for trusting the run after it. The census behind that
# distinction: of 200 `run:` steps in lint.yml + ci.yml, 21 hold two or
# more substantive commands, and exactly two were independent self-tests
# sequenced together — this step and the `scripts/` shared-module step
# above. Every other one is a precondition or a dependency, where the
# abort is the correct semantics.
#
# ⛔ Not one step per self-test: a plain step split does not fix this at
# all — Actions skips a job's remaining steps once a step fails, so the
# mask survives the split verbatim. Restoring it would take an `if:` on
# each gate step, and a condition is a way for a PR to arrange that a gate
# does not run on it (the reason the required-context pin step carries
# none). Both gates that read step structure were checked and would
# TOLERATE a split — `check-shard-attestation` scans ci.yml only, and
# `check-required-contexts` pins job-level properties plus the one
# `check:required-contexts` step — so this is a choice on the merits,
# not a constraint.
- name: Shallow-history guard self-tests
run: |
# Tolerate-and-collect (#10814) — see the note above this step. Each
# self-test runs unconditionally and prints its own verdict; the step
# still FAILS when any of them does, naming every one that failed.
# ⛔ Never let the collector swallow the exit code — a green step over
# a red self-test looks identical to success. Both directions are pinned
# by `node scripts/check-step-collectors.mjs --self-test`, which extracts
# THIS block from THIS file and drives it under `bash -e` with stubs.
failed=""
run_self_test() {
echo "-- $*"
if "$@"; then
echo "PASS $*"
else
echo "FAIL $*"
failed="${failed} $*"$'\n'
fi
return 0
}
run_self_test node scripts/pm/git-history.mjs --self-test
run_self_test node scripts/check-engine-split-ratio.mjs --self-test
run_self_test bash scripts/collect-release-notes.sh --self-test
if [ -n "$failed" ]; then
echo ""
echo "Shallow-history guard self-tests — the following FAILED:"
printf "%s" "$failed"
exit 1
fi
echo "Shallow-history guard self-tests — all three ran and passed"
# Step-collector gate (#10814) — the guard over the two steps above, and
# over any step that grows their shape later. It has a static half and a
# dynamic half, and the dynamic one is the load-bearing part:
#
# STATIC: one `run:` block invoking `--self-test` on TWO OR MORE DISTINCT
# scripts must route them through a collector. Distinct scripts testing
# themselves are independent by construction, so there is no reading under
# which a failure in one should skip the others. Deliberately narrow: it
# does NOT flag `<gate> --self-test` + `<gate>`, this job's dominant shape,
# where the abort IS the point (a checker whose self-test failed has no
# verdict worth printing), nor ci.yml's `mkdir -p` / `psql ALTER SYSTEM`
# dependency sequences. Swept when it was written: 343 `run:` steps across
# 26 workflows, exactly 2 matched, both of them above.
#
# DYNAMIC: nothing static can tell a collector that PROPAGATES the exit
# code from one that swallows it, and the swallowing kind is worse than
# the masking it replaces — a green step over a red self-test, which from
# outside is indistinguishable from success. So `--self-test` extracts each
# live block out of THIS file and runs it as `bash -e <file>` — the same
# invocation Actions uses — against stubs with controlled exit codes, and
# reads "did this command run" from the STUB's own side effect rather than
# from the block's output, so the block cannot vouch for itself. Both
# directions are pinned in every position, and the same command list is
# also driven through the PRE-FIX bare sequence, which must mask — 1 of 3
# commands executing when the first fails, 3 of 3 when none does. A harness
# that cannot reproduce the defect cannot certify the fix.
#
# Invoked as `node scripts/…` rather than through a `pnpm check:*` alias:
# see the GATE INVOCATION IDIOM note at the top of this file, which is
# where the correction this comment used to carry now lives in full.
# Temp-dir fixtures, no network, ~1 s.
- name: Step-collector gate (self-tests that mask each other)
run: |
node scripts/check-step-collectors.mjs --self-test
node scripts/check-step-collectors.mjs
# Self-test wiring gate (#11150) — the guard over the `--self-test` legs
# themselves, in this file and every other workflow.
#
# A gate whose defect class is its MATCHING RULE cannot detect its own
# regression on a clean tree. Green means the finding set is empty;
# weakening the rule can only SHRINK that set; and the empty set is the
# fixed point of shrinking. So the production verdict is byte-identical
# before and after the rule breaks, and `--self-test` — which supplies an
# adversarial input a clean tree by construction does not contain — is the
# ONLY instrument watching it.
#
# Measured rather than argued, one ablation per gate with the mutation
# confirmed on disk before any reading was taken: loosening
# `check-auth-mount-ledger`'s exact `METHOD path` accounting into a
# strict-prefix credit, and deleting the recognizer `check-error-code-
# casing` needed for the lowercase codes that shipped past it, each left
# the production run GREEN and reddened only the self-test. So the
# `<gate> --self-test` + `<gate>` steps throughout this file are not
# ceremony beside the live run — for that family the first line is the
# only line carrying coverage, and deleting it to save CI time would
# remove the coverage while every available signal still said the gate
# works.
#
# What this step holds is the mechanically decidable SUPERSET: every
# script CI runs that ships a `--self-test` must have that self-test run
# by CI too. Membership needs no classifier, and that is the point —
# membership in the family itself is NOT statically decidable. The same
# kind of ablation on `check-dispatcher-error-vocabulary` REDDENS its
# production run, because a row it declares stops being reached and its
# ledger is an equality rather than an emptiness; while a second rule
# inside `check-route-envelope` moves neither run. Two rules in one file
# can sit on opposite sides, so a family enumerated by name would wire the
# wrong set and then read as complete.
#
# Scripts whose self-test CI runs by some route other than the flag (a
# wrapper that spawns another tool's, a gate that runs its own cases on
# every invocation) are a shrink-only ledger inside the script, and every
# row carries evidence that is re-checked on each run rather than
# believed.
#
# ⚠️ No count is written into this comment on purpose: a number in CI
# prose goes stale in silence, which is the same class of defect as the
# card that produced this step. The gate prints its own scope line.
#
# Invoked as `node scripts/…` rather than through a `pnpm check:*` alias:
# see the GATE INVOCATION IDIOM note at the top of this file. Reads
# `scripts/` and `.github/workflows/` off disk, no network, ~1 s.
- name: Self-test wiring gate
run: |
node scripts/check-self-test-wired.mjs --self-test
node scripts/check-self-test-wired.mjs
# Self-test output vs. the runner's command parser (#11886). Sibling of
# the step above and sharing its population: that one asks whether CI RUNS
# each self-test, this one asks what each self-test PRINTS while it does.
#
# The defect it closes was live on every PR in this repo. A line of
# `scripts/pm/ci-failure.mjs`'s self-test prose named a workflow-command
# token literally, inside backticks, as documentation of what that tool
# anchors on. The runner does not know a token is being quoted: it parsed
# it and minted `annotation_level: failure` on a check run whose
# conclusion was SUCCESS. On `d63b01436` that artifact was the ONLY
# annotation the `Lint & Repo Gates` run carried. A failure-level
# annotation on a green required check is a claim that something failed,
# and a repo that mints one on every PR is teaching its readers to skim
# exactly the surface other gates report findings into.
#
# ⚠️ INVISIBLE TO EVERY LOCAL RUN, which is the whole reason it is a gate:
# the text is ordinary prose on disk and in a terminal, and only a runner
# transforms it. It was found by reading one job's annotations through the
# API, not by anything in this file.
#
# Two facts were measured on real runs and the gate's shape follows them:
# the legacy `##[...]` form is parsed ANYWHERE in a printed line (the
# measured token sat at column 18 and was still consumed), while the
# `::...::` form is parsed only at LINE START (two mid-sentence ones print
# on every PR from `check-prerelease-pin-watch` and mint nothing). So the
# first detector is unanchored and the second is anchored — flagging inert
# prose would be a false positive the next author would be right to delete.
#
# It RUNS the self-tests rather than reasoning about their source, because
# "is this token printed?" is only answerable by printing it. A static
# prefilter picks which ones to run — over-selecting freely, since the
# verdict never comes from it — which is what keeps this step at ~14 s
# instead of the ~4 min the whole population costs.
#
# Invoked as `node scripts/…` rather than through a `pnpm check:*` alias:
# see the GATE INVOCATION IDIOM note at the top of this file. Reads
# `scripts/` and `.github/workflows/` off disk and spawns the selected
# self-tests; no network.
- name: Self-test workflow-command gate
run: |
node scripts/check-self-test-workflow-commands.mjs --self-test
node scripts/check-self-test-workflow-commands.mjs
# Verify-lock entry-point self-test (#9661). `scripts/pm/os-verify-lock.sh`
# is the ONE way an agent takes the container's shared heavy-verify lock,
# and it is the enforcement of a rule that used to live only in prose: the
# acquisition budget os-dev.md declares is now unrepresentable above its
# cap, grants are ordered by a ticket file, and every run reports how long
# it held. Measured before it existed, in one container: five live waiters,
# three of them 6x over the declared cap, and the compliant ones were the
# ones not verifying — a convention whose violators win is not a convention.
#
# Nothing in CI *uses* the lock (GitHub runners are one job per machine),
# so this step exists to keep the mechanism honest rather than to serialise
# anything: 48 cases over the budget clamp, ticket liveness (dead pid,
# REUSED pid, over-age), fd hygiene, exit-code passthrough, and real
# multi-process contention — queue timeout, legacy free-hand coexistence in
# both directions, and three staggered waiters acquiring in arrival order.
#
# Since #10289 it also guards the BASH 3.2 FLOOR and the bounded-refusal
# rule, which is why the count moved: `/usr/bin/env bash` is 3.2.57 on
# macOS, and a bash 4+/5+ construct there does not fail loudly — it makes
# the acquisition deadline unreachable and turns the wait into an unbounded
# spin that prints no VERDICT at all. So the suite now scans the
# acquisition path for 4+/5+ constructs, runs a real acquisition with
# EPOCHSECONDS/EPOCHREALTIME unset and mapfile disabled, and asserts that
# an unusable host (no `flock`) and a stopped clock each REFUSE with a
# verdict and a non-zero exit rather than waiting forever.
#
# It runs entirely on a private lock under a temp dir; it never touches
# /tmp/os-heavy-verify.lock, so a runner and an agent container behave the
# same. Unconditional and un-`if:`-ed, like the self-tests above — an
# exemption is what a self-test must not have. No network, ~40 s.
- name: Verify-lock entry-point self-test
run: bash scripts/pm/os-verify-lock.sh --self-test
# ci-failure self-test (#9898). Fifth member of the PM self-test family
# above, and until now the odd one out: `scripts/pm/ci-failure.mjs` — the
# one command from "a check is red" to "here is the failing assertion" —
# shipped a `--self-test` that NO job ran, so it executed only when a
# human or an agent typed it.
#
# This tool is worth the second because the rot it would hide is the exact
# defect its own card was about. The file was found near-complete but
# NEVER RUN LIVE: node 22's `fetch` ignores HTTPS_PROXY, so behind an agent
# container's proxy every read answered 401 and the tool's own transport
# probe reported PREREQUISITE NOT MET (exit 3) — and looked right doing it.
# ⇒ A retrieval tool that has stopped working presents as a tool correctly
# declining to work. Nothing about that shape reads as broken, which is
# what makes an unrun self-test the wrong economy here specifically.
#
# The self-test is also materially larger than when the card was filed:
# #9966/PR #10157 corrected the transport probe (a healthy `/rate_limit`
# was greening containers whose repo-scoped reads are refused — the fourth
# measured container class) and added nine cases, which is why this step
# was deliberately ordered AFTER that fix. Wiring a false green into a
# required job would have pinned it as CI-enforced truth.
#
# Beyond the pure predicates it pins the LIVE WIRING: `resolveStep` is run
# against the real `.github/workflows/` tree, so reshaping the workflows
# until no step name resolves to a `run:` block reddens here rather than
# silently degrading every gate failure from "here is the command" into
# "no substitute available".