-
Notifications
You must be signed in to change notification settings - Fork 10
5560 lines (5314 loc) · 350 KB
/
Copy pathlint.yml
File metadata and controls
5560 lines (5314 loc) · 350 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.
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# ⭐ GATE ORDER IS DELIBERATE, not topical (#13690, maintainer ruling
# 2026-09-03): the `Lint & Repo Gates` job below runs its gates sequentially
# and aborts at the first non-zero exit, so the cheap, frequently-red gates run
# FIRST — `Docs anchors resolve to real headings` and `ADR anchors + number
# uniqueness` sit immediately after the six setup steps because they produced
# 78% of this job's reds, and running them there shrinks the unmeasured tail on
# the majority of red runs at no cost to a green one (whose wall clock is the
# sum of its steps, and so is order-independent) — ⛔ so do not re-sort them
# back among their topical neighbours as tidying.
# ─────────────────────────────────────────────────────────────────────────────
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
# #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so
# the `Check Documentation Links` job resolves a link's FILE and never its
# `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a
# link to a heading that does not exist is reported `[200] OK`. So a
# cross-file anchor was a two-sided invariant with no owner: rename the
# heading, leave the inbound link, ship green. This resolves every internal
# `#fragment` against the destination page's real heading ids, computed
# with `github-slugger` — the same package fumadocs-core renders the site
# with, so the gate and the renderer cannot disagree about a slug.
#
# It lives in this job rather than in check-links.yml for two reasons: it
# needs a workspace install (that job is deliberately a checkout plus two
# dependency-free `node` checks), and it is REQUIRED where that lane is
# advisory. The four anchors that were already dead were fixed in the same
# PR, so this ships with no baseline and no allowlist.
#
# ── FOLDED IN, deliberately: the `file:line` anchor half ──────────────
#
# `content/docs/permissions/system-context.mdx` calls itself "the
# authority" for every behaviour keyed off `ExecutionContext.isSystem`
# and says it is "built by census over the whole repo, not by recall".
# Nothing held it to either claim, and it rotted at a measured rate: 101
# of its 111 anchors pointed at a line that no longer held what the row
# named, 19 days after the census was written, with CI green throughout.
#
# ⭐ The load-bearing direction is CENSUS -> PAGE, not page -> code. At
# the commit that motivated the card, re-resolving EVERY anchor on the
# page would have passed while it was missing 32 sites and its headline
# was 29 too low: a gate that only checks what a page already says can
# never find what the page failed to say. Measured again here on the
# shipped gate -- with an unanchored read site injected, the resolution
# checks report 0 findings and the population check names the site.
#
# It is a second command in THIS step rather than a step of its own: this
# job has no paths filter, so the census runs on code-only PRs (the ones
# that ADD a read site), and folding costs no new check context, no new
# `check:*` manifest key, and no new required-status entry for one page.
# The `--self-test` invocation is what `check:self-test-wired` requires,
# and it is the only instrument on this gate's matching rules -- a clean
# tree cannot tell a working rule from a weakened one.
#
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
# GATE INVOCATION IDIOM note at the top of this file.
- name: Docs anchors resolve to real headings
run: |
pnpm check:doc-anchors
node scripts/check-system-context-census.mjs --self-test
node scripts/check-system-context-census.mjs
# #3723 ADR anchors: code an accepted ADR governs must keep naming it.
# That incident reversed three accepted ADRs with a patch-level changeset,
# and the mechanism was simply that the edited file never mentioned them —
# so the author could not have known. Presence check only; the invariant
# travels in the failure message (scripts/adr-anchors/, one file per anchor since #6957).
#
# Since #5992 the same script also audits the premise the anchors rest on:
# an ADR number names exactly ONE decision. Three numbers were each claimed
# by two unrelated records (0010/0019/0057) — grandfathered on an explicit
# shrink-only allowlist in the script, so a FOURTH collision goes red here.
# The `--self-test` in the pnpm script provokes that red path on synthetic
# directories every run, because a collision is rare enough that nobody
# would otherwise see the gate fail before it mattered.
- name: ADR anchors + number uniqueness (governed code names its decision)
run: pnpm check:adr-anchors
# 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 first three of these are the 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.
#
# `import-prerequisite.mjs` is the FOURTH module here, from a different
# family and for the same reason (#14007). It is not one of the #10608
# delegates — it is the shared `PREREQUISITE NOT MET` frame that 45 gates
# IMPORT (42 under `scripts/**`, 3 under `packages/lint/scripts/**`), and
# its `--self-test` is the ONE place the inherited advisory is pinned. The
# module says so itself: *"Pinned HERE and nowhere else, because this is
# the one copy 45 importers print."* Since #13983 the same self-test also
# pins the exit-code CLASS those 45 gates return for an unmet prerequisite
# (`EXIT_PREREQUISITE_NOT_MET`, distinct from a gate's own findings exit).
# Nothing ran it: `git grep -n "import-prerequisite" -- .github/workflows
# package.json` was EMPTY, so 60 cases executed only when a human or an
# agent typed the command by hand.
#
# ⛔ NOT a hole in `check:self-test-wired`, whose green is correct by its
# own definition: its population is the scripts CI RUNS, and this file is a
# MODULE no workflow can invoke, so it sat in that gate's remainder by
# construction. The blast radius and the population rule point in opposite
# directions here: the file with the largest inheritance surface in the
# farm is the one shape the wiring gate cannot see. Naming it in this step
# is what moves it INTO that population (161 -> 162 scripts CI runs that
# ship a `--self-test`), and it is the smaller of the two remedies — the
# other being to widen the gate to a transitive closure, which #14007
# explicitly does not ask for. ⛔ It is also not a licence to sweep the
# rest of that remainder from here; that is a separate card.
#
# What runs here is the self-tests, not the modules' callers: for the first
# three the callers are the two gate steps below, which already run on
# every PR; for `import-prerequisite` the callers are the 45 importing
# gates, whose ordinary runs take the branch where the dependency IS
# present and therefore observe none of what the self-test pins.
# 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 three are in-process fixtures, ~0.6 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, comment-mask and prerequisite-frame self-tests UNRUN
# while the log shows only the one failure. `ts-parse` is both the slowest
# of the four and the one that spawns real children, so it is the likeliest
# to be red — precisely the masking direction. The four modules are
# independent of one another, so collecting loses nothing.
#
# ⭐ Collected is also why the fourth leg is HERE rather than in a step of
# its own: Actions skips a job's remaining steps once a step fails, so a
# standalone step further down this job would be masked by every gate above
# it — the same defect one level up, and the split `check-step-collectors`
# explicitly refuses as a remedy. Inside this block the frame's self-test
# prints its own verdict whatever the other three do, and it does so early
# in the job.
- name: scripts/ shared-module self-tests (parse · entry predicate · comment mask · prerequisite frame)
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
run_self_test node scripts/import-prerequisite.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 four 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
# A package's published `src/**` may only import workspace packages its own
# manifest DECLARES (#10062). The class was filed with one member and a
# mitigation — "type-only, so nothing lands in the emitted JavaScript" —
# and grew to four with no signal, one of them a VALUE import whose
# implementation the bundler then INLINED into a second package's dist.
# Both halves of the mitigation are now ledger EVIDENCE instead of prose:
# a `type-only` row reds the moment its import stops being type-only.
# Node builtins plus the shared comment mask only — no node_modules, so a
# reviewer can run it in place. Its `--self-test` runs FIRST: the
# production run over a fixed tree is green by construction, so it cannot
# tell a working matcher from a dead one, and the specifier FLOOR is the
# other half (a dead matcher finds nothing, and nothing is what success
# looks like — so it refuses with exit 2 rather than passing).
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
# GATE INVOCATION IDIOM note at the top of this file.
# Scans ~2k non-test src files across 78 workspace packages, no spawns; ~1s.
- name: Published src imports only declared workspace deps
run: |
node scripts/check-undeclared-dep-imports.mjs --self-test
node scripts/check-undeclared-dep-imports.mjs
# A text-family column a DECLARED INDEX keys on must declare a `maxLength`
# (#12147, route A of #11374). Without one `driver-sql` emits it TEXT, MySQL
# refuses `ALTER TABLE ... ADD INDEX` with ER_BLOB_KEY_WITHOUT_LENGTH, and the
# object lands REGISTERED-BUT-BROKEN with its declared index silently absent
# (measured live on MySQL 8.0.46, #12058: 12 of 44 platform objects, sys_session
# among them). Enforcement used to be per-package pins, and each one was widened
# by a column that had escaped the previous scope -- objects keep moving across
# package boundaries under ADR-0029 K2, so a boundary-scoped pin re-opens the
# hole every time one moves. A central importing pin is NOT available: measured
# on PR #12143, it would invert the dependency graph. So this is a class-level
# source scan over every `*.object.ts`.
# Node builtins plus the shared comment mask only -- no node_modules, so a
# reviewer can run it in place. Its `--self-test` runs FIRST, and that leg is
# the load-bearing one: the production run over a fixed tree is green by
# construction, so it cannot tell a working matcher from a dead one. The other
# half is the FLOORS -- a sweep that finds nothing because it swept nothing
# reports exactly what a clean tree reports, so an empty population is `exit 2`
# rather than a pass. Unclassifiable shapes refuse for the same reason.
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
# GATE INVOCATION IDIOM note at the top of this file.
# Scans 113 *.object.ts files, no spawns; ~0.3s.
- name: Keyed text-family columns declare their bound (#12147)
run: |
node scripts/check-keyed-text-bounds.mjs --self-test
node scripts/check-keyed-text-bounds.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
# The vendored HotCRM permissions artifact has NO non-test loader (#13419).
# `security-plugin.ts` folds a POSITION name into the permission-set
# request, so a permission set named after a position is granted with no
# `sys_position_permission_set` row behind it. The #13419 census reports
# exactly two such folds — `sales_rep` and `sales_manager` — and both take
# their permission-set half from a fixture that only one TEST reads. That
# makes them a collision between an example app's position names and a
# test fixture's set names rather than grants any deployment holds, which
# is why the ruling's 物化 worklist is empty in this repo. The empty
# worklist is the thing being pinned: wire that artifact into a real
# composition and this reddens, instead of two live authorization grants
# appearing with nothing said. Its `--self-test` supplies the loader cases
# a clean tree by construction does not contain — without them the
# production zero could equally be a scanner that stopped matching.
# Invoked as `node` rather than `pnpm check:*`: see the GATE INVOCATION
# IDIOM note at the top of this file. ~1s, no spawns.
- name: The #13419 name-fold fixture has no non-test loader
run: |
node scripts/check-position-name-fold-loaders.mjs --self-test
node scripts/check-position-name-fold-loaders.mjs
# The dashboard widget `options` census vs the spec (#12926). The parser's
# `CONSUMED_WIDGET_OPTION_KEYS` decides which `options` keys get a
# `unconsumed-widget-option` warning, and five of its six members are the
# properties `DashboardWidgetOptionsSchema` declares in THIS repo's spec.
# That half was a literal pin asserted against itself, so a key added to
# the schema left the parser warning about spec-legal, renderer-consumed
# metadata — the shape that gets a diagnostic deleted by the next person
# who hits it. The sixth member (`description`) is a resolver output, kept
# as a ledger row that is checked from both ends rather than skipped.
- name: Widget `options` census derived from the spec, not pinned
run: pnpm check:widget-option-census
# The sdui-parser LOCKSTEP invariant (#12719 / #13150). Two copies of the
# constrained-JSX parser exist: objectui's runs in the RENDERER, this one
# runs in the SAVE GATE, and #12719 states that they must byte-agree on
# the accepted grammar and on the diagnostic codes — an invariant that had
# been enforced by NOTHING through three manual ports, two of which landed
# only because someone happened to file a card naming the drift.
#
# Compared against a VENDORED RECORD of objectui's side, so this step
# needs no network, no build and no objectui checkout. Read the gate's
# header for what that buys and what it costs: it sees this copy moving
# away from the recorded objectui state; it does NOT see objectui moving
# afterwards, and only a `.objectui-sha` bump — the moment a divergence
# starts SHIPPING — brings that direction back into view.
#
# Unfiltered on purpose. The record lives under packages/sdui-parser/, the
# pin is a root dotfile, and this workflow declares no paths: filtering it
# to the parser subtree would go dormant on exactly the PR that moves the
# pin. Cost is one TypeScript parse of seven small files, ~0.4 s.
- name: sdui-parser stays in lockstep with objectui's copy
run: pnpm check:sdui-lockstep
# The checked-in SDUI public-tier manifest (#12924, ruled 2026-08-29: wire
# it). `resolveSduiManifest()` degrades to parse-only SILENTLY when the
# repo-root `sdui.manifest.json` is absent or unreadable, so nothing in
# the production path ever says "the gate lost its whitelist" — this step
# is the thing that notices. Offline record checks only (existence, shape,
# sha256 vs the provenance record, record pin vs `.objectui-sha`): the
# regenerate-and-compare half runs where a regeneration can (the Node
# generator needs the published @object-ui set — see
# scripts/gen-sdui-manifest-node.mjs), and a pin bump goes red HERE until
# it has been re-run — the same moment the lockstep step above forces the
# parser parity re-verification, with an objectui checkout guaranteed in
# hand. Unfiltered for the same reason as its neighbour: filtering to the
# artefact would go dormant on exactly the PR that moves the pin.
# Direct invocation per the GATE INVOCATION IDIOM at the top of this file.
- name: SDUI manifest is present, intact and fresh at the objectui pin
run: node scripts/check-sdui-manifest.mjs --self-test && node scripts/check-sdui-manifest.mjs
# 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).
#
# ⛔ This paragraph deliberately carries NO cost figure (#12831). The line
# that used to close it — "Reads two files and walks packages/; ~0.3s" —
# described the 61-case self-test named at the top, and the part that grew
# since is exactly the part it denied: the live-tree cases run the same
# discovery the tool does (every workflow file, and then the source of
# every gate that discovery finds), build temporary git repositories and
# drive `changedPathsFromGit` against them, and sweep the tracked corpus.
# That is not "two files", and it is not a walk of packages/. The estimate
# went with the description rather than being refreshed: a replacement has
# to be read off a real CI run of THIS step, because a reading taken in an
# agent container is a reading of a contended box and not of a runner
# (#12528). A cost nobody re-measures ends up endorsing a decision it has
# no standing to endorse, which is what this one had become. No live
# counts stand here either, for the same reason — they rot the same way,
# and it is the SHAPE of the work, not its size, that the argument needs.
#
# ⛔ Cost was never what makes this step unconditional. The FIRST
# paragraph above is, and it stands without this half: an exemption is
# what a self-test must not have, or the gap moves. Losing the estimate is
# therefore NOT an argument for an `if:`, a label read or a paths filter —
# weakening this gate is a maintainer call, and no reading of this note
# authorises one.
- name: PM dispatch-gates self-test
run: pnpm check:pm-dispatch-gates
# Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER
# (#12762). The step above proves the extractor still works; this one
# proves it still has something to extract. `extractWatchHints` reads
# source text, so a declaration rewritten from a literal into a mapped
# expression over the gate's population constant contributes NO hint at
# all while its runtime value is unchanged — every local assertion about
# that value stays green, and the gate silently leaves every dispatch
# brief and scores a quiet green for every card in the tree it walks.
# Measured on this tree: the two ledger self-tests above do NOT catch it
# (a computed declaration with the literal kept in a neighbouring comment
# ran all three of them green), and only one of the fifteen declarations
# carried an own-source pin. The search is scoped to the declaration
# STATEMENT: a whole-file search finds the gate's own hint spelled again
# in a runtime assertion or a comment and stays green on the computed
# form, which is exactly how the two earlier per-file pins failed.
# Repo-wide sweep of authored JS/TS, no spawns; ~0.5s.
- name: ROOT_DIR_WATCH_HINTS declarations are literals
run: pnpm check:watch-hint-literal
# The other half of the same failure (#13519). The step above holds that a
# declaration is still READABLE BY A TEXT SCANNER; this one holds that
# what it reads still names something. A family whose declared literals
# all reach zero tracked files prints as an ORDINARY SILENCE — byte for
# byte the output of a gate that declared nothing — so the derivation and
# the gate disagree about whether a population was declared and neither
# says so. Measured on the tree this landed against: two families, one of
# them a gate that sweeps every workflow file and appeared on no card that
# edited one. The rule is per-FAMILY, not per-literal: a gate may spell a
# slug or a sentinel path beside a live declaration, and only a whole
# declaration that reaches nothing is refused. No allowlist and no gate
# names — the stronger rule ("a gate that enumerates a directory must
# declare one") was implemented, measured at 86 findings over 114
# enumerating gate files, and refused as an allowlist with a verdict
# attached. Reads the derivation once over the tracked corpus; ~5s.
- name: A declared gate population reaches the tree
run: pnpm check:declared-population-live
# 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
# Clause-② carrier + declaration checker self-test (#13922 · #12409 ·
# #13914). Same family and the same split as the two steps either side:
# the LIVE sweep reads a shared board over the API and is report-only, so
# only the offline self-test runs here — a clause-② desync on somebody
# else's pair is a fact about the board, not about the PR that happens to
# run CI next, and failing that PR would punish the wrong actor.
#
# ⛔ Do not "promote" the live sweep into this step. The script's own
# `--pair <pr>` mode is the per-PR predicate, and it is a PRE-ARM tool a
# seat runs against its own pair — deliberately not a merge gate, because
# a new blocking context over board state is a required-set change and
# the required set is the maintainer's.
#
# The self-test is offline and imports `check-half-states.mjs` for the
# label constant, the `prDeliversCard` delivery relation and the shared
# PREREQUISITE-NOT-MET exit code — one source for each, so this checker
# and H31 cannot come to disagree about which PR delivers which card.
- name: Clause-② carrier checker self-test
run: pnpm check:pm-clause2-carriers
# 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