@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.
@@ -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 free-memory primitive: `cpus()` is the fallback when navigator cannot
7
- // answer, and `freemem()` is the only reader of available memory.
8
- import { cpus, freemem } from 'node:os';
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
- * Deliberately MORE workers than cores, bounded by memory rather than by a fixed count.
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
- * `cpus - 1` is the intuitive default and it was measured to be worthless exactly where it has to
21
- * pay off. On a 4-core `ubuntu-latest` — the runner this repo commits to — the `unit` step:
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
- * | workers | wall |
24
- * |---------|-------|
25
- * | serial | 43.2s |
26
- * | 3 (cpus - 1) | 44.8s | <- the old default: slower than not sharding at all
27
- * | 4 | 41.6s |
28
- * | 6 | 34.8s |
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
- * Three workers on four cores loses to serial because sharding is not free — each worker reloads
31
- * the framework's module graph — and three of them cannot cover that cost. The reason more-than-
32
- * cores wins is that a test worker is not CPU-bound end to end: it spends real time on module
33
- * resolution, on `--isolate` rebuilding a registry per file, and on waiting for its database.
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 WORKER_OVERSUBSCRIBE = 1.5;
38
+ export const GATE_BUDGET_CAP = 4 * 1024 * 1024 * 1024;
47
39
 
48
- /** The floor the paragraph above names: a 1-core box shards rather than reverting to serial. */
49
- export const WORKER_FLOOR = 2;
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 budgeted at, for the memory bound above. MEASURED — peak RSS of the whole
53
- * `x test unit` process tree on the notificado.co corpus (381 files, PGlite per worker), 12-core
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 RSS |
57
- * |---------|---------------|
58
- * | 8 | 20.7 GB |
59
- * | 12 | 22.5 GB |
60
- * | 16 | 24.3 GB |
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 MARGINAL worker costs ~0.45 GB (the slope); the ~17 GB intercept is the corpus itself —
63
- * every file's module graph and database, retained per worker — and does not shrink with fewer
64
- * workers, so it is not this bound's to budget. The constant is the slope doubled and rounded to a
65
- * power of two, because free memory is read once, before a single worker has started.
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 WORKER_BYTES = 1024 * 1024 * 1024;
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
- /** Bun ships no memory primitive; `freemem()` is libuv's MemAvailable on Linux. */
77
- export const availableMemory = (): number => freemem();
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
- * `ceil(cpus x 1.5)`, held to what free memory can carry and never below the floor. The file-count
81
- * clamp is `test-passes.ts`'s (every pass is clamped to its own file list), because only the
82
- * caller knows the selection.
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 defaultWorkers = (
85
- available: number = availableCpus(),
86
- freeBytes: number = availableMemory(),
87
- ): number => {
88
- const byCpu = Math.ceil(available * WORKER_OVERSUBSCRIBE);
89
- const byMemory = Math.floor(freeBytes / WORKER_BYTES);
90
- return Math.max(WORKER_FLOOR, Math.min(byCpu, byMemory, WORKER_CEILING));
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`), and each of those scans is a CPU-bound process
96
- * of its own. `defaultWorkers()`' oversubscription fills a worker's own stalls when nothing else
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
- available: number = availableCpus(),
103
- freeBytes: number = availableMemory(),
104
- ): number => Math.min(defaultWorkers(available, freeBytes), Math.max(WORKER_FLOOR, available));
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
+ }
@@ -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
+ }