Repository navigation
Expand file tree
/
Copy pathcheck-type-check-coverage.mjs
More file actions
6125 lines (5973 loc) · 331 KB
/
Copy pathcheck-type-check-coverage.mjs
File metadata and controls
6125 lines (5973 loc) · 331 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// check-type-check-coverage -- every workspace package's TypeScript is read by
// tsc somewhere, or its absence is a recorded, tracked decision (#4311).
//
// 66 of 77 workspace packages build with tsup, which transpiles with esbuild
// and never type-checks. `vitest run` does not type-check either. And the CI
// typecheck job covered exactly four targets (spec, examples,
// downstream-contract, docs code blocks) -- so for most packages NOTHING read
// src/ or the tests with a type checker at all. #4311 measured the hole:
// 380 code-tier errors across 18 packages, 241 of them in driver-sql. A green
// test suite no tsc has ever read is not evidence of a contract; this gate
// makes the coverage hole itself the failure, so it can only shrink.
//
// What the first burn-down found is worth knowing before reading a number
// below as "N test literals to fix". The three drivers (driver-sql 241,
// driver-sqlite-wasm 27, driver-memory 23) were filed as one playbook --
// author-tier literals in a parsed-tier parameter -- and 165 of their 292
// were something else entirely: 118 for a `bypassTenantAudit` driver option
// that SqlDriver read (through an `as any`), the engine set, and two services
// passed, while `DriverOptionsSchema` had never declared it; 41 for a bare-id
// `findOne(object, id)` branch on no contract, which the other two drivers
// answered differently; 4 for a `tenancy` key `initObjects` consumed but did
// not declare; 19 for an analytics `timezone` default. The tests were right
// and the types were wrong. A tsc count is a place to look, never a verdict.
//
// The reverse also holds: a LOW count can be call sites opting out. Those same
// three packages carried 111 `as any` casts on driver-call arguments. Removing
// them left 66 fresh errors -- every one a real missing `object` the cast had
// hidden, including an `orderBy: [['id','asc']]` tuple the driver reads as
// `item.field` and therefore silently dropped, inside a helper whose whole job
// was reading rows in order. 43 of the casts were needed by nothing at all;
// exactly 2 were load-bearing (tests feeding a filter the AST gate refuses, on
// purpose). Onboarding a package is supposed to make its `typecheck` mean
// something, so the casts belong in the diff too.
//
// node scripts/check-type-check-coverage.mjs # structural, sub-second
// node scripts/check-type-check-coverage.mjs --re-measure # + refreshes the
// # ledgered packages' dependency closure,
// # then runs tsc per ledger entry
// node scripts/check-type-check-coverage.mjs --re-measure --lower
// # + writes each measured number back into
// # the ledger below (#6376). Refuses on a
// # closure it cannot make current itself
// # -- see BUILT CLOSURE.
// node scripts/check-type-check-coverage.mjs --self-test
//
// ## Exit codes -- and why a REFUSAL has one of its own
//
// 0 the tree was read and every invariant above holds.
// 1 a FINDING: a structural problem, or -- under `--re-measure` -- a ledger
// entry that drifted UPWARD. A claim about the tree.
// 3 PREREQUISITE NOT MET. The gate refused to measure, so nothing was
// measured and the run says NOTHING about the ledger. NOT a pass, and
// NOT a finding.
//
// The split is the sibling convention, not a local invention:
// `check-test-completeness.mjs` states it in its own failure text ("Exit code
// 3, distinct from a finding's 1") and `check-dual-build-cjs-loads.mjs`
// answers the IDENTICAL condition -- a gate that reads built output, run
// against a tree with no `dist/` -- with 3. This gate used to answer it by
// letting the refusal reach node's uncaught handler, which exits 1.
//
// ⭐ Why the class matters HERE in particular, more than it does for a gate
// whose 1 means "something is wrong somewhere". Exit 1 from this gate has one
// specific meaning: a package's recorded debt went UP. The remedy that meaning
// prescribes ends at the ledger below, and raising a DEBT/TEST_DEBT entry is a
// MAINTAINER-only act this file spells out at length. So a reader who takes an
// unmeasurable run for that red is pointed straight at the one place this
// evidence must never send them. The prose said so all along -- but the prose
// is not what an exit-code reader reads. A CI step, a wrapper, or an agent
// reconciling a derived gate family sees the number and nothing else.
//
// ⛔ The boundary, deliberately. Exit 3 is for a prerequisite the WORLD failed
// to supply and that the caller clears with a named command: an unbuilt or
// stale dependency closure, a closure that does not build, an absent `turbo`
// or `tsc` binary, a tsc that could not be spawned or could not read the
// project it was handed. A malformed `tsconfig.json` checked INTO the tree
// stays exit 1 -- that is a fact about the tree, which is what a finding is,
// and `readTsconfig` keeps throwing it.
//
// Invariants, per workspace package (the root workspace package included --
// #4311's audit counted its top-level TypeScript like any other package's):
//
// COVERED the package declares a `typecheck` script, OR carries a DEBT
// entry (measured tsc error count + tracking issue) or an EXEMPT
// entry (why type-checking cannot apply) below. A new package
// must arrive covered -- the ledger is closed to new debt.
// REAL a declared `typecheck` script actually invokes tsc. A script
// that echoes, lints, or runs tests is not type coverage.
// TESTS_COVERED
// a package whose own `*.test.ts` / `*.spec.ts` files sit outside
// every tsc program that accounts for it carries a measured
// TEST_DEBT entry. `tsc --noEmit` reads the package tsconfig, so a
// test file that config never reaches is hidden from the very
// check the `typecheck` script advertises -- COVERED and REAL both
// pass while nothing reads the files #4311 is actually about. This
// invariant is why the ledger is two ledgers: DEBT is "src does
// not check", TEST_DEBT is "src checks, tests are hidden", and
// they are independent.
//
// DECIDED PER FILE, and both ways a file can fall out (#7353).
// Until then this asked one per-config question -- does some
// `exclude` name the tests -- which detects a config that steers
// tsc AWAY from them and NEVER a config that simply never steered
// tsc TOWARD them. `include: ["src"]` beside a sibling `test/`
// tree, no `exclude` at all, read as fully covered: #7312 moved
// two example apps from hiding their tests to compiling them and
// this headline did not move by one file, because neither app had
// ever counted toward it. The census that came with the repair
// found 3 such packages and 65 such files, `packages/cli` alone
// hiding 56 test files worth 188 raw errors that no gate, ledger
// or CI job had ever read. Per file also makes PARTIAL coverage
// sayable: cli keeps 54 of its 110 test files under `include`,
// and 56 is the true number rather than 0 or 110.
//
// Read across ALL of a package's tsconfigs, not just
// `tsconfig.json` (#5286). The build config has a reason to
// exclude tests -- ci.yml gates that no test file reaches the
// published artifact -- so the supported repair is a SIBLING
// `tsconfig.test.json` wired into the `typecheck` script. Judging
// only `tsconfig.json` would keep calling such a package hidden
// while tsc reads every one of its tests. The sibling must be
// NAMED in the typecheck script chain: a config no script invokes
// reads as coverage and delivers none, which is this gate's own
// subject matter.
// SOURCES_COVERED
// a package that DECLARES a `typecheck` script has no directory of
// non-test source sitting outside every tsc program that script
// runs, or the directory carries an UNCHECKED_SOURCE_DEBT entry
// below (#10756).
//
// The layer above is package-granular and this one is not, which
// is the whole gap. COVERED asks "does a `typecheck` script
// exist"; it cannot see a package that has one whose tsconfig
// `include` omits an entire source directory.
// `@objectstack/objectql` passed as covered with
// `packages/objectql/scripts/**` -- a compatibility checker with a
// documented CLI, imported by one of its own tests -- in no
// program its `typecheck` ran, and a `number` initialised with a
// string could sit in that directory without moving the gate.
//
// NOT the same invariant as TESTS_COVERED, and worth saying why
// it is not folded into it: that one decides per test FILE, and a
// non-test module is hidden by the same `include` for the same
// reason while belonging to neither ledger --
// `packages/cli/test/helpers/serve-process.ts` is the measured
// instance. The AGENTS.md rule both generalise ("never `exclude`
// tests from a package's tsconfig") is written about a file glob,
// so it does not reach a directory no `include` ever named.
//
// Scoped to SUBDIRECTORIES: a package-root tool config
// (`vitest.config.ts`, `tsup.config.ts`) is unchecked TypeScript
// too, but it is one repo-wide convention across 42 packages
// rather than 42 decisions, and the census that seeded this
// invariant kept the two apart -- 54 root configs, 11 files in a
// source directory. The exclusion is argued in full, both ways,
// on UNCHECKED_SOURCE_DEBT below.
// GENERATED_COVERED
// a tsconfig `include` entry rooted in a GENERATED path -- one the
// repo's own ignore rules say is not checked in -- is either
// produced by the package's own `typecheck` script BEFORE tsc
// runs, or carries a row in GENERATED_INCLUDE_ROOTS below saying
// it is deliberately not produced, and why (#10880).
//
// One notch further along than REAL, and the distance is the
// whole point. REAL asks whether the script invokes tsc at all;
// this asks whether the PROGRAM tsc gets is the one the config
// advertises. `apps/docs/tsconfig.json` includes
// `.next/types/**/*.ts`, written by `next typegen`, and the wired
// script was a bare `tsc --noEmit`: measured on main @ 7d483e1e5f
// with `.next` deleted, it exited 0 having compiled none of the
// generated route types (`tsc --listFiles` 1225 -> 1231 once
// typegen ran, the difference including a 160-line `validator.ts`
// that checks 13 route entry points). An ablation gave a layout a
// `params` shape its route cannot supply: the bare script stayed
// at exit 0 / 0 errors and the typegen'd one failed TS2344.
// COVERED, REAL, TESTS_COVERED, SOURCES_COVERED and RUNNABLE all
// passed the whole time, because every one of them is a question
// about files that EXIST.
//
// The failure DIRECTION is what makes this an invariant rather
// than a lint. An absent generated directory makes tsc read GREEN
// over files that were never in the program -- silence that looks
// exactly like success. Every other way of getting the same
// config wrong (a stale directory, a generator that fails) ends
// in a red somebody reads.
//
// "Declared, and deliberately NOT generated" has to be a passing
// state, because deleting the offending glob is not available as
// a repair: Next owns that array and `writeConfigurationDefaults`
// re-adds `.next/dev/types/**/*.ts` on the next `next dev` /
// `next build` (#10879 measured it by running that routine
// against a narrowed copy -- the glob came straight back and the
// file was rewritten). A guard that only accepted "generated by
// the script" would leave the honest answer unsayable and push
// the next author toward a narrowing Next undoes. So a row may
// declare no generator at all -- with a reason, which is the part
// a human reviews and the part nothing mechanical can supply.
// PINS_CHECKED
// a test file containing a `@ts-expect-error` directive sits
// inside a tsc program, or is listed in PHANTOM_PIN_DEBT below.
// `@ts-expect-error` is the retirement channel the
// spec-property-retirement playbook leans on ("tsc is the best
// sweeper"): the directive is supposed to go red the day the
// removed key comes back. In an unchecked file it evaluates
// NEVER -- deleting the directive line leaves the suite just as
// green, which is a phantom check wearing a pin's clothes
// (#5286). Independent of TESTS_COVERED: a file can sit outside
// `include` without any exclusion naming it, which is how
// `packages/metadata-core/test/` hid.
// RUNNABLE turbo.json declares the `typecheck` task, the root `typecheck`
// script aggregates it (`turbo run typecheck`, the build/test
// convention), and lint.yml invokes it -- a script CI never
// executes is not coverage either (#4203: gates that only run
// where nobody runs them, rot).
// RECONCILED in both directions: a DEBT/EXEMPT entry for a package that now
// declares `typecheck`, or that no longer exists, is an error.
// A ledger that can only accrete rots into a list nobody trusts.
// MEASURED (--re-measure only) every DEBT / TEST_DEBT number is RE-RUN, and
// a package whose real `tsc --noEmit` count now EXCEEDS its
// recorded one is an error. Without this the ledger asserted only
// that a positive number was written down: `errors: 28` and
// `errors: 1` were equally acceptable, so a package's real count
// could grow without bound while the gate reported success
// (#5278). Measured drift at filing time, all in one direction:
// metadata-protocol 28 -> 63, service-analytics 3 -> 7,
// service-automation 2 -> 5.
//
// Asymmetric on purpose. Growth is red -- that is the ratchet.
// SHRINKAGE is an informational line, never red: making a package
// better must not also make CI fail until someone edits a number,
// or the ledger starts charging a toll on exactly the work it
// exists to encourage. A count that reaches 0 is reported as a
// graduation candidate, and graduating is still a deliberate PR
// (add the `typecheck` script, delete the entry -- COVERED and
// RECONCILED are what force the pair).
//
// THE SURPLUS (#6376). That asymmetry has a price, and it is not
// bookkeeping: the gap between a recorded number and a smaller
// measured one is a live PERMISSION ALLOWANCE. Nothing else reads
// these layers. A DEBT entry exists ONLY where no `typecheck`
// script does (RECONCILED forbids both at once) and a TEST_DEBT
// entry ONLY where no tsconfig the typecheck script invokes reads
// the tests -- so for EVERY entry in both ledgers, this number is
// the sole gatekeeper of the layer it measures, and while the gap
// is open any regression smaller than it lands green.
//
// Not a hypothesis. driver-mongodb recorded 43 while measuring 10:
// #6210 had narrowed six contract methods and retired 33 errors,
// and nothing lowered the entry. Reverting `aggregate(object,
// query: DriverQuery)` back to `QueryAST` measured 12 -- and
// 12 < 43, so the only gate that could pin that signature stayed
// silent. PR #6356 lowered the entry to 10; the same reversion
// then reported +2 and went red. Read it as the sentence it is:
// A LEGITIMATE IMPROVEMENT SILENTLY MUTED THE NEXT PERSON'S PIN.
//
// So the surplus is now REPORTED -- per entry and as a total, on
// every green re-measure -- because an allowance nobody can see is
// the part that does the damage. Measured on main @ 1818998: 11 of
// 34 entries carried a combined surplus of 310 raw errors, 15% of
// everything the two ledgers record, with 199 of it in a single
// entry (plugin-approvals, 547 recorded / 348 measured). What this
// gate deliberately does NOT do is make a surplus red: the ruling
// above stands and an improvement still lands green. What it does
// instead is make closing one FREE -- `--lower` writes the
// measured numbers back into the ledger, so flattening an entry
// never requires a human to type a measured number. Whether the
// surplus should additionally be enforced WAS #6376's open
// question; #6376 closed (PR #6510) deciding NOT to -- report
// and offer `--lower`, never fail on a surplus by itself -- and
// that decision is this paragraph's ruling above, not a pointer
// to an issue that is now closed with nothing left to read there
// (#11497).
//
// SWEPT LEDGER-WIDE at ead731756 (#12723), which is the first time
// all 31 entries were re-measured to answer the surplus question
// rather than one entry noticed in passing. 4 carried slack, worth
// 118 raw errors: objectql 354 -> 251, runtime 227 -> 217,
// plugin-auth 97 -> 94, plugin-approvals 347 -> 345. The shape of
// that result is the part worth keeping, and it is not the one the
// card predicted. ALL FOUR ARE TEST_DEBT; not one of the 13 DEBT
// entries had moved, each measuring its recorded number to the
// unit. That asymmetry has a mechanical cause rather than a moral
// one: a DEBT entry's number is what the package's OWN `typecheck`
// would report if it had one, so ordinary repair work in those
// packages is visible to whoever does it, while a TEST_DEBT number
// is only ever produced by the temp project this file generates --
// nothing a contributor runs reports it, so fixing a test-layer
// type error here moves a number NOBODY SEES until the next
// re-measure. The surplus is therefore not evenly distributed
// noise; it accumulates in exactly the layer whose measurement is
// invisible outside this gate.
//
// ⛔ Read that 118 as a measurement of ONE MOMENT, not as a
// standing property. It is what four months of ordinary repair
// work banked between sweeps, and the entries above sit at their
// measurement again as of that sha -- so the next reader must
// re-measure rather than quote this paragraph, exactly as the
// bootstrap-margin roster above must not be read as a current
// inventory. What the number IS good for is sizing the open
// design question: whether a surplus should additionally make this
// gate RED (a self-tightening ratchet). #6376 decided not to when
// the mechanism was built, and this sweep is the first evidence
// about what that costs in practice.
//
// What drifts is not only the NUMBER but the note's COMPOSITION:
// service-automation's note named `engine.test.ts:2547/2577` as
// the whole debt while three TS2341 in a different file, from an
// unrelated PR, had joined it. So when this invariant makes you
// raise a count, rewrite the note to match what the pile is now
// made of -- and when the delta cannot be attributed, say so in
// the note rather than inventing composition.
//
// LOWERING desynchronises the composition too, and for a long
// time this paragraph only said "raise". It was written that way
// because a note describing a pile LARGER than what exists reads
// as misleading in the safe direction -- but a note that itemises
// 34 + 24 + 34 above a field reading 89 does not mislead safely,
// it CONTRADICTS ITS OWN FIELD, and nothing mechanical read the
// prose to say so. Two instances were repaired by hand on the
// same day: service-automation opening `code-tier 5.` over
// `errors: 3` (#10721), and metadata itemising 92 over
// `errors: 89` (#10775). Neither repair closed the class, because
// `--lower` MINTS it -- it rewrites the digits and carries the
// note through untouched, so the sanctioned one-command way to
// close a surplus was also the way to desynchronise a note
// (#10722).
//
// So the rule has a mechanical half in both places now. A leading
// tier itemisation must sum to `errors` (COMPOSITION, below), and
// `--lower` writes `compositionAt` beside the number it lowers so
// the itemisation says out loud which pile it still describes.
// Raising a count still means rewriting the note: `compositionAt`
// may only ever declare a pile LARGER than the field, so it
// cannot launder a raise -- and an unattributable delta is still
// an admitted gap rather than an invented composition.
//
// One thing to know before re-measuring: a `pull_request` run
// compiles your branch MERGED INTO the current main, not your
// branch. So the number to record is the one measured on a tree
// merged with main as of that moment, and a sweep that re-measures
// MANY entries races every PR landing beside it -- #5278's own PR
// went red on `@objectstack/rest` twice for exactly that reason,
// three rest-touching PRs having landed between the sweep and the
// run. That race is a bootstrapping cost, not a standing one: once
// this invariant is on main, the PR that adds the errors is the PR
// that goes red, which is the whole point.
//
// The MERGE QUEUE sharpens the same edge, and is worth its own
// paragraph because the usual remedy does not work there. The queue
// builds your PR as merged onto the head of the queue, which keeps
// moving as the entries ahead of you land -- so a count frozen even
// minutes earlier can already be stale, and RE-RUNNING the failed
// job cannot fix it (a rerun replays the same merge ref, so it
// re-measures the same stale base). The only repair is a new commit
// carrying a re-measured number. #5278's PR was kicked from the
// queue on `@objectstack/objectql` +2 -- #5802's registry tests and
// #5850's new file -- and while it sat there red-looping, two
// unrelated PRs queued behind it were each kicked once as
// collateral, then landed untouched once it left the queue. So: if
// a re-measure PR goes red in the queue, take it OUT of the queue
// before repairing it, and if you are the one re-measuring, push
// the calibration immediately after measuring rather than batching
// it with other work.
//
// Before treating any such red as base drift, falsify the other
// explanation: run `--re-measure` TWICE on the same tree. Identical
// output means the count is deterministic and calibration is the
// right answer; a count that oscillates would mean tsc itself is
// nondeterministic here, which is a tolerance question for this
// ratchet and NOT a calibration -- take it back to #5278. Measured
// 2026-08-06 on the objectql case: two back-to-back runs were
// byte-identical, so this gate has no known nondeterminism.
//
// BOOTSTRAP MARGINS (#5278 option A) -- ALL FIVE PAID OFF, kept
// here because the shape is what the next hot landing will want.
// The two paragraphs above describe a race that the
// exact-calibration loop lost five times running -- objectql
// 333 -> 334 -> 335 -> 339 and lint 30 -> 32, each calibration
// stale within minutes of being pushed, while seven unrelated PRs
// were kicked from the queue as collateral. The maintainer's
// ruling was to land the invariant with a DOCUMENTED margin on the
// packages that proved hottest rather than run a sixth lap:
// `@objectstack/objectql`, `@objectstack/lint`,
// `@objectstack/rest`, `@objectstack/service-storage` and
// `@objectstack/mcp` were recorded at their measurement PLUS TEN,
// each saying so in its own note with the measured number and sha.
//
// ⚠️ NONE OF THE FIVE CARRIES THAT SLACK TODAY. The roster above is
// the ruling's, not a current inventory, and reading it as one is
// the mistake this paragraph exists to stop. Four were tightened
// onto their exact measurement: rest 163 -> 155 (#6939 / #7038,
// PR #7248), then lint 42 -> 20, mcp 63 -> 53 and
// service-storage 52 -> 51 in one sweep (#7888 / PR #8225), with
// lint going 20 -> 19 in #8728. The fifth was never lowered and did
// not need to be: objectql's RECORDED 355 is untouched and now
// MEASURES 355 exactly, so its +10 was spent by real growth rather
// than handed back. Both routes end in the same place, and it is
// the intended one -- every entry in both ledgers now sits at its
// measurement, so the next new error anywhere is red on arrival.
//
// A margin remains the ledger's ONLY sanctioned slack, and it is
// deliberately loud: nothing here may sit above its measurement,
// and a margin is not a place to hide a real increase. Because
// shrinkage is informational, any entry above its measurement
// prints its own `ℹ ... can be lowered` line on every run -- that
// line IS the tightening worklist, and closing it is a follow-up
// PR, not a thing to leave running for months. Re-establishing a
// margin deliberately is still available and is a maintainer call,
// exactly as raising any ceiling is.
//
// ALL FIVE per-entry notes now narrate the present tense, and
// each carries a tally re-measured rather than rescaled: lint in
// #8728, then mcp, service-storage and objectql at 62b2655d8. The
// two that had to be fixed as ARITHMETIC were mcp and
// service-storage, whose notes still read "RECORDED 63 is a
// bootstrap margin" against an `errors` field that already
// disagreed with them. objectql needed something else, and the
// difference is the part worth keeping: its note was TRUE AS
// HISTORY and misleading only as present tense, because its
// margin was never tightened away -- it was spent by growth, so
// the entry stands at a recorded number it now measures exactly.
// A note that states a RECORDED number the field beside it
// contradicts is the single most reliable way to make the next
// reader mis-derive this policy: it is what made #8728's dispatch
// conclude the margins might still be live, costing a full
// five-package re-measure to disprove.
//
// The margins were not a precaution; two of the first four were
// paid out inside a single hour of the landing flight. objectql
// moved 339 -> 345 (#5749 / PR #6013 extending
// summary-rollup.test.ts)
// and service-storage 42 -> 41 -> 42 (the two halves of the
// `IStorageService.list(prefix)` retirement, #5540 / PR #5983 then
// #5541 / PR #6061, landing hours apart). Recorded exactly, both
// would have been red on a base nobody could have measured in
// advance. Note the second shape especially: a retirement split
// across two PRs moves a count DOWN and then back UP, so an exact
// number recorded between the halves is stale before it is pushed.
//
// The FIFTH margin, `@objectstack/mcp`, is the one the gate found
// by itself, and it is the cleanest evidence that this invariant
// does what it was built for. #5278's own PR reached the merge
// queue and was kicked at 03:25:18Z on a single red line: mcp
// TEST_DEBT recorded 52, tsc reported 53. The +1 was NOT
// introduced by the PR ahead of it in the queue (#6077's own
// queue generation was green) -- it is a pre-existing drift that
// nothing in this repo had ever been able to see, surfaced the
// first time the ledger was re-measured against a moving base.
// A ratchet whose introducing PR is the first thing it catches is
// a ratchet that works. It takes a margin rather than an exact
// number for the same reason the other four do: packages/mcp took
// a feature landing the same day (#6077 projecting skill
// `instructions` as MCP prompts), so it is an actively-moving
// package, and an exact number on an actively-moving package is
// precisely the bet option D lost five times running.
//
// The root is the one asymmetry: its `typecheck` script is the workspace
// aggregator, so its OWN top-level TypeScript is covered by a `typecheck:root`
// script (tsc, invoked from lint.yml) or by a ledger entry like anyone else.
//
// DEBT is frozen debt, not a permission slip. Every entry below was measured
// by running the package's own `tsc --noEmit` on main (see the issue for the
// code-tier / config-tier / noise split -- raw counts here include all three),
// and every entry is RE-MEASURED by `--re-measure` (the MEASURED invariant), so
// "frozen" is now enforced rather than asserted. To onboard a package: fix (or
// config-fix) its errors, add `"typecheck": "tsc --noEmit"` to its package.json,
// and delete its entry here in the same PR. Deleting the entry without the
// script fails COVERED; keeping the entry alongside the script fails RECONCILED.
//
// TEST_DEBT is the same discipline for the second hole. The first pass of this
// gate (#4324) counted a package covered the moment it declared `typecheck` --
// and 27 of the 48 it waved through exclude `**/*.test.ts` from the tsconfig
// that very script runs against, hiding 568 test files and 1451 errors behind
// a green check. `spec` alone hid 902 across 272 test files. To onboard: drop
// the exclusion from tsconfig.json and delete the entry here in the same PR.
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, posix, resolve } from 'node:path';
import { getHeapStatistics } from 'node:v8';
import {
selfTest as workspaceEnumeratorSelfTest,
workspaceEnumeratorFloorFailures,
workspacePackageDirs,
} from './workspace-enumerator.mjs';
// `typecheck`-script -> tsconfig program set. Shared with
// `check-type-source-resolution.mjs` since #11490, which needs the identical
// answer to decide its POPULATION: two copies of this predicate drift, and the
// symptom of drift is a green gate on either side.
import {
configsNamedByTypecheck,
typecheckScriptChain,
SELF_TEST_CASE_COUNT as TYPECHECK_CONFIGS_CASES,
selfTest as typecheckConfigsSelfTest,
} from './typecheck-configs.mjs';
// Anchored to the script, not to cwd: the verdict must not depend on where the
// guard was invoked from.
const ROOT = resolve(import.meta.dirname, '..');
const SELF = 'scripts/check-type-check-coverage.mjs';
const TRACKING_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/4311';
// The exit-code contract, NAMED rather than spelled inline at each site, so the
// self-test pins the value each path actually returns instead of a comment
// about it -- the shape `check-test-completeness.mjs` uses for the same split.
//
// ⛔ Module-local, NOT exported, and that is a decision rather than an
// oversight: this file's top level RUNS (it is a gate, invoked as a script and
// nothing else), so exporting any binding at all would make it importable for
// that binding and run the whole gate inside the importer -- the class
// `check:entry-guard` refuses, and it caught this constant block on its first
// run. The sibling that does export its codes carries `isEntrypoint` for
// exactly this reason. Nothing imports this file today; the day something
// needs to, the guard comes with the export.
const EXIT_OK = 0;
const EXIT_FINDINGS = 1;
const EXIT_PREREQUISITE_NOT_MET = 3;
/**
* The text a refusal prints, as a VALUE -- so the self-test can assert on the
* advisory without spawning a process or stubbing `process.exit`, the split
* `import-prerequisite.mjs` documents for the same reason.
*
* The gate's own refusal message is embedded VERBATIM. Every one of them was
* already argued at length at its throw site, and none of that reasoning is
* this frame's to restate, shorten or improve -- the frame adds only the two
* things a reader could not get from the message: what CLASS of result this is,
* and which exit code carries it.
*
* The pipe advisory is not decoration. `EXIT=$?` written after `cmd | tail -40`
* reads TAIL's status, and `head`/`tail` essentially never fail -- so a refusal
* and a green run are the same `0` there, which is the one reading this whole
* exit-code split exists to make impossible.
*
* @param {string} message the refusal, in the words of the site that raised it
* @returns {string}
*/
function prerequisiteNotMetText(message) {
return (
`\ncheck-type-check-coverage: PREREQUISITE NOT MET\n\n` +
`${message}\n\n` +
` ⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says\n` +
` NOTHING about whether any DEBT or TEST_DEBT number is still correct. In particular\n` +
` it is NOT evidence that a recorded number went up, and ⛔ no ledger entry below may\n` +
` be raised on it -- raising one is a maintainer's act even when the evidence is real.\n` +
` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — capture it BEFORE any pipe:\n` +
` \`node ${SELF} --re-measure > /tmp/type-check-debt.log 2>&1; echo "EXIT=$?"\`.\n` +
` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` +
` is the false green. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover this gate's own code.)`
);
}
/**
* Refuse to measure, and say so in the exit code as well as in the prose.
*
* ⛔ Prints and EXITS rather than throwing. A thrown refusal reaches node's
* uncaught handler, which exits 1 -- the code this gate reserves for "a
* recorded debt number went UP" -- and prints a stack trace over a message
* whose whole job is to be read. The self-test pins that the three functions
* that refuse contain no bare `throw new Error(` for exactly this reason: the
* rot this repairs is one careless `throw` away from returning.
*
* @param {string} message the refusal, in the words of the site that raised it
* @returns {never}
*/
function refusePrerequisite(message) {
console.error(prerequisiteNotMetText(message));
process.exit(EXIT_PREREQUISITE_NOT_MET);
}
// An `exclude` pattern that names tests (`**/*.test.ts`, `**/*.spec.tsx`, ...)
// and the files such a pattern hides. Kept deliberately broad: the question is
// "does this config steer tsc away from the test layer", not "which exact glob".
const TEST_GLOB = /\*\.(test|spec)\.tsx?$/;
const TEST_FILE = /\.(test|spec)\.tsx?$/;
// A TypeScript source file, i.e. one whose content can reach a generated
// `.d.ts`. Used only by the built-closure freshness read (#8271).
const SOURCE_FILE = /\.([cm]?ts|tsx)$/;
// A `@ts-expect-error` in DIRECTIVE position -- first thing on its own comment
// line, where the compiler reads it. Prose that merely mentions the directive
// (several files in this repo explain why they do NOT use one) must not count,
// or PINS_CHECKED would fire on documentation.
const PIN_DIRECTIVE = /^[ \t]*(?:\/\/|\/\*|\*)[ \t]*@ts-expect-error\b/m;
const PIN_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/5286';
// The in-tree precedent SOURCES_COVERED's remedy points at: the sibling config
// that put `packages/spec/scripts/**` into a program, named in that package's
// `typecheck` script. Its own header states this defect class in the words the
// finding later used ("the directory was never INCLUDED by anything"), which is
// why the remedy names a file rather than describing a shape (#10756).
const SPEC_SCRIPTS_PRECEDENT = 'packages/spec/tsconfig.scripts.json';
// The in-tree precedents TESTS_COVERED's remedy points at for the `rootDir`
// half of the sibling-config route. Both put a test tree that sits outside the
// build config's `rootDir` into a program, and both had to widen `rootDir` to
// the repo root to do it -- the step the remedy named no way to discover until
// #14943, measured on #14835 at 116 x TS6059 inherited and 3 more at `"."`.
const TEST_ROOTDIR_PRECEDENT = '`packages/client/tsconfig.test.json` and `packages/cli/tsconfig.test.json`';
const GENERATED_INCLUDE_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/10880';
// A path in the root program whose edits move the `@objectstack/spec-monorepo`
// count below, declared as a bare, whole-literal path so the dispatch
// derivation can read it. It accounts for 29 of that entry's 80 -- the largest
// single contributor, and the one an ordinary card actually edits. The note on
// that entry carries the accounting and references this constant instead of
// spelling the path inside its sentence.
//
// Why the SHAPE and not just the mention: scripts/pm/dispatch-gates.mjs scans
// each gate's own module body for path literals and accepts one only when the
// WHOLE string is path-shaped. Named mid-sentence, this path was discarded, and
// the family then scored neither matched nor undetermined but SILENT -- printed
// nowhere at all. A card editing that file was told no check family names its
// paths, which reads as "no gates apply" when it means "no gate names this
// path", and a dev who trusted it would skip the gate most likely to redden the
// diff (measured on such a card: this gate stayed green only because the change
// was designed around the coupling).
//
// This is per-coupling manual upkeep, deliberately. The root program is
// everything outside packages/apps/examples, so no list here can ever be
// complete -- add a constant when a coupling has actually been measured, the way
// this one was. Deleting this one does not go quiet: dispatch-gates' own
// self-test pins that a card editing this path derives check:type-check-coverage.
const ROOT_PROGRAM_COUPLED_SCRIPT = 'scripts/check-test-typecheck.mts';
// Package name -> { errors, note? }. `errors` is the raw `tsc --noEmit` count
// measured per package. Seeded on main @ b07d829 (2026-07-31), re-measured
// after the NodeNext repair below, and re-measured WHOLESALE on main @ 5ab08428
// (2026-08-06) when #5278 found the ledger had been drifting untested since:
// 7 of these 15 entries were understated, none overstated. Notes carry the
// composition as measured at that sha; where the delta could not be attributed
// (this repo's clone is shallow, so per-file blame was not available) the note
// says so rather than inventing a story for it.
// Raw counts include all three of #4311's tiers -- code-tier (real defects),
// config-tier (the check itself misconfigured: TS2591/TS2584 missing
// `types:["node"]`, TS2835/TS2307 module resolution) and noise (TS7006
// implicit-any params, TS6133 unused) -- so each note says what the pile is
// made of. Nobody should mistake a config-tier pile for real breakage, or --
// worse -- the reverse: `core` at 91 raw has 3 real errors, while
// `driver-sql`'s 241 were ALL real (and, as the header notes, mostly real
// about the types rather than about the tests).
//
// The tiers are not independent, which is why these numbers get re-measured
// rather than decremented. Under `moduleResolution: NodeNext` a relative
// import without its `.js` extension does not resolve, so every symbol it
// names degrades to `any` -- and each callback over one of those symbols then
// reports TS7006 "implicitly any". Repairing 44 such imports in this PR closed
// 110 errors across eight packages, most of them "noise" that was never noise
// at all, and simultaneously EXPOSED 12 real defects in `service-settings`
// that the unresolved imports had been masking. A config-tier count is an
// upper bound on nothing: fix the config first, then read the residue.
//
// `@objectstack/metadata` GRADUATED from this ledger (#14342; entry: 89 raw,
// repaired to 0, the route #13978 took for `metadata-protocol` and ruled by
// precedent for this one). It is worth a line because it is the sharpest
// measurement of the paragraph directly above, and because the composition
// this entry recorded was wrong in a way no re-measure could show. Its opening
// itemisation read, verbatim, 'code-tier 30 (TS2345 x30); config-tier 25
// (TS2835 x25); noise 34 (TS7006 x33, TS6133)'. Repairing the 25 TS2835 alone
// took the pile to 52 -- ALL 33 TS7006 dissolved (they were the
// cascade, not noise the package owed), while 20 TS2322 and 1 TS18048 appeared
// that the 89 had never contained. So the true code tier was 51, not 30: 30
// TS2345 (the one shared mock `PluginContext` literal, closed by adding the two
// members it lacked to the single factory), 20 TS2322 (`(evt) => arr.push(evt)`
// in a watcher slot typed `void | Promise<void>` -- a concise arrow body whose
// `number` only becomes visible once the callback parameter has a real type,
// and which the void-return assignability rule does NOT forgive because the
// target is a union), and 1 TS18048. This is the `rest` lesson (TS18048 x13
// exposed the same way) measured a second time, on a package whose note had
// already itemised its own tiers with confidence: a tier split read off an
// unrepaired config is a guess about what is UNDER it, and the only honest way
// to size the code tier is to fix the config and look.
//
// `@objectstack/service-cluster` GRADUATED from this ledger (#14181; entry: 1
// raw, repaired to 0). Its single TS2322 was the very shape the paragraph above
// itemises for `metadata` -- `(m) => received.push(m.payload)` in a slot typed
// `void | Promise<void>` -- caught here in the package's own CONTRACT witness.
// It is worth a line because this package reached the ledger by a different road
// than the rest: it had NO `typecheck` script at all, so its build config never
// ran even though that config DOES include the tests. Repaired by the #5286
// route -- a `tsconfig.test.json` over the test layer, named by a new `typecheck`
// script -- so the entry is deleted rather than lowered.
//
// `@objectstack/service-storage` GRADUATED from this ledger (#15050, the
// `packages/services/**` sibling of #14181; entry: 51 raw, repaired to 0 --
// re-measured at exactly 51, confirming the entry's own composition note).
// Same road as `service-cluster` (no `typecheck` script at all; BUILD config
// already includes tests) but a longer repair: unlike that package, THIS
// one's BUILD reading was not already clean, so both programs needed fixing,
// not just the test-only split. code-tier 8 (TS2339 x4 a `driveInsert` test
// helper's spread dropped its `Record<string, unknown>` index signature --
// annotated the return type; TS2347 x4 a fake `ctx: any`'s
// `getService<T>(...)` calls -- converted to `getService(...) as T`, the
// pattern one call site in the same file had already adopted for exactly this
// reason). config-tier 26 (TS2835 x23 relative imports missing `.js` under
// BUILD's NodeNext resolution -- ADDED the extension rather than routed
// around it, since that resolves under both BUILD's NodeNext and the split's
// bundler mode; TS2550 x3 `Array.prototype.at` needing `lib` es2022 --
// rewritten to indexed access rather than widening the shared BUILD
// `tsconfig.json`). noise 17 (TS7006 x15, all a downstream CASCADE from the
// same unresolved imports -- cleared as a side effect of the TS2835 repair,
// the same shape `@objectstack/core` reported at 98 -> 4; TS6196 x1 dead
// import, TS6133 x1 unused param, both one-line). The two readings AGREE at
// 0/0 after repair, the same result `service-cluster` reported.
//
// `@objectstack/service-knowledge` GRADUATED from this ledger too (#15049,
// PR #15032's sibling for `packages/services/**`; entry: 10 raw, repaired to
// 0 under BOTH the build config and the new `tsconfig.test.json` split). This
// one is worth a line because the split did NOT confirm this entry's own
// 3-code-tier guess -- it found 4, the same "a tier split read off an
// unrepaired config is a guess about what is UNDER it" lesson the paragraph
// above states for `metadata` and `service-storage`. Fixing the 3 TS2835 (the
// config-tier third, and the noise: the unresolved imports made
// `KnowledgeService` `any`, which suppressed the TypeScript excess-property
// check on an `ExecutionContext` literal) uncovered a 4th real error the
// undivided reading had masked: `roles: ['member']`, a field the spec renamed
// to `positions` (`execution-context.zod.ts`: "Formerly `roles`") that no
// check had ever read with the renamed type. The other 3 code-tier errors
// were exactly this entry's guess: two `vi.fn()` mocks stubbing
// `IDataEngine.find` typed with fewer parameters than the call site they
// stand in for, so `.mock.calls[N]` indexed past a TS-inferred EMPTY tuple
// (TS2493/TS2352), and a third mock's parameter type omitted the `where`
// field the real call passes (TS2339). All 4 are fixed in the test file,
// matching each mock's type to the call site it stubs; `ExecutionContext`
// itself was not touched (it was correct -- the test's field name was stale).
//
// `@objectstack/service-automation` GRADUATED from this ledger (#15048; entry:
// 3 raw, repaired to 0), the `packages/services/**` sibling of the
// `service-cluster` graduation above (#14181/PR #15032) -- same road in: no
// `typecheck` script at all (only `build` and `test`), and a BUILD
// `tsconfig.json` that does NOT exclude tests, so the program that would have
// read them already existed and was simply never invoked. Measured BOTH ways
// (`tsc -p tsconfig.json`, which already included the tests, vs the new
// `tsconfig.test.json`): 3 and 3 -- the two readings AGREE, so this package
// carried no config-tier pile either, and the 3 were genuinely code-tier from
// the start. All 3 were TS2341 ("Property 'flows' is private..."), all in
// `src/nested-region-parity.test.ts` (95/151/180), where three tests dot-read
// the PRIVATE `AutomationEngine#flows` map directly instead of the class's own
// public accessor -- `await engine.getFlow(name)`, already the idiom every
// other test file in this package uses. Repaired by replacing the three
// private reads with that existing public call (no widened source signature,
// no cast, no bracket-notation workaround); the tests were made `async` where
// they were not already. Repaired by the #5286 route -- a `tsconfig.test.json`
// over the test layer, named by a new `typecheck` script -- so the entry is
// deleted rather than lowered.
const DEBT = {
'@objectstack/cloud-connection': {
errors: 13,
note: 'code-tier 11 (TS2493 tuple indexing) + 2 config-tier.',
},
'@objectstack/hono': {
errors: 3,
note: 'all code-tier (TS2769/TS18046).',
},
'@objectstack/observability': {
errors: 11,
note: 'all code-tier (TS2554 wrong arity x10, TS2552).',
},
'@objectstack/spec-monorepo': {
errors: 26,
compositionAt: 80,
note: 'the workspace root itself: code-tier 4 (TS2304 x2, TS2339 x2); config-tier 68 '
+ '(TS2591 x28 / TS2584 x22 -- the root tsconfig still has no `types:["node"]` -- plus TS2307 x17 '
+ 'and TS2550); noise 8 (TS7006 x7, TS6133). Re-measured 80 at 5ab08428, up from 50. This entry '
+ 'drifts differently from a package: the root program is `scripts/` and the top-level configs '
+ '(everything outside packages/apps/examples), so it grows whenever the repo gains a script -- '
+ ROOT_PROGRAM_COUPLED_SCRIPT + ' alone accounts for 29 of the 80, and the analytics-reconcile '
+ 'tree for 32. Almost all of it is one missing `types:["node"]`, not 80 defects. One wrinkle to '
+ 'know before reading this number as "the root scripts": `exclude` only drops files from the '
+ 'initial walk, so example sources IMPORTED by a script are still pulled into the program -- 4 of '
+ 'the 80 are reported in examples/app-showcase/src, and this entry therefore moves with the '
+ 'showcase as well as with scripts/.',
},
};
// Package name -> why running tsc over it is not applicable at all. An EXEMPT
// entry is a statement about the package's nature, not about its debt; if the
// nature changes, the RECONCILED direction forces this entry out.
const EXEMPT = {
'@objectstack/console':
'Published objectui build artifact -- package.json/README/CHANGELOG plus a dist/ pulled in by `pnpm objectui:refresh`. No TypeScript sources, no tsconfig; the sources are type-checked in the objectui repo.',
};
// Package name -> { errors, note? } for packages whose tsconfig excludes their
// own test files. `errors` is what `tsc --noEmit` reports once the exclusion is
// lifted, measured by re-running each package's own config with the test globs
// dropped. HOW MANY files that exclusion hides is NOT recorded here: this gate
// counts them on every run (`testCoverage()` -> `pkg.testFiles`, sub-second, no
// compiler), so the summary derives the number instead of reading a copy of it
// (#5826). An entry that carries a `tests` field is rejected -- see the
// RECONCILED loop in evaluate(). These packages are NOT uncovered: their src
// type-checks and most declare `typecheck`. What they hide is the test layer,
// which is where #4311 found the defects (a passing vitest run proves the code
// executes, not that the call shapes match). Sorted by what each is hiding,
// worst first.
// `@objectstack/spec` graduated in #5286: `tsconfig.test.json` (a sibling of
// the build config, named by the `typecheck` script) compiles its 295 test
// files, so nothing is hidden any more and TESTS_COVERED no longer wants an
// entry here. The residue that lifting the exclusion surfaced did not vanish
// with the entry -- it moved to `packages/spec/test-typecheck-debt.json`, a
// PER-FILE exact ratchet re-measured by tsc on every run, which is strictly
// stronger than the frozen package-level number this ledger could hold. The
// number that used to sit here (272 files / 902 errors) was also stale by 23
// files, which is the other argument for a measurement the gate derives -- an
// argument this ledger has now acted on rather than only recorded (#5826).
//
// Re-measured wholesale on main @ 5ab08428 (2026-08-06) with the DEBT ledger
// above, for the same reason (#5278): 10 of these 19 `errors` numbers were
// understated, 2 overstated, 7 exact. That sweep also refreshed a SECOND
// hand-written number, `tests`, which had drifted just as far in the same
// direction (66 -> 101 for runtime, 87 -> 125 for objectql; 12 of 19 entries
// stale, every one of them understated) because adding a test file to an
// excluded package was invisible to everything. The two numbers needed
// OPPOSITE repairs, which is why they were two issues: `errors` cannot be
// known without running the compiler, so freeze + re-measure (#5278) is the
// only shape available to it; the file count is FREE -- this gate already
// computes it on every run to decide TESTS_COVERED -- so the fix is to delete
// the copy and read the live count (#5826). A refreshed copy would have been
// accurate for exactly as long as it took the next test file to land; a
// derived one cannot drift at all, which is why this one is gone rather than
// re-ratcheted.
//
// THE THREE ENTRIES THAT ARRIVED WITH #7353 -- cli 188, metadata-fs 6,
// example-showcase 4 -- were not new debt and did not slip past a ledger that is
// closed to it. They are debt this gate had never been able to SEE: TESTS_COVERED
// asked whether an `exclude` named the tests, so a package that had simply never
// pointed `include` at its test tree answered "covered" while nothing compiled a
// line of it. All three were in that state before this ledger existed. 198 raw
// errors is what the blind spot was worth on the day it was measured, and the
// only thing that changed to surface them is the question.
//
// TWO OF THE THREE HAVE SINCE GRADUATED (#7923), which is the point of a TEST_DEBT
// entry: it is a holding position that makes a layer ratchet, not a destination.
// Both were re-measured on the merged ref before repair and both matched their
// recorded numbers exactly (metadata-fs 6, example-showcase 4), so the entries
// were deleted against measurements rather than against hope.
// - `@objectstack/metadata-fs` took the sibling-config route, because its
// `rootDir` is `src` and its `dev` script emits (`tsc --watch`, `outDir:
// dist`): a package-root `rootDir` in the BUILD config would relocate
// `dist/index.js` and start emitting compiled tests. `tsconfig.test.json`
// beside it is named by the `typecheck` script -- the #5286 mechanism, the
// same one `packages/metadata-core` uses for the structurally identical hole.
// - `@objectstack/example-showcase` took the widened-`include` route (#7312's
// shape for app-crm / app-todo), because its `rootDir` is already `.` and
// nothing needed neutralising. Its glob was `e2e/**/*.spec.ts` and NOT
// `e2e/**/*`, holding the same line the deleted entry's note drew: the
// wholesale glob would pull in `e2e/global-setup.ts`, a fixture rather than a
// test, and bill the test layer 6 errors that are not its own.
// (historical: until #8062 / PR #8178, that file sat read by no tsc program
// at all. The package's own tsconfig now takes the wholesale `e2e/**/*`
// glob directly, and the 6 errors are fixed at their source -- a file-local
// `declare const process` plus `mkdirSync`/`writeFileSync` on the `node:fs`
// shim in `examples/app-showcase/types/node-shim.d.ts` -- so
// `global-setup.ts` is now read and type-checks clean. The lesson survives
// the fix: widen a hidden test layer's `include` one file at a time and
// measure each addition, because a wholesale glob can bill the layer for a
// non-test file it never asked to cover.)
// `@objectstack/cli` (144 raw across 65 files, after #8612 repaired the first
// two of its 59 missing import extensions) is deliberately NOT part of that
// graduation -- it is a programme rather than a sitting, and its entry stands.
//
// `@objectstack/trigger-record-change` GRADUATED from this ledger (entry: 9 raw
// TS2353, re-measured 0). It is worth a line here because BOTH remedies the
// graduation message offered at the time were wrong for it -- that message has
// since been split per ledger (#11491), and this is the case it was split on:
// - "add a `typecheck` script" -- it already had one, and always had. The
// package was never in DEBT's hole ("src does not check"); it was in this
// ledger's ("src checks, tests are hidden"), which is why the message's
// first branch has nothing to do.
// - "drop the test exclusion" -- MEASURED as a red `main`. It resolves
// exactly the 10-file program this ledger's re-measure scored at 0 and
// leaves `dist/` byte-identical (tsup builds `src/index.ts` alone), but the
// 7 tests it re-admits import four workspace packages the BUILD config's
// program never contained, and `check:type-source-resolution` goes 0 -> 1
// naming them, against a registry that is shrink-only and whose own message
// rules that widening the entry is not the fix.
// So it took the #5286 sibling route (`tsconfig.test.json` named by the
// `typecheck` script), which puts the same 10 files in front of tsc while
// leaving `tsconfig.json` -- the only config that gate reads -- untouched. The
// general lesson, which is this ledger's to carry: the two remedies are
// interchangeable only where the excluded tests import nothing the src layer
// does not, and that is a property to MEASURE per package, never to assume.
//
// `@objectstack/rest` GRADUATED from this ledger (#12542; entry: 155 raw,
// re-measured 37 raw across 13 files under the sibling program). It is worth a
// line because the gap between those two numbers is the whole argument for
// fixing the CONFIG before reading the residue, and this entry's own note had
// predicted it: 121 of the 155 were TS2835 plus the implicit-any pile it
// causes, and TS2550 x16 was one `Array.prototype.at` message against a `lib`
// older than es2022. Under `tsconfig.test.json`'s vitest-matching module
// semantics both classes go to ZERO -- TS2835 x72 -> 0, TS2550 x16 -> 0,
// TS7006 x49 -> 4 -- because this package is `"type": "module"`, so NodeNext
// was compiling extensionless relative imports as unresolvable ESM and every
// symbol they named was `any`. What is LEFT is a different shape from what the
// old tally described, and it grew in one place while collapsing in four:
// TS2554 x14 (unchanged to the unit), TS18048 x13 (a class the 155 never
// contained AT ALL -- 'possibly undefined' reads that only become visible once
// the imports above them resolve to real types), TS2345 x5, TS7006 x4,
// TS6133 x1 = 37. That is the #8612 lesson this ledger already carries,
// measured a second time: collapsing a cascade EXPOSES errors as well as
// removing them, and a note sized on the TS2835 line alone would have read as
// "155 minus 121 = 34" and been wrong in both directions.
//
// `@objectstack/plugin-security` GRADUATED from this ledger (#13176; entry: 11
// raw, re-measured 11 EXACTLY at aa16721b6 before repair — same 6 files, same
// per-file counts as the card measured at 1a540e82b, so the number was still
// true when it was retired). It is worth a line for what the split between the
// two module semantics says, because this package is the third shape:
// `packages/rest` fixed the CONFIG and 118 of its 155 collapsed; here the
// config-tier is only 2 of the 11 — TS1470 (`import.meta` compiled as CommonJS)
// and TS2550 (`Array.prototype.at` against a `lib` older than es2022) — and the
// other 9 were real test-code type errors, every one of them REPAIRED in the
// same PR rather than ledgered. So this package leaves with no
// `test-typecheck-debt.json` at all, the call `metadata-core`, `metadata-fs` and
// `trigger-record-change` made: at zero residue a bare
// `tsc --noEmit -p tsconfig.test.json` is the stronger gate, since any error is
// red immediately with no ledger to be added to. The TS1470 is the one worth
// carrying forward: `src/seed-write-refusal.test.ts` documents an author
// steering AROUND that diagnostic — writing `__dirname` instead of
// `import.meta.url` — to keep this entry from going 11 to 12, for a program
// whose verdict no `typecheck` script ever ran. A hidden layer does not only
// hide errors; it also shapes the code written into it.
//
// SINCE MEASURED, across this whole ledger rather than on that one package
// (#11491, at e47d5ef61, by dropping each entry's `"**/*.test.ts"` exclusion
// and reading `check:type-source-resolution`): 14 of the 18 entries that HAVE
// an exclusion go red the way trigger-record-change did, 4 stay green
// (`objectql`, `lint`, `formula`, `verify`), and the 19th (`cli`) has no
// exclusion to drop at all -- its tests are hidden by an `include` that never
// reaches them. So that package was the majority case, not the exception, and
// the graduation message no longer offers the exclusion route without its
// precondition. Re-measure before relying on the split: it moves with every
// import a test file gains.
//
// ── #14062: three plugin entries GRADUATED, and what replaced them ───────────
//
// `plugin-approvals` (345), `plugin-auth` (94) and `plugin-sharing` (3) left
// this ledger on 2026-09-02, and `knowledge-ragflow` (4) left DEBT above in the
// same change. ⛔ None of them was PAID DOWN — read that first, because a
// deleted debt entry normally means the errors are gone and here it does not.
//
// The director ruling of 2026-09-01 on #14062 (maintainer verbatim: 「同意」)
// onboarded all fourteen `packages/plugins/**` packages into the
// `check:test-typecheck` instrument: each has a `tsconfig.test.json` its
// `typecheck` script NAMES, so `hidesTests` is false for all of them and this
// gate's per-PACKAGE approximation of the debt has nothing left to approximate.
// The same errors are now held one level finer, per FILE and per SIGNATURE, in
// each package's own `test-typecheck-debt.json` — 324 / 94 / 3 / 3 respectively
// (approvals reads 324 rather than 345 because the test program uses vitest's
// module semantics, which subtracts 21 config-tier diagnostics that were never
// about the tests). That is the graduation this ledger's own message asks for:
// "the repair is the same one spec took -- put the file in a tsc program", and
// the entry goes when the program exists, not when the number reaches zero.
//
// `@objectstack/lint` (16) left this ledger under the same shape on 2026-09-02
// (#14173, wired to this instrument per the triage ruling on that card): the
// recorded 16 (TS7006 x11, TS2835 x5) was entirely a config-tier pile -- it
// dissolves under `tsconfig.test.json`'s `bundler` resolution, the same effect
// measured above -- and the 6 residual TS6059 that replace it in
// `test-typecheck-debt.json` were never part of the 16 at all; this entry's own
// prior note already excluded them ("never this package's debt ... reporting on
// its own inherited rootDir").
//
// ── #14504: `@objectstack/runtime` GRADUATED, and it was not paid down ──────
//
// `runtime` (206) left this ledger on 2026-09-03, under the same shape as the
// four above and for the same reason: it now has a `tsconfig.test.json` its
// `typecheck` script NAMES, so `hidesTests` is false for it and this gate's
// per-PACKAGE approximation has nothing left to approximate. ⛔ Read that first
// — a deleted TEST_DEBT entry normally means the errors are gone, and here it
// does not. Not one of the 206 was repaired.
//
// The identical population is now held one level finer, per FILE and per
// SIGNATURE, in `packages/runtime/test-typecheck-debt.json`: **191 errors
// across 27 of the package's 214 test files**, measured at 224f8ea4a0 with the
// closure built. The 206 -> 191 step is the config-tier subtraction this
// ledger's top note describes, attributed in both directions with no
// remainder: -19 that dissolve under the test program's vitest-matching module
// semantics (TS2835 x13, the TS7006 x4 cascading above them, TS2550 x2) and
// +4 that collapsing the cascade EXPOSED (TS2322 x4 in
// src/seed-loader.test.ts, previously masked by an `any` from the unresolved
// import). The 206 recorded here was exact and stayed exact to the end.
//
// ⚠️ The pin half of this gate reported nothing for `runtime` in either
// direction and still does not: measured on the way out, this package's test
// layer holds ZERO `@ts-expect-error` directives, so PINS_CHECKED had no
// subject here. The hole was the 206 errors nothing read, not a dead pin.
//
// So the shrink-only guarantee did not loosen here; it moved to a strictly
// sharper instrument, one that also reddens on a wholesale substitution of
// error IDENTITY at a constant total, which a per-package integer cannot see.
const TEST_DEBT = {
// ── #14710: `@objectstack/cli` GRADUATED, and it was not paid down ─────────
//
// `cli` (144) left this ledger on 2026-09-03, under the same shape as the five
// above and for the same reason: it now has a `tsconfig.test.json` its
// `typecheck` script NAMES, so `hidesTests` is false for it and this gate's
// per-PACKAGE approximation has nothing left to approximate. ⛔ Read that first
// — a deleted TEST_DEBT entry normally means the errors are gone, and here it
// does not. Not one of the 144 was repaired: this change edits no test file.