cursedbelt 4.3.0 โ†’ 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/react/components/Autocomplete.d.ts.map +1 -1
  2. package/dist/react/components/Autocomplete.js +1 -1
  3. package/dist/react/components/Autocomplete.js.map +1 -1
  4. package/dist/react/components/ComboboxField.d.ts.map +1 -1
  5. package/dist/react/components/ComboboxField.js +1 -1
  6. package/dist/react/components/ComboboxField.js.map +1 -1
  7. package/dist/react/media-gallery/MediaGallery.d.ts +9 -2
  8. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  9. package/dist/react/media-gallery/MediaGallery.js +12 -4
  10. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  11. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  12. package/dist/styles-areas/analytics.css +1 -1
  13. package/dist/styles-areas/data-table.css +1 -1
  14. package/dist/styles-areas/fields.css +1 -1
  15. package/dist/styles-areas/media-gallery.css +1 -1
  16. package/dist/styles-areas/wizard.css +1 -1
  17. package/package.json +50 -6
  18. package/scripts/demoServer.ts +211 -0
  19. package/scripts/gate.ts +101 -0
  20. package/scripts/guardrailsEnforce.spec.ts +69 -0
  21. package/scripts/guardrailsEnforce.ts +59 -19
  22. package/scripts/paths.ts +1 -1
  23. package/src/demoFixture.spec.ts +54 -10
  24. package/src/demoStaticServer.spec.ts +169 -0
  25. package/src/publishShape.spec.ts +79 -0
  26. package/src/react/components/Autocomplete.tsx +11 -1
  27. package/src/react/components/ComboboxField.spec.tsx +12 -0
  28. package/src/react/components/ComboboxField.tsx +11 -1
  29. package/src/react/media-gallery/MediaGallery.spec.tsx +23 -0
  30. package/src/react/media-gallery/MediaGallery.tsx +26 -9
  31. package/src/styles-areas/analytics.css +1 -1
  32. package/src/styles-areas/data-table.css +1 -1
  33. package/src/styles-areas/fields.css +1 -1
  34. package/src/styles-areas/media-gallery.css +1 -1
  35. package/src/styles-areas/wizard.css +1 -1
  36. package/src/testFilesRunInParallel.spec.ts +3 -3
  37. package/src/typecheckCachesAreSeparate.spec.ts +2 -2
  38. package/src/verifyGraph.spec.ts +75 -108
  39. package/scripts/verify.ts +0 -296
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ๐Ÿ”ด Four `tsc` projects run AT ONCE in `scripts/verify.ts`. Two of them writing one
2
+ * ๐Ÿ”ด Four `tsc` projects run AT ONCE under `$FORGE/tools/gate.ts`. Two of them writing one
3
3
  * `.tsbuildinfo` is a corrupt cache, and a corrupt cache is a typecheck that skips files.
4
4
  *
5
5
  * ## Why this file exists
@@ -83,7 +83,7 @@ describe('the typecheck caches', () => {
83
83
  const shared = [...byFile.entries()].filter(([, names]) => names.length > 1);
84
84
  expect(
85
85
  shared.map(([file, names]) => `${relative(ROOT, file)} โ† ${names.join(', ')}`),
86
- 'these projects share one incremental cache. `scripts/verify.ts` runs them ' +
86
+ 'these projects share one incremental cache. `$FORGE/tools/gate.ts` runs them ' +
87
87
  'concurrently, so both processes write the same file and whatever survives is what ' +
88
88
  'the NEXT run trusts. Give each project its own `tsBuildInfoFile` โ€” an `extends` ' +
89
89
  'inherits the parent\'s.',
@@ -1,49 +1,70 @@
1
1
  /**
2
2
  * ๐Ÿ”ด The gate got faster on 2026-09-15. This is the file that stops it getting smaller.
3
3
  *
4
- * `bun run verify` was `paths && typecheck && demo:check && build && test && e2e` โ€” six
5
- * serial links, 142.0s measured end to end on an idle machine, five of them single-threaded
6
- * work queued behind each other on a fourteen-core Mac. It is now a dependency graph
7
- * (`scripts/verify.ts`) that runs the same stages concurrently.
4
+ * `bun run verify` was `paths && typecheck && demo:check && build && test && e2e` โ€” six serial
5
+ * links, 142.0s measured end to end on an idle machine, five of them single-threaded work queued
6
+ * behind each other on a fourteen-core Mac. It became a dependency graph, and on 2026-09-20 the
7
+ * SCHEDULER for that graph moved to `$FORGE/tools/gate.ts` so the other twenty repos could have
8
+ * it too. The stages are now the `gate` block in this repo's `package.json`.
8
9
  *
9
- * Every speed-up of a gate is one edit away from being a deletion of a gate, and the two
10
- * look identical in a diff: dropping `e2e` from the graph makes `verify` four times faster
11
- * and every test still passes. This generation's third rule is that a repo's gate proves
12
- * that repo, so the SET of stages is pinned here, by name, against the chain that existed
13
- * before the change. Adding a stage is free; removing one has to argue with this file.
10
+ * Every speed-up of a gate is one edit away from being a deletion of a gate, and the two look
11
+ * identical in a diff: dropping `e2e` from the graph makes `verify` four times faster and every
12
+ * test still passes. This generation's third rule is that a repo's gate proves that repo, so the
13
+ * SET of stages is pinned here, by name, against the chain that existed before the change.
14
+ * Adding a stage is free; removing one has to argue with this file.
14
15
  *
15
- * The literal list below is deliberate duplication. A test that derived the expectation from
16
- * `STAGES` would assert that the graph equals itself.
16
+ * The literal list below is deliberate duplication. A test that derived the expectation from the
17
+ * declaration would assert that the graph equals itself.
18
+ *
19
+ * ## ๐Ÿ”ด What moved out of this file, and where it is checked now
20
+ *
21
+ * `graphFault`, `jobLimit` and `timingTable` were this repo's; they are the shared runner's now,
22
+ * and `autopilot/test/gate.test.ts` drives every case this file used to โ€” the dangling `needs`,
23
+ * the cycle, the duplicate script, the job cap and its overrides, the table's overlap and its
24
+ * skipped stage. `$FORGE/tools/check-gate-graph.ts` additionally asserts the graph is acyclic and
25
+ * fully reachable on EVERY gate of every repo, out of `bun run paths`, which is strictly more
26
+ * often than a spec in one repo could. What stays here is what is about THIS repo: the stage set,
27
+ * the two edges, and why `build` is a leaf.
17
28
  */
18
29
  import { describe, expect, it } from 'bun:test';
19
30
  import { readFileSync, readdirSync, statSync } from 'node:fs';
20
31
  import { join, resolve } from 'node:path';
21
32
  import pkg from '../package.json';
22
- import { graphFault, jobLimit, ROOT, STAGES, timingTable } from '../scripts/verify';
33
+
34
+ /** The repo root โ€” this file is `src/`, one level down. Never a literal. */
35
+ const ROOT = resolve(import.meta.dir, '..');
36
+
37
+ interface Stage {
38
+ script: string;
39
+ needs?: readonly string[];
40
+ why?: string;
41
+ }
42
+
43
+ /** The declared graph, read as data. There is no import of the scheduler to drift from. */
44
+ const STAGES: readonly Stage[] = (pkg as { gate?: { stages?: Stage[] } }).gate?.stages ?? [];
23
45
 
24
46
  /**
25
47
  * What `verify` ran before it became a graph, expanded to the leaf scripts.
26
48
  *
27
- * `typecheck` was one script running three `tsc` projects serially; it is now three scripts
28
- * so they can run at once, and `typecheck` still runs all three for anybody typing it by
29
- * hand. `styles` is new โ€” it is the first two steps of `build`, hoisted so that the things
30
- * which READ the generated stylesheets can wait on the thing that writes them.
49
+ * `typecheck` was one script running three `tsc` projects serially; it is now three scripts so
50
+ * they can run at once, and `typecheck` still runs all three for anybody typing it by hand.
51
+ * `styles` is new โ€” it is the first two steps of `build`, hoisted so that the things which READ
52
+ * the generated stylesheets can wait on the thing that writes them.
31
53
  */
32
54
  const MUST_RUN = [
33
55
  'paths',
34
56
  'typecheck:root',
35
- // ๐Ÿ”ด `typecheck:server` is deliberately absent since 2026-09-15 (task 148), and this is
36
- // the one removal this list is allowed to have: `src/server` is not in this repo any
37
- // more. It moved to `cursedbelt-server`, whose own `verify` typechecks it โ€” this
38
- // generation's third rule is that a repo's gate proves THAT repo, so a stage here
39
- // checking code that lives elsewhere would be the violation, not the fix. Removed in the
40
- // same commit as the stage in scripts/verify.ts, which is what this comment is for.
57
+ // ๐Ÿ”ด `typecheck:server` is deliberately absent since 2026-09-15 (task 148), and this is the
58
+ // one removal this list is allowed to have: `src/server` is not in this repo any more. It
59
+ // moved to `cursedbelt-server`, whose own `verify` typechecks it โ€” this generation's third
60
+ // rule is that a repo's gate proves THAT repo, so a stage here checking code that lives
61
+ // elsewhere would be the violation, not the fix.
41
62
  'typecheck:specs',
42
- // ๐Ÿ”ด Not part of the original chain โ€” added 2026-09-16 (task 172), and pinned here the
43
- // same way, because it is the only thing that compiles `scripts/`. `cursedbelt/guardrails`
44
- // is an EXPORT resolving into that tree, so dropping this stage puts four apps back to
45
- // inheriting type errors from a file nothing here checks. "Adding a stage is free" cuts
46
- // both ways: once added, removing it argues with this list.
63
+ // ๐Ÿ”ด Not part of the original chain โ€” added 2026-09-16 (task 172), and pinned here the same
64
+ // way, because it is the only thing that compiles `scripts/`. `cursedbelt/guardrails` is an
65
+ // EXPORT resolving into that tree, so dropping this stage puts four apps back to inheriting
66
+ // type errors from a file nothing here checks. "Adding a stage is free" cuts both ways: once
67
+ // added, removing it argues with this list.
47
68
  'typecheck:scripts',
48
69
  'demo:check',
49
70
  'styles',
@@ -59,8 +80,9 @@ describe('the verify graph', () => {
59
80
  expect(
60
81
  missing,
61
82
  `\`bun run verify\` no longer runs:\n ${missing.join('\n ')}\n` +
62
- 'A gate that got faster by proving less is not faster. Add the stage back, or โ€” if it ' +
63
- 'genuinely belongs somewhere else now โ€” say so here and in scripts/verify.ts together.',
83
+ 'A gate that got faster by proving less is not faster. Add the stage back to the `gate` ' +
84
+ 'block in package.json, or โ€” if it genuinely belongs somewhere else now โ€” say so here ' +
85
+ 'and there together.',
64
86
  ).toEqual([]);
65
87
  });
66
88
 
@@ -71,49 +93,42 @@ describe('the verify graph', () => {
71
93
  });
72
94
 
73
95
  it('is the thing `bun run verify` actually runs', () => {
74
- // Without this the graph can be perfect and unreachable: `verify` could quietly go
75
- // back to a chain, and every assertion above would still pass.
76
- expect(pkg.scripts.verify).toInclude('scripts/verify.ts');
77
- });
78
-
79
- it('has no dangling `needs` and no cycle', () => {
80
- expect(graphFault(STAGES)).toBeNull();
96
+ // Without this the graph can be perfect and unreachable: `verify` could quietly go back to
97
+ // a chain, or the `gate` block could become data nothing reads, and every assertion above
98
+ // would still pass. `check-gate-graph.ts` makes the same assertion from outside, on every
99
+ // gate โ€” this is the one that fails in THIS repo's own suite.
100
+ expect(pkg.scripts.verify).toInclude('gate');
101
+ expect(pkg.scripts.gate).toInclude('scripts/gate.ts');
81
102
  });
82
103
 
83
- it('catches a dangling `needs` rather than idling with work left', () => {
84
- // The negative control. A typo in `needs` makes a stage that can never become ready,
85
- // and a scheduler that simply runs out of ready work would exit 0 having skipped it โ€”
86
- // "nothing failed" reported as "everything passed", which is the false green in its
87
- // purest form.
88
- expect(graphFault([{ script: 'a' }, { script: 'b', needs: ['typo'] }])).toInclude('typo');
89
- });
90
-
91
- it('catches a cycle', () => {
92
- expect(graphFault([{ script: 'a', needs: ['b'] }, { script: 'b', needs: ['a'] }])).toInclude('cycle');
93
- });
94
-
95
- it('catches two stages claiming the same script', () => {
96
- expect(graphFault([{ script: 'a' }, { script: 'a' }])).toInclude('same script');
104
+ it('๐Ÿ”ด a publish re-proves every stage, because a publish cannot be taken back', () => {
105
+ // The stamp store means a stage whose bytes are already proved is not re-run, which is
106
+ // right for a gate and wrong for the one run that ships bytes to a registry that will
107
+ // never let them be replaced. `--force` is the re-roll.
108
+ expect(pkg.scripts.prepublishOnly).toInclude('--force');
97
109
  });
98
110
  });
99
111
 
100
112
  describe('the two edges the graph rests on', () => {
101
113
  it('keeps everything that reads the generated stylesheets behind `styles`', () => {
102
114
  // `build` regenerates src/styles-static.css and src/styles-utilities.css;
103
- // stylesStaticMatches.spec.ts and stylesUtilitiesMatches.spec.ts assert those exact
104
- // files are current, and the demo bundle e2e drives is compiled from them. Any of
105
- // those three running beside the generators is a writer racing a reader.
115
+ // stylesStaticMatches.spec.ts and stylesUtilitiesMatches.spec.ts assert those exact files
116
+ // are current, and the demo bundle e2e drives is compiled from them. Any of those three
117
+ // running beside the generators is a writer racing a reader.
106
118
  for (const script of ['build', 'test', 'e2e']) {
107
119
  const stage = STAGES.find((s) => s.script === script);
108
120
  expect(stage?.needs, `\`${script}\` must wait for \`styles\``).toContain('styles');
121
+ // ๐Ÿ”ด And it must say WHY. An edge nobody can explain is an edge nobody can safely
122
+ // remove, and the shared runner carries a `why` field for exactly this reason.
123
+ expect(stage?.why, `\`${script}\`'s edge must explain itself`).toBeTruthy();
109
124
  }
110
125
  });
111
126
 
112
127
  it('๐Ÿ”ด no spec reads dist/, which is why `build` is a leaf', () => {
113
- // The graph runs `build` BESIDE `test` and `e2e` rather than in front of them, and
114
- // that is only sound while nothing under test can observe the compiled output. The
115
- // build stages into dist.next and swaps, so a reader of `dist/` during a build sees
116
- // the previous bytes or โ€” for one `mv` โ€” no directory at all.
128
+ // The graph runs `build` BESIDE `test` and `e2e` rather than in front of them, and that is
129
+ // only sound while nothing under test can observe the compiled output. The build stages
130
+ // into dist.next and swaps, so a reader of `dist/` during a build sees the previous bytes
131
+ // or โ€” for one `mv` โ€” no directory at all.
117
132
  const offenders: string[] = [];
118
133
  const walk = (dir: string): void => {
119
134
  for (const entry of readdirSync(dir)) {
@@ -127,8 +142,8 @@ describe('the two edges the graph rests on', () => {
127
142
  // `namedSubpathsResolve.spec.ts` needs for the same reason.
128
143
  if (entry === 'verifyGraph.spec.ts') continue;
129
144
  const text = readFileSync(full, 'utf8');
130
- // A filesystem read whose path argument mentions dist โ€” not the word in prose,
131
- // and not `dist.next` inside buildIsStaged's assertions about the SCRIPT TEXT.
145
+ // A filesystem read whose path argument mentions dist โ€” not the word in prose, and
146
+ // not `dist.next` inside buildIsStaged's assertions about the SCRIPT TEXT.
132
147
  if (/(?:readFileSync|readdirSync|existsSync|statSync|Bun\.file|new Glob)\([^)]*['"`][^'"`]*\bdist\b/.test(text)) {
133
148
  offenders.push(full.slice(ROOT.length + 1));
134
149
  }
@@ -138,55 +153,7 @@ describe('the two edges the graph rests on', () => {
138
153
  expect(
139
154
  offenders,
140
155
  `these specs read dist/, so \`build\` is no longer a leaf and the graph is wrong:\n ${offenders.join('\n ')}\n` +
141
- 'Either give them `needs: ["build"]` in scripts/verify.ts, or stop reading dist.',
156
+ 'Either give them `needs: ["build"]` in the `gate` block, or stop reading dist.',
142
157
  ).toEqual([]);
143
158
  });
144
159
  });
145
-
146
- describe('the job limit', () => {
147
- it('defaults to a third of the cores, because a stage is not a process', () => {
148
- // e2e alone spawns seven Playwright workers; build spawns two tsc processes. Six
149
- // stages is already twenty-odd processes on a machine with fourteen cores.
150
- expect(jobLimit({}, 14)).toBe(4);
151
- expect(jobLimit({}, 4)).toBe(2);
152
- expect(jobLimit({}, 64)).toBe(6);
153
- });
154
-
155
- it('honours an explicit override, including 1', () => {
156
- // ๐Ÿ”ด `1` is the old serial behaviour, and it must stay reachable without a second
157
- // gate script existing for somebody to run instead of this one.
158
- expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '1' }, 14)).toBe(1);
159
- expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '9' }, 14)).toBe(9);
160
- });
161
-
162
- it('ignores nonsense rather than running zero stages at a time', () => {
163
- expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: 'lots' }, 14)).toBe(4);
164
- expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '0' }, 14)).toBe(4);
165
- expect(jobLimit({ CURSEDBELT_VERIFY_JOBS: '-3' }, 14)).toBe(4);
166
- });
167
- });
168
-
169
- describe('the timing table', () => {
170
- it('reports the overlap, which is the whole point of the change', () => {
171
- const table = timingTable(
172
- [
173
- { script: 'test', ok: true, ms: 60_000, output: '' },
174
- { script: 'e2e', ok: true, ms: 30_000, output: '' },
175
- ],
176
- 60_000,
177
- );
178
- expect(table).toInclude('1.5ร— overlap');
179
- expect(table).toInclude('test');
180
- });
181
-
182
- it('says so plainly when nothing overlapped', () => {
183
- const table = timingTable([{ script: 'paths', ok: true, ms: 1_000, output: '' }], 1_000);
184
- expect(table).toInclude('No overlap');
185
- });
186
-
187
- it('shows a skipped stage as skipped, never as passing', () => {
188
- const table = timingTable([{ script: 'e2e', ok: false, ms: 0, output: '', skipped: true }], 1_000);
189
- expect(table).toInclude('skipped');
190
- expect(table).not.toInclude(' ok');
191
- });
192
- });
package/scripts/verify.ts DELETED
@@ -1,296 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * `bun run verify` โ€” every check this repo has, run as a dependency GRAPH instead of a
4
- * six-link `&&` chain.
5
- *
6
- * ## Why this stopped being a chain
7
- *
8
- * Measured 2026-09-15 on an idle machine, stage by stage, with the chain in place:
9
- *
10
- * paths 0.4s ยท typecheck 22.6s (three tsc projects) ยท demo:check 7.2s
11
- * ยท build 13.2s ยท test 67.6s ยท e2e 32.2s โ†’ 142.0s end to end
12
- *
13
- * Five of those are single-threaded `tsc`/scan work that share nothing but the source tree,
14
- * and they were queued behind one another on a fourteen-core machine. As a graph the same
15
- * stages finish in roughly half the wall clock, and the saving grows with load: this repo's
16
- * gate is two thirds of every gate second the generation has ever spent.
17
- *
18
- * ๐Ÿ”ด NOTHING HERE IS A SMALLER GATE. Every script the chain ran is still run, and
19
- * `src/verifyGraph.spec.ts` fails if one is dropped or orphaned. This generation's third
20
- * rule is that a repo's gate proves that repo โ€” a gate that got faster by proving less is
21
- * not faster, it is gone. There is deliberately no `verify:fast`: one gate, or the fast one
22
- * becomes the one everybody runs and the slow one becomes the one nobody does.
23
- *
24
- * ## The edges, and why these are the only ones
25
- *
26
- * `build` regenerates `src/styles-static.css` and `src/styles-utilities.css` (its first two
27
- * steps), and `stylesStaticMatches.spec.ts` and `stylesUtilitiesMatches.spec.ts` read those
28
- * exact files and assert they are current. Run concurrently against a STALE stylesheet, one
29
- * process writes the bytes the other is asserting on โ€” a flake that appears only on the
30
- * commit that changed a component, which is the worst possible day for it.
31
- *
32
- * So the generators are their own stage and everything that touches those bytes waits for
33
- * it. Once `styles` has run, the same generators inside `build` find their output current
34
- * and write nothing (they compare before writing) โ€” which is what makes `build` safe to run
35
- * beside `test` and `e2e` rather than in front of them.
36
- *
37
- * ๐Ÿ”ด `build` is a LEAF, not a prerequisite. No spec under `src` opens `dist/` โ€” checked by
38
- * `verifyGraph.spec.ts` rather than asserted here โ€” and the demo resolves every
39
- * `cursedbelt/โ€ฆ` specifier to SOURCE through the export map, so nothing in `test` or `e2e`
40
- * can see the compiled output at all.
41
- *
42
- * ## What it prints
43
- *
44
- * Buffered per stage, never interleaved: six concurrent stages writing to one transcript is
45
- * output nobody can read. The cost of buffering is silence, and silence is exactly what
46
- * `autopilot/src/proc.ts` exists to prevent โ€” so a heartbeat names what is still running and
47
- * for how long. A reader can tell "thinking" from "dead" without knowing anything about the
48
- * suite.
49
- *
50
- * ๐Ÿ”ด A failing stage's output is printed LAST, after the timing table. The runner keeps the
51
- * final 4 KB of a gate as the evidence it files against the task (`worker.ts`), so whatever
52
- * is at the tail is what a human reads first. The chain got this for free by stopping at the
53
- * failure; a graph has to put it there on purpose.
54
- */
55
- import { cpus } from "node:os";
56
- import { resolve } from "node:path";
57
-
58
- export interface Stage {
59
- /**
60
- * The `package.json` script this stage runs. ๐Ÿ”ด A script name, never a command line โ€”
61
- * `package.json` stays the one place every command is spelled, so this file cannot drift
62
- * from what `bun run <thing>` does.
63
- */
64
- script: string;
65
- /** Stages that must be GREEN before this one may start. */
66
- needs?: readonly string[];
67
- /** Why the edge exists. An edge nobody can explain is an edge nobody can safely remove. */
68
- why?: string;
69
- }
70
-
71
- /**
72
- * The gate. ๐Ÿ”ด Order in this list means nothing โ€” `needs` is the only thing that sequences
73
- * anything, and the scheduler starts whatever is ready.
74
- */
75
- export const STAGES: readonly Stage[] = [
76
- { script: "paths" },
77
- { script: "typecheck:root" },
78
- { script: "typecheck:specs" },
79
- // ๐Ÿ”ด Added 2026-09-16 (task 172). `tsconfig.json` includes `src` only, so the eleven
80
- // files under `scripts/` were compiled by nothing โ€” including `guardrailsEnforce.ts`,
81
- // which package.json EXPORTS as `cursedbelt/guardrails`. Four apps were typechecking a
82
- // file this repo's own gate had never looked at, and inheriting its errors.
83
- { script: "typecheck:scripts" },
84
- { script: "demo:check" },
85
- { script: "styles" },
86
- {
87
- script: "build",
88
- needs: ["styles"],
89
- why: "its first two steps are the same generators; two writers on one stylesheet is a race",
90
- },
91
- {
92
- script: "test",
93
- needs: ["styles"],
94
- why: "stylesStaticMatches / stylesUtilitiesMatches read those two files and assert they are current",
95
- },
96
- {
97
- script: "e2e",
98
- needs: ["styles"],
99
- why: "the demo bundle it drives is compiled from the same stylesheets",
100
- },
101
- ];
102
-
103
- /** The repo root โ€” this file's own directory, one level up. Never a literal. */
104
- export const ROOT = resolve(import.meta.dir, "..");
105
-
106
- /**
107
- * How many stages may run at once.
108
- *
109
- * ๐Ÿ”ด A THIRD of the cores, not all of them, and the reason is that a stage is not a process.
110
- * `e2e` alone spawns seven Playwright workers and `build` spawns two `tsc` processes, so a
111
- * cap of six stages is already twenty-odd processes. This Mac reached twenty-one concurrent
112
- * `bun test` processes once and the machine-wide PreToolUse warning exists because of it.
113
- * Parallelism never reduces the total CPU work; over-subscribing only converts it into
114
- * thrash, and browser specs degrade worse than anything else under contention.
115
- *
116
- * An explicit `CURSEDBELT_VERIFY_JOBS` wins, including `1`, which is how a bisect gets the
117
- * old serial behaviour back without a second gate script existing.
118
- */
119
- export function jobLimit(env: NodeJS.ProcessEnv = process.env, cpuCount: number = cpus().length): number {
120
- const asked = Number(env.CURSEDBELT_VERIFY_JOBS);
121
- if (Number.isFinite(asked) && asked >= 1) return Math.floor(asked);
122
- return Math.max(2, Math.min(6, Math.floor(cpuCount / 3)));
123
- }
124
-
125
- /**
126
- * ๐Ÿ”ด Every stage reachable, and every `needs` naming a stage that exists.
127
- *
128
- * Both halves matter and they fail in opposite directions: a typo in `needs` would otherwise
129
- * make a stage that can NEVER run (the scheduler would idle with work left, and "nothing
130
- * failed" is not the same as "everything passed"), while a cycle would deadlock the same way.
131
- * Checked before anything is spawned, so a broken graph costs a second rather than a gate.
132
- */
133
- export function graphFault(stages: readonly Stage[] = STAGES): string | null {
134
- const names = new Set(stages.map((s) => s.script));
135
- if (names.size !== stages.length) return "two stages name the same script";
136
- for (const stage of stages) {
137
- for (const need of stage.needs ?? []) {
138
- if (!names.has(need)) return `\`${stage.script}\` needs \`${need}\`, which is not a stage`;
139
- }
140
- }
141
- // Kahn's algorithm: anything still unresolved when nothing more can be resolved is a cycle.
142
- const done = new Set<string>();
143
- for (;;) {
144
- const ready = stages.filter((s) => !done.has(s.script) && (s.needs ?? []).every((n) => done.has(n)));
145
- if (ready.length === 0) break;
146
- for (const s of ready) done.add(s.script);
147
- }
148
- const stuck = stages.filter((s) => !done.has(s.script)).map((s) => s.script);
149
- return stuck.length > 0 ? `these stages can never run (a cycle in \`needs\`): ${stuck.join(", ")}` : null;
150
- }
151
-
152
- export interface StageResult {
153
- script: string;
154
- ok: boolean;
155
- ms: number;
156
- output: string;
157
- /** Not started, because something it needed failed or the run had already gone red. */
158
- skipped?: boolean;
159
- }
160
-
161
- function secs(ms: number): string {
162
- return `${(ms / 1000).toFixed(1)}s`;
163
- }
164
-
165
- /** The table every run ends with, green or red. Pure, so the shape of it is testable. */
166
- export function timingTable(results: readonly StageResult[], wallMs: number): string {
167
- const width = Math.max(10, ...results.map((r) => r.script.length));
168
- const cpu = results.reduce((a, r) => a + r.ms, 0);
169
- const lines = [` ${"stage".padEnd(width)} elapsed result`];
170
- for (const r of [...results].sort((a, b) => b.ms - a.ms)) {
171
- const verdict = r.skipped ? "skipped" : r.ok ? "ok" : "FAILED";
172
- lines.push(` ${r.script.padEnd(width)} ${secs(r.ms).padStart(9)} ${verdict}`);
173
- }
174
- lines.push(
175
- "",
176
- ` ${secs(wallMs)} of wall clock; ${secs(cpu)} of stage time. ` +
177
- `${cpu > wallMs ? `${(cpu / wallMs).toFixed(1)}ร— overlap.` : "No overlap โ€” one stage at a time."}`,
178
- );
179
- return lines.join("\n");
180
- }
181
-
182
- async function runStage(stage: Stage): Promise<StageResult> {
183
- const started = Date.now();
184
- const child = Bun.spawn([process.execPath, "run", stage.script], {
185
- cwd: ROOT,
186
- stdout: "pipe",
187
- // Merged rather than kept apart: a compiler writes diagnostics to one and progress to
188
- // the other, and a reader needs them interleaved the way the stage wrote them.
189
- stderr: "pipe",
190
- });
191
- const [out, err, code] = await Promise.all([
192
- new Response(child.stdout).text(),
193
- new Response(child.stderr).text(),
194
- child.exited,
195
- ]);
196
- return { script: stage.script, ok: code === 0, ms: Date.now() - started, output: `${out}${err}` };
197
- }
198
-
199
- /** Bytes of a failing stage's output that reach the tail. Enough for a stack, not a build log. */
200
- const FAILURE_TAIL = 12_000;
201
-
202
- export async function verify(stages: readonly Stage[] = STAGES): Promise<number> {
203
- const fault = graphFault(stages);
204
- if (fault) {
205
- process.stdout.write(`\n๐Ÿ”ด the verify graph is broken: ${fault}\n`);
206
- return 2;
207
- }
208
- const jobs = jobLimit();
209
- const startedAt = Date.now();
210
- process.stdout.write(`\nโ–ธ verify โ€” ${stages.length} stages, up to ${jobs} at once.\n\n`);
211
-
212
- const results = new Map<string, StageResult>();
213
- const running = new Map<string, number>();
214
- let red = false;
215
-
216
- // ๐Ÿ”ด Names what is STILL running, never a spinner. The question a reader has at minute
217
- // four of a Playwright suite is "which stage, and how long has it been there" โ€” a
218
- // progress animation answers neither, and the transcript this lands in is a file.
219
- const heartbeat = setInterval(() => {
220
- if (running.size === 0) return;
221
- const live = [...running.entries()]
222
- .map(([script, at]) => `${script} ${secs(Date.now() - at)}`)
223
- .sort()
224
- .join(" ยท ");
225
- process.stdout.write(` โง— still running: ${live}\n`);
226
- }, 30_000);
227
- // Never hold the process open on the timer alone.
228
- heartbeat.unref?.();
229
-
230
- const inFlight = new Set<Promise<void>>();
231
- const start = (stage: Stage): void => {
232
- running.set(stage.script, Date.now());
233
- process.stdout.write(` โ–ถ ${stage.script}\n`);
234
- const task = runStage(stage).then((result) => {
235
- running.delete(stage.script);
236
- results.set(result.script, result);
237
- if (!result.ok) red = true;
238
- process.stdout.write(` ${result.ok ? "โœ”" : "โœ˜"} ${result.script} ${secs(result.ms)}\n`);
239
- });
240
- inFlight.add(task);
241
- void task.finally(() => inFlight.delete(task));
242
- };
243
-
244
- for (;;) {
245
- // ๐Ÿ”ด Once anything is red, nothing NEW starts โ€” the chain would never have reached it โ€”
246
- // but whatever is already running is left to finish. Killing an in-flight stage buys
247
- // back seconds that are already spent and throws away a second, independent failure
248
- // that would otherwise be in the same report.
249
- const ready = red
250
- ? []
251
- : stages.filter(
252
- (s) =>
253
- !results.has(s.script) &&
254
- !running.has(s.script) &&
255
- (s.needs ?? []).every((n) => results.get(n)?.ok === true),
256
- );
257
- while (ready.length > 0 && running.size < jobs) {
258
- const next = ready.shift();
259
- if (next) start(next);
260
- }
261
- if (inFlight.size === 0) break;
262
- await Promise.race(inFlight);
263
- }
264
- clearInterval(heartbeat);
265
-
266
- for (const stage of stages) {
267
- if (!results.has(stage.script)) {
268
- results.set(stage.script, { script: stage.script, ok: false, ms: 0, output: "", skipped: true });
269
- }
270
- }
271
-
272
- const all = stages.map((s) => results.get(s.script) as StageResult);
273
- const failed = all.filter((r) => !r.ok && !r.skipped);
274
- const skipped = all.filter((r) => r.skipped);
275
- const wall = Date.now() - startedAt;
276
-
277
- process.stdout.write(`\n${timingTable(all, wall)}\n`);
278
- if (skipped.length > 0) {
279
- process.stdout.write(
280
- `\n โญ not run, because the gate was already red: ${skipped.map((r) => r.script).join(", ")}\n`,
281
- );
282
- }
283
- if (failed.length === 0) {
284
- process.stdout.write(`\nโœ… verify green in ${secs(wall)}.\n`);
285
- return 0;
286
- }
287
- // ๐Ÿ”ด LAST. `worker.ts` files the final 4 KB of this as the evidence against the task.
288
- for (const r of failed) {
289
- process.stdout.write(`\n\n${"โ•".repeat(72)}\n๐Ÿ”ด FAILED: bun run ${r.script} (${secs(r.ms)})\n${"โ•".repeat(72)}\n`);
290
- process.stdout.write(r.output.length > FAILURE_TAIL ? `โ€ฆ\n${r.output.slice(-FAILURE_TAIL)}` : r.output);
291
- }
292
- process.stdout.write(`\n\n๐Ÿ”ด verify FAILED in ${secs(wall)}: ${failed.map((r) => r.script).join(", ")}\n`);
293
- return 1;
294
- }
295
-
296
- if (import.meta.main) process.exit(await verify());