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.
- package/dist/react/components/Autocomplete.d.ts.map +1 -1
- package/dist/react/components/Autocomplete.js +1 -1
- package/dist/react/components/Autocomplete.js.map +1 -1
- package/dist/react/components/ComboboxField.d.ts.map +1 -1
- package/dist/react/components/ComboboxField.js +1 -1
- package/dist/react/components/ComboboxField.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +9 -2
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +12 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/analytics.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/package.json +50 -6
- package/scripts/demoServer.ts +211 -0
- package/scripts/gate.ts +101 -0
- package/scripts/guardrailsEnforce.spec.ts +69 -0
- package/scripts/guardrailsEnforce.ts +59 -19
- package/scripts/paths.ts +1 -1
- package/src/demoFixture.spec.ts +54 -10
- package/src/demoStaticServer.spec.ts +169 -0
- package/src/publishShape.spec.ts +79 -0
- package/src/react/components/Autocomplete.tsx +11 -1
- package/src/react/components/ComboboxField.spec.tsx +12 -0
- package/src/react/components/ComboboxField.tsx +11 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +23 -0
- package/src/react/media-gallery/MediaGallery.tsx +26 -9
- package/src/styles-areas/analytics.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/testFilesRunInParallel.spec.ts +3 -3
- package/src/typecheckCachesAreSeparate.spec.ts +2 -2
- package/src/verifyGraph.spec.ts +75 -108
- package/scripts/verify.ts +0 -296
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ๐ด Four `tsc` projects run AT ONCE
|
|
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.
|
|
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.',
|
package/src/verifyGraph.spec.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
|
63
|
-
'genuinely belongs somewhere else now โ say so here
|
|
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
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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('
|
|
84
|
-
// The
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
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
|
-
//
|
|
105
|
-
//
|
|
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
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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());
|