@ultimat3/cli 22.6.2 → 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.
@@ -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
+ }
package/src/verify-run.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
6
6
  import { msg } from './messages';
7
7
  import type { CommandResult, Finding, StepResult } from './output';
8
- import { sharedWorkers } from './test-workers';
9
8
  import {
10
9
  floorRequires,
11
10
  readVerifyFloor,
@@ -41,23 +40,27 @@ export async function runVerify(
41
40
  await pending;
42
41
  pending = undefined;
43
42
  };
44
- // A parallel suite inside that window shares the cores with the static group, so its DEFAULT
45
- // width is one per core (`sharedWorkers`); an explicit `--workers` is the caller's and stands.
46
- const shared: VerifyContext =
47
- ctx.workers === undefined && beside.length > 0 ? { ...ctx, workers: sharedWorkers() } : ctx;
43
+ // Isolation is the floor file's unless the caller said: absent in both is off (22.7).
44
+ const base: VerifyContext =
45
+ ctx.isolate === undefined && floor?.isolate !== undefined
46
+ ? { ...ctx, isolate: floor.isolate }
47
+ : ctx;
48
+ // A parallel suite inside the overlap window used to be narrowed to one worker per core; the
49
+ // default is at most that already (`test-workers.ts`), so the window runs the same plan.
50
+ const shared: VerifyContext = base;
48
51
  for (const step of selected) {
49
52
  if (beside.includes(step)) continue;
50
53
  if (step.name === SERIAL_SUITES[0]) {
51
54
  pending = Promise.all(
52
55
  beside.map(async (other) => {
53
- byName.set(other.name, await runStep(other, ctx, floor));
56
+ byName.set(other.name, await runStep(other, base, floor));
54
57
  }),
55
58
  ).then(() => undefined);
56
59
  } else if (!SERIAL_SUITES.includes(step.name)) {
57
60
  await join();
58
61
  }
59
62
  const inWindow = pending !== undefined && SERIAL_SUITES.includes(step.name);
60
- byName.set(step.name, await runStep(step, inWindow ? shared : ctx, floor));
63
+ byName.set(step.name, await runStep(step, inWindow ? shared : base, floor));
61
64
  }
62
65
  await join();
63
66
  // Reported in the declared order, whatever order the steps finished in: the table, `--json` and
@@ -113,7 +116,7 @@ const onlyList = (only: VerifyContext['only']): readonly string[] | undefined =>
113
116
  * vacuous gate stayed invisible. It names the skipped steps, not just how many: "17/17" is worth
114
117
  * something only when the gap is visible in the same glance.
115
118
  */
116
- function verifySummary(input: {
119
+ export function verifySummary(input: {
117
120
  readonly results: readonly StepResult[];
118
121
  readonly failed: readonly string[];
119
122
  readonly skipped: readonly string[];
@@ -196,7 +199,9 @@ async function runStep(
196
199
  // ONE definition of "nothing ran", read twice, because the floor decides which of the two
197
200
  // things it means — exactly as it already does for a step whose `applies` said no.
198
201
  const tests = outcome.tests;
199
- const nothingRan = tests !== undefined && tests.ran === 0;
202
+ // A shard's slice may hold nothing, or only skipped tests, and still be a correct slice: the
203
+ // "did the suite run at all" question is asked by `x verify merge`, on the summed counts.
204
+ const nothingRan = tests !== undefined && tests.ran === 0 && outcome.shard === undefined;
200
205
  const required = floorRequires(floor, step.name);
201
206
  // A step the floor requires whose suite executed nothing is the same vanished suite as a step
202
207
  // with no files at all — the run just had to finish before it could be seen. Appended to the
@@ -213,6 +218,8 @@ async function runStep(
213
218
  findings: [...outcome.findings, ...vanished],
214
219
  ...(outcome.output === undefined ? {} : { output: outcome.output }),
215
220
  ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
221
+ ...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
222
+ ...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
216
223
  ...(tests === undefined ? {} : { tests }),
217
224
  };
218
225
  }
@@ -0,0 +1,145 @@
1
+ // `x verify --only <step> --shard i/n`: one CI job's slice of a parallel test step, and the facts
2
+ // `x verify merge` needs to prove that n jobs' slices were the whole corpus exactly once.
3
+ //
4
+ // The split is a PURE function of the sorted file list, n and (optionally) a timings file — never
5
+ // of the machine, the clock or the order a glob yielded — so job 3 of 8 is the same files on every
6
+ // runner, and a failing shard reproduces locally with the same flags.
7
+
8
+ import { renderFixShellArg } from '@ultimat3/core';
9
+ import { CryptoHasher } from 'bun';
10
+ import type { ShardFacts } from './output';
11
+ import { VerifyShardInvalidError } from './verify-errors';
12
+ import type { VerifyStepName } from './verify-step';
13
+
14
+ /** The steps a shard may split: the parallel suites. `live` and `e2e` are serial by design. */
15
+ export const SHARDABLE_STEPS: readonly VerifyStepName[] = ['unit', 'contract', 'job'];
16
+
17
+ export interface ShardSpec {
18
+ /** 1-based, as a CI matrix counts. */
19
+ readonly index: number;
20
+ readonly total: number;
21
+ }
22
+
23
+ /** `3/8` → `{ index: 3, total: 8 }`, refused unless `1 <= i <= n`. */
24
+ export function parseShard(raw: string): ShardSpec {
25
+ const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(raw);
26
+ const index = match === null ? Number.NaN : Number(match[1]);
27
+ const total = match === null ? Number.NaN : Number(match[2]);
28
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || total < 1 || index < 1) {
29
+ throw new VerifyShardInvalidError({ reason: `"${raw}" is not i/n (e.g. 2/4)` });
30
+ }
31
+ if (index > total) {
32
+ throw new VerifyShardInvalidError({
33
+ reason: `shard ${String(index)} does not exist in a ${String(total)}-way split (1..${String(total)})`,
34
+ fix: `x verify --only unit --shard ${renderFixShellArg(`${String(total)}/${String(total)}`, '<i/n>')} --json`,
35
+ });
36
+ }
37
+ return { index, total };
38
+ }
39
+
40
+ /**
41
+ * The steps a shard run may name: every one must be shardable. Refused with the step named, and a
42
+ * fix that keeps the shardable ones — `live`/`e2e` belong in a job of their own.
43
+ */
44
+ export function assertShardable(only: readonly VerifyStepName[] | undefined): void {
45
+ if (only === undefined || only.length === 0) {
46
+ throw new VerifyShardInvalidError({
47
+ reason: 'a shard is one slice of ONE parallel step and needs --only to name it',
48
+ fix: 'x verify --only unit --shard 1/4 --json',
49
+ });
50
+ }
51
+ const refused = only.filter((step) => !SHARDABLE_STEPS.includes(step));
52
+ if (refused.length > 0) {
53
+ const kept = only.filter((step) => SHARDABLE_STEPS.includes(step));
54
+ throw new VerifyShardInvalidError({
55
+ reason: `${refused.join(', ')} cannot be split (only ${SHARDABLE_STEPS.join(', ')}); run ${
56
+ refused.length === 1 ? 'it' : 'them'
57
+ } in a job of its own without --shard`,
58
+ fix:
59
+ kept.length === 0
60
+ ? `x verify --only ${renderFixShellArg(refused.join(','), '<step,step>')} --json`
61
+ : `x verify --only ${renderFixShellArg(kept.join(','), '<step,step>')} --shard 1/4 --json`,
62
+ });
63
+ }
64
+ }
65
+
66
+ /** sha256 of the sorted list, one path a line: equal across shards iff they split one corpus. */
67
+ export const corpusHash = (files: readonly string[]): string => {
68
+ const hasher = new CryptoHasher('sha256');
69
+ hasher.update([...files].sort().join('\n'));
70
+ return hasher.digest('hex');
71
+ };
72
+
73
+ /** Per-file durations in ms — `{ "path": ms }`, as in Bun's `--timings` file's `files`. */
74
+ export type Timings = Readonly<Record<string, number>>;
75
+
76
+ /**
77
+ * Which files shard `index` of `total` runs. Round-robin over the sorted list by default — every
78
+ * shard gets a slice of every directory. With `timings`, greedy longest-first: each file, slowest
79
+ * first (ties by path), goes to the shard with the least total so far (ties to the lowest index);
80
+ * a file the timings do not know is costed at their median, so a new file cannot pile onto one job.
81
+ */
82
+ export function shardFiles(
83
+ files: readonly string[],
84
+ spec: ShardSpec,
85
+ timings?: Timings,
86
+ ): ShardFacts {
87
+ const sorted = [...new Set(files)].sort();
88
+ const hash = corpusHash(sorted);
89
+ let picked: string[];
90
+ if (timings === undefined) {
91
+ picked = sorted.filter((_, position) => position % spec.total === spec.index - 1);
92
+ } else {
93
+ const known = sorted
94
+ .map((file) => timings[file])
95
+ .filter((ms): ms is number => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0)
96
+ .sort((a, b) => a - b);
97
+ const median = known.length === 0 ? 1 : (known[Math.floor(known.length / 2)] as number);
98
+ const cost = (file: string): number => {
99
+ const ms = Object.hasOwn(timings, file) ? timings[file] : undefined;
100
+ return typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 ? ms : median;
101
+ };
102
+ const order = [...sorted].sort((a, b) => cost(b) - cost(a) || (a < b ? -1 : a > b ? 1 : 0));
103
+ const loads = Array.from({ length: spec.total }, () => 0);
104
+ const owner = new Map<string, number>();
105
+ for (const file of order) {
106
+ let best = 0;
107
+ for (let shard = 1; shard < spec.total; shard += 1) {
108
+ if ((loads[shard] as number) < (loads[best] as number)) best = shard;
109
+ }
110
+ loads[best] = (loads[best] as number) + cost(file);
111
+ owner.set(file, best);
112
+ }
113
+ picked = sorted.filter((file) => owner.get(file) === spec.index - 1);
114
+ }
115
+ return { index: spec.index, total: spec.total, corpusHash: hash, files: picked };
116
+ }
117
+
118
+ /** A timings file, or a refusal naming it — a CI job must not silently fall back to round-robin. */
119
+ export async function readTimings(path: string): Promise<Timings> {
120
+ const file = Bun.file(path);
121
+ if (!(await file.exists())) {
122
+ throw new VerifyShardInvalidError({
123
+ reason: `--timings ${path} does not exist`,
124
+ fix: 'bun test --timings=timings.json --update-timings # writes it, then commit or cache it',
125
+ });
126
+ }
127
+ let parsed: unknown;
128
+ try {
129
+ parsed = JSON.parse(await file.text());
130
+ } catch {
131
+ parsed = undefined;
132
+ }
133
+ // Bun's own `--update-timings` writes `{ "version": 1, "files": { "path": ms } }`; a bare map
134
+ // is accepted too.
135
+ if (typeof parsed === 'object' && parsed !== null && 'files' in parsed) {
136
+ parsed = (parsed as { files: unknown }).files;
137
+ }
138
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
139
+ throw new VerifyShardInvalidError({
140
+ reason: `--timings ${path} is not a JSON object of { "path": ms }`,
141
+ fix: 'bun test --timings=timings.json --update-timings',
142
+ });
143
+ }
144
+ return parsed as Timings;
145
+ }
@@ -5,7 +5,7 @@
5
5
  import { ERROR_DOCS_URL } from '@ultimat3/core';
6
6
  import type { ExecResult, Runner } from './exec';
7
7
  import { execOutput } from './exec';
8
- import type { Finding } from './output';
8
+ import type { Finding, ShardFacts } from './output';
9
9
  import type { TestCounts } from './test-counts';
10
10
 
11
11
  /**
@@ -78,6 +78,21 @@ export interface VerifyContext {
78
78
  * narrowing: no value of it changes which steps run or what "green" means.
79
79
  */
80
80
  readonly workers?: number;
81
+ /**
82
+ * A fresh global per test file (`bun test --isolate`). Absent means `x.verify.json`'s
83
+ * `"isolate"`, and absent there means off — the default since 22.7.
84
+ */
85
+ readonly isolate?: boolean;
86
+ /**
87
+ * One slice of an n-way CI split of each selected parallel step's file list (`--shard i/n`,
88
+ * 1-based `index`). Only with `only`; `x verify merge` reassembles the verdict.
89
+ */
90
+ readonly shard?: {
91
+ readonly index: number;
92
+ readonly total: number;
93
+ /** Bun `--timings` durations: greedy longest-first instead of round-robin. */
94
+ readonly timings?: Readonly<Record<string, number>>;
95
+ };
81
96
  /**
82
97
  * Some steps, by name — an iteration loop, and the one thing here that IS a narrowing. The
83
98
  * whole gate costs minutes (3m19s at the framework root on 12 cores, `As of 2026-09-23`), which
@@ -103,6 +118,10 @@ export interface StepOutcome {
103
118
  * reading the step list's source.
104
119
  */
105
120
  readonly workers?: number;
121
+ /** Why `workers` is what it is — `4 workers (budget 4.0 GB)`. Printed on the step line. */
122
+ readonly widthReason?: string;
123
+ /** The CI slice this step ran, under `--shard`: which files, and a hash of the whole list. */
124
+ readonly shard?: ShardFacts;
106
125
  /**
107
126
  * What the suite executed, for the steps that run one. Absent means this step spawned no test
108
127
  * process at all — a check step, or the `eval` step answering with declarations alone — and is