-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathcheck-dual-build-cjs-loads.mjs
More file actions
1605 lines (1520 loc) · 89 KB
/
Copy pathcheck-dual-build-cjs-loads.mjs
File metadata and controls
1605 lines (1520 loc) · 89 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-dual-build-cjs-loads -- every published `require` entry point must
* actually load.
*
* node scripts/check-dual-build-cjs-loads.mjs # the gate
* node scripts/check-dual-build-cjs-loads.mjs --list # every entry it found
* node scripts/check-dual-build-cjs-loads.mjs --self-test # verify the checker
*
* ## The bug it exists to prevent (#12971)
*
* `@objectstack/metadata-core` declares `"type": "module"` with a dual
* `exports` map, so `require('@objectstack/metadata-core')` is a published,
* supported entry point. #12843 added `createRequire(import.meta.url)` to one
* source file -- correct for the ESM output, and written with a `try`/`catch`
* around it and a comment reasoning that the CJS build would see `undefined`
* there. tsup does not rewrite the identifier: at this build `target` esbuild
* emits `import.meta` VERBATIM into `dist/index.cjs`, and outside an ES module
* that is a PARSE-time error. The module therefore never begins executing --
* so neither the `typeof require === 'function'` fast path above it nor the
* `catch` around it ever runs, and the package's WHOLE `require` condition is
* unloadable, for every consumer and every code path:
*
* $ node -e "require('./dist/index.cjs')"
* SyntaxError: Cannot use 'import.meta' outside a module
*
* Downstream (measured in `objectstack-ai/cloud`): `@objectstack/organizations`
* resolves through that condition, fails to load, and the ADR-0093 D5
* fail-closed tenancy wall then correctly refuses to boot the walled EE
* runtime -- reddening cloud's only required check. One line in one source
* file, one release away from every CJS consumer of the package.
*
* Two properties make this class invisible without a gate, and they are why
* this is a gate rather than a fixed line:
*
* 1. **The repo's own tests never see it.** vitest resolves workspace
* packages through the `import` condition (and often through source
* aliases), so every suite stays green while the `require` half is
* rubble. `pnpm build` is green too -- the bytes emit fine, they just
* cannot be parsed by the runtime they are declared for.
* 2. **The author cannot see it either.** The two sibling packages that hit
* this before (`packages/runtime` #10993, `packages/metadata-protocol`
* #11235) each carry a long `shims: true` comment in their tsup config
* warning the next author -- and the next author was in a THIRD package
* that had no such comment, because there is nothing to read a comment
* in a file you never open.
*
* ## What it checks, per published `require` entry point
*
* PARSES every CommonJS file the package emits (`dist/**\/*.cjs`, plus
* `*.js` when the manifest is not `"type": "module"`) parses as
* CommonJS. This is the #12971 class, and it is checked over the
* whole emitted set rather than the entry alone because code
* splitting puts the offending line in a shared chunk as easily as
* in `index.cjs`.
* LOADS `require(<entry>)` in a fresh child process completes.
* AGREES for the entries a probe is declared on
* (`DUAL_FORMAT_BEHAVIOUR_PROBES` below), the same exported call
* answers the same value through the `require` and the `import`
* condition, and that value is the declared one. Loading is not
* agreement, and the #12971 repair works by making the CJS output
* resolve the SAME anchor -- so agreement is the property a
* regression would actually break, quietly.
* TYPED the declaration a `require` consumer RESOLVES is CommonJS-
* flavoured and is really on disk. This is the #13112 class and it
* is a different question from the three above: those ask whether
* the JavaScript works, this asks whether the TYPES the same
* consumer reads describe the module kind they were resolved for.
*
* ## Why BOTH, when either alone would have caught #12971
*
* They fail differently and neither subsumes the other. A parse check is total
* -- it reads every emitted byte, including chunks no entry happens to pull in
* on the day it runs -- but it says nothing about a module that parses and
* then throws at load. A `require()` smoke is the real question a consumer
* asks, but it only ever reaches the graph the entry actually imports. Running
* both costs one extra spawn per file and removes the "gate was green, package
* still broken" answer from both directions.
*
* ## The ledger, and the one thing it may never silence
*
* `scripts/dual-build-cjs-loads.baseline.json` is a shrink-only, hand-edited
* list of entries that legitimately cannot be `require()`d, each with a
* reason. It exists because a `require` condition can be unloadable for a
* cause that is not ours at all: `@objectstack/metadata-core`'s `./testing`
* subpath and `@objectstack/service-cluster`'s re-export vitest, and vitest
* REFUSES to be required from CommonJS by design ("Vitest cannot be imported
* in a CommonJS module using require()"). That is a real declared-but-unusable
* entry point -- a separate defect from this one, filed rather than fixed here
* -- and pretending the gate is green over it would be the lie this file
* exists to stop.
*
* ⛔ **A SyntaxError is never ledgerable.** The ledger accepts a load-time
* failure only; an entry whose emitted bytes do not PARSE is refused with a
* pointer back to the shim, whatever the ledger says. That asymmetry is the
* whole gate: a runtime load failure is a fact about a dependency, a parse
* failure is always a fact about what WE emitted. Pinned in `--self-test`.
*
* The ledger reconciles in BOTH directions, and "both" is two checks rather
* than one, because a row goes stale two different ways:
*
* * a ledgered entry that now LOADS is a finding -- the exemption must be
* deleted in the same PR that fixes it, so a stale row is an error rather
* than dead text;
* * a ledgered entry that is no longer in the POPULATION at all -- the
* subpath stopped declaring a `require` condition, the package was renamed
* or unpublished -- is a finding too, and until #13014 it was not. Nothing
* else could see it: the only read of the ledger was `ledger[r.id]` from
* inside the walk over DISCOVERED rows, so a key no row names was never
* looked up, and a lookup that never happens cannot report. Measured on
* `8cb96ec41b` before the fix -- a row exempting a package that does not
* exist left the pass line byte-identical, exit 0, the id unmentioned.
*
* The second direction is the shape the card is about rather than a detail of
* this file: a lookup that comes back empty must be an ERROR, never silence.
* The same file already had it right one invariant over -- `runBehaviourProbes`
* refuses a probe naming an entry point that no longer exists, because it "is
* asserting nothing" -- so the ledger was the odd one out, not a new idea.
*
* ## Vacuity floors -- an empty sweep reports what a clean tree reports
*
* Every count in this gate's pass line is also a way for it to pass having read
* nothing: a manifest walk that discovers nothing, an `exports` resolver that
* reads no `require` condition, a CommonJS collector that matches no file, a
* probe table that was emptied. Each produces zero findings, and zero findings
* is exactly what success looks like. So each carries a floor derived from the
* census recorded in `MEASURED` below and held with margin, and below any of
* them the gate REFUSES (`exit 2`) rather than passing. Same idiom and same
* reason as `check-keyed-text-bounds.mjs` (five floors) and
* `check-undeclared-dep-imports.mjs` (three) -- and, measured 2026-08-29, the
* same exposure below: those are the only other two gates in `scripts/` that
* record a census beside inequality floors, and both had already drifted from
* their own record.
*
* ## The provenance of a floor, and how to reproduce it
*
* `MEASURED` is a claim about ONE named commit -- `MEASURED.ref` -- and never
* about `main`. It is what this gate's own instrument printed on that tree, and
* it is what the floors under it were derived from. It is NOT a statement about
* the tree you are running on. To re-derive it, take the ref from `MEASURED`
* (every passing run prints it) and:
*
* git worktree add --detach ../os-cjs-provenance "$REF" # $REF = MEASURED.ref
* cd ../os-cjs-provenance
* node scripts/check-dual-build-cjs-loads.mjs --list # entries + packages
*
* ⚠️ Two of the four counters are cheap to reproduce and two are not, and that
* asymmetry is the thing a reader most needs to know:
*
* entries, packages read from `packages/**\/package.json` ALONE -- no
* install, no build, because `--list` returns before the
* prerequisite check. Re-derived at `MEASURED.ref` on
* 2026-08-29: 103 / 67, exactly the record.
* cjsFiles, probes read from emitted `dist/`, so reproducing them costs a
* full `pnpm install && pnpm build` AT that ref. ⛔ Do
* not read a matching `cjsFiles` as corroboration of the
* other two -- it moves with the BUILD, not with the
* source. On 2026-08-29 the built tree read 610 against a
* recorded 613 while `probes` still matched exactly.
*
* ## This population shrinks for good reasons, not only grows
*
* A floor reads as if the thing under it only ever rises. This one does not. A
* package with no `exports` map publishes its `main` AS a require entry point,
* so giving it an accurate ESM-only `exports` map REMOVES it from this
* population -- an improvement that decrements `entries` AND `packages`. That
* is exactly how this record first parted company with the tree: it was exact
* at `MEASURED.ref` and stayed exact for its whole life until
* `@objectstack/cli` left the population that way. ⇒ a drop here is not
* evidence of a broken walk; only a drop below the floor is.
*
* ## Why no equality against the tree, and why no band either
*
* An equality reds on every legitimate move, in both directions -- the very PR
* that declared the `exports` map above would have reddened it. A band around
* the record is the next thing to reach for, and it fails a derivation rather
* than a taste test. Two reasons, either one sufficient:
*
* 1. The band already exists and is called the FLOOR. `MIN_ENTRIES` IS
* `MEASURED.entries` minus the headroom this gate declared. A second,
* narrower band would be a second tolerance for one fact, and its width
* would be invented rather than measured.
* 2. No width measures anything. Measured 2026-08-29, the three gates in
* `scripts/` carrying a record of this shape had drifted -1, -5 and +15
* from theirs, in both directions, within days of landing. A band narrow
* enough to notice this file's -1 reds on the +15 next door; one wide
* enough to survive the +15 cannot see a -1.
*
* So the repair is not enforcement. What was missing is that a GREEN run never
* showed the reader the two numbers side by side, so the record could stop
* describing the tree with nothing, anywhere, saying so. `provenanceLine`
* prints both on every pass: the drift is a fact in the log now, not a
* discovery.
*
* ## Where it runs, and why not in the lint job
*
* It needs a real `dist/`, so it is a step in **Build Core** (ci.yml), beside
* "Verify capability packages ship a runtime entry" and "No compiled test
* files in any dist" -- the same genre, the same phase, and a required
* context. With no `dist/` it exits 3 (`PREREQUISITE NOT MET`) naming
* `pnpm build`; ⛔ it never degrades to a silent green (Route & surface
* ownership §3: a verifier that quietly skips is worse than none).
*
* It is the DYNAMIC half of a pair. `scripts/check-published-files.mjs` owns
* the static half -- that the manifest's declared paths are whitelisted for
* npm -- and cannot know whether the bytes at those paths load. Same split as
* `check-override-consistency.mjs` (static) beside `publish-smoke.sh`
* (dynamic); the publish smoke does not cover this because the project it
* scaffolds is ESM and never calls `require()`.
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isEntrypoint } from './invoked-as.mjs';
// ── The self-test's own battery roster and floor (#13489) ──────────────────
//
// This self-test used to decide success by "no failure was recorded" and
// nothing else, so "every case held" and "the cases never ran" printed the same
// line. Closed the way PR #13487 validated on check-doc-authoring: what is
// pinned is the registered NAMES, not a number. Every section opens with
// `battery('<name>')`, every assertion is attributed to the battery most
// recently opened, and the floor requires the OPENED set to equal the DECLARED
// set with each battery at or above its own count.
//
// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 keeps
// a total "right" the moment a sibling grows. A set difference says WHICH
// battery stopped; a count says only that something did.
//
// The counts are a FLOOR, not an equality — adding cases is ordinary work and
// must not red. A battery BELOW its floor means cases stopped running; the
// remedy is to find what stopped registering.
//
// The machinery lives HERE, at module scope, rather than inside the self-test:
// this self-test's assertion sink is not a block-bodied helper in its body (it
// is a concise arrow, or a module-scope function), so there is no in-body
// helper to thread a per-run ledger through. Module scope is safe because the
// self-test runs once per process, and it is what lets the existing sink route
// through `registerCase()` with no case rewritten and no assertion changed.
const SELF_TEST_BATTERIES = Object.freeze({
'the declaration itself': 4,
'the exports resolver': 8,
'the types resolver: what a `require` consumer actually READS': 6,
'module kind: the reason the invariant is not "must end in .d.cts"': 6,
'diagnostics classification': 3,
'the fixture tree': 7,
'the ledger, both directions': 10,
'AGREES: the cross-format behaviour probe, both directions': 5,
'TYPED: the #13112 class, in both directions': 9,
'TYPED_EXEMPTIONS, both directions': 8,
'prerequisite, never a silent green': 1,
'a built tree missing one declared target IS a finding': 1,
'the vacuity floors, each driven to zero': 13,
'provenance: the record must stay reproducible, and visibly so': 6,
'the real ledger is well-formed and shrink-only in shape': 6,
});
// DELETING an entry silences that battery's floor exactly as effectively as
// zeroing it, so the roster's own size is pinned too.
const SELF_TEST_BATTERY_FLOOR = 15;
// The key an assertion is filed under when no battery is open. It is not a
// declared battery, so it reds by the same set difference rather than silently
// inflating whichever battery happened to run last.
const UNATTRIBUTED_BATTERY = '(no battery open)';
// ⚠️ None of these helpers is named with a self-test spelling, deliberately and
// on the record: `check:pm-dispatch-gates` anchors on a top-level declaration
// whose NAME spells self-test, and every such name owes a row in that gate's
// COMPOUND_ANCHOR_LEDGER. These are the battery ROSTER's machinery -- they hold
// no fixtures to mask and read no path literal -- so the accurate name is the
// one that says `battery`, not the one that would owe a ledger row for a role
// this code does not have.
/** Cases registered per battery: `battery()` opens one, `registerCase()` files into it. */
const batteryCases = new Map();
let openBattery = null;
/** Open a battery. Every assertion after this line is attributed to it. */
function battery(name) {
openBattery = name;
}
/** Called by the self-test's own assertion sink, once per assertion. */
function registerCase() {
const name = openBattery ?? UNATTRIBUTED_BATTERY;
batteryCases.set(name, (batteryCases.get(name) ?? 0) + 1);
}
/**
* The floor: every declared battery RAN, and ran its cases (#13489).
*
* Evaluated after every battery has had its chance and BEFORE the verdict, so
* the success line can only be printed by a run in which the set of batteries
* that registered assertions EQUALS the set declared.
*/
function batteryFloorFailures() {
const declared = Object.keys(SELF_TEST_BATTERIES);
const problems = [];
if (declared.length < SELF_TEST_BATTERY_FLOOR) {
problems.push(
`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned `
+ `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`,
);
}
for (const [name, count] of batteryCases) {
if (declared.includes(name)) continue;
problems.push(
`self-test battery "${name}" registered ${count} case(s) but is not declared in `
+ 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.',
);
}
for (const name of declared) {
const count = batteryCases.get(name) ?? 0;
if (count >= SELF_TEST_BATTERIES[name]) continue;
problems.push(
count === 0
? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. `
+ 'The verdict below would have claimed those cases hold.'
: `self-test battery "${name}" registered ${count} case(s), below its pinned floor of `
+ `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`,
);
}
if (problems.length) {
problems.push(
'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the '
+ 'number. Find what stopped registering (an early return, a deleted block, a guard that now '
+ 'skips) and restore it.',
);
}
return problems;
}
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '..');
const SCAN_ROOT = 'packages';
const BASELINE_PATH = 'scripts/dual-build-cjs-loads.baseline.json';
/**
* ## The dispatch-gates declaration -- the `ROOT_DIR_WATCH_HINTS` idiom
*
* `scripts/pm/dispatch-gates.mjs` derives WHICH gates a card owes by matching
* path literals in a gate's source against the card's changed files. `SCAN_ROOT`
* above is a bare single-segment word, which `hintCovers` refuses as too
* generic, and the population sentence lives in prose the hint extractor masks
* by design -- so without this declaration the gate would be invisible to the
* derivation for every card in the tree, including the one shape it exists to
* catch.
*
* Two literals -- the two files whose CONTENT this gate's verdict is a
* function of, and which it can name at a precision worth having:
*
* `packages/**\/package.json` declares the `require` condition -- adding
* one puts a new entry into the population.
* 74 tracked files, 1.0% of the tree.
* `packages/**\/tsup.config.ts` decides the emitted bytes; DROPPING
* `shims: true` is how a fixed package
* regresses, and it is a one-line edit that
* touches no source file at all. 20 tracked
* files, 0.3%.
*
* ⛔ A third literal is deliberately NOT declared, and the omission is
* measured rather than an oversight. #12843 arrived through
* `packages/**\/src/**` -- one source line, no manifest change, no config
* change -- so that spelling has the best recall of the three. It reaches
* **4482 files, 62.2% of the tracked tree** (98.8% of them really are in a
* dual-built package, so the imprecision is not the problem). Declaring it
* would name this gate on nearly every card in the repo, which is the trade
* `scripts/pm/bare-root-worklist.mjs` records as REFUSE-WIDE for rows at 39%,
* one column narrower than this one: recall bought at the cost of precision,
* on the column whose whole value is precision. And this gate does not READ
* those files at all -- it reads `dist/` -- so declaring them would be
* declaring a population the gate does not walk, which that ledger names as
* the costlier error of the two. The recall is not lost: the gate is a step in
* **Build Core**, a required context that runs on every PR, so the cost of the
* omission is one CI round trip, not a missed defect. The row in that ledger
* carries this measurement.
*
* ⛔ Spelled as LITERALS, never built from `SCAN_ROOT` -- the extractor reads
* source text, so a computed template would produce no hint and leave the gate
* as invisible as no declaration at all. Pinned in `--self-test`.
*/
const ROOT_DIR_WATCH_HINTS = ['packages/**/package.json', 'packages/**/tsup.config.ts'];
/**
* ## AGREES -- the third invariant, and the one loading alone cannot give you
*
* A dual build has two outputs of one source, and "both load" is a weaker
* claim than "both behave the same". #12971's fix works by making the CJS
* output derive a REAL module URL rather than carrying `import.meta` verbatim,
* so the thing actually at risk on a regression is not loadability but
* AGREEMENT: a shim that resolved to the wrong anchor would load fine and
* quietly answer `null` where the ESM build answers a version — and `null` is
* a legal value here (`resolveInstalledSpecVersion()` returning it closes the
* ADR-0087 forward-conversion window), so the whole degradation is silent.
*
* Each probe names one export, an argument-free call, and the expected value,
* evaluated in BOTH conditions of the same package and required to match each
* other AND the expectation. Node evaluates the two in separate child
* processes, so neither can borrow the other's module graph.
*
* ⛔ This lives here rather than in the package's vitest suite because it can
* only be asked of BUILT output, and `Test Core` has none: turbo's `test` task
* declares `dependsOn: ["^build"]` (dependencies only, never the package's own
* dist) and explicitly excludes `dist/**` from its inputs, so a suite reading
* its own `dist/` would be unbuilt in CI and un-invalidated by a rebuild —
* the `check:cross-package-test-inputs` failure shape, one package over. The
* package's own unit suite still covers the FUNCTION; what needs built bytes
* is the cross-FORMAT claim, and this is where built bytes exist.
*
* @type {{pkg: string, subpath: string, export: string, expect: string, why: string}[]}
*/
const DUAL_FORMAT_BEHAVIOUR_PROBES = [
{
pkg: '@objectstack/metadata-core',
subpath: '.',
export: 'resolveInstalledSpecVersion',
// The installed @objectstack/spec version, read from the workspace rather
// than hard-coded, so the probe survives every release.
expect: 'spec-version',
why: "#12843's intent: the ADR-0087 forward-conversion window opens only on positive version evidence, and #12971 was a broken attempt to obtain that evidence from the CJS build. Both formats must answer the installed spec version, not `null`.",
},
];
/**
* ## TYPED's declared exemptions -- and the one thing they are NOT
*
* Giving a `require` condition its own `.d.cts` splits one declaration into
* two, and TypeScript compares a class with a `private` member NOMINALLY: two
* declaration files mean two incompatible identities of the same class, even
* byte-for-byte identical ones. That is TypeScript's dual-package hazard, on
* the type axis, and it is a property of the DEPENDENCY, never of the consumer
* that trips over it.
*
* `@objectstack/core` is where the repo actually meets it. `ObjectKernel`
* carries `private plugins` and travels through `PluginContext.getKernel()`
* into every plugin's `init`, so the moment core ships two declarations, any
* compilation that reaches core through BOTH resolution modes sees two
* `ObjectKernel`s. Measured on this branch, whole-repo `pnpm build`:
*
* 28 packages split (core included) RED — @objectstack/verify, 5 × TS2345
* 27 packages split (core held back) GREEN — 71/71 tasks
* 28 + the 37-package ESM mirror RED — @objectstack/plugin-dev, TS2345
*
* The mirror round is the informative one: it moves the failure rather than
* removing it. Splitting `@objectstack/objectql` is what fixes `verify` and
* what breaks `plugin-dev`, because a split dependency resolves core one way
* from its `.d.mts` and the other from its `.d.ts`. So the exemption is not
* "core is hard", it is: **core cannot be split until `ObjectKernel`'s
* identity stops being nominal**, and that is a decision about core's public
* types, not about an exports map.
*
* ⛔ An exemption here is NOT a quieter red. It is a declaration that the
* entry's `.d.cts` ships unreachable ON PURPOSE, with the measurement that
* says why. Shrink-only: an entry that can be split must lose its row in the
* same PR that splits it, and a row naming an entry point that no longer
* exists is a finding (the same both-directions rule the load ledger learned).
*
* @type {Record<string, {reason: string}>}
*/
const TYPED_EXEMPTIONS = Object.freeze({
'@objectstack/core#.': {
reason: 'Splitting core\'s declarations gives `ObjectKernel` (which carries `private plugins`) two nominal identities, and it crosses every plugin boundary via `PluginContext.getKernel()`. Measured: with core split, `pnpm build` fails in @objectstack/verify with 5 × TS2345; with core held back and the other 27 packages split, 71/71 tasks pass. Blocked on a decision about core\'s public types, not on this exports map (#13112).',
},
'@objectstack/core#./logger': {
reason: 'Same declaration set as `@objectstack/core#.` — the subpath is emitted from the same tsup pass and splits the same identities. Held back with its sibling so core has one resolution story rather than two (#13112).',
},
});
const EXIT_OK = 0;
const EXIT_FINDINGS = 1;
const EXIT_REFUSE = 2;
const EXIT_PREREQ = 3;
// ---------------------------------------------------------------------------
// Vacuity floors, and the provenance of the census they were derived from --
// the header is the authority on how to reproduce it and on why it is recorded
// rather than enforced. The ref lives INSIDE the record, so a count and the
// tree it came from cannot be edited apart, and every site that quotes either
// interpolates from here instead of restating it. Re-measuring UP is free;
// LOWERING a floor to make a run pass is the move this block exists to make
// visible in a diff.
const MEASURED = Object.freeze({
// The commit this census was taken on -- a full install and build, then this
// gate. Immutable, so the record stays reproducible forever even as `main`
// moves away from it. ⛔ Never repoint it without re-running all four counts.
ref: '8cb96ec41b',
entries: 103,
packages: 67,
cjsFiles: 613,
probes: 1,
});
const MIN_ENTRIES = 90;
const MIN_PACKAGES = 58;
const MIN_CJS_FILES = 520;
const MIN_PROBES = 1;
const MIN_TYPED_JUDGED = 88;
/**
* TYPED's own census record -- a SECOND single-ref claim beside `MEASURED`,
* never a fifth count inside it. The four counts above were taken on
* `MEASURED.ref`, where the TYPED invariant's subject did not exist yet (the
* 27 typed manifests are what #13112's fix landed), so folding this number
* under that commit's name would make the refusal message cite a tree the
* number does not come from — a small lie in the one place a reader goes to
* decide whether a floor is trustworthy. Same discipline as `MEASURED`: one
* claim about one named commit; ⛔ never repoint without re-running the count.
* `dist/` is unaffected by that diff (an `exports` map decides what a resolver
* READS, never what tsup EMITS), so the count is the one a clean build of that
* commit produces.
*/
const MEASURED_TYPED = Object.freeze({ ref: '196612a313', typedJudged: 102 });
/**
* The first floor a run falls below, as a refusal message -- or `null` when
* every count clears. Pure, so `--self-test` drives every floor with no tree.
*
* @param {{entries?: number, packages?: number, cjsFiles?: number, probes?: number}} counts
* @returns {string | null}
*/
export function floorProblem(counts) {
const rows = [
[counts?.entries ?? 0, MIN_ENTRIES, MEASURED.entries, 'published `require` entry point(s)',
'The manifest walk or the `exports` resolver broke. With no entries nothing is required, nothing is parsed, and the gate prints what a clean tree prints.',
MEASURED.ref],
[counts?.packages ?? 0, MIN_PACKAGES, MEASURED.packages, 'publishable package(s)',
'Entries were found but collapsed onto a fraction of the tree — the walk is reading part of `packages/`, not the whole of it.',
MEASURED.ref],
[counts?.cjsFiles ?? 0, MIN_CJS_FILES, MEASURED.cjsFiles, 'emitted CommonJS file(s)',
'This is the PARSES population. `commonJsFilesUnder` matched (almost) nothing, so `node --check` ran over an empty set and every byte we emit went unread.',
MEASURED.ref],
[counts?.probes ?? 0, MIN_PROBES, MEASURED.probes, 'cross-format behaviour probe(s) run',
'AGREES is the invariant loading alone cannot give you, and an empty probe table satisfies it vacuously.',
MEASURED.ref],
[counts?.typedJudged ?? 0, MIN_TYPED_JUDGED, MEASURED_TYPED.typedJudged, 'require entry point(s) JUDGED by TYPED',
'This is the TYPED population — entries reached and answered, clean or not. It does not move when packages are defective, only when the row loop stops asking, so a fall here means TYPED went silent rather than that the tree got worse.',
MEASURED_TYPED.ref],
];
for (const [got, min, measured, what, why, at] of rows) {
if (got >= min) continue;
return `measured only ${got} ${what}, below the floor of ${min} (${measured} on ${at}).\n`
+ ` ${why}\n`
+ ' ⛔ NOT a pass: nothing, or nearly nothing, was read.';
}
return null;
}
/**
* The provenance footer for a PASSING run: the census this run read, the floors
* it cleared, the census those floors were derived from, and the ref that
* census belongs to -- side by side.
*
* This is the whole repair. The floors are inequalities on purpose, so no run
* can ever contradict the record; without this line the record could stop
* describing the tree and every green log would look identical either way. The
* delta is reported as INFORMATION and never as a verdict: this population
* moves in both directions for good reasons (see the header), and only the
* floors decide anything.
*
* Pure, so `--self-test` drives it with no tree.
*
* @param {{entries?: number, packages?: number, cjsFiles?: number, probes?: number}} counts
* @returns {string}
*/
export function provenanceLine(counts) {
const got = [counts?.entries ?? 0, counts?.packages ?? 0, counts?.cjsFiles ?? 0, counts?.probes ?? 0];
const rec = [MEASURED.entries, MEASURED.packages, MEASURED.cjsFiles, MEASURED.probes];
const floors = [MIN_ENTRIES, MIN_PACKAGES, MIN_CJS_FILES, MIN_PROBES];
const delta = got.map((g, i) => (g === rec[i] ? '=' : `${g > rec[i] ? '+' : ''}${g - rec[i]}`));
return ` provenance — entries/packages/cjsFiles/probes: this run ${got.join('/')}`
+ ` · floors ${floors.join('/')} · derived from ${rec.join('/')} measured on ${MEASURED.ref}`
+ ` (${delta.join('/')} vs the record).\n`
+ ' ⚠ The delta is information, not a verdict — this population grows AND shrinks for good'
+ ' reasons, and only the floors decide. Reproduce the record: see this file\'s header.';
}
/**
* Ledger rows naming an id the discovered population does not contain. Pure.
*
* A separate pass over `Object.keys(ledger)` rather than another branch inside
* the row walk, and that is the whole point: the row walk can only ever reach a
* key some row NAMES, so the orphan direction is unreachable from there. See
* the header for the measurement.
*
* @param {Record<string, {reason?: string}>} ledger
* @param {{id: string}[]} rows
* @returns {string[]}
*/
export function orphanLedgerRows(ledger, rows) {
const ids = new Set((rows ?? []).map((r) => r.id));
return Object.keys(ledger ?? {})
.filter((id) => !ids.has(id))
.sort()
.map((id) => `${id} — ${BASELINE_PATH} exempts an entry point that is not in the population: `
+ 'no published `require` condition resolves to it. Delete the row — it is exempting nothing, '
+ 'and a reader takes it for coverage that was never checked.');
}
const PARSE_CONCURRENCY = 8;
// ---------------------------------------------------------------------------
// Population
// ---------------------------------------------------------------------------
/** Every `package.json` under `<root>/packages`, node_modules and dist pruned. */
export function manifestPaths(root) {
const out = [];
const start = join(root, SCAN_ROOT);
if (!existsSync(start)) return out;
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.turbo') continue;
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name === 'package.json') out.push(p);
}
};
walk(start);
return out.sort();
}
/**
* Resolve one `exports` value the way node resolves it under the `require`
* condition: descend condition objects taking `require` / `node` / `default`,
* never `import`, `browser` or `module`.
*
* The nesting matters and is easy to get wrong: `@objectstack/spec` spells its
* entry `{"require": {"types": "...", "default": "./dist/index.js"}}`, so the
* string lives one level BELOW the `require` key. A resolver that only accepts
* a string directly under `require` silently drops every package spelled that
* way -- measured while writing this gate: 19 of the 105 entries. Pinned in
* `--self-test`.
*
* @param {unknown} node an `exports` subtree
* @param {boolean} inRequire are we already inside a `require` condition?
* @returns {string | null}
*/
export function resolveRequireTarget(node, inRequire = false) {
if (typeof node === 'string') return inRequire ? node : null;
if (Array.isArray(node)) {
for (const n of node) {
const r = resolveRequireTarget(n, inRequire);
if (r) return r;
}
return null;
}
if (node === null || typeof node !== 'object') return null;
for (const [k, v] of Object.entries(node)) {
if (k === 'types' || k === 'import' || k === 'browser' || k === 'module' || k.startsWith('.')) continue;
if (k === 'require') {
const r = resolveRequireTarget(v, true);
if (r) return r;
continue;
}
if (k === 'node' || k === 'default') {
const r = resolveRequireTarget(v, inRequire);
if (r) return r;
}
}
return null;
}
/**
* Resolve the declaration file a `require`-condition consumer READS, the way
* TypeScript's node16/nodenext resolver does: walk the condition object in key
* order, take `types` wherever it is reached, descend `require` / `node` /
* `default`, never `import` / `module` / `browser`.
*
* ⚠️ `types` matches at ANY level -- including as a SIBLING of
* `import`/`require`, which is the whole #13112 defect. A sibling `types`
* answers for BOTH conditions, so a dual-build package hands its `require`
* consumer the ESM-flavoured `.d.ts` while the `.d.cts` twin tsup emitted
* beside `index.cjs` is named by nothing and ships unreachable. A resolver
* that looked only INSIDE the `require` branch would report "no types" for the
* defective shape and "no types" for a legitimately CJS-first package alike,
* and could not tell the two apart -- so it is the RESOLVED path that is
* judged here, never the spelling.
*
* @param {unknown} node an `exports` subtree
* @returns {string | null}
*/
export function resolveRequireTypes(node) {
if (typeof node === 'string' || node === null || typeof node !== 'object') return null;
if (Array.isArray(node)) {
for (const n of node) {
const r = resolveRequireTypes(n);
if (r) return r;
}
return null;
}
for (const [k, v] of Object.entries(node)) {
if (k.startsWith('.') || k === 'import' || k === 'module' || k === 'browser') continue;
if (k === 'types') {
if (typeof v === 'string') return v;
continue;
}
if (k === 'require' || k === 'node' || k === 'default') {
const r = resolveRequireTypes(v);
if (r) return r;
}
}
return null;
}
/**
* The module kind TypeScript assigns a declaration file: the extension decides
* when it carries one (`.d.cts` is always CommonJS, `.d.mts` always ESM),
* otherwise the package's `"type"` decides -- the same rule TypeScript applies
* to `.cjs` / `.mjs` / `.js`. So `dist/index.d.ts` is CJS-flavoured in a
* CJS-first package and ESM-flavoured in a `"type": "module"` one, and the
* SAME path is correct under `require` in the first and wrong in the second.
* ⛔ Hence the invariant is module KIND, never the `.d.cts` extension: demanding
* the extension would red every correct CJS-first package in the repo.
*
* @param {string} path a declaration path
* @param {boolean} isModuleType the package declares `"type": "module"`
* @returns {'commonjs' | 'module'}
*/
export function declarationModuleKind(path, isModuleType) {
if (path.endsWith('.d.cts')) return 'commonjs';
if (path.endsWith('.d.mts')) return 'module';
return isModuleType ? 'module' : 'commonjs';
}
/**
* The declaration one subpath hands a `require` consumer. Mirrors
* `importTargetFor`'s subpath lookup; falls back to the root `types`/`typings`
* field for the `(main)` row, which is what a resolver reads when there is no
* `exports` map at all.
*
* @returns {string | null}
*/
export function requireTypesFor(pkg, subpath) {
const ex = pkg?.exports;
if (ex && typeof ex === 'object' && !Array.isArray(ex)) {
const keys = Object.keys(ex);
if (keys.some((k) => k.startsWith('.'))) return subpath in ex ? resolveRequireTypes(ex[subpath]) : null;
if (subpath === '.') return resolveRequireTypes(ex);
return null;
}
for (const key of ['types', 'typings']) {
if (typeof pkg?.[key] === 'string') return pkg[key].startsWith('./') ? pkg[key] : `./${pkg[key]}`;
}
return null;
}
/**
* The published `require` entry points of one manifest.
*
* @param {any} pkg parsed package.json
* @returns {{subpath: string, target: string}[]}
*/
export function requireEntries(pkg) {
const out = [];
const ex = pkg?.exports;
if (ex && typeof ex === 'object' && !Array.isArray(ex)) {
const keys = Object.keys(ex);
if (keys.some((k) => k.startsWith('.'))) {
for (const [sub, v] of Object.entries(ex)) {
if (!sub.startsWith('.') || sub.includes('*')) continue;
const t = resolveRequireTarget(v);
if (t) out.push({ subpath: sub, target: t });
}
} else {
const t = resolveRequireTarget(ex);
if (t) out.push({ subpath: '.', target: t });
}
} else if (!ex && typeof pkg?.main === 'string') {
// No `exports` map: `main` IS the require entry point.
out.push({ subpath: '(main)', target: pkg.main });
}
return out;
}
/**
* The `import`-condition twin of one subpath, for the AGREES probe. Mirror of
* `resolveRequireTarget` with the two condition names swapped.
*
* @returns {string | null}
*/
export function importTargetFor(pkg, subpath) {
const pick = (node, inImport = false) => {
if (typeof node === 'string') return inImport ? node : null;
if (Array.isArray(node)) {
for (const n of node) {
const r = pick(n, inImport);
if (r) return r;
}
return null;
}
if (node === null || typeof node !== 'object') return null;
for (const [k, v] of Object.entries(node)) {
if (k === 'types' || k === 'require' || k === 'browser' || k.startsWith('.')) continue;
if (k === 'import' || k === 'module') {
const r = pick(v, true);
if (r) return r;
continue;
}
if (k === 'node' || k === 'default') {
const r = pick(v, inImport);
if (r) return r;
}
}
return null;
};
const ex = pkg?.exports;
if (ex && typeof ex === 'object' && !Array.isArray(ex)) {
const keys = Object.keys(ex);
if (keys.some((k) => k.startsWith('.'))) return subpath in ex ? pick(ex[subpath]) : null;
if (subpath === '.') return pick(ex);
}
return null;
}
/** Collect the whole population: one row per published `require` entry point. */
export function collectEntries(root) {
const rows = [];
for (const mp of manifestPaths(root)) {
let pkg;
try {
pkg = JSON.parse(readFileSync(mp, 'utf8'));
} catch {
continue;
}
if (!pkg?.name || pkg.private === true) continue;
const dir = dirname(mp);
for (const { subpath, target } of requireEntries(pkg)) {
const importTarget = importTargetFor(pkg, subpath);
const typesTarget = requireTypesFor(pkg, subpath);
rows.push({
id: `${pkg.name}#${subpath}`,
pkg: pkg.name,
subpath,
target,
dir,
relDir: relative(root, dir),
abs: resolve(dir, target),
importAbs: importTarget ? resolve(dir, importTarget) : null,
typesTarget,
typesAbs: typesTarget ? resolve(dir, typesTarget) : null,
isModuleType: pkg.type === 'module',
});
}
}
return rows;
}
/**
* Every emitted file in `dir`'s tree that node parses as CommonJS: `.cjs`
* always, `.js` only when the package is not `"type": "module"`.
*/
function commonJsFilesUnder(dir, isModuleType) {
const out = [];
if (!existsSync(dir)) return out;
const walk = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.cjs') || (e.name.endsWith('.js') && !isModuleType)) out.push(p);
}
};
walk(dir);
return out;
}
// ---------------------------------------------------------------------------
// Probes -- both run in child processes, so a hard crash is a finding, not a
// dead gate.
// ---------------------------------------------------------------------------
function run(args) {
return new Promise((res) => {
const c = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let err = '';
let out = '';
c.stderr.on('data', (d) => {
err += d;
});
c.stdout.on('data', (d) => {
out += d;
});
c.on('close', (code) => res({ code, err, out: out.trim() }));
c.on('error', (e) => res({ code: 1, err: String(e), out: '' }));
});
}
async function mapLimited(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const k = i++;
if (k >= items.length) return;
out[k] = await fn(items[k]);
}
});
await Promise.all(workers);
return out;
}
/** First `…Error: …` line of a node diagnostic, trimmed for a one-line report. */
export function firstErrorLine(stderr) {
const line = String(stderr)
.split('\n')
.map((l) => l.trim())
.find((l) => /^[A-Za-z]*Error(:| )/.test(l));
return (line || String(stderr).split('\n').find(Boolean) || 'unknown failure').slice(0, 200);
}
/** Does this diagnostic say the bytes failed to PARSE? Never ledgerable. */
export function isParseFailure(stderr) {
return /\bSyntaxError\b/.test(String(stderr));
}
// ---------------------------------------------------------------------------
// The scan
// ---------------------------------------------------------------------------
/**
* @returns {Promise<{rows: any[], findings: string[], prereq: string[], ledgerHits: string[], staleLedger: string[], orphanLedger: string[], cjsFileCount: number, typedEntries: number, typedJudged: number, typedExempt: string[], typedOrphans: string[], probesRun: number}>}
*/
export async function scan(root, ledger, probes = DUAL_FORMAT_BEHAVIOUR_PROBES, exemptions = TYPED_EXEMPTIONS) {
const rows = collectEntries(root);
const findings = [];
const prereq = [];
const ledgerHits = [];
const staleLedger = [];
// Computed off the population alone, so it survives the prerequisite early
// return below: an orphaned exemption is a fact about the ledger, not about
// whether anything was built.
const orphanLedger = orphanLedgerRows(ledger, rows);
let cjsFileCount = 0;
// TYPED's own count, and its findings held aside so they report together
// rather than interleaved with the per-row LOADS diagnostics.
let typedEntries = 0;
const typedFindings = [];
// ⚠️ The floor counts entries JUDGED, never entries CLEAN. Measured while
// ablating #13112's fix: with the floor on the clean count, restoring the 28
// defective manifests drove it from 102 to 67 and the gate REFUSED (exit 2,
// "nothing was read") instead of reporting its 35 findings — a real
// regression rendered as a broken instrument, which is the one reading a
// vacuity floor must never produce. A judged count is invariant to how many
// entries are defective and falls only when the walk or the resolver stops
// asking, which is the thing the floor is for.
let typedJudged = 0;
const typedExempt = [];
// Both directions, same rule the load ledger learned the hard way: a row that
// no longer names a live entry point is a finding, not dead text.
const typedOrphans = Object.keys(exemptions)
.filter((id) => !rows.some((r) => r.id === id))
.map((id) => `${id} — TYPED_EXEMPTIONS carries a row for this id, but no published require condition resolves to it. Delete the row; it is exempting nothing.`);
for (const r of rows) {
r.distDir = join(r.dir, 'dist');
r.exists = existsSync(r.abs) && statSync(r.abs).isFile();
if (!r.exists) {
if (!existsSync(r.distDir)) prereq.push(`${r.id} -> ${r.target} (no ${relative(root, r.distDir)})`);
else findings.push(`${r.id}: declared require target ${r.target} is NOT emitted, though ${relative(root, r.distDir)} exists — the manifest advertises an entry point npm would ship and node cannot resolve.`);
continue;
}
r.cjsFiles = commonJsFilesUnder(r.distDir, r.isModuleType);
cjsFileCount += r.cjsFiles.length;
// TYPED — see the header. Asked only of rows whose require target really
// emitted, so a package that failed to build answers the LOADS question
// first rather than collecting a second finding about the same absence.
typedJudged++;
if (!r.typesTarget) {
findings.push(
`${r.id}: the require condition resolves NO \`types\` — a consumer reading this entry point gets whatever `
+ `TypeScript finds beside ${r.target} by file adjacency, or nothing. Declare it: `
+ `"require": { "types": "./<the .d.cts twin>", "default": "${r.target}" }.`,
);
} else if (!(existsSync(r.typesAbs) && statSync(r.typesAbs).isFile())) {
findings.push(
`${r.id}: the require condition declares types ${r.typesTarget}, which is NOT emitted though `
+ `${relative(root, r.distDir)} exists — the manifest promises a declaration npm would ship and tsc cannot read.`,
);
} else if (declarationModuleKind(r.typesTarget, r.isModuleType) !== 'commonjs') {
if (exemptions[r.id]) {
typedExempt.push(`${r.id} — ${exemptions[r.id].reason}`);
continue;
}
typedFindings.push(
`${r.id}: the require condition resolves types ${r.typesTarget}, which is ESM-flavoured in a `
+ `"type": "module" package — so a CommonJS consumer under node16/nodenext resolution is handed an ES-module `
+ `declaration for a CommonJS entry point and gets TS1479 ("the referenced file is an ECMAScript module and `
+ `cannot be imported with 'require'"), while the JavaScript at ${r.target} loads perfectly. Give each `
+ `condition its own types: "import": { "types": "./x.d.ts", "default": "./x.js" }, `
+ `"require": { "types": "./x.d.cts", "default": "./x.cjs" }.`,
);
} else {
typedEntries++;