@ultimat3/cli 22.6.0 → 22.6.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.6.0",
3
+ "version": "22.6.2",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.6.0",
42
- "@ultimat3/admin": "22.6.0",
43
- "@ultimat3/ai": "22.6.0",
44
- "@ultimat3/auth": "22.6.0",
45
- "@ultimat3/cache": "22.6.0",
46
- "@ultimat3/core": "22.6.0",
47
- "@ultimat3/db": "22.6.0",
48
- "@ultimat3/entity": "22.6.0",
49
- "@ultimat3/flags": "22.6.0",
50
- "@ultimat3/http": "22.6.0",
51
- "@ultimat3/i18n": "22.6.0",
52
- "@ultimat3/jobs": "22.6.0",
53
- "@ultimat3/mail": "22.6.0",
54
- "@ultimat3/manifest": "22.6.0",
55
- "@ultimat3/mcp": "22.6.0",
56
- "@ultimat3/money": "22.6.0",
57
- "@ultimat3/notify": "22.6.0",
58
- "@ultimat3/policy": "22.6.0",
59
- "@ultimat3/pwa": "22.6.0",
60
- "@ultimat3/query": "22.6.0",
61
- "@ultimat3/realtime": "22.6.0",
62
- "@ultimat3/render": "22.6.0",
63
- "@ultimat3/schema": "22.6.0",
64
- "@ultimat3/seo": "22.6.0",
65
- "@ultimat3/storage": "22.6.0",
66
- "@ultimat3/testing": "22.6.0",
67
- "@ultimat3/time": "22.6.0",
41
+ "@ultimat3/action": "22.6.2",
42
+ "@ultimat3/admin": "22.6.2",
43
+ "@ultimat3/ai": "22.6.2",
44
+ "@ultimat3/auth": "22.6.2",
45
+ "@ultimat3/cache": "22.6.2",
46
+ "@ultimat3/core": "22.6.2",
47
+ "@ultimat3/db": "22.6.2",
48
+ "@ultimat3/entity": "22.6.2",
49
+ "@ultimat3/flags": "22.6.2",
50
+ "@ultimat3/http": "22.6.2",
51
+ "@ultimat3/i18n": "22.6.2",
52
+ "@ultimat3/jobs": "22.6.2",
53
+ "@ultimat3/mail": "22.6.2",
54
+ "@ultimat3/manifest": "22.6.2",
55
+ "@ultimat3/mcp": "22.6.2",
56
+ "@ultimat3/money": "22.6.2",
57
+ "@ultimat3/notify": "22.6.2",
58
+ "@ultimat3/policy": "22.6.2",
59
+ "@ultimat3/pwa": "22.6.2",
60
+ "@ultimat3/query": "22.6.2",
61
+ "@ultimat3/realtime": "22.6.2",
62
+ "@ultimat3/render": "22.6.2",
63
+ "@ultimat3/schema": "22.6.2",
64
+ "@ultimat3/seo": "22.6.2",
65
+ "@ultimat3/storage": "22.6.2",
66
+ "@ultimat3/testing": "22.6.2",
67
+ "@ultimat3/time": "22.6.2",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -3,13 +3,11 @@
3
3
  // nothing lists keeps the positional `anonymous-job-2` that `job()` minted, on the queue row, in
4
4
  // `x.manifest.json` and in every dead-letter trace — under a green gate (plan 101 slice 11 b).
5
5
 
6
+ import { API_INDEX } from './app-root';
6
7
  import { containedPath } from './generate-write';
7
8
  import { camel } from './templates/naming';
8
9
  import { wrapList } from './templates/wrap';
9
10
 
10
- /** The one file `defineApi` is called from in a scaffolded app. */
11
- export const API_INDEX = 'apps/web/api/index.ts';
12
-
13
11
  /** One module to import as a namespace and list under a `defineApi` key. */
14
12
  export interface ApiEntry {
15
13
  readonly key: 'jobs' | 'tasks';
package/src/app-load.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  // Bun ships no `Bun.*` path API: `relative`/`sep` turn an absolute scan hit into the app-root-
7
7
  // relative POSIX path every finding and every manifest fact is keyed by.
8
- import { join, relative, sep } from 'node:path';
8
+ import { join, relative, resolve, sep } from 'node:path';
9
9
  import { listActions, registerActions } from '@ultimat3/action';
10
10
  import { describeEntities } from '@ultimat3/entity';
11
11
  import { localeConfig } from '@ultimat3/i18n';
@@ -28,7 +28,7 @@ import {
28
28
  // accident one refactor away from compiling every app's `.tsx` to `React.createElement`. The named
29
29
  // import is a value import, so the side effect holds without the bare line it replaced.
30
30
  import { setStylesheetRoot } from '@ultimat3/render/server';
31
- import { APP_CONFIG_FILE } from './app-root';
31
+ import { API_INDEX, APP_CONFIG_FILE } from './app-root';
32
32
  import { collectDeclaredCodes } from './error-contract';
33
33
  import type { Finding } from './output';
34
34
  import { findingFrom } from './output';
@@ -116,11 +116,17 @@ export function resetAppLoad(): void {
116
116
  }
117
117
 
118
118
  /**
119
- * Every module path the app globs match, SORTED. `Bun.Glob` answers in directory order, which is
120
- * the filesystem's — ext4 hashes names with a per-filesystem seed — so two pods of one image
121
- * imported the app in two orders, registered its stylesheets in two orders, and served two
122
- * different `/styles/<hash>.css` for one page (notificado.co, 22.3.2). Import order is the
123
- * stylesheet cascade's order, so it must be the same everywhere.
119
+ * Every module path the app globs match, SORTED — with the API index first. `Bun.Glob` answers in
120
+ * directory order, which is the filesystem's — ext4 hashes names with a per-filesystem seed — so
121
+ * two pods of one image imported the app in two orders, registered its stylesheets in two orders,
122
+ * and served two different `/styles/<hash>.css` for one page (notificado.co, 22.3.2). Import order
123
+ * is the stylesheet cascade's order, so it must be the same everywhere.
124
+ *
125
+ * The API index leads because `defineApi({ http: { pathStyle } })` is what every action URL is
126
+ * derived under: sorted, `apps/admin/**` evaluated before it, and a module-level
127
+ * `derivePath('signOut').path` there captured the default style's `/api/outs/sign` — a form
128
+ * posting to a URL no route served (notificado.co, 22.6.0). Anything still evaluated before the
129
+ * declaration (the index's own imports) is refused at it, `X_ACTION_PATH_DERIVED_EARLY`.
124
130
  */
125
131
  export async function appModulePaths(root: string): Promise<readonly string[]> {
126
132
  // Pattern by pattern, as before — only the order WITHIN a pattern was the filesystem's.
@@ -134,7 +140,9 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
134
140
  }
135
141
  found.push(...matched.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
136
142
  }
137
- return found;
143
+ // Resolved, like the glob's own answers: `loadApp('.')` is a legal call.
144
+ const index = resolve(root, API_INDEX);
145
+ return found.includes(index) ? [index, ...found.filter((path) => path !== index)] : found;
138
146
  }
139
147
 
140
148
  export async function loadApp(root: string): Promise<LoadedApp> {
package/src/app-root.ts CHANGED
@@ -6,6 +6,12 @@ import { dirname, join, resolve } from 'node:path';
6
6
  import { BunVersionError, NotInAppError } from './errors';
7
7
 
8
8
  export const APP_CONFIG_FILE = 'app.config.ts';
9
+
10
+ /**
11
+ * The one file `defineApi` is called from in a scaffolded app — here, beside the config file, so
12
+ * the module scan can import it first without pulling the generators into the serve graph.
13
+ */
14
+ export const API_INDEX = 'apps/web/api/index.ts';
9
15
  export const MANIFEST_FILE = 'x.manifest.json';
10
16
  /**
11
17
  * The floor the shipped `x` enforces, and it must not sit below what `x` EMITS. It said `1.3.0`
@@ -4,7 +4,14 @@
4
4
  import { TEST_TYPES } from '@ultimat3/testing/test-types';
5
5
  import { DEFAULT_BASE } from './affected';
6
6
  import type { CommandSpec } from './parse';
7
- import { SERIAL_TYPES, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
7
+ import {
8
+ MEMORY_SHARE,
9
+ SERIAL_TYPES,
10
+ WORKER_BYTES,
11
+ WORKER_CEILING,
12
+ WORKER_FLOOR,
13
+ WORKER_OVERSUBSCRIBE,
14
+ } from './test-workers';
8
15
 
9
16
  export const testSpec: CommandSpec = {
10
17
  name: 'test',
@@ -20,7 +27,7 @@ export const testSpec: CommandSpec = {
20
27
  {
21
28
  name: 'workers',
22
29
  type: 'string',
23
- summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to free memory, min ${WORKER_FLOOR}; max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
30
+ summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to ${MEMORY_SHARE * 100}% of free memory at ${WORKER_BYTES / 2 ** 30} GiB a worker, min ${WORKER_FLOOR}; max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
24
31
  },
25
32
  {
26
33
  name: 'worker',
@@ -2,7 +2,13 @@
2
2
  // read it without loading `cmd-verify.ts`, which `registry.ts` imports only when the command runs.
3
3
 
4
4
  import type { CommandSpec } from './parse';
5
- import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
5
+ import {
6
+ MEMORY_SHARE,
7
+ WORKER_BYTES,
8
+ WORKER_CEILING,
9
+ WORKER_FLOOR,
10
+ WORKER_OVERSUBSCRIBE,
11
+ } from './test-workers';
6
12
 
7
13
  export const verifySpec: CommandSpec = {
8
14
  name: 'verify',
@@ -16,7 +22,7 @@ export const verifySpec: CommandSpec = {
16
22
  {
17
23
  name: 'workers',
18
24
  type: 'string',
19
- summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to free memory; min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
25
+ summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to ${MEMORY_SHARE * 100}% of free memory at ${WORKER_BYTES / 2 ** 30} GiB a worker; min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
20
26
  },
21
27
  {
22
28
  name: 'only',
package/src/messages.ts CHANGED
@@ -265,6 +265,8 @@ const CATALOG = {
265
265
  'cli.test.affected.none': 'nothing is affected by {base}...HEAD — 0 test file(s) ran',
266
266
  'cli.test.empty': 'no test file matches {selection} — 0 test file(s) ran (--allow-empty)',
267
267
  'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
268
+ 'cli.test.batch': '── batch {batch} of {batches} · {files} file(s) ──',
269
+ 'cli.test.batches': ' · {batches} batches',
268
270
  'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
269
271
  'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
270
272
  'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
@@ -0,0 +1,114 @@
1
+ // Spending one pass as a few `bun test --parallel=N` processes in sequence, not one. The width of
2
+ // a pass is `test-workers.ts`'s to decide; how many files one set of N workers is allowed to chew
3
+ // through before it is thrown away is this file's.
4
+ //
5
+ // WHY THERE IS MORE THAN ONE PROCESS, `As of 2026-09-27`. A `--parallel` worker is not freed file
6
+ // by file: it keeps its heap for the life of the `bun test` process and it GROWS with every file it
7
+ // is handed (JIT, module-resolution caches, PGlite heaps the collector has not yet returned). On
8
+ // notificado.co's unit corpus (768 files, PGlite per file, 12-core box, Bun 1.4.0), whole-tree RSS
9
+ // at 12 workers:
10
+ //
11
+ // | form | peak tree RSS | wall |
12
+ // |---------------------------------------------|---------------|--------|
13
+ // | 1x `--parallel=12`, 768 files (~64/worker) | 12.4-13.3 GB | 134-158s |
14
+ // | 3x `--parallel=12`, 256 files (~21/worker) | 10.8-11.4 GB | 133-147s |
15
+ // | 6x `--parallel=12`, 128 files (~11/worker) | 9.7 GB | 166s |
16
+ //
17
+ // (Wall is noisy — the box was shared, at load 30-40 — but batching at the same width never
18
+ // measured slower than not batching outside one outlier run.)
19
+ //
20
+ // One process's footprint scales with the files each worker sees, so an unbatched run's peak is a
21
+ // function of the CORPUS: every test file an app adds raises it, and nothing ever gives it back
22
+ // until the run ends. A batch boundary gives all of it back. At `BATCH_FILES_PER_WORKER` files per
23
+ // worker the peak is a function of the WIDTH alone — and the wall clock does not move, because a
24
+ // worker restart costs a second against a batch of ~40s. Halving the batch again bought 1.5 GB for
25
+ // 25% more wall, which is why the constant is not smaller.
26
+ //
27
+ // THE DEAL IS ROUND-ROBIN over the sorted list, so every batch gets a slice of every directory
28
+ // (a slow directory is not one batch's tail) and the split is a pure function of the file list and
29
+ // the width: `x test unit --workers N` reruns exactly the batches the failing run ran.
30
+
31
+ import type { ExecResult, Runner } from './exec';
32
+ import { execOutput } from './exec';
33
+ import { msg } from './messages';
34
+ import { BATCH_FILES_PER_WORKER } from './test-workers';
35
+
36
+ /**
37
+ * The file groups one pass runs as, in order. One group — the list itself — whenever the list fits
38
+ * in `workers x BATCH_FILES_PER_WORKER`, which is every small selection and every `--filter`.
39
+ * Otherwise the fewest groups that fit, dealt round-robin so their sizes differ by at most one.
40
+ */
41
+ export function testBatches(files: readonly string[], workers: number): readonly string[][] {
42
+ const sorted = [...files].sort();
43
+ const cap = Math.max(1, Math.trunc(workers)) * BATCH_FILES_PER_WORKER;
44
+ const count = Math.max(1, Math.ceil(sorted.length / cap));
45
+ const batches: string[][] = Array.from({ length: count }, () => []);
46
+ sorted.forEach((file, index) => {
47
+ batches[index % count]?.push(file);
48
+ });
49
+ return batches;
50
+ }
51
+
52
+ /** Every batch's own result, and what they add up to. */
53
+ export interface BatchedRun {
54
+ /** One per batch, in order — `countsOf` reads each one's own Bun summary. */
55
+ readonly results: readonly ExecResult[];
56
+ readonly ok: boolean;
57
+ /** The first non-zero exit, or 0. */
58
+ readonly code: number;
59
+ /** Summed: the batches ran one after another. */
60
+ readonly durationMs: number;
61
+ /**
62
+ * Every batch's output, each under a line naming it when there was more than one. One batch is
63
+ * `execOutput` of it, byte for byte, so a small run reads exactly as it always has.
64
+ */
65
+ readonly output: string;
66
+ }
67
+
68
+ /**
69
+ * Run `batches` one after another — never `Promise.all`, which would put every batch's workers on
70
+ * the machine at once and undo the bound — and keep going after a red one, because the caller
71
+ * asked for the whole selection and a report that stops at the first failure hides the rest. The
72
+ * one exception is a caller that asked to stop (`stopOnFailure`, i.e. `-- --bail`).
73
+ */
74
+ export async function runBatches(input: {
75
+ readonly runner: Runner;
76
+ readonly batches: readonly (readonly string[])[];
77
+ readonly argsFor: (files: readonly string[]) => readonly string[];
78
+ readonly options: Parameters<Runner>[1];
79
+ /**
80
+ * Launch no further batch once one is red. Set for a caller-forwarded `--bail`: Bun stops a
81
+ * process at its threshold, and without this the next batch would start over from zero.
82
+ */
83
+ readonly stopOnFailure?: boolean;
84
+ }): Promise<BatchedRun> {
85
+ const results: ExecResult[] = [];
86
+ for (const files of input.batches) {
87
+ const result = await input.runner(input.argsFor(files), input.options);
88
+ results.push(result);
89
+ if (!result.ok && input.stopOnFailure === true) break;
90
+ }
91
+ const first = results.find((result) => !result.ok);
92
+ const output =
93
+ results.length === 1
94
+ ? execOutput(results[0] as ExecResult)
95
+ : results
96
+ .map((result, index) =>
97
+ [
98
+ msg('cli.test.batch', {
99
+ batch: index + 1,
100
+ batches: results.length,
101
+ files: input.batches[index]?.length ?? 0,
102
+ }),
103
+ execOutput(result),
104
+ ].join('\n'),
105
+ )
106
+ .join('\n');
107
+ return {
108
+ results,
109
+ ok: first === undefined,
110
+ code: first?.code ?? 0,
111
+ durationMs: results.reduce((sum, result) => sum + result.durationMs, 0),
112
+ output,
113
+ };
114
+ }
@@ -2,6 +2,11 @@
2
2
  // reproduces it exactly. Split out of cmd-test.ts because a printed reproduction is only true if
3
3
  // it carries every input to the run — that rule is this file's, and argv parsing is that one's.
4
4
  //
5
+ // ONE PROCESS PER BATCH, N WORKERS. Until 2026-09-27 it was one process for the whole selection;
6
+ // a worker's heap grows with every file it runs, so that process's peak grew with the corpus, and
7
+ // `test-batches.ts` now spends each pass as a few `--parallel=N` processes in sequence — the
8
+ // measurement is its header. Everything below still holds inside a batch.
9
+ //
5
10
  // ONE PROCESS, N WORKERS, `As of 2026-08-27`. This file used to pack the files into N bins itself
6
11
  // (largest-first greedy over file SIZE) and `Promise.all` one `bun test` per bin. Bun 1.4 runs the
7
12
  // pool itself — `--parallel=N`, which starts each file on the next free worker — so the packer is
@@ -39,7 +44,6 @@
39
44
  import { ERROR_DOCS_URL } from '@ultimat3/core';
40
45
  import type { AffectedSelection } from './affected';
41
46
  import type { Runner } from './exec';
42
- import { execOutput } from './exec';
43
47
  import { msg } from './messages';
44
48
  import type { CommandResult, Finding, JsonValue, StepResult } from './output';
45
49
  import { quoteArg } from './shell-quote';
@@ -48,6 +52,7 @@ import { testEnvOverrides } from './test-dotenv';
48
52
  /** `@ultimat3/testing`'s `ISOLATED_ENV`, restated: `cli → testing` is a runtime edge, kept to fixtures. */
49
53
  export const ISOLATED_TEST_ENV = 'ULTIMATE_TEST_ISOLATED';
50
54
 
55
+ import { runBatches, testBatches } from './test-batches';
51
56
  import { testPasses } from './test-passes';
52
57
  import type { TestFile } from './test-select';
53
58
  import type { TestType } from './verify-tests';
@@ -219,10 +224,10 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
219
224
  });
220
225
 
221
226
  /**
222
- * ONE `bun test` PER PASS, and one pass unless the selection mixes serial files with the rest —
223
- * `test-passes.ts` decides that, and this spends it. Bun owns the pool inside a pass and hands
224
- * each free worker the next file, so nothing here decides which file runs where; see this file's
225
- * header for what that measured.
227
+ * ONE `bun test` PER BATCH, a pass being as many batches as `test-batches.ts` cuts it into, and
228
+ * one pass unless the selection mixes serial files with the rest — `test-passes.ts` decides that,
229
+ * and this spends it. Bun owns the pool inside a batch and hands each free worker the next file, so
230
+ * nothing here decides which file runs where; see this file's header for what that measured.
226
231
  *
227
232
  * Sequential, never `Promise.all`: the whole point of a serial pass is that nothing runs beside
228
233
  * it. And every pass runs even after one fails — the caller asked for a suite, and a report that
@@ -241,6 +246,9 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
241
246
  // Every run this spawns is isolated (`--parallel` or `--isolate`) unless the caller opted out.
242
247
  ...((options.passthrough ?? []).includes('--no-isolate') ? {} : { [ISOLATED_TEST_ENV]: '1' }),
243
248
  };
249
+ const passthrough = options.passthrough ?? [];
250
+ const watching = passthrough.includes('--watch');
251
+ const bailing = passthrough.some((arg) => arg === '--bail' || arg.startsWith('--bail='));
244
252
  const passes = testPasses({
245
253
  files: options.files,
246
254
  workers: options.workers,
@@ -250,18 +258,28 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
250
258
  const started = performance.now();
251
259
  const steps: StepResult[] = [];
252
260
  const spent: JsonValue[] = [];
261
+ let batchCount = 0;
253
262
  let ok = true;
254
263
  let exitCode = 0;
255
264
  for (const pass of passes) {
256
265
  const files = pass.files.map((file) => file.path);
257
- const result = await options.runner(
258
- testArgs({
259
- files,
260
- workers: pass.workers,
261
- ...(only === undefined ? {} : { shard: only }),
262
- ...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
263
- }),
264
- {
266
+ // A `--worker` rerun is one process over one shard and is never split: shard i of the rerun
267
+ // must be the same files as shard i of the run it reproduces. Nor is `-- --watch`, which never
268
+ // exits, so a second batch would never start. Every other pass is spent in `test-batches.ts`'
269
+ // batches, one `bun test` after another.
270
+ const batches = only === undefined && !watching ? testBatches(files, pass.workers) : [files];
271
+ const result = await runBatches({
272
+ runner: options.runner,
273
+ batches,
274
+ stopOnFailure: bailing,
275
+ argsFor: (batch) =>
276
+ testArgs({
277
+ files: batch,
278
+ workers: Math.min(pass.workers, batch.length),
279
+ ...(only === undefined ? {} : { shard: only }),
280
+ ...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
281
+ }),
282
+ options: {
265
283
  cwd: options.root,
266
284
  ...(Object.keys(envOverrides).length === 0 && only === undefined
267
285
  ? {}
@@ -272,24 +290,28 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
272
290
  },
273
291
  }),
274
292
  },
275
- );
293
+ });
294
+ batchCount += batches.length;
276
295
  const plan = planOf(options, pass);
277
296
  const label =
278
297
  only === undefined ? `${pass.workers} worker(s)` : `shard ${only} of ${pass.workers}`;
279
298
  steps.push({
280
- name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files`,
299
+ name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files${
300
+ batches.length > 1 ? msg('cli.test.batches', { batches: batches.length }) : ''
301
+ }`,
281
302
  ok: result.ok,
282
303
  durationMs: result.durationMs,
283
304
  // `output.ts` documents this field as absent for a NON-test step, so omitting it here made
284
305
  // `renderJson` describe the test step as one — recoverable only by parsing `name`.
285
306
  workers: pass.workers,
286
307
  findings: result.ok ? [] : [failureOf(result.code, files.length, plan)],
287
- output: execOutput(result),
308
+ output: result.output,
288
309
  });
289
310
  spent.push({
290
311
  ...(pass.type === undefined ? {} : { type: pass.type }),
291
312
  files: files.length,
292
313
  workers: pass.workers,
314
+ ...(batches.length > 1 ? { batches: batches.length } : {}),
293
315
  ok: result.ok,
294
316
  exitCode: result.code,
295
317
  reproduce: reproduceFor(plan),
@@ -321,6 +343,8 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
321
343
  // Only when the split made more than one, so a single-pass run's JSON is byte-identical to
322
344
  // what it has always been — and a mixed one can never be read as if it were a single run.
323
345
  ...(spent.length > 1 ? { passes: spent } : {}),
346
+ // Only when some pass was split, for the same reason: an unbatched run's JSON is unchanged.
347
+ ...(batchCount > spent.length ? { batches: batchCount } : {}),
324
348
  ok,
325
349
  exitCode,
326
350
  reproduce: reproduceFor(plan),
@@ -35,11 +35,11 @@ export function availableCpus(): number {
35
35
  *
36
36
  * The bound is memory, not cores. A worker is a whole Bun process with the framework's module
37
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.
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
+ * ceiling is now what the machine can actually hold — `MEMORY_SHARE` of `os.freemem()`
40
+ * (MemAvailable on Linux, so reclaimable page cache counts as free) divided by `WORKER_BYTES` —
41
+ * which binds on a small CI runner, on a loaded laptop and on a box shared with other sessions,
42
+ * the places an unbounded count swaps or, with no swap, is OOM-killed.
43
43
  *
44
44
  * The floor of 2 keeps a 1-core box sharding rather than silently reverting to serial.
45
45
  */
@@ -49,22 +49,44 @@ export const WORKER_OVERSUBSCRIBE = 1.5;
49
49
  export const WORKER_FLOOR = 2;
50
50
 
51
51
  /**
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`:
52
+ * What one test worker is budgeted at, for the memory bound above. MEASURED — whole-tree RSS of
53
+ * `x test unit` on the notificado.co corpus (768 files, PGlite per file), 12-core box, Bun 1.4.0,
54
+ * `As of 2026-09-27`, batched by `BATCH_FILES_PER_WORKER`:
55
55
  *
56
- * | workers | peak tree RSS |
57
- * |---------|---------------|
58
- * | 8 | 20.7 GB |
59
- * | 12 | 22.5 GB |
60
- * | 16 | 24.3 GB |
56
+ * | workers | batches | peak tree RSS | largest single worker |
57
+ * |---------|---------|---------------|-----------------------|
58
+ * | 12 | 3 | 10.8-11.4 GB | 1.38-1.41 GB |
59
+ * | 14 | 3 | 12.2 GB | 1.39 GB |
60
+ * | 18 | 2 | 15.9 GB | 1.47 GB |
61
61
  *
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.
62
+ * Unbatched, the same box at 22.6.1's default of 18 workers peaked at 17.1-18.2 GB, and the same
63
+ * run's wall clock was 132-167s against 152-163s for this default (14 workers, 3 batches) — within
64
+ * the noise of a box at load 30-40.
65
+ *
66
+ * The constant is the LARGEST single worker rounded up, not the average (~0.88 GB), so the bound
67
+ * holds even on the run where every worker peaks at the same moment. The 2026-09-25 figure it
68
+ * replaces (1 GiB, from a "~0.45 GB slope over a ~17 GB intercept") budgeted the slope and waved
69
+ * the intercept away as "the corpus"; that intercept was per-worker growth over an unbatched run,
70
+ * and an 18-worker default on 35 GB free took a 45 GB box with no swap down (2026-09-27).
71
+ */
72
+ export const WORKER_BYTES = 1.5 * 1024 * 1024 * 1024;
73
+
74
+ /**
75
+ * The share of available memory a default-width run may plan to use. Never all of it, because free
76
+ * memory is read ONCE, before a single worker has started, and a dev box is never only running the
77
+ * tests: a type-checker, an editor's language server, another agent's gate all grow after that
78
+ * read. A run that plans on every free byte is one neighbour away from the OOM killer. 0.6 and not
79
+ * less because the plan is made at the LARGEST worker's peak: measured, the whole tree peaks at
80
+ * ~0.9 GB a worker, so the real peak lands near 35-40% of what was available at the start.
81
+ */
82
+ export const MEMORY_SHARE = 0.6;
83
+
84
+ /**
85
+ * Files each worker is handed before its `bun test` process is thrown away and a fresh one takes
86
+ * the next batch (`test-batches.ts`, which carries the measurement). What makes the peak a function
87
+ * of the width rather than of the corpus.
66
88
  */
67
- export const WORKER_BYTES = 1024 * 1024 * 1024;
89
+ export const BATCH_FILES_PER_WORKER = 24;
68
90
 
69
91
  /**
70
92
  * The most `--workers` accepts, on either command. Not a default and not a memory rule — a sanity
@@ -77,7 +99,8 @@ export const WORKER_CEILING = 64;
77
99
  export const availableMemory = (): number => freemem();
78
100
 
79
101
  /**
80
- * `ceil(cpus x 1.5)`, held to what free memory can carry and never below the floor. The file-count
102
+ * `ceil(cpus x 1.5)`, held to `MEMORY_SHARE` of free memory at `WORKER_BYTES` a worker, and never
103
+ * below the floor. The file-count
81
104
  * clamp is `test-passes.ts`'s (every pass is clamped to its own file list), because only the
82
105
  * caller knows the selection.
83
106
  */
@@ -86,7 +109,7 @@ export const defaultWorkers = (
86
109
  freeBytes: number = availableMemory(),
87
110
  ): number => {
88
111
  const byCpu = Math.ceil(available * WORKER_OVERSUBSCRIBE);
89
- const byMemory = Math.floor(freeBytes / WORKER_BYTES);
112
+ const byMemory = Math.floor((freeBytes * MEMORY_SHARE) / WORKER_BYTES);
90
113
  return Math.max(WORKER_FLOOR, Math.min(byCpu, byMemory, WORKER_CEILING));
91
114
  };
92
115
 
@@ -3,8 +3,8 @@
3
3
  // is never a race, and a race is never a selection bug.
4
4
 
5
5
  import type { Runner } from './exec';
6
- import { execOutput } from './exec';
7
6
  import type { Finding } from './output';
7
+ import { runBatches, testBatches } from './test-batches';
8
8
  import { countsOf } from './test-counts';
9
9
  import { testEnvOverrides } from './test-dotenv';
10
10
  import type { TestFile } from './test-select';
@@ -26,7 +26,8 @@ export interface ParallelRunOptions {
26
26
  }
27
27
 
28
28
  /**
29
- * ONE `bun test --parallel=N`, not N processes this file spawns and packs itself.
29
+ * `bun test --parallel=N` per batch (`test-batches.ts`), not N processes this file spawns and
30
+ * packs itself.
30
31
  *
31
32
  * Two things make an arbitrary partition safe here and neither is optional. `--parallel` implies
32
33
  * `--isolate`, so every FILE gets a fresh module registry — half a dozen registries in this
@@ -42,9 +43,17 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
42
43
  const files = options.files.map((file) => file.path);
43
44
  const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
44
45
  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 }),
46
+ // Batched exactly as `x test` batches the same selection at the same width, so the gate's
47
+ // `fix:` line — `x test <type> --workers N` — reruns the very processes that failed.
48
+ const batches = testBatches(files, workers);
49
+ const result = await runBatches({
50
+ runner: options.runner,
51
+ batches,
52
+ argsFor: (batch) => testArgs({ files: batch, workers: Math.min(workers, batch.length) }),
53
+ options: {
54
+ cwd: options.root,
55
+ ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
56
+ },
48
57
  });
49
58
  // `failureOf` is `x test`'s own, imported rather than restated: the two paths report the SAME
50
59
  // failed `bun test`, so a second literal here is two `cause:` strings and two `fix:` lines free
@@ -56,9 +65,11 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
56
65
  ok: findings.length === 0,
57
66
  findings,
58
67
  workers,
59
- tests: countsOf([result]),
68
+ // Per batch: each process printed its own summary, and one parse of the joined text would
69
+ // read only the last one.
70
+ tests: countsOf(result.results),
60
71
  // Only on failure: a green run's summary is already the step table's, and the reader of a red
61
72
  // gate needs the assertion diff.
62
- ...(result.ok ? {} : { output: execOutput(result) }),
73
+ ...(result.ok ? {} : { output: result.output }),
63
74
  };
64
75
  }