@ultimat3/cli 22.6.1 → 22.7.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/package.json +28 -28
- package/src/app-load.ts +88 -47
- package/src/app-reload-graph.ts +228 -0
- package/src/cmd-build-spec.ts +8 -1
- package/src/cmd-build.ts +23 -8
- package/src/cmd-dev-fixture.ts +26 -0
- package/src/cmd-dev.ts +10 -0
- package/src/cmd-test-spec.ts +9 -3
- package/src/cmd-test.ts +25 -2
- package/src/cmd-verify-spec.ts +37 -7
- package/src/cmd-verify.ts +81 -5
- package/src/dev-route-table.ts +7 -0
- package/src/error-codes.ts +8 -0
- package/src/mcp-errors.ts +5 -0
- package/src/messages.ts +3 -0
- package/src/output.ts +26 -0
- package/src/templates/scaffold-docs.ts +3 -1
- package/src/test-batches.ts +145 -0
- package/src/test-shards.ts +111 -23
- package/src/test-slots.ts +168 -0
- package/src/test-workers.ts +175 -63
- package/src/verify-errors.ts +49 -0
- package/src/verify-floor.ts +28 -0
- package/src/verify-merge.ts +235 -0
- package/src/verify-run.ts +16 -9
- package/src/verify-shard.ts +145 -0
- package/src/verify-step.ts +20 -1
- package/src/verify-test-run.ts +63 -11
- package/src/verify-tests.ts +39 -4
package/src/test-workers.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// suite two different ways, and the `--worker N` reproduction a shard failure prints would then
|
|
4
4
|
// name a shard the gate never ran.
|
|
5
5
|
|
|
6
|
-
// Bun ships no CPU-count or
|
|
7
|
-
// answer, and `
|
|
8
|
-
|
|
6
|
+
// Bun ships no CPU-count or memory primitive: `cpus()` is the fallback when navigator cannot
|
|
7
|
+
// answer, and `totalmem()` is the only reader of the machine's RAM.
|
|
8
|
+
// why: Bun ships no CPU-count or total-memory primitive.
|
|
9
|
+
import { cpus, totalmem } from 'node:os';
|
|
9
10
|
import type { TestType } from '@ultimat3/testing';
|
|
11
|
+
import { TestBudgetInvalidError } from './verify-errors';
|
|
10
12
|
|
|
11
13
|
/** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
|
|
12
14
|
export function availableCpus(): number {
|
|
@@ -15,56 +17,75 @@ export function availableCpus(): number {
|
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
|
-
*
|
|
20
|
+
* The width of a default run is a MEMORY question first and a CPU question second, and the memory
|
|
21
|
+
* it plans on is a BUDGET, not "whatever looks free".
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* Until 22.7 the default was `ceil(cpus x 1.5)` held to 60% of `os.freemem()`. Both halves failed
|
|
24
|
+
* on the machines Ultimate is for (8-16 GB, 2-8 cores, often two agents' gates at once):
|
|
22
25
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* - `freemem()` is MemAvailable, which counts reclaimable page cache, so a box that had just built
|
|
27
|
+
* read as mostly free. Two gates planned independently, each took 14-18 workers, and on
|
|
28
|
+
* 2026-09-27 a 45 GB box with no swap was OOM-killed with 36 `bun` processes holding ~40 GB.
|
|
29
|
+
* - 1.5x the cores only paid when every worker was stalled on `--isolate` rebuilding a module
|
|
30
|
+
* registry per file. Without isolation (the default since 22.7, below) a worker is CPU-bound,
|
|
31
|
+
* and oversubscribing only multiplies memory.
|
|
29
32
|
*
|
|
30
|
-
*
|
|
31
|
-
* the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* Oversubscribing fills those stalls.
|
|
35
|
-
*
|
|
36
|
-
* The bound is memory, not cores. A worker is a whole Bun process with the framework's module
|
|
37
|
-
* graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite. Until
|
|
38
|
-
* 22.3 that was a FIXED ceiling of 8, which held a 12-core box to 8 workers with 30 GB free: the
|
|
39
|
-
* notificado.co `unit` step (381 files) sat at 51s on 8 workers against a 90s gate budget. The
|
|
40
|
-
* ceiling is now what the machine can actually hold — `os.freemem()` (MemAvailable on Linux, so
|
|
41
|
-
* reclaimable page cache counts as free) divided by `WORKER_BYTES` — which still binds on a small
|
|
42
|
-
* CI runner and on a loaded laptop, the two places an unbounded count would swap.
|
|
43
|
-
*
|
|
44
|
-
* The floor of 2 keeps a 1-core box sharding rather than silently reverting to serial.
|
|
33
|
+
* So: `min(GATE_BUDGET_CAP, max(GATE_BUDGET_FLOOR, GATE_BUDGET_SHARE x TOTAL memory))` — total, never free, so the plan
|
|
34
|
+
* is the same on every run of the same machine — divided by `WORKER_BYTES`, clamped to 1..cpus.
|
|
35
|
+
* `ULTIMATE_TEST_MEMORY_BUDGET` (e.g. `3g`) replaces the budget, `ULTIMATE_TEST_MAX_WORKERS` caps
|
|
36
|
+
* the width, and an explicit `--workers` wins over both.
|
|
45
37
|
*/
|
|
46
|
-
export const
|
|
38
|
+
export const GATE_BUDGET_CAP = 4 * 1024 * 1024 * 1024;
|
|
47
39
|
|
|
48
|
-
/**
|
|
49
|
-
export const
|
|
40
|
+
/** A quarter of the machine: an editor, a language server and another agent's gate need the rest. */
|
|
41
|
+
export const GATE_BUDGET_SHARE = 0.25;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The budget never plans below this: enough for TWO workers at `WORKER_BYTES`. A quarter of an
|
|
45
|
+
* 8 GB laptop (2 GiB) planned one worker, ~4.7 min for notificado.co's unit tier; two stay inside
|
|
46
|
+
* the owner's 1-4 GB gate target (~2.6 GB measured).
|
|
47
|
+
*/
|
|
48
|
+
export const GATE_BUDGET_FLOOR = 2.75 * 1024 * 1024 * 1024;
|
|
50
49
|
|
|
51
50
|
/**
|
|
52
|
-
* What one test worker is
|
|
53
|
-
*
|
|
54
|
-
* box, `As of 2026-09-25`:
|
|
51
|
+
* What one test worker is planned at. MEASURED, whole-tree RSS sampled every 200 ms, Bun 1.4.0,
|
|
52
|
+
* 12-core box, `As of 2026-09-27`, NO isolation:
|
|
55
53
|
*
|
|
56
|
-
* | workers | peak tree
|
|
57
|
-
*
|
|
58
|
-
* |
|
|
59
|
-
* |
|
|
60
|
-
* |
|
|
54
|
+
* | corpus | workers | batch | peak tree | tree / worker |
|
|
55
|
+
* |-----------------------------------------|---------|-------|-----------|---------------|
|
|
56
|
+
* | framework unit (1620 files) | 4 | 24 | 3.61-3.78 GB | 0.90-0.95 GB |
|
|
57
|
+
* | notificado.co unit (768 files) | 4 | 24 | 4.26-4.58 GB | 1.07-1.15 GB |
|
|
58
|
+
* | notificado.co unit (768 files) | 4 | 48 | 4.35 GB | 1.09 GB |
|
|
59
|
+
* | notificado.co unit (768 files) | 3 | 24 | 3.28-3.61 GB | 1.09-1.20 GB |
|
|
61
60
|
*
|
|
62
|
-
* The
|
|
63
|
-
*
|
|
64
|
-
* workers
|
|
65
|
-
*
|
|
61
|
+
* The planning figure is what the TREE costs per worker at its peak — the `x` parent and Bun's
|
|
62
|
+
* coordinator included — rounded up from the heaviest corpus, not the largest single worker:
|
|
63
|
+
* workers do not all peak at the same instant. The floor of a worker is not the runner: a Bun
|
|
64
|
+
* process that has booted ONE PGlite sits at 0.9-1.1 GB RSS even after `close()` and a full GC
|
|
65
|
+
* (measured standalone), so a suite on the embedded database cannot plan below ~1 GB a worker.
|
|
66
|
+
*/
|
|
67
|
+
export const WORKER_BYTES = 1.25 * 1024 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
/** The fewest workers `--workers` accepts and the fewest a default plans: one is a legal width. */
|
|
70
|
+
export const WORKER_FLOOR = 1;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Files each ISOLATED worker is handed before its `bun test` process is thrown away and a fresh one
|
|
74
|
+
* takes the next batch (`test-batches.ts`, which carries the measurement): under `--isolate` every
|
|
75
|
+
* file re-evaluates the module graph and the heap grows per file, so the recycle point is what
|
|
76
|
+
* makes the peak a function of the width rather than of the corpus.
|
|
77
|
+
*/
|
|
78
|
+
export const BATCH_FILES_PER_WORKER = 24;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The recycle point WITHOUT isolation (the default since 22.7). A shared worker's peak is set by
|
|
82
|
+
* its live PGlite, not by how many files it has run, and a batch boundary is pure cost: every
|
|
83
|
+
* worker restarts, re-imports the app and re-boots its database, and the batch waits on its
|
|
84
|
+
* slowest file. Measured on notificado.co's unit tier (768 files, 281 s of file time, 3 workers,
|
|
85
|
+
* no isolation): 6 batches of 48/worker 150-172 s, ONE batch 92-94 s at 3.9 GB peak — the work
|
|
86
|
+
* floor (281 s / 3). 256 a worker keeps a recycle point for a corpus far past that size.
|
|
66
87
|
*/
|
|
67
|
-
export const
|
|
88
|
+
export const SHARED_BATCH_FILES_PER_WORKER = 256;
|
|
68
89
|
|
|
69
90
|
/**
|
|
70
91
|
* The most `--workers` accepts, on either command. Not a default and not a memory rule — a sanity
|
|
@@ -73,35 +94,126 @@ export const WORKER_BYTES = 1024 * 1024 * 1024;
|
|
|
73
94
|
*/
|
|
74
95
|
export const WORKER_CEILING = 64;
|
|
75
96
|
|
|
76
|
-
/**
|
|
77
|
-
export const
|
|
97
|
+
/** Replaces the budget: `3g`, `512m`, `4GiB`, or a byte count. */
|
|
98
|
+
export const MEMORY_BUDGET_ENV = 'ULTIMATE_TEST_MEMORY_BUDGET';
|
|
99
|
+
|
|
100
|
+
/** Caps the default width: a positive integer. */
|
|
101
|
+
export const MAX_WORKERS_ENV = 'ULTIMATE_TEST_MAX_WORKERS';
|
|
102
|
+
|
|
103
|
+
/** Bun ships no memory primitive; `totalmem()` is the machine's (or the cgroup-blind host's) RAM. */
|
|
104
|
+
export const totalMemory = (): number => totalmem();
|
|
105
|
+
|
|
106
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
107
|
+
|
|
108
|
+
const UNITS: Readonly<Record<string, number>> = {
|
|
109
|
+
'': 1,
|
|
110
|
+
b: 1,
|
|
111
|
+
k: 1024,
|
|
112
|
+
kb: 1024,
|
|
113
|
+
kib: 1024,
|
|
114
|
+
m: 1024 ** 2,
|
|
115
|
+
mb: 1024 ** 2,
|
|
116
|
+
mib: 1024 ** 2,
|
|
117
|
+
g: 1024 ** 3,
|
|
118
|
+
gb: 1024 ** 3,
|
|
119
|
+
gib: 1024 ** 3,
|
|
120
|
+
};
|
|
78
121
|
|
|
79
122
|
/**
|
|
80
|
-
* `
|
|
81
|
-
*
|
|
82
|
-
*
|
|
123
|
+
* `3g` → 3 GiB. Binary units whatever the spelling, because the reader is sizing against RSS and
|
|
124
|
+
* RSS is pages. `undefined` for anything else — the caller refuses it with the value in the cause,
|
|
125
|
+
* rather than a typo silently meaning "the default".
|
|
83
126
|
*/
|
|
84
|
-
export const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
return
|
|
127
|
+
export const parseBytes = (raw: string): number | undefined => {
|
|
128
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(raw);
|
|
129
|
+
if (match === null) return undefined;
|
|
130
|
+
const unit = (match[2] ?? '').toLowerCase();
|
|
131
|
+
if (!Object.hasOwn(UNITS, unit)) return undefined;
|
|
132
|
+
const bytes = Math.floor(Number(match[1]) * (UNITS[unit] as number));
|
|
133
|
+
return Number.isFinite(bytes) && bytes > 0 ? bytes : undefined;
|
|
91
134
|
};
|
|
92
135
|
|
|
136
|
+
/** Why the width is what it is, in words a step line can print. */
|
|
137
|
+
export interface WorkerPlan {
|
|
138
|
+
readonly workers: number;
|
|
139
|
+
readonly budgetBytes: number;
|
|
140
|
+
/** Which bound decided the width. */
|
|
141
|
+
readonly boundBy: 'budget' | 'cpus' | 'max-workers';
|
|
142
|
+
/** `4 workers (budget 4.0 GB)` — the step line's suffix. */
|
|
143
|
+
readonly reason: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const gb = (bytes: number): string => (bytes / 1024 ** 3).toFixed(1);
|
|
147
|
+
|
|
148
|
+
/** The budget a default run plans on: the env override, or `min(4 GiB, max(2.75 GiB, 25% of RAM))`. */
|
|
149
|
+
export function memoryBudget(total: number = totalMemory(), env: Env = Bun.env): number {
|
|
150
|
+
const raw = env[MEMORY_BUDGET_ENV];
|
|
151
|
+
if (raw !== undefined && raw.trim() !== '') {
|
|
152
|
+
const bytes = parseBytes(raw);
|
|
153
|
+
if (bytes === undefined) throw new TestBudgetInvalidError(MEMORY_BUDGET_ENV, raw);
|
|
154
|
+
return bytes;
|
|
155
|
+
}
|
|
156
|
+
return Math.min(
|
|
157
|
+
GATE_BUDGET_CAP,
|
|
158
|
+
Math.max(GATE_BUDGET_FLOOR, Math.floor(Math.max(0, total) * GATE_BUDGET_SHARE)),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const maxWorkersOf = (env: Env): number | undefined => {
|
|
163
|
+
const raw = env[MAX_WORKERS_ENV];
|
|
164
|
+
if (raw === undefined || raw.trim() === '') return undefined;
|
|
165
|
+
const value = Number(raw.trim());
|
|
166
|
+
if (!Number.isInteger(value) || value < 1) throw new TestBudgetInvalidError(MAX_WORKERS_ENV, raw);
|
|
167
|
+
return value;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `clamp(1..cpus, floor(budget / WORKER_BYTES))`, then `ULTIMATE_TEST_MAX_WORKERS`. Never more
|
|
172
|
+
* workers than cores, never fewer than one. The file-count clamp is `test-passes.ts`'s, because
|
|
173
|
+
* only the caller knows the selection.
|
|
174
|
+
*/
|
|
175
|
+
export function workerPlan(
|
|
176
|
+
cpus: number = availableCpus(),
|
|
177
|
+
total: number = totalMemory(),
|
|
178
|
+
env: Env = Bun.env,
|
|
179
|
+
): WorkerPlan {
|
|
180
|
+
const budgetBytes = memoryBudget(total, env);
|
|
181
|
+
const cores = Math.max(1, Math.trunc(cpus));
|
|
182
|
+
const byBudget = Math.max(WORKER_FLOOR, Math.floor(budgetBytes / WORKER_BYTES));
|
|
183
|
+
const cap = maxWorkersOf(env);
|
|
184
|
+
let workers = Math.min(cores, byBudget, WORKER_CEILING);
|
|
185
|
+
let boundBy: WorkerPlan['boundBy'] = byBudget <= cores ? 'budget' : 'cpus';
|
|
186
|
+
if (cap !== undefined && cap < workers) {
|
|
187
|
+
workers = cap;
|
|
188
|
+
boundBy = 'max-workers';
|
|
189
|
+
}
|
|
190
|
+
const noun = workers === 1 ? 'worker' : 'workers';
|
|
191
|
+
const why =
|
|
192
|
+
boundBy === 'max-workers'
|
|
193
|
+
? `${MAX_WORKERS_ENV}=${String(cap)}`
|
|
194
|
+
: boundBy === 'cpus'
|
|
195
|
+
? `${String(cores)} cores, budget ${gb(budgetBytes)} GB`
|
|
196
|
+
: `budget ${gb(budgetBytes)} GB`;
|
|
197
|
+
return { workers, budgetBytes, boundBy, reason: `${String(workers)} ${noun} (${why})` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The width alone — `workerPlan(...).workers`. */
|
|
201
|
+
export const defaultWorkers = (
|
|
202
|
+
cpus: number = availableCpus(),
|
|
203
|
+
total: number = totalMemory(),
|
|
204
|
+
env: Env = Bun.env,
|
|
205
|
+
): number => workerPlan(cpus, total, env).workers;
|
|
206
|
+
|
|
93
207
|
/**
|
|
94
208
|
* The width for a parallel suite that SHARES the machine — `x verify` runs the static steps beside
|
|
95
|
-
* `live`, `job`, `e2e` and `eval` (`verify-run.ts`)
|
|
96
|
-
*
|
|
97
|
-
* wants the cores; beside six other processes it only multiplies the contention. Measured on
|
|
98
|
-
* notificado.co, 8 vCPU (#537): at 1.5x, `errors` went 1.8s alone → 7.9s in the gate and
|
|
99
|
-
* `boundaries` 2.3s → 9.7s. So: one worker per core, still held to what memory can carry.
|
|
209
|
+
* `live`, `job`, `e2e` and `eval` (`verify-run.ts`). The default is already one worker per core at
|
|
210
|
+
* most, so this is the same plan; it stays a name of its own so the overlap window has one reader.
|
|
100
211
|
*/
|
|
101
212
|
export const sharedWorkers = (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
213
|
+
cpus: number = availableCpus(),
|
|
214
|
+
total: number = totalMemory(),
|
|
215
|
+
env: Env = Bun.env,
|
|
216
|
+
): number => defaultWorkers(cpus, total, env);
|
|
105
217
|
|
|
106
218
|
/**
|
|
107
219
|
* Which types run across worker processes, and why the other two cannot.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The gate's sizing and CI-split refusals: a bad budget env var, an unsplittable `--shard`, and a
|
|
2
|
+
// `x verify merge` part that is not a verify document. Split from errors.ts at its size ceiling.
|
|
3
|
+
|
|
4
|
+
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `ULTIMATE_TEST_MEMORY_BUDGET` or `ULTIMATE_TEST_MAX_WORKERS` set to something that does not
|
|
8
|
+
* parse. Refused rather than ignored: a typo read as "the default" is a run sized for a machine
|
|
9
|
+
* the caller said this is not.
|
|
10
|
+
*/
|
|
11
|
+
export class TestBudgetInvalidError extends UltimateError {
|
|
12
|
+
constructor(name: string, value: string) {
|
|
13
|
+
super({
|
|
14
|
+
code: 'X_TEST_BUDGET_INVALID',
|
|
15
|
+
cause: `${name}=${JSON.stringify(value)} does not parse — ${
|
|
16
|
+
name === 'ULTIMATE_TEST_MAX_WORKERS'
|
|
17
|
+
? 'expected a positive integer'
|
|
18
|
+
: 'expected a size such as 3g, 512m or 4GiB'
|
|
19
|
+
}`,
|
|
20
|
+
fix:
|
|
21
|
+
name === 'ULTIMATE_TEST_MAX_WORKERS'
|
|
22
|
+
? 'ULTIMATE_TEST_MAX_WORKERS=4 x verify'
|
|
23
|
+
: 'ULTIMATE_TEST_MEMORY_BUDGET=3g x verify',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `x verify --shard` asked for a split the gate cannot make. */
|
|
29
|
+
export class VerifyShardInvalidError extends UltimateError {
|
|
30
|
+
constructor(input: { reason: string; fix?: string }) {
|
|
31
|
+
super({
|
|
32
|
+
code: 'X_VERIFY_SHARD_INVALID',
|
|
33
|
+
cause: `x verify --shard: ${input.reason}`,
|
|
34
|
+
fix: input.fix ?? 'x verify --only unit --shard 1/4 --json',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A part handed to `x verify merge` that is not an `x verify --json` document. */
|
|
40
|
+
export class VerifyMergeInputError extends UltimateError {
|
|
41
|
+
constructor(input: { file: string; reason: string; sourceError?: unknown }) {
|
|
42
|
+
super({
|
|
43
|
+
code: 'X_VERIFY_MERGE_INPUT',
|
|
44
|
+
cause: `x verify merge: ${input.file} ${input.reason}`,
|
|
45
|
+
fix: 'x verify --only <step> --json > part.json # one document per part, the last line of stdout',
|
|
46
|
+
...(input.sourceError === undefined ? {} : { sourceError: input.sourceError }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/verify-floor.ts
CHANGED
|
@@ -41,6 +41,13 @@ export interface VerifyFloor {
|
|
|
41
41
|
* found` from its own shell, not a framework opinion about which compiler is correct.
|
|
42
42
|
*/
|
|
43
43
|
readonly typecheckBin?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Run every parallel test file in a fresh global (`bun test --isolate`). Absent or `false` is
|
|
46
|
+
* the default since 22.7: one global per worker process, reused across the files it runs — 2-5x
|
|
47
|
+
* faster, measured (`test-workers.ts`). A repository whose tests lean on per-file module state
|
|
48
|
+
* says `true` here once, in the file that configures the gate, and `x test`/`x verify` honour it.
|
|
49
|
+
*/
|
|
50
|
+
readonly isolate?: boolean;
|
|
44
51
|
/** Why part of the file is not a floor. The `manifest` step reports these; nothing swallows them. */
|
|
45
52
|
readonly problems: readonly string[];
|
|
46
53
|
}
|
|
@@ -51,6 +58,22 @@ export const BUDGET_FIELD = 'agentsMdMaxBytes';
|
|
|
51
58
|
/** The floor's typecheck-binary key. Named once: the problem quotes it and the fix repairs it. */
|
|
52
59
|
export const TYPECHECK_BIN_FIELD = 'typecheckBin';
|
|
53
60
|
|
|
61
|
+
/** The floor's isolation key. */
|
|
62
|
+
export const ISOLATE_FIELD = 'isolate';
|
|
63
|
+
|
|
64
|
+
function readIsolate(payload: Record<string, unknown> | undefined): {
|
|
65
|
+
isolate?: boolean;
|
|
66
|
+
problems: readonly string[];
|
|
67
|
+
} {
|
|
68
|
+
const raw = payload?.[ISOLATE_FIELD];
|
|
69
|
+
if (raw === undefined) return { problems: [] };
|
|
70
|
+
if (typeof raw !== 'boolean')
|
|
71
|
+
return {
|
|
72
|
+
problems: [`"${ISOLATE_FIELD}" is ${JSON.stringify(raw)}, which is not true or false`],
|
|
73
|
+
};
|
|
74
|
+
return { isolate: raw, problems: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
/**
|
|
55
78
|
* `agentsMdMaxBytes`, or a reason it is not one. A budget that is not a positive whole number is
|
|
56
79
|
* the caller's bug and must not silently fall back to the default: a floor file that says
|
|
@@ -123,16 +146,19 @@ export function parseVerifyFloor(
|
|
|
123
146
|
const record = asRecord(payload);
|
|
124
147
|
const budget = readBudget(record);
|
|
125
148
|
const typecheckBin = readTypecheckBin(record);
|
|
149
|
+
const isolate = readIsolate(record);
|
|
126
150
|
const steps = record?.['steps'];
|
|
127
151
|
if (!Array.isArray(steps)) {
|
|
128
152
|
return {
|
|
129
153
|
steps: [],
|
|
130
154
|
...(budget.budget === undefined ? {} : { agentsMdMaxBytes: budget.budget }),
|
|
131
155
|
...(typecheckBin.bin === undefined ? {} : { typecheckBin: typecheckBin.bin }),
|
|
156
|
+
...(isolate.isolate === undefined ? {} : { isolate: isolate.isolate }),
|
|
132
157
|
problems: [
|
|
133
158
|
'it has no "steps" array of step names',
|
|
134
159
|
...budget.problems,
|
|
135
160
|
...typecheckBin.problems,
|
|
161
|
+
...isolate.problems,
|
|
136
162
|
],
|
|
137
163
|
};
|
|
138
164
|
}
|
|
@@ -142,7 +168,9 @@ export function parseVerifyFloor(
|
|
|
142
168
|
steps: named.filter((step) => declared.includes(step)),
|
|
143
169
|
...(budget.budget === undefined ? {} : { agentsMdMaxBytes: budget.budget }),
|
|
144
170
|
...(typecheckBin.bin === undefined ? {} : { typecheckBin: typecheckBin.bin }),
|
|
171
|
+
...(isolate.isolate === undefined ? {} : { isolate: isolate.isolate }),
|
|
145
172
|
problems: [
|
|
173
|
+
...isolate.problems,
|
|
146
174
|
...(named.length === steps.length ? [] : ['"steps" holds an entry that is not a string']),
|
|
147
175
|
...typecheckBin.problems,
|
|
148
176
|
...(unknown.length === 0
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// `x verify merge <part.json…>`: n CI jobs' `x verify --only … [--shard i/n] --json` documents
|
|
2
|
+
// back into ONE gate verdict. Green only when the parts are provably the whole gate:
|
|
3
|
+
//
|
|
4
|
+
// - every step of `VERIFY_STEP_NAMES` reported by exactly one part — or, for a sharded step, by
|
|
5
|
+
// shards 1..n exactly once each, all counting the same corpus (`corpusHash`);
|
|
6
|
+
// - the zero-tests floor applied to the SUMMED counts of a sharded step, which each shard deferred;
|
|
7
|
+
// - any gap is a named `X_VERIFY_MERGE_INCOMPLETE` finding, never a silent pass.
|
|
8
|
+
//
|
|
9
|
+
// The merged document is a gate run (no `notAGateRun`), because the question it answers — did
|
|
10
|
+
// every step run, over the whole tree, and pass — is the gate's question.
|
|
11
|
+
|
|
12
|
+
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
13
|
+
import { msg } from './messages';
|
|
14
|
+
import type { CommandResult, Finding, StepResult } from './output';
|
|
15
|
+
import type { TestCounts } from './test-counts';
|
|
16
|
+
import { VerifyMergeInputError } from './verify-errors';
|
|
17
|
+
import type { VerifyFloor } from './verify-floor';
|
|
18
|
+
import { floorRequires, skippedSuiteFinding } from './verify-floor';
|
|
19
|
+
import { verifySummary } from './verify-run';
|
|
20
|
+
import { VERIFY_STEP_NAMES } from './verify-step';
|
|
21
|
+
|
|
22
|
+
interface PartStep {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly ok: boolean;
|
|
25
|
+
readonly durationMs: number;
|
|
26
|
+
readonly skipped: boolean;
|
|
27
|
+
readonly findings: readonly Finding[];
|
|
28
|
+
readonly workers?: number;
|
|
29
|
+
readonly tests?: TestCounts;
|
|
30
|
+
readonly output?: string;
|
|
31
|
+
readonly shard?: {
|
|
32
|
+
readonly index: number;
|
|
33
|
+
readonly total: number;
|
|
34
|
+
readonly corpusHash: string;
|
|
35
|
+
readonly files: readonly string[];
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VerifyPart {
|
|
40
|
+
readonly file: string;
|
|
41
|
+
readonly durationMs: number;
|
|
42
|
+
readonly steps: readonly PartStep[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
46
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The LAST non-empty line that parses: `bin/check --json` prints the build's document and then
|
|
50
|
+
* the gate's, and a reader takes the last one.
|
|
51
|
+
*/
|
|
52
|
+
export function parsePart(file: string, text: string): VerifyPart {
|
|
53
|
+
const lines = text
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((line) => line.trim())
|
|
56
|
+
.filter((line) => line.length > 0);
|
|
57
|
+
if (lines.length === 0) throw new VerifyMergeInputError({ file, reason: 'is empty' });
|
|
58
|
+
let doc: unknown;
|
|
59
|
+
try {
|
|
60
|
+
doc = JSON.parse(lines[lines.length - 1] as string);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new VerifyMergeInputError({
|
|
63
|
+
file,
|
|
64
|
+
reason: `does not end in a JSON document (${renderThrowable(error)})`,
|
|
65
|
+
sourceError: error,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!isRecord(doc) || doc['command'] !== 'verify' || !Array.isArray(doc['steps'])) {
|
|
69
|
+
throw new VerifyMergeInputError({
|
|
70
|
+
file,
|
|
71
|
+
reason: 'is not an x verify --json document (no "command": "verify" with "steps")',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const data = isRecord(doc['data']) ? doc['data'] : {};
|
|
75
|
+
const steps: PartStep[] = [];
|
|
76
|
+
for (const raw of doc['steps'] as unknown[]) {
|
|
77
|
+
if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['ok'] !== 'boolean') {
|
|
78
|
+
throw new VerifyMergeInputError({ file, reason: 'holds a step without a name and ok' });
|
|
79
|
+
}
|
|
80
|
+
steps.push(raw as unknown as PartStep);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
file,
|
|
84
|
+
durationMs: typeof data['durationMs'] === 'number' ? data['durationMs'] : 0,
|
|
85
|
+
steps,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const gap = (cause: string, at?: string): Finding => ({
|
|
90
|
+
code: 'X_VERIFY_MERGE_INCOMPLETE',
|
|
91
|
+
cause,
|
|
92
|
+
fix: 'x verify merge parts/*.json --json # after every CI job uploaded its part',
|
|
93
|
+
docs: ERROR_DOCS_URL,
|
|
94
|
+
...(at === undefined ? {} : { at }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const sumCounts = (steps: readonly PartStep[]): TestCounts | undefined => {
|
|
98
|
+
const counted = steps.filter((step) => step.tests !== undefined);
|
|
99
|
+
if (counted.length === 0) return undefined;
|
|
100
|
+
return counted.reduce<TestCounts>(
|
|
101
|
+
(sum, step) => ({
|
|
102
|
+
ran: sum.ran + (step.tests?.ran ?? 0),
|
|
103
|
+
skipped: sum.skipped + (step.tests?.skipped ?? 0),
|
|
104
|
+
}),
|
|
105
|
+
{ ran: 0, skipped: 0 },
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** One step's entries from every part, folded into one step result and its gaps. */
|
|
110
|
+
function mergeStep(
|
|
111
|
+
name: string,
|
|
112
|
+
entries: readonly { readonly part: string; readonly step: PartStep }[],
|
|
113
|
+
floor: VerifyFloor | undefined,
|
|
114
|
+
): StepResult {
|
|
115
|
+
if (entries.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
name,
|
|
118
|
+
ok: false,
|
|
119
|
+
durationMs: 0,
|
|
120
|
+
findings: [gap(`step "${name}" is in no part — no CI job ran it`, name)],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const sharded = entries.filter((entry) => entry.step.shard !== undefined);
|
|
124
|
+
const whole = entries.filter((entry) => entry.step.shard === undefined);
|
|
125
|
+
const gaps: Finding[] = [];
|
|
126
|
+
if (whole.length > 1 || (whole.length > 0 && sharded.length > 0)) {
|
|
127
|
+
gaps.push(
|
|
128
|
+
gap(
|
|
129
|
+
`step "${name}" is reported by ${String(entries.length)} parts (${entries
|
|
130
|
+
.map((entry) => entry.part)
|
|
131
|
+
.join(', ')}) — each step belongs to one job, or to one full set of shards`,
|
|
132
|
+
name,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (sharded.length > 0) {
|
|
137
|
+
const totals = new Set(sharded.map((entry) => entry.step.shard?.total));
|
|
138
|
+
const hashes = new Set(sharded.map((entry) => entry.step.shard?.corpusHash));
|
|
139
|
+
if (totals.size > 1) {
|
|
140
|
+
gaps.push(gap(`step "${name}" mixes splits of ${[...totals].join(' and ')} shards`, name));
|
|
141
|
+
}
|
|
142
|
+
if (hashes.size > 1) {
|
|
143
|
+
gaps.push(
|
|
144
|
+
gap(
|
|
145
|
+
`step "${name}"'s shards counted ${String(hashes.size)} different corpora — every job must check out the same commit`,
|
|
146
|
+
name,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const total = Math.max(...sharded.map((entry) => entry.step.shard?.total ?? 0));
|
|
151
|
+
const seen = new Map<number, number>();
|
|
152
|
+
for (const entry of sharded) {
|
|
153
|
+
const index = entry.step.shard?.index ?? 0;
|
|
154
|
+
seen.set(index, (seen.get(index) ?? 0) + 1);
|
|
155
|
+
}
|
|
156
|
+
const missing = Array.from({ length: total }, (_, i) => i + 1).filter((i) => !seen.has(i));
|
|
157
|
+
const doubled = [...seen].filter(([, count]) => count > 1).map(([index]) => index);
|
|
158
|
+
if (missing.length > 0) {
|
|
159
|
+
gaps.push(
|
|
160
|
+
gap(
|
|
161
|
+
`step "${name}" is missing shard(s) ${missing.map((i) => `${String(i)}/${String(total)}`).join(', ')}`,
|
|
162
|
+
name,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (doubled.length > 0) {
|
|
167
|
+
gaps.push(gap(`step "${name}" has shard(s) ${doubled.join(', ')} more than once`, name));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const steps = entries.map((entry) => entry.step);
|
|
171
|
+
const tests = sumCounts(steps);
|
|
172
|
+
// The floor each shard deferred, on the summed counts: a split step whose shards together ran
|
|
173
|
+
// nothing is the same vanished suite a whole run that ran nothing is.
|
|
174
|
+
const nothingRan = sharded.length > 0 && tests !== undefined && tests.ran === 0;
|
|
175
|
+
const required = floorRequires(floor, name);
|
|
176
|
+
const floorGap = nothingRan && required ? [skippedSuiteFinding(name, tests.skipped)] : [];
|
|
177
|
+
const findings = [...steps.flatMap((step) => step.findings), ...gaps, ...floorGap];
|
|
178
|
+
const output = steps
|
|
179
|
+
.filter((step) => !step.ok && step.output !== undefined && step.output.length > 0)
|
|
180
|
+
.map((step) => step.output as string)
|
|
181
|
+
.join('\n');
|
|
182
|
+
const skipped =
|
|
183
|
+
sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
|
|
184
|
+
return {
|
|
185
|
+
name,
|
|
186
|
+
ok: steps.every((step) => step.ok) && gaps.length === 0 && floorGap.length === 0,
|
|
187
|
+
// The slowest job: the parts ran side by side, and wall time is what a CI run waits on.
|
|
188
|
+
durationMs: Math.max(...steps.map((step) => step.durationMs)),
|
|
189
|
+
...(skipped ? { skipped: true } : {}),
|
|
190
|
+
findings,
|
|
191
|
+
...(output.length === 0 ? {} : { output }),
|
|
192
|
+
...(tests === undefined ? {} : { tests }),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The gate verdict n parts add up to. */
|
|
197
|
+
export function mergeParts(
|
|
198
|
+
parts: readonly VerifyPart[],
|
|
199
|
+
floor: VerifyFloor | undefined,
|
|
200
|
+
declared: readonly string[] = VERIFY_STEP_NAMES,
|
|
201
|
+
): CommandResult {
|
|
202
|
+
const unknown = parts.flatMap((part) =>
|
|
203
|
+
part.steps
|
|
204
|
+
.filter((step) => !declared.includes(step.name))
|
|
205
|
+
.map((step) => gap(`${part.file} reports "${step.name}", which x verify does not run`)),
|
|
206
|
+
);
|
|
207
|
+
const steps = declared.map((name) =>
|
|
208
|
+
mergeStep(
|
|
209
|
+
name,
|
|
210
|
+
parts.flatMap((part) =>
|
|
211
|
+
part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
|
|
212
|
+
),
|
|
213
|
+
floor,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
const failed = steps.filter((step) => !step.ok).map((step) => step.name);
|
|
217
|
+
const skipped = steps.filter((step) => step.skipped === true).map((step) => step.name);
|
|
218
|
+
const durationMs = Math.max(0, ...parts.map((part) => part.durationMs));
|
|
219
|
+
const ok = failed.length === 0 && unknown.length === 0;
|
|
220
|
+
const summary = verifySummary({ results: steps, failed, skipped, totalMs: durationMs });
|
|
221
|
+
return {
|
|
222
|
+
ok,
|
|
223
|
+
command: 'verify',
|
|
224
|
+
summary: msg('cli.verify.merged', { parts: parts.length, summary }),
|
|
225
|
+
steps,
|
|
226
|
+
...(unknown.length === 0 ? {} : { findings: unknown }),
|
|
227
|
+
data: {
|
|
228
|
+
failed,
|
|
229
|
+
skipped,
|
|
230
|
+
durationMs,
|
|
231
|
+
merged: { parts: parts.map((part) => part.file) },
|
|
232
|
+
},
|
|
233
|
+
exitCode: ok ? 0 : 1,
|
|
234
|
+
};
|
|
235
|
+
}
|