@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.
- package/package.json +28 -28
- package/src/app-load.ts +88 -47
- package/src/app-reload-graph.ts +228 -0
- package/src/cmd-build-spec.ts +8 -1
- package/src/cmd-build.ts +23 -8
- package/src/cmd-dev-fixture.ts +26 -0
- package/src/cmd-dev.ts +10 -0
- package/src/cmd-test-spec.ts +9 -10
- package/src/cmd-test.ts +25 -2
- package/src/cmd-verify-spec.ts +37 -13
- package/src/cmd-verify.ts +81 -5
- package/src/dev-route-table.ts +7 -0
- package/src/error-codes.ts +8 -0
- package/src/mcp-errors.ts +5 -0
- package/src/messages.ts +1 -0
- package/src/output.ts +26 -0
- package/src/templates/scaffold-docs.ts +3 -1
- package/src/test-batches.ts +35 -4
- package/src/test-shards.ts +74 -10
- package/src/test-slots.ts +168 -0
- package/src/test-workers.ts +169 -80
- package/src/verify-errors.ts +49 -0
- package/src/verify-floor.ts +28 -0
- package/src/verify-merge.ts +235 -0
- package/src/verify-run.ts +16 -9
- package/src/verify-shard.ts +145 -0
- package/src/verify-step.ts +20 -1
- package/src/verify-test-run.ts +47 -6
- package/src/verify-tests.ts +39 -4
package/src/cmd-dev.ts
CHANGED
|
@@ -13,8 +13,10 @@ import type { OverlayNotice, RequestContext } from '@ultimat3/http';
|
|
|
13
13
|
import { asCtx } from '@ultimat3/http';
|
|
14
14
|
import type { Manifest } from '@ultimat3/manifest';
|
|
15
15
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
16
|
+
import { createIsrController } from '@ultimat3/render/server';
|
|
16
17
|
import { loadSignInPath } from './app-auth';
|
|
17
18
|
import { appManifest } from './app-manifest';
|
|
19
|
+
import { enableReloadTracking } from './app-reload-graph';
|
|
18
20
|
import { requireAppRoot } from './app-root';
|
|
19
21
|
import { loadAppRuntime } from './app-runtime';
|
|
20
22
|
import { devSpec } from './cmd-dev-spec';
|
|
@@ -128,6 +130,9 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
128
130
|
// EVERY boot: a scratch server (`x shot`, `ui.shot`) boots here too, and without this a
|
|
129
131
|
// fail-closed dev actor installs nothing — the picture is of a 401. Idempotent.
|
|
130
132
|
declareDevEnvironment(options.env);
|
|
133
|
+
// Before the first scan: the watcher's rescans evict a saved module AND everything importing it,
|
|
134
|
+
// and that needs the import graph recorded as each module is first loaded (`app-reload-graph.ts`).
|
|
135
|
+
enableReloadTracking(true);
|
|
131
136
|
// The declaration lands on `process.env`; the env this boot passes on must carry it too, or every
|
|
132
137
|
// service resolved from `options.env` still reads no `ULTIMATE_ENV`.
|
|
133
138
|
const env = needsDevEnvironmentDeclaration(options.env)
|
|
@@ -224,7 +229,11 @@ async function bootDev(
|
|
|
224
229
|
};
|
|
225
230
|
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
226
231
|
|
|
232
|
+
// `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
|
|
233
|
+
// its whole ttl after every save, whatever the modules behind it now said.
|
|
234
|
+
const isr = createIsrController({ buildId });
|
|
227
235
|
const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
|
|
236
|
+
isr,
|
|
228
237
|
root: options.root,
|
|
229
238
|
env: options.env,
|
|
230
239
|
buildId,
|
|
@@ -283,6 +292,7 @@ async function bootDev(
|
|
|
283
292
|
state.manifest = manifest;
|
|
284
293
|
state.appFindings = findings;
|
|
285
294
|
state.islands = islands;
|
|
295
|
+
for (const path of isr.store().paths()) isr.store().delete(path);
|
|
286
296
|
state.reloads += 1;
|
|
287
297
|
state.reloadFinding = undefined;
|
|
288
298
|
options.onReload?.(file, Math.round(performance.now() - started));
|
package/src/cmd-test-spec.ts
CHANGED
|
@@ -4,20 +4,13 @@
|
|
|
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 {
|
|
8
|
-
MEMORY_SHARE,
|
|
9
|
-
SERIAL_TYPES,
|
|
10
|
-
WORKER_BYTES,
|
|
11
|
-
WORKER_CEILING,
|
|
12
|
-
WORKER_FLOOR,
|
|
13
|
-
WORKER_OVERSUBSCRIBE,
|
|
14
|
-
} from './test-workers';
|
|
7
|
+
import { SERIAL_TYPES, WORKER_BYTES, WORKER_CEILING, WORKER_FLOOR } from './test-workers';
|
|
15
8
|
|
|
16
9
|
export const testSpec: CommandSpec = {
|
|
17
10
|
name: 'test',
|
|
18
11
|
summary:
|
|
19
12
|
'run one test type — or the whole suite — across N workers, one isolated database per worker',
|
|
20
|
-
usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
|
|
13
|
+
usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--isolate] [--json] [-- <bun test flags>]`,
|
|
21
14
|
positionalChoices: TEST_TYPES,
|
|
22
15
|
// The one command that hands a tail to another tool — `bun test` — and the reason
|
|
23
16
|
// `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
|
|
@@ -27,7 +20,13 @@ export const testSpec: CommandSpec = {
|
|
|
27
20
|
{
|
|
28
21
|
name: 'workers',
|
|
29
22
|
type: 'string',
|
|
30
|
-
summary: `bun worker count (default: ${
|
|
23
|
+
summary: `bun worker count (default: one per ${WORKER_BYTES / 2 ** 30} GiB of a min(4 GiB, max(2.75 GiB, 25% of RAM)) budget, at most one per core; ULTIMATE_TEST_MEMORY_BUDGET and ULTIMATE_TEST_MAX_WORKERS override, min ${WORKER_FLOOR}; max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'isolate',
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
summary:
|
|
29
|
+
'a fresh global per test file (bun test --isolate) — off by default since 22.7; x.verify.json "isolate": true makes it the repo default',
|
|
31
30
|
},
|
|
32
31
|
{
|
|
33
32
|
name: 'worker',
|
package/src/cmd-test.ts
CHANGED
|
@@ -26,7 +26,16 @@ import {
|
|
|
26
26
|
sampleFiles,
|
|
27
27
|
} from './test-select';
|
|
28
28
|
import { runShards } from './test-shards';
|
|
29
|
-
import {
|
|
29
|
+
import { machineLease } from './test-slots';
|
|
30
|
+
import type { WorkerPlan } from './test-workers';
|
|
31
|
+
import {
|
|
32
|
+
availableCpus,
|
|
33
|
+
SERIAL_TYPES,
|
|
34
|
+
totalMemory,
|
|
35
|
+
WORKER_CEILING,
|
|
36
|
+
workerPlan,
|
|
37
|
+
} from './test-workers';
|
|
38
|
+
import { readVerifyFloor } from './verify-floor';
|
|
30
39
|
import type { TestType } from './verify-tests';
|
|
31
40
|
import { TEST_TYPES } from './verify-tests';
|
|
32
41
|
|
|
@@ -159,7 +168,12 @@ export const testCommand: CliCommand = {
|
|
|
159
168
|
});
|
|
160
169
|
}
|
|
161
170
|
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
162
|
-
const
|
|
171
|
+
const explicit = readIndex(ctx.args, 'workers', 1);
|
|
172
|
+
const plan =
|
|
173
|
+
explicit === undefined ? workerPlan(availableCpus(), totalMemory(), ctx.env) : undefined;
|
|
174
|
+
const requested = explicit ?? (plan as WorkerPlan).workers;
|
|
175
|
+
const floor = await readVerifyFloor(ctx.cwd);
|
|
176
|
+
const isolate = flagBool(ctx.args, 'isolate') || floor?.isolate === true;
|
|
163
177
|
// The width a `--worker` index is judged against, and nothing else: which files really run one
|
|
164
178
|
// at a time is `test-passes.ts`, off the FILES rather than off the positional. This line read
|
|
165
179
|
// the positional alone until 2026-09 and the comment here claimed it made a serial type serial
|
|
@@ -183,6 +197,10 @@ export const testCommand: CliCommand = {
|
|
|
183
197
|
env: ctx.env,
|
|
184
198
|
files,
|
|
185
199
|
workers,
|
|
200
|
+
...(isolate ? { isolate } : {}),
|
|
201
|
+
// A default-width run leases its workers from the machine pool, so a second `x test` or
|
|
202
|
+
// gate on the same box runs narrower instead of doubling its memory.
|
|
203
|
+
...(plan === undefined ? {} : withLease(machineLease(plan.workers, ctx.env))),
|
|
186
204
|
...(only === undefined ? {} : { only }),
|
|
187
205
|
...(filter === undefined ? {} : { filter }),
|
|
188
206
|
...(type === undefined ? {} : { type }),
|
|
@@ -201,3 +219,8 @@ export const testCommand: CliCommand = {
|
|
|
201
219
|
return scope === undefined ? result : withScope(result, scope);
|
|
202
220
|
},
|
|
203
221
|
};
|
|
222
|
+
|
|
223
|
+
const withLease = (
|
|
224
|
+
lease: ReturnType<typeof machineLease>,
|
|
225
|
+
): { lease?: NonNullable<ReturnType<typeof machineLease>> } =>
|
|
226
|
+
lease === undefined ? {} : { lease };
|
package/src/cmd-verify-spec.ts
CHANGED
|
@@ -2,33 +2,57 @@
|
|
|
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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
WORKER_FLOOR,
|
|
10
|
-
WORKER_OVERSUBSCRIBE,
|
|
11
|
-
} from './test-workers';
|
|
5
|
+
import { WORKER_BYTES, WORKER_CEILING, WORKER_FLOOR } from './test-workers';
|
|
6
|
+
|
|
7
|
+
/** `run` is the gate (and what a bare `x verify` means); `merge` folds CI parts into its verdict. */
|
|
8
|
+
export const VERIFY_SUBCOMMANDS = ['run', 'merge'] as const;
|
|
12
9
|
|
|
13
10
|
export const verifySpec: CommandSpec = {
|
|
14
11
|
name: 'verify',
|
|
15
12
|
summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
|
|
16
|
-
usage:
|
|
13
|
+
usage:
|
|
14
|
+
'x verify [--only <step>[,<step>…] [--shard i/n [--timings file]]] [--workers N] [--isolate] [--json] · x verify merge <part.json…> [--json]',
|
|
17
15
|
requiresApp: true,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
16
|
+
subcommands: VERIFY_SUBCOMMANDS,
|
|
17
|
+
defaultSubcommand: 'run',
|
|
18
|
+
// `--workers` and `--isolate` change how the test steps run, never which steps run. `--only`
|
|
19
|
+
// runs the steps it names and says so in both renderers — never silently, which is the whole
|
|
20
|
+
// of what makes it safe to have. `--shard` narrows further, and only `merge` turns shards back
|
|
21
|
+
// into a gate verdict.
|
|
21
22
|
flags: [
|
|
22
23
|
{
|
|
23
24
|
name: 'workers',
|
|
24
25
|
type: 'string',
|
|
25
|
-
|
|
26
|
+
subcommands: ['run'],
|
|
27
|
+
summary: `test processes per parallel step (default: one per ${WORKER_BYTES / 2 ** 30} GiB of a min(4 GiB, max(2.75 GiB, 25% of RAM)) budget, at most one per core; ULTIMATE_TEST_MEMORY_BUDGET and ULTIMATE_TEST_MAX_WORKERS override; min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
26
28
|
},
|
|
27
29
|
{
|
|
28
30
|
name: 'only',
|
|
29
31
|
type: 'string',
|
|
32
|
+
subcommands: ['run'],
|
|
33
|
+
summary:
|
|
34
|
+
'run the named step(s), comma-separated, in one process — an iteration loop or one CI job, NOT A GATE RUN; the gate is this command with no flag, or `x verify merge` over every job',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'shard',
|
|
38
|
+
type: 'string',
|
|
39
|
+
subcommands: ['run'],
|
|
40
|
+
summary:
|
|
41
|
+
'with --only unit|contract|job: run shard i of an n-way split of each step’s sorted file list (round-robin), for one CI job; `x verify merge` checks all n',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'timings',
|
|
45
|
+
type: 'string',
|
|
46
|
+
subcommands: ['run'],
|
|
47
|
+
summary:
|
|
48
|
+
'with --shard: a bun --timings JSON ({ "path": ms }) — split greedy longest-first instead of round-robin',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'isolate',
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
subcommands: ['run'],
|
|
30
54
|
summary:
|
|
31
|
-
'
|
|
55
|
+
'a fresh global per test file (bun test --isolate) — off by default since 22.7; x.verify.json "isolate": true makes it the repo default',
|
|
32
56
|
},
|
|
33
57
|
],
|
|
34
58
|
};
|
package/src/cmd-verify.ts
CHANGED
|
@@ -7,18 +7,26 @@
|
|
|
7
7
|
// reader of either can take it for one. `--skip` stays refused — it would let a caller drop the
|
|
8
8
|
// step that was going to fail and still read the output as a whole-tree verdict.
|
|
9
9
|
|
|
10
|
+
// why: Bun ships no path-joining primitive: `join`/`isAbsolute` resolve a part path against the cwd.
|
|
11
|
+
import { isAbsolute, join } from 'node:path';
|
|
10
12
|
import { nearestName, renderFixShellArg } from '@ultimat3/core';
|
|
11
13
|
import { requireAppRoot } from './app-root';
|
|
12
14
|
import { verifySpec } from './cmd-verify-spec';
|
|
13
15
|
import type { CliCommand, CommandContext } from './command';
|
|
14
|
-
import { BadFlagError } from './errors';
|
|
16
|
+
import { BadFlagError, MissingPositionalError } from './errors';
|
|
15
17
|
import { readIntFlag } from './flag-number';
|
|
16
|
-
import type { CommandResult } from './output';
|
|
18
|
+
import type { CommandResult, JsonValue } from './output';
|
|
17
19
|
import type { ParsedArgs } from './parse';
|
|
18
|
-
import { flagString } from './parse';
|
|
20
|
+
import { flagBool, flagString } from './parse';
|
|
19
21
|
import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
|
|
20
22
|
import { VERIFY_STEPS } from './verify-checks';
|
|
23
|
+
import { VerifyMergeInputError, VerifyShardInvalidError } from './verify-errors';
|
|
24
|
+
import { readVerifyFloor } from './verify-floor';
|
|
25
|
+
import type { VerifyPart } from './verify-merge';
|
|
26
|
+
import { mergeParts, parsePart } from './verify-merge';
|
|
21
27
|
import { runVerify } from './verify-run';
|
|
28
|
+
import type { ShardSpec, Timings } from './verify-shard';
|
|
29
|
+
import { assertShardable, parseShard, readTimings } from './verify-shard';
|
|
22
30
|
import type { VerifyStepName } from './verify-step';
|
|
23
31
|
import { VERIFY_STEP_NAMES } from './verify-step';
|
|
24
32
|
|
|
@@ -32,20 +40,88 @@ export const verifyCommand: CliCommand = {
|
|
|
32
40
|
spec: verifySpec,
|
|
33
41
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
34
42
|
const root = requireAppRoot('verify', ctx.cwd).dir;
|
|
35
|
-
|
|
43
|
+
if (ctx.args.subcommand === 'merge') return mergeCommand(root, ctx.args.positionals, ctx.cwd);
|
|
44
|
+
// Every reader before the run: an unrunnable flag must be refused in milliseconds, not after
|
|
36
45
|
// `tsc -b` has spent fourteen seconds on a run the caller cannot use.
|
|
37
46
|
const workers = readWorkers(ctx.args);
|
|
38
47
|
const only = readOnlySteps(ctx.args);
|
|
39
|
-
|
|
48
|
+
const shard = await readShard(ctx.args, only);
|
|
49
|
+
const isolate = flagBool(ctx.args, 'isolate') ? true : undefined;
|
|
50
|
+
const result = await runVerify(VERIFY_STEPS, {
|
|
40
51
|
root,
|
|
41
52
|
runner: ctx.runner,
|
|
42
53
|
env: ctx.env,
|
|
43
54
|
...(workers === undefined ? {} : { workers }),
|
|
44
55
|
...(only === undefined ? {} : { only }),
|
|
56
|
+
...(shard === undefined ? {} : { shard }),
|
|
57
|
+
...(isolate === undefined ? {} : { isolate }),
|
|
45
58
|
});
|
|
59
|
+
return shard === undefined ? result : withShardData(result, shard);
|
|
46
60
|
},
|
|
47
61
|
};
|
|
48
62
|
|
|
63
|
+
/**
|
|
64
|
+
* `--shard i/n` (and `--timings`), validated before anything runs: only beside `--only`, and only
|
|
65
|
+
* over the parallel suites. `--timings` without `--shard` is refused rather than ignored.
|
|
66
|
+
*/
|
|
67
|
+
export async function readShard(
|
|
68
|
+
args: ParsedArgs,
|
|
69
|
+
only: readonly VerifyStepName[] | undefined,
|
|
70
|
+
): Promise<(ShardSpec & { readonly timings?: Timings }) | undefined> {
|
|
71
|
+
const raw = flagString(args, 'shard');
|
|
72
|
+
const timingsPath = flagString(args, 'timings');
|
|
73
|
+
if (raw === undefined) {
|
|
74
|
+
if (timingsPath !== undefined) {
|
|
75
|
+
throw new VerifyShardInvalidError({
|
|
76
|
+
reason: '--timings balances a --shard split and does nothing without one',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const spec = parseShard(raw);
|
|
82
|
+
assertShardable(only);
|
|
83
|
+
if (timingsPath === undefined) return spec;
|
|
84
|
+
return { ...spec, timings: await readTimings(timingsPath) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `data.shard`: which slice this part is, per step — what `x verify merge` reads back. */
|
|
88
|
+
const withShardData = (result: CommandResult, shard: ShardSpec): CommandResult => {
|
|
89
|
+
const data = (result.data ?? {}) as Record<string, JsonValue>;
|
|
90
|
+
const steps: Record<string, JsonValue> = {};
|
|
91
|
+
for (const step of result.steps ?? []) {
|
|
92
|
+
if (step.shard === undefined) continue;
|
|
93
|
+
steps[step.name] = { corpusHash: step.shard.corpusHash, files: [...step.shard.files] };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...result,
|
|
97
|
+
data: { ...data, shard: { index: shard.index, total: shard.total, steps } },
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async function mergeCommand(
|
|
102
|
+
root: string,
|
|
103
|
+
files: readonly string[],
|
|
104
|
+
cwd: string,
|
|
105
|
+
): Promise<CommandResult> {
|
|
106
|
+
if (files.length === 0) {
|
|
107
|
+
throw new MissingPositionalError({
|
|
108
|
+
command: 'verify merge',
|
|
109
|
+
positional: 'part.json…',
|
|
110
|
+
example: 'x verify merge parts/*.json --json',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const parts: VerifyPart[] = [];
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
const path = isAbsolute(file) ? file : join(cwd, file);
|
|
116
|
+
const handle = Bun.file(path);
|
|
117
|
+
if (!(await handle.exists())) {
|
|
118
|
+
throw new VerifyMergeInputError({ file, reason: 'does not exist' });
|
|
119
|
+
}
|
|
120
|
+
parts.push(parsePart(file, await handle.text()));
|
|
121
|
+
}
|
|
122
|
+
return mergeParts(parts, await readVerifyFloor(root));
|
|
123
|
+
}
|
|
124
|
+
|
|
49
125
|
/**
|
|
50
126
|
* The steps `--only` names, or nothing: one step (`--only lint`) or a comma-separated list
|
|
51
127
|
* (`--only typecheck,lint,boundaries`) run in ONE process, in the gate's declared order whatever
|
package/src/dev-route-table.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { RealtimeConfig } from '@ultimat3/core';
|
|
6
6
|
import type { RateLimitStore, Route } from '@ultimat3/http';
|
|
7
7
|
import { describeRoutes } from '@ultimat3/render';
|
|
8
|
+
import type { IsrController } from '@ultimat3/render/server';
|
|
8
9
|
import type { Storage } from '@ultimat3/storage';
|
|
9
10
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
10
11
|
import { mountAppMcp } from './app-mcp';
|
|
@@ -43,6 +44,11 @@ export interface DevRouteTableInput {
|
|
|
43
44
|
readonly rateLimitStore?: RateLimitStore | undefined;
|
|
44
45
|
/** `apps/<app>/runtime.ts`'s plain `routes`, mounted as the container mounts them. */
|
|
45
46
|
readonly appRoutes?: readonly Route[] | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The ISR controller the page routes render through — `x dev`'s own, because a reload has to
|
|
49
|
+
* empty it: a stored render outlives the module that produced it.
|
|
50
|
+
*/
|
|
51
|
+
readonly isr?: IsrController | undefined;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface DevRouteTable {
|
|
@@ -126,6 +132,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
126
132
|
...pagePostRoutes(),
|
|
127
133
|
...appRoutes({
|
|
128
134
|
buildId: input.buildId,
|
|
135
|
+
...(input.isr === undefined ? {} : { isr: input.isr }),
|
|
129
136
|
resolveIsland: (file) => input.islands().resolverFor(file),
|
|
130
137
|
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
131
138
|
persisted: sync.persisted,
|
package/src/error-codes.ts
CHANGED
|
@@ -198,6 +198,10 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
198
198
|
'X_FEATURE_UNKNOWN',
|
|
199
199
|
'X_ROUTE_ASYNC_PAGE',
|
|
200
200
|
'X_BUDGET_PARAMS_UNDECLARED',
|
|
201
|
+
'X_TEST_BUDGET_INVALID',
|
|
202
|
+
'X_VERIFY_SHARD_INVALID',
|
|
203
|
+
'X_VERIFY_MERGE_INCOMPLETE',
|
|
204
|
+
'X_VERIFY_MERGE_INPUT',
|
|
201
205
|
] as const;
|
|
202
206
|
|
|
203
207
|
/**
|
|
@@ -345,6 +349,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
345
349
|
X_ROUTE_ASYNC_PAGE: 'a route exports an async Page',
|
|
346
350
|
X_BUDGET_PARAMS_UNDECLARED:
|
|
347
351
|
'a dynamic route with a budget declares no prerender() paths to weigh it by',
|
|
352
|
+
X_TEST_BUDGET_INVALID: 'a test memory budget or worker cap env var does not parse',
|
|
353
|
+
X_VERIFY_SHARD_INVALID: 'x verify --shard was given a spec or a step list it cannot split',
|
|
354
|
+
X_VERIFY_MERGE_INCOMPLETE: 'x verify merge was handed parts that do not add up to the whole gate',
|
|
355
|
+
X_VERIFY_MERGE_INPUT: 'a part handed to x verify merge is not an x verify --json document',
|
|
348
356
|
};
|
|
349
357
|
|
|
350
358
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/mcp-errors.ts
CHANGED
|
@@ -38,6 +38,11 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
38
38
|
X_ENV_MISSING: 'x env check --json',
|
|
39
39
|
X_ENV_EXAMPLE_DRIFT: 'x env example --json',
|
|
40
40
|
X_TEST_NO_FILES: 'x test --json # from the repo root, or pass --cwd to it',
|
|
41
|
+
X_TEST_BUDGET_INVALID: 'ULTIMATE_TEST_MEMORY_BUDGET=3g x verify --json',
|
|
42
|
+
X_VERIFY_SHARD_INVALID: 'x verify --only unit --shard 1/4 --json',
|
|
43
|
+
X_VERIFY_MERGE_INCOMPLETE:
|
|
44
|
+
'x verify merge parts/*.json --json # after every CI job uploaded its part',
|
|
45
|
+
X_VERIFY_MERGE_INPUT: 'x verify --only unit --json > part.json # one document per part',
|
|
41
46
|
X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
|
|
42
47
|
X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
|
|
43
48
|
X_GENERATE_JSON_INVALID:
|
package/src/messages.ts
CHANGED
|
@@ -294,6 +294,7 @@ const CATALOG = {
|
|
|
294
294
|
// included. Its own line and not `allSkipped` with a zero, because the repair differs — there is
|
|
295
295
|
// no skipped test to read a reason off, so the files the step selected hold no test.
|
|
296
296
|
'cli.verify.ranNothing': 'found no test to run in the file(s) it selected',
|
|
297
|
+
'cli.verify.merged': 'merged {parts} part(s) — {summary}',
|
|
297
298
|
'cli.verify.serial': 'serial',
|
|
298
299
|
'cli.verify.workers': '{workers} workers',
|
|
299
300
|
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
package/src/output.ts
CHANGED
|
@@ -6,6 +6,16 @@ import { ERROR_DOCS_URL, renderThrowable, singleLine, stringField } from '@ultim
|
|
|
6
6
|
import { msg } from './messages';
|
|
7
7
|
import type { TestCounts } from './test-counts';
|
|
8
8
|
|
|
9
|
+
/** One shard's share of one step's corpus — what `x verify merge` checks the parts against. */
|
|
10
|
+
export interface ShardFacts {
|
|
11
|
+
readonly index: number;
|
|
12
|
+
readonly total: number;
|
|
13
|
+
/** sha256 of the step's WHOLE sorted file list: every shard of one split carries the same one. */
|
|
14
|
+
readonly corpusHash: string;
|
|
15
|
+
/** The files this shard ran. */
|
|
16
|
+
readonly files: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
export interface Finding {
|
|
10
20
|
readonly code: string;
|
|
11
21
|
readonly cause: string;
|
|
@@ -31,6 +41,10 @@ export interface StepResult {
|
|
|
31
41
|
readonly output?: string;
|
|
32
42
|
/** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
|
|
33
43
|
readonly workers?: number;
|
|
44
|
+
/** Why the width is what it is — `4 workers (budget 4.0 GB)`. */
|
|
45
|
+
readonly widthReason?: string;
|
|
46
|
+
/** Under `x verify --shard`: this step's slice of its corpus. */
|
|
47
|
+
readonly shard?: ShardFacts;
|
|
34
48
|
/**
|
|
35
49
|
* What the step's suite executed. Absent for a step that spawned no test process, which is NOT
|
|
36
50
|
* the same state as `{ ran: 0 }` — a step with no suite and a suite whose every test skipped
|
|
@@ -175,6 +189,7 @@ const mark = (step: StepResult): string => {
|
|
|
175
189
|
*/
|
|
176
190
|
const width = (step: StepResult): string => {
|
|
177
191
|
if (step.workers === undefined || step.skipped === true) return '';
|
|
192
|
+
if (step.widthReason !== undefined) return ` ${singleLine(step.widthReason)}`;
|
|
178
193
|
return ` ${step.workers === 1 ? msg('cli.verify.serial') : msg('cli.verify.workers', { workers: step.workers })}`;
|
|
179
194
|
};
|
|
180
195
|
|
|
@@ -227,6 +242,17 @@ export function renderJson(result: CommandResult): string {
|
|
|
227
242
|
skipped: step.skipped === true,
|
|
228
243
|
findings: step.findings,
|
|
229
244
|
...(step.workers === undefined ? {} : { workers: step.workers }),
|
|
245
|
+
...(step.widthReason === undefined ? {} : { widthReason: step.widthReason }),
|
|
246
|
+
...(step.shard === undefined
|
|
247
|
+
? {}
|
|
248
|
+
: {
|
|
249
|
+
shard: {
|
|
250
|
+
index: step.shard.index,
|
|
251
|
+
total: step.shard.total,
|
|
252
|
+
corpusHash: step.shard.corpusHash,
|
|
253
|
+
files: [...step.shard.files],
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
230
256
|
// The counts the human line's `why()` renders, as numbers: a `--json` reader deciding whether
|
|
231
257
|
// a skipped lane is missing a suite or missing a prerequisite needs the same fact CI's log has.
|
|
232
258
|
...(step.tests === undefined ? {} : { tests: step.tests }),
|
|
@@ -174,7 +174,9 @@ build_flags=""
|
|
|
174
174
|
for arg in "$@"; do
|
|
175
175
|
case "$arg" in --json|-j) build_flags="--json" ;; esac
|
|
176
176
|
done
|
|
177
|
-
|
|
177
|
+
# \`--no-preflight\`: the build's own preflight is x verify's first six steps (typecheck, lint,
|
|
178
|
+
# boundaries, filesize, package-shape, errors), and the gate below runs them anyway — once is enough.
|
|
179
|
+
bunx x build --target static --no-preflight $build_flags
|
|
178
180
|
exec bunx x verify "$@"
|
|
179
181
|
`;
|
|
180
182
|
|
package/src/test-batches.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import type { ExecResult, Runner } from './exec';
|
|
32
32
|
import { execOutput } from './exec';
|
|
33
33
|
import { msg } from './messages';
|
|
34
|
+
import { SLOT_HELD_ENV } from './test-slots';
|
|
34
35
|
import { BATCH_FILES_PER_WORKER } from './test-workers';
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -38,9 +39,13 @@ import { BATCH_FILES_PER_WORKER } from './test-workers';
|
|
|
38
39
|
* in `workers x BATCH_FILES_PER_WORKER`, which is every small selection and every `--filter`.
|
|
39
40
|
* Otherwise the fewest groups that fit, dealt round-robin so their sizes differ by at most one.
|
|
40
41
|
*/
|
|
41
|
-
export function testBatches(
|
|
42
|
+
export function testBatches(
|
|
43
|
+
files: readonly string[],
|
|
44
|
+
workers: number,
|
|
45
|
+
perWorker: number = BATCH_FILES_PER_WORKER,
|
|
46
|
+
): readonly string[][] {
|
|
42
47
|
const sorted = [...files].sort();
|
|
43
|
-
const cap = Math.max(1, Math.trunc(workers)) *
|
|
48
|
+
const cap = Math.max(1, Math.trunc(workers)) * Math.max(1, Math.trunc(perWorker));
|
|
44
49
|
const count = Math.max(1, Math.ceil(sorted.length / cap));
|
|
45
50
|
const batches: string[][] = Array.from({ length: count }, () => []);
|
|
46
51
|
sorted.forEach((file, index) => {
|
|
@@ -53,6 +58,8 @@ export function testBatches(files: readonly string[], workers: number): readonly
|
|
|
53
58
|
export interface BatchedRun {
|
|
54
59
|
/** One per batch, in order — `countsOf` reads each one's own Bun summary. */
|
|
55
60
|
readonly results: readonly ExecResult[];
|
|
61
|
+
/** The width each batch really ran at, after the machine lease. */
|
|
62
|
+
readonly widths: readonly number[];
|
|
56
63
|
readonly ok: boolean;
|
|
57
64
|
/** The first non-zero exit, or 0. */
|
|
58
65
|
readonly code: number;
|
|
@@ -74,7 +81,15 @@ export interface BatchedRun {
|
|
|
74
81
|
export async function runBatches(input: {
|
|
75
82
|
readonly runner: Runner;
|
|
76
83
|
readonly batches: readonly (readonly string[])[];
|
|
77
|
-
|
|
84
|
+
/** `width` is the batch's real worker count — the ask, narrowed by the machine lease. */
|
|
85
|
+
readonly argsFor: (files: readonly string[], width: number) => readonly string[];
|
|
86
|
+
/** The width each batch asks for; required with `lease`. */
|
|
87
|
+
readonly workers?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Lease the batch's workers from the machine pool (`test-slots.ts`) before it starts, and give
|
|
90
|
+
* them back when it exits — so two runs on one box share its budget batch by batch.
|
|
91
|
+
*/
|
|
92
|
+
readonly lease?: (want: number) => Promise<{ readonly count: number; release(): void }>;
|
|
78
93
|
readonly options: Parameters<Runner>[1];
|
|
79
94
|
/**
|
|
80
95
|
* Launch no further batch once one is red. Set for a caller-forwarded `--bail`: Bun stops a
|
|
@@ -83,9 +98,24 @@ export async function runBatches(input: {
|
|
|
83
98
|
readonly stopOnFailure?: boolean;
|
|
84
99
|
}): Promise<BatchedRun> {
|
|
85
100
|
const results: ExecResult[] = [];
|
|
101
|
+
const widths: number[] = [];
|
|
86
102
|
for (const files of input.batches) {
|
|
87
|
-
const
|
|
103
|
+
const want = Math.max(1, Math.min(input.workers ?? files.length, files.length));
|
|
104
|
+
const lease = input.lease === undefined ? undefined : await input.lease(want);
|
|
105
|
+
const width = Math.min(want, lease?.count ?? want);
|
|
106
|
+
let result: ExecResult;
|
|
107
|
+
try {
|
|
108
|
+
result = await input.runner(
|
|
109
|
+
input.argsFor(files, width),
|
|
110
|
+
lease === undefined
|
|
111
|
+
? input.options
|
|
112
|
+
: { ...input.options, env: { ...input.options.env, [SLOT_HELD_ENV]: '1' } },
|
|
113
|
+
);
|
|
114
|
+
} finally {
|
|
115
|
+
lease?.release();
|
|
116
|
+
}
|
|
88
117
|
results.push(result);
|
|
118
|
+
widths.push(width);
|
|
89
119
|
if (!result.ok && input.stopOnFailure === true) break;
|
|
90
120
|
}
|
|
91
121
|
const first = results.find((result) => !result.ok);
|
|
@@ -106,6 +136,7 @@ export async function runBatches(input: {
|
|
|
106
136
|
.join('\n');
|
|
107
137
|
return {
|
|
108
138
|
results,
|
|
139
|
+
widths,
|
|
109
140
|
ok: first === undefined,
|
|
110
141
|
code: first?.code ?? 0,
|
|
111
142
|
durationMs: results.reduce((sum, result) => sum + result.durationMs, 0),
|