@ultimat3/cli 1.2.0 → 3.0.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.
Files changed (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +14 -8
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
package/src/cmd-verify.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { existsSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import { renderThrowable } from '@ultimat3/core';
8
9
  import type { Manifest } from '@ultimat3/manifest';
9
10
  import {
10
11
  AGENTS_MD_FILENAME,
@@ -14,22 +15,39 @@ import {
14
15
  } from '@ultimat3/manifest';
15
16
  import { checkAgentsMd } from './app-agents-md';
16
17
  import { checkAppBoundaries } from './app-boundaries';
18
+ import { envExampleFindings } from './app-env';
17
19
  import { appManifest, readAppManifest } from './app-manifest';
18
20
  import { OPENAPI_FILE, openApiJson } from './app-openapi';
19
21
  import { APP_CONFIG_FILE, requireAppRoot } from './app-root';
20
22
  import { checkBudgets, readBuildStats } from './budgets';
21
23
  import type { CliCommand, CommandContext } from './command';
22
- import { checkDrift } from './drift';
24
+ import { checkDestructiveMigrations } from './db-destructive';
25
+ import { checkDocumentStyles, documentSurfaces } from './document-styles';
26
+ import { checkSourceDrift } from './drift';
23
27
  import { checkErrorFixes } from './error-contract';
28
+ import { readIntFlag } from './flag-number';
29
+ import { guardFindings } from './guards';
24
30
  import { msg } from './messages';
25
31
  import type { CommandResult, Finding, StepResult } from './output';
26
32
  import { findingFrom } from './output';
33
+ import type { ParsedArgs } from './parse';
34
+ import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
35
+ import {
36
+ floorProblemFindings,
37
+ floorRequires,
38
+ readVerifyFloor,
39
+ skippedSuiteFinding,
40
+ vanishedSuiteFinding,
41
+ } from './verify-floor';
27
42
  import type { StepOutcome, VerifyContext, VerifyStep, VerifyStepName } from './verify-step';
28
- import { fromExec, fromFindings, hostFindings, passed } from './verify-step';
43
+ import { fromExec, fromFindings, hostFindings } from './verify-step';
29
44
  import { TEST_STEPS } from './verify-tests';
30
45
  import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './workspace-checks';
31
46
 
32
47
  /** The whole contract, in cost order. Every check the framework knows how to make lives here. */
48
+ /** The one file that makes the `roadmap` step answerable, and therefore what `applies` reads. */
49
+ const ROADMAP_FILE = join('docs', 'idea', '14-roadmap.md');
50
+
33
51
  export const VERIFY_STEPS: readonly VerifyStep[] = [
34
52
  {
35
53
  name: 'typecheck',
@@ -47,7 +65,11 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
47
65
  },
48
66
  {
49
67
  name: 'lint',
50
- summary: 'biome: no any, no default exports, no raw colours',
68
+ // Only what biome actually enforces. It claimed "no default exports" while the rule was off
69
+ // (it is not in `recommended`) and "no raw colours" over a file type biome ignores entirely —
70
+ // two thirds of the line were enforced by nothing. `noDefaultExport` is now on in `biome.json`;
71
+ // the colour rule is `packages/ui/src/tokens/tokens.test.ts`, and rides on the `unit` step.
72
+ summary: 'biome: format, no any, no default exports, no unused imports',
51
73
  async run(ctx) {
52
74
  const result = await ctx.runner(['bunx', 'biome', 'check', '.'], { cwd: ctx.root });
53
75
  return fromExec(result, {
@@ -59,10 +81,21 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
59
81
  },
60
82
  {
61
83
  name: 'boundaries',
62
- summary: 'surface, layer and package-tier imports',
84
+ summary: "surface, layer and package-tier imports, and the app's own guards",
85
+ // An app's `guards/` rides here rather than becoming an eighteenth step, for the reason the
86
+ // seam already states: a host adds findings to a step, it can never add, remove, reorder or
87
+ // skip one — so "green" keeps meaning exactly what it meant. This is the step whose host slot
88
+ // already carries "rules this repo makes about itself that the framework cannot know" (the
89
+ // monorepo's tier table arrives through it), and it runs third, before any suite, so a
90
+ // convention failure is reported in seconds rather than after the tests.
91
+ //
92
+ // Discovered, not registered: `guardFindings` reads the directory. A guard that had to
93
+ // announce itself is a guard an app can forget to announce, which is the coupling axiom 8's
94
+ // extension model rejects.
63
95
  run: async (ctx) =>
64
96
  fromFindings([
65
97
  ...(await checkAppBoundaries(ctx.root)),
98
+ ...(await guardFindings(ctx.root)),
66
99
  ...(await hostFindings(ctx, 'boundaries')),
67
100
  ]),
68
101
  },
@@ -88,10 +121,20 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
88
121
  ...TEST_STEPS,
89
122
  {
90
123
  name: 'drift',
91
- summary: 'schema vs migrations',
124
+ summary: 'schema source vs migrations, and every destructive statement declared',
92
125
  // Only an app owns migrations; a package monorepo's `packages/db` is the driver, not a schema.
126
+ // Source, not database: the gate runs in CI with nothing listening, and the database half is
127
+ // the post-migrate verification `runMigrations` performs where a connection is already open.
128
+ //
129
+ // The destructive rail rides here rather than becoming an eighteenth step because it asks this
130
+ // step's own question — do the committed migrations still describe what the app is doing to its
131
+ // schema? — off the same directory, in the same pass, with no database either.
93
132
  applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
94
- run: async (ctx) => fromFindings(await checkDrift(ctx.root)),
133
+ run: async (ctx) =>
134
+ fromFindings([
135
+ ...(await checkSourceDrift(ctx.root)),
136
+ ...(await checkDestructiveMigrations(ctx.root)),
137
+ ]),
95
138
  },
96
139
  {
97
140
  name: 'contract-diff',
@@ -112,24 +155,62 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
112
155
  },
113
156
  {
114
157
  name: 'budgets',
115
- summary: 'per-route JS bytes and LCP',
158
+ summary: 'per-route JS bytes and LCP, and the global style layer every document carries',
159
+ // The global-style assertion rides here rather than becoming an eighteenth step, because this
160
+ // step already asks the one question it asks: what does the document this build emits actually
161
+ // contain? It is also the same app load — `appManifest` fills render's stylesheet registry on
162
+ // its way through — so a separate step would pay for a second one to answer half a question.
163
+ //
164
+ // A repo with no `app.config.ts` is the framework monorepo, which renders no documents and has
165
+ // no stylesheet registry to read; there is nothing for either half to weigh.
166
+ applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
116
167
  async run(ctx) {
168
+ // No stats file is NOT "nothing to weigh" — it is every declared budget unmeasured, which is
169
+ // the case `checkBudgets` already names per route (`X_BUDGET_UNMEASURED`). Skipping the half
170
+ // entirely is how a step that has never run once reported green: `.x/` is gitignored, so no
171
+ // CI run and neither gated app has ever had a `build-stats.json` for it to read.
172
+ //
173
+ // Handed over as `undefined` and never as `?? { routes: [] }`: "no build has run here" and
174
+ // "a build ran and could not weigh this route" are two different instructions, and only the
175
+ // caller knows which of them is true. The step still does not BUILD — measuring here would
176
+ // make `x verify` a static build on every run (8.2s on `dummy/social-media-clone`, and 5.9s
177
+ // to a hard `X_PRERENDER_FAILED` on `examples/dummy`), and it would be a second builder
178
+ // beside `apps/web/prerender.ts`, which is where an app reads `SITE_ORIGIN`.
117
179
  const stats = await readBuildStats(ctx.root);
118
- if (stats === undefined) return passed;
119
- const { manifest } = await appManifest(ctx.root);
120
- return fromFindings(checkBudgets(manifest, stats));
180
+ // The load's own findings, FIRST and never dropped. A module that would not import registers
181
+ // no route, so its budget is missing from the manifest and every route it declared reads as
182
+ // `X_BUDGET_UNMEASURED` — the symptom, pointing the reader at `x build` for a file that will
183
+ // not compile. `contract-diff` reports these too when it applies; two red steps naming one
184
+ // broken module is honest, and one of them silently green over it is the false green.
185
+ const { manifest, findings } = await appManifest(ctx.root);
186
+ return fromFindings([
187
+ ...findings,
188
+ ...checkDocumentStyles(documentSurfaces()),
189
+ ...checkBudgets(manifest, stats),
190
+ ]);
121
191
  },
122
192
  },
123
193
  {
124
194
  name: 'manifest',
125
- summary: 'the two files an agent reads: generated facts, hand-written conventions',
195
+ summary: 'the files an agent reads: generated facts, hand-written conventions, the env example',
126
196
  // No `applies`. The drift half has nothing to compare against until `x manifest` has run
127
197
  // once, and says so by finding nothing — but `AGENTS.md` is required of every repo the gate
128
198
  // runs in, so the step always has a question to answer and must never report as skipped.
199
+ //
200
+ // `.env.example` joins this step rather than becoming an eighteenth: the question is the same
201
+ // one — "does a committed, generated file still describe the code?" — and the step list is the
202
+ // definition of shippable, so it grows only when a genuinely new question needs asking.
203
+ //
204
+ // `x.verify.json` is here for that same question and no other: this step judges the floor
205
+ // FILE, `runVerify` judges the suites against it. A name the gate does not run can never
206
+ // vanish, so a typo in the floor covers nothing — which is the false green the floor exists to
207
+ // close, and it is only visible if something reads the file for its own sake.
129
208
  async run(ctx) {
130
209
  const agents = await checkAgentsMd(ctx.root);
131
210
  const findings = [
132
211
  ...(await driftFindings(ctx.root)),
212
+ ...(await envExampleFindings(ctx.root)),
213
+ ...floorProblemFindings(await readVerifyFloor(ctx.root)),
133
214
  ...agents.findings,
134
215
  ...(await hostFindings(ctx, 'manifest')),
135
216
  ];
@@ -147,8 +228,11 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
147
228
  name: 'roadmap',
148
229
  summary: "every roadmap milestone's status marker matches what is actually on disk",
149
230
  // A generated app ships no `docs/idea/14-roadmap.md` — only the framework monorepo does, so
150
- // only a host that registers this check has anything for the step to verify.
151
- applies: async (ctx) => ctx.hostChecks?.roadmap !== undefined,
231
+ // the FILE is what decides. It keyed on `ctx.hostChecks?.roadmap` until `As of 2026-08`, which
232
+ // is a fact about the CALL: a caller of the exported `runVerify(VERIFY_STEPS, ctx)` passing no
233
+ // `hostChecks`, in a repo whose committed `x.verify.json` names `roadmap`, got
234
+ // `X_VERIFY_SUITE_VANISHED` — whose `fix:` is the command that had just failed.
235
+ applies: async (ctx) => existsSync(join(ctx.root, ROADMAP_FILE)),
152
236
  run: async (ctx) => fromFindings(await hostFindings(ctx, 'roadmap')),
153
237
  },
154
238
  ];
@@ -200,11 +284,24 @@ export async function runVerify(
200
284
  steps: readonly VerifyStep[],
201
285
  ctx: VerifyContext,
202
286
  ): Promise<CommandResult> {
287
+ const floor = await readVerifyFloor(ctx.root);
203
288
  const results: StepResult[] = [];
204
289
  for (const step of steps) {
205
290
  const applies = step.applies === undefined ? true : await step.applies(ctx);
206
291
  if (!applies) {
207
- results.push({ name: step.name, ok: true, durationMs: 0, skipped: true, findings: [] });
292
+ // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
293
+ // that claim, committed — so "nothing to check" now means the suite was deleted, and the
294
+ // gate says so on the step's own line rather than counting one more thing not to worry
295
+ // about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
296
+ // the summary, `data.failed`, and the reference-app gate's own red list.
297
+ const required = floorRequires(floor, step.name);
298
+ results.push({
299
+ name: step.name,
300
+ ok: !required,
301
+ durationMs: 0,
302
+ skipped: !required,
303
+ findings: required ? [vanishedSuiteFinding(step.name)] : [],
304
+ });
208
305
  continue;
209
306
  }
210
307
  const started = performance.now();
@@ -214,31 +311,71 @@ export async function runVerify(
214
311
  findings: [findingOf(error, step.name)],
215
312
  }),
216
313
  );
314
+ // A step the floor requires whose suite executed nothing is the same vanished suite as a step
315
+ // with no files at all — the run just had to finish before it could be seen. Appended to the
316
+ // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
317
+ const vanished =
318
+ floorRequires(floor, step.name) && outcome.tests !== undefined && outcome.tests.ran === 0
319
+ ? [skippedSuiteFinding(step.name, outcome.tests.skipped)]
320
+ : [];
217
321
  results.push({
218
322
  name: step.name,
219
- ok: outcome.ok,
323
+ ok: outcome.ok && vanished.length === 0,
220
324
  durationMs: Math.round(performance.now() - started),
221
- findings: outcome.findings,
325
+ findings: [...outcome.findings, ...vanished],
222
326
  ...(outcome.output === undefined ? {} : { output: outcome.output }),
327
+ ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
223
328
  });
224
329
  }
225
330
  const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
331
+ const skippedSteps = results.filter((step) => step.skipped === true).map((step) => step.name);
226
332
  const totalMs = results.reduce((sum, step) => sum + step.durationMs, 0);
227
333
  return {
228
334
  ok: failedSteps.length === 0,
229
335
  command: 'verify',
230
- summary:
231
- failedSteps.length === 0
232
- ? msg('cli.verify.pass', { count: results.length, ms: totalMs })
233
- : msg('cli.verify.fail', { failed: failedSteps.length, count: results.length }),
336
+ summary: verifySummary({ results, failed: failedSteps, skipped: skippedSteps, totalMs }),
234
337
  steps: results,
235
- data: { failed: failedSteps, durationMs: totalMs },
338
+ // `skipped` is a list beside `failed` and not a count, because the two answer the same kind of
339
+ // question — *which* steps, not how many — and a caller ratcheting on coverage needs the names.
340
+ data: { failed: failedSteps, skipped: skippedSteps, durationMs: totalMs },
236
341
  exitCode: failedSteps.length === 0 ? 0 : 1,
237
342
  };
238
343
  }
239
344
 
345
+ /**
346
+ * What the counts are allowed to claim. A step that does not apply is recorded green so the run
347
+ * continues, and the summary counted it among the "all 17 steps passed" — so a repo whose `job`
348
+ * and `eval` suites do not exist reported the same line as a repo where both ran. `--json` carried
349
+ * the per-step flag all along; the one line every reader actually sees did not, which is how a
350
+ * vacuous gate stayed invisible. It names the skipped steps, not just how many: "17/17" is worth
351
+ * something only when the gap is visible in the same glance.
352
+ */
353
+ function verifySummary(input: {
354
+ readonly results: readonly StepResult[];
355
+ readonly failed: readonly string[];
356
+ readonly skipped: readonly string[];
357
+ readonly totalMs: number;
358
+ }): string {
359
+ const params = {
360
+ count: input.results.length,
361
+ passed: input.results.filter((step) => step.ok && step.skipped !== true).length,
362
+ failed: input.failed.length,
363
+ skipped: input.skipped.length,
364
+ names: input.skipped.join(', '),
365
+ ms: input.totalMs,
366
+ };
367
+ const clean = input.skipped.length === 0;
368
+ if (input.failed.length === 0) {
369
+ return msg(clean ? 'cli.verify.pass' : 'cli.verify.passSkipped', params);
370
+ }
371
+ return msg(clean ? 'cli.verify.fail' : 'cli.verify.failSkipped', params);
372
+ }
373
+
240
374
  function findingOf(error: unknown, step: string): Finding {
241
- const cause = error instanceof Error ? error.message : String(error);
375
+ // A step may throw anything, including an Error that fights being read: `instanceof` runs a
376
+ // Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
377
+ // gate's own report down with it — the one message that may never be lost.
378
+ const cause = renderThrowable(error);
242
379
  return {
243
380
  code: 'X_VERIFY_FAILED',
244
381
  cause: `step "${step}" threw: ${cause}`,
@@ -251,15 +388,50 @@ export const verifyCommand: CliCommand = {
251
388
  spec: {
252
389
  name: 'verify',
253
390
  summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
254
- usage: 'x verify [--json]',
391
+ usage: 'x verify [--workers N] [--json]',
255
392
  requiresApp: true,
256
- flags: [],
393
+ // The only flag, and it is not `--only`/`--skip` in disguise: it changes how wide the test
394
+ // steps spread, never which steps run. Every step still runs, so "green" still means the
395
+ // same thing at `--workers 1` as at `--workers 8`.
396
+ flags: [
397
+ {
398
+ name: 'workers',
399
+ type: 'string',
400
+ summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
401
+ },
402
+ ],
257
403
  },
258
404
  async run(ctx: CommandContext): Promise<CommandResult> {
259
405
  const root = requireAppRoot('verify', ctx.cwd).dir;
260
- return runVerify(VERIFY_STEPS, { root, runner: ctx.runner });
406
+ const workers = readWorkers(ctx.args);
407
+ return runVerify(VERIFY_STEPS, {
408
+ root,
409
+ runner: ctx.runner,
410
+ ...(workers === undefined ? {} : { workers }),
411
+ });
261
412
  },
262
413
  };
263
414
 
415
+ /**
416
+ * Both bounds are the constants the flag summary already names, so `x help verify` and the reader
417
+ * cannot disagree. Exported for the test that pins them: the command's `run` reaches this only
418
+ * after the whole gate would have started.
419
+ *
420
+ * `max` is the ceiling. Without it `--workers 5000` parsed, `planShards` clamped only to the file
421
+ * count, and `runParallel` `Promise.all`ed one Bun process per test file. `min` is `WORKER_FLOOR`,
422
+ * the same number `defaultWorkers` will not go below — the gate spreads or it does not shard, and
423
+ * `--workers 1` was a serial run the summary said was impossible. `x test --workers 1` stays legal
424
+ * and is deliberately NOT this reader: `runShards` clamps the width to the file count, so a
425
+ * one-file corpus makes `X_TEST_SHARD_FAILED`'s own `fix:` say `--workers 1`.
426
+ */
427
+ export const readWorkers = (args: ParsedArgs): number | undefined =>
428
+ readIntFlag(args, {
429
+ name: 'workers',
430
+ command: 'verify',
431
+ min: WORKER_FLOOR,
432
+ max: WORKER_CEILING,
433
+ example: 'x verify --workers 4',
434
+ });
435
+
264
436
  export const verifyStepNames = (): readonly VerifyStepName[] =>
265
437
  VERIFY_STEPS.map((step) => step.name);