@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/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
@@ -2,13 +2,16 @@
2
2
  // files belong to a type and this one owns what happens to them once selected — a wrong file list
3
3
  // is never a race, and a race is never a selection bug.
4
4
 
5
+ // why: Bun ships no path-join primitive; the timings cache lives under the run's root.
6
+ import { join } from 'node:path';
5
7
  import type { Runner } from './exec';
6
- import { execOutput } from './exec';
7
8
  import type { Finding } from './output';
9
+ import { runBatches, testBatches } from './test-batches';
8
10
  import { countsOf } from './test-counts';
9
11
  import { testEnvOverrides } from './test-dotenv';
10
12
  import type { TestFile } from './test-select';
11
- import { failureOf, testArgs } from './test-shards';
13
+ import { failureOf, ISOLATED_TEST_ENV, TEST_TIMINGS_FILE, testArgs } from './test-shards';
14
+ import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
12
15
  import type { StepOutcome } from './verify-step';
13
16
  // Type-only, so nothing here evaluates verify-tests.ts and the two files cannot form a cycle.
14
17
  import type { TestType } from './verify-tests';
@@ -23,10 +26,17 @@ export interface ParallelRunOptions {
23
26
  readonly type: TestType;
24
27
  /** This process's own environment. Optional and defaulted to `Bun.env` — see `VerifyContext.env`. */
25
28
  readonly env?: Readonly<Record<string, string | undefined>>;
29
+ /** A fresh global per file — `x.verify.json` `"isolate": true` or `--isolate`. Default off. */
30
+ readonly isolate?: boolean;
31
+ /** Why the width is what it is — printed on the step line. */
32
+ readonly widthReason?: string;
33
+ /** The machine-pool lease (`test-slots.ts`), for a default-width run. */
34
+ readonly lease?: (want: number) => Promise<{ readonly count: number; release(): void }>;
26
35
  }
27
36
 
28
37
  /**
29
- * ONE `bun test --parallel=N`, not N processes this file spawns and packs itself.
38
+ * `bun test --parallel=N` per batch (`test-batches.ts`), not N processes this file spawns and
39
+ * packs itself.
30
40
  *
31
41
  * Two things make an arbitrary partition safe here and neither is optional. `--parallel` implies
32
42
  * `--isolate`, so every FILE gets a fresh module registry — half a dozen registries in this
@@ -41,24 +51,66 @@ export interface ParallelRunOptions {
41
51
  export async function runParallel(options: ParallelRunOptions): Promise<StepOutcome> {
42
52
  const files = options.files.map((file) => file.path);
43
53
  const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
44
- const envOverrides = testEnvOverrides(options.root, options.env ?? Bun.env);
45
- const result = await options.runner(testArgs({ files, workers }), {
46
- cwd: options.root,
47
- ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
54
+ const envOverrides = {
55
+ ...testEnvOverrides(options.root, options.env ?? Bun.env),
56
+ ...(options.isolate === true ? { [ISOLATED_TEST_ENV]: '1' } : {}),
57
+ };
58
+ // Batched exactly as `x test` batches the same selection at the same width, so the gate's
59
+ // `fix:` line — `x test <type> --workers N` — reruns the very processes that failed.
60
+ const batches = testBatches(
61
+ files,
62
+ workers,
63
+ options.isolate === true ? BATCH_FILES_PER_WORKER : SHARED_BATCH_FILES_PER_WORKER,
64
+ );
65
+ const result = await runBatches({
66
+ runner: options.runner,
67
+ batches,
68
+ workers,
69
+ ...(options.lease === undefined || workers === 1 ? {} : { lease: options.lease }),
70
+ argsFor: (batch, width) =>
71
+ testArgs({
72
+ files: batch,
73
+ workers: width,
74
+ isolate: options.isolate === true,
75
+ timings: join(options.root, TEST_TIMINGS_FILE),
76
+ }),
77
+ options: {
78
+ cwd: options.root,
79
+ ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
80
+ },
48
81
  });
49
82
  // `failureOf` is `x test`'s own, imported rather than restated: the two paths report the SAME
50
83
  // failed `bun test`, so a second literal here is two `cause:` strings and two `fix:` lines free
51
84
  // to drift — and the one that drifts is the gate's, which is the one an agent reads first.
52
85
  const findings: readonly Finding[] = result.ok
53
86
  ? []
54
- : [failureOf(result.code, files.length, { workers, type: options.type })];
87
+ : [
88
+ failureOf(result.code, files.length, {
89
+ workers,
90
+ type: options.type,
91
+ ...(options.isolate === true ? { isolate: true } : {}),
92
+ }),
93
+ ];
94
+ // The width the run REACHED: a batch the machine pool narrowed ran narrower than planned, and a
95
+ // step line that printed the plan would name a width that never ran.
96
+ const reached = Math.max(1, ...result.widths);
97
+ const narrowed = reached < workers;
55
98
  return {
56
99
  ok: findings.length === 0,
57
100
  findings,
58
- workers,
59
- tests: countsOf([result]),
101
+ workers: reached,
102
+ ...(options.widthReason === undefined
103
+ ? {}
104
+ : {
105
+ widthReason: narrowed
106
+ ? `${String(reached)} of ${options.widthReason} — machine slots held by another run`
107
+ : options.widthReason,
108
+ }),
109
+ // Per batch: each process printed its own summary, and one parse of the joined text would
110
+ // read only the last one.
111
+ tests: countsOf(result.results),
60
112
  // Only on failure: a green run's summary is already the step table's, and the reader of a red
61
113
  // gate needs the assertion diff.
62
- ...(result.ok ? {} : { output: execOutput(result) }),
114
+ ...(result.ok ? {} : { output: result.output }),
63
115
  };
64
116
  }
@@ -18,8 +18,11 @@ import { countsOf } from './test-counts';
18
18
  import { testEnvOverrides } from './test-dotenv';
19
19
  import type { TestFile } from './test-select';
20
20
  import { discoverTests } from './test-select';
21
- import { defaultWorkers, SERIAL_TYPES } from './test-workers';
21
+ import { machineLease } from './test-slots';
22
+ import type { WorkerPlan } from './test-workers';
23
+ import { availableCpus, SERIAL_TYPES, totalMemory, workerPlan } from './test-workers';
22
24
  import { withE2eApp } from './verify-e2e';
25
+ import { shardFiles } from './verify-shard';
23
26
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
24
27
  import { fromExec, fromFindings } from './verify-step';
25
28
  import { runParallel } from './verify-test-run';
@@ -212,16 +215,48 @@ const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcom
212
215
 
213
216
  const runType = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
214
217
  if (isSerial(type)) return runSerial(ctx, type);
215
- const files = await filesFor(ctx.root, type);
218
+ const discoveredFiles = await filesFor(ctx.root, type);
219
+ // Under `--shard`, this job runs its slice and says which — `x verify merge` checks the slices.
220
+ // An EMPTY slice is a pass with zero tests, never the serial fallback below (which would run
221
+ // the whole type): the zero-tests floor is `merge`'s to apply, on the summed counts.
222
+ const shard =
223
+ ctx.shard === undefined
224
+ ? undefined
225
+ : shardFiles(
226
+ discoveredFiles.map((file) => file.path),
227
+ ctx.shard,
228
+ ctx.shard.timings,
229
+ );
230
+ if (shard !== undefined && shard.files.length === 0) {
231
+ return { ok: true, findings: [], workers: 0, tests: { ran: 0, skipped: 0 }, shard };
232
+ }
233
+ const files =
234
+ shard === undefined
235
+ ? discoveredFiles
236
+ : discoveredFiles.filter((file) => shard.files.includes(file.path));
216
237
  if (files.length === 0) return runSerial(ctx, type);
217
- return runParallel({
238
+ const env = ctx.env ?? Bun.env;
239
+ const plan =
240
+ ctx.workers === undefined ? workerPlan(availableCpus(), totalMemory(), env) : undefined;
241
+ const workers = ctx.workers ?? (plan as WorkerPlan).workers;
242
+ const lease = plan === undefined ? undefined : machineLease(plan.workers, env);
243
+ const outcome = await runParallel({
218
244
  root: ctx.root,
219
245
  runner: ctx.runner,
220
246
  files,
221
- workers: ctx.workers ?? defaultWorkers(),
247
+ workers,
222
248
  type,
249
+ isolate: ctx.isolate === true,
250
+ widthReason:
251
+ plan === undefined
252
+ ? `${String(Math.min(workers, files.length))} workers (--workers)`
253
+ : files.length < plan.workers
254
+ ? `${String(files.length)} of ${plan.reason}`
255
+ : plan.reason,
256
+ ...(lease === undefined ? {} : { lease }),
223
257
  ...(ctx.env === undefined ? {} : { env: ctx.env }),
224
258
  });
259
+ return shard === undefined ? outcome : { ...outcome, shard };
225
260
  };
226
261
 
227
262
  const isApp = (root: string): boolean => existsSync(join(root, APP_CONFIG_FILE));