@ultimat3/cli 6.0.0 → 8.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 (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
@@ -0,0 +1,382 @@
1
+ // `x shot <route>` — a rendered route, on disk, for a reader who cannot open a browser. The
2
+ // picture is `shot.png`; the half that gates is `verdict.json`, because a picture cannot say that
3
+ // the island threw, that nothing hydrated, or that the document photographed is the sign-in page.
4
+ //
5
+ // Never a step of `x verify`: it needs a real browser, and a gate that goes red because a machine
6
+ // has no Chrome is a gate that fails for reasons unrelated to the change.
7
+
8
+ import { mkdirSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
11
+ import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
12
+ import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
13
+ import { requireAppRoot } from './app-root';
14
+ import { appBrowser, browserBinaryExists, executablePathFrom } from './browser-launcher';
15
+ import { startDev } from './cmd-dev';
16
+ import type { CliCommand, CommandContext } from './command';
17
+ import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
18
+ import { DEV_BINDING } from './dev-roles';
19
+ import { resolveServices } from './dev-services';
20
+ import { BadFlagError, MissingPositionalError } from './errors';
21
+ import { intFlagOr, PORT_RANGE } from './flag-number';
22
+ import type { CommandResult } from './output';
23
+ import type { ParsedArgs } from './parse';
24
+ import { flagBool, flagString } from './parse';
25
+ import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
26
+ import type { IslandCount, ShotArtifacts } from './shot-verdict';
27
+ import {
28
+ buildVerdict,
29
+ ISLAND_PROBE,
30
+ parseIslandProbe,
31
+ shotLines,
32
+ shotSummary,
33
+ verdictJson,
34
+ } from './shot-verdict';
35
+
36
+ /** Kernel-picked by default: :3000 is usually another project's dev server, not a free port. */
37
+ const DEFAULT_PORT = 0;
38
+
39
+ /**
40
+ * How long the page is left alone after `load` before it is photographed: exactly the
41
+ * `requestIdleCallback` deadline `@ultimat3/render`'s hydration runtime gives an `idle` island —
42
+ * shoot sooner and the verdict reports `booted: 0` for a page that hydrates perfectly. READ from
43
+ * that runtime rather than restated, because two copies of one number that must agree is the drift
44
+ * axiom 2 refuses: the settle window is not "2 seconds", it is "the deadline the runtime uses".
45
+ */
46
+ export const DEFAULT_SETTLE_MS = IDLE_HYDRATE_TIMEOUT_MS;
47
+
48
+ export const SHOT_DIR = join('.x', 'shot');
49
+ export const SHOT_IMAGE = 'shot.png';
50
+ export const SHOT_VERDICT = 'verdict.json';
51
+
52
+ /** A directory name a route can never escape: everything that is not a letter or digit is a dash. */
53
+ export function shotSlug(route: string): string {
54
+ const slug = route.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
55
+ return slug === '' ? 'root' : slug.toLowerCase();
56
+ }
57
+
58
+ const pathOf = (url: string): string => {
59
+ try {
60
+ return new URL(url).pathname;
61
+ } catch {
62
+ return '/';
63
+ }
64
+ };
65
+
66
+ /**
67
+ * A reserved name (RFC 2606) that resolves nowhere, so the origin check below can never be
68
+ * satisfied by an accident of what the app's own host happens to be.
69
+ */
70
+ const ROUTE_BASE = 'http://route.invalid';
71
+
72
+ /**
73
+ * Where a browser would actually go. The refusal above reads `scheme:` and nothing else, and this
74
+ * is the question it was standing in for: a path is a path only if resolving it lands back on the
75
+ * origin it was resolved against.
76
+ */
77
+ const resolvedOrigin = (path: string): string => {
78
+ try {
79
+ return new URL(path, ROUTE_BASE).origin;
80
+ } catch {
81
+ // A path `new URL` will not parse is one no browser will fetch either, and reporting it as the
82
+ // origin it is not is the honest answer here.
83
+ return '';
84
+ }
85
+ };
86
+
87
+ const refuseRoute = (reason: string): never => {
88
+ throw new BadFlagError({
89
+ flag: 'route',
90
+ command: 'shot',
91
+ reason,
92
+ // A placeholder, because there is nothing safe to substitute: unlike an absolute URL, an
93
+ // origin-escaping route carries no path the caller can be assumed to have meant.
94
+ fix: 'x shot /<path> --json',
95
+ });
96
+ };
97
+
98
+ /**
99
+ * A path on the app, never a URL. `x shot https://example.com` would photograph somebody else's
100
+ * site through a headless browser inside your network, which is the SSRF shape `allowHosts` exists
101
+ * to refuse — so it is refused here, at the argument, where the reader can still see why.
102
+ *
103
+ * `scheme:` was the ONLY spelling refused until 2026-08-22, and it is one of four: `//evil/x` is a
104
+ * protocol-relative URL, `\evil\x` is the same thing to every URL parser (a backslash IS a slash
105
+ * for a special scheme), and a TAB inside the path is deleted by the parser before the host is
106
+ * read, so `/⇥/evil/x` becomes `//evil/x`. Each one reached `new URL(route, server.url)` and came
107
+ * back pointed at another host. `allowHostsFrom` one layer down could not catch any of them: it
108
+ * allows a HOSTNAME, and the hostname it is given is the one the page has already left — which is
109
+ * how `x shot //localhost:9200/_cat/indices` photographed whatever else was on the dev box.
110
+ */
111
+ export function readRoute(raw: string | undefined): string {
112
+ if (raw === undefined || raw.trim() === '') {
113
+ throw new MissingPositionalError({ command: 'shot', positional: 'route', example: 'x shot /' });
114
+ }
115
+ const route = raw.trim();
116
+ if (/^[a-z][a-z0-9+.-]*:/i.test(route)) {
117
+ throw new BadFlagError({
118
+ flag: 'route',
119
+ command: 'shot',
120
+ reason: `"${route}" is an absolute URL; x shot photographs a route of the app under test`,
121
+ // The path out of the URL when it parses — the refusal's own fix line has to be runnable,
122
+ // and `new URL('http://')` throws, so the fallback is the route every app has.
123
+ fix: `x shot ${pathOf(route)} --json`,
124
+ });
125
+ }
126
+ const path = route.startsWith('/') ? route : `/${route}`;
127
+ // Its own refusal rather than folded into the origin check: `/a\b` stays on this origin and is
128
+ // still not the route that was typed — the verdict would record `/a\b` beside a picture of
129
+ // `/a/b`, which is the artifact lying about its own subject.
130
+ if (path.includes('\\')) {
131
+ return refuseRoute(`"${route}" contains a backslash, which a URL parser reads as "/"`);
132
+ }
133
+ const origin = resolvedOrigin(path);
134
+ if (origin !== ROUTE_BASE) {
135
+ return refuseRoute(
136
+ `"${route}" is not a path on the app: a browser resolves it to ${origin === '' ? 'no URL at all' : origin}`,
137
+ );
138
+ }
139
+ return path;
140
+ }
141
+
142
+ /**
143
+ * The three integer flags, each read with its NAME as an argument. `intFlagOr` takes the name in a
144
+ * `name:` field, which `flag-reads.ts` counts as a declaration rather than a read — so a command
145
+ * whose only mention of `--settle` is inside that object declares a flag the rule reports as
146
+ * having no reader. The example is derived from the default, so it is always a runnable line.
147
+ */
148
+ const intFlag = (
149
+ args: ParsedArgs,
150
+ name: string,
151
+ min: number,
152
+ fallback: number,
153
+ max?: number,
154
+ ): number =>
155
+ intFlagOr(
156
+ args,
157
+ {
158
+ name,
159
+ command: 'shot',
160
+ min,
161
+ ...(max === undefined ? {} : { max }),
162
+ example: `x shot / --${name} ${fallback}`,
163
+ },
164
+ fallback,
165
+ );
166
+
167
+ export interface ShotServer {
168
+ readonly url: string;
169
+ /** Which server the picture is of. Reported, because the two have different failure modes. */
170
+ readonly origin: 'booted' | 'reused';
171
+ stop(): Promise<void>;
172
+ }
173
+
174
+ export interface ShotRun {
175
+ readonly route: string;
176
+ readonly outDir: string;
177
+ readonly driver: ScrapeDriver;
178
+ readonly boot: () => Promise<ShotServer>;
179
+ readonly settleMs: number;
180
+ readonly timeoutMs: number;
181
+ readonly fullPage: boolean;
182
+ /**
183
+ * `--allow-hosts`, verbatim. The app's own host is added once the server is up and never here:
184
+ * with `--port 0` the port — and therefore the origin — does not exist until after the boot.
185
+ */
186
+ readonly extraHosts?: string | undefined;
187
+ readonly now?: (() => Date) | undefined;
188
+ }
189
+
190
+ /** Nothing here may replace the failure that caused it, so a teardown throw is swallowed. */
191
+ const quietly = async (stop: () => Promise<void>): Promise<void> => {
192
+ await stop().catch(() => undefined);
193
+ };
194
+
195
+ /**
196
+ * Boot (or find) the server, photograph one route, write both artifacts. The driver and the boot
197
+ * are ARGUMENTS: `bun test` drives this with `fakeBrowser()` and a stub server, so the whole
198
+ * command is proved on a machine with no Chrome — which this command is explicitly excluded from
199
+ * the gate for needing.
200
+ */
201
+ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
202
+ const server = await options.boot();
203
+ let session: ScrapeSession | undefined;
204
+ try {
205
+ const requestedUrl = new URL(options.route, server.url).toString();
206
+ session = await options.driver.open({
207
+ name: 'x shot',
208
+ // The host the picture is of, plus whatever the caller named. Never `*`: a headless browser
209
+ // inside your network is the widest SSRF surface an app can own, and a screenshot command is
210
+ // not the place to open it by default. Every refusal lands in the verdict's `refused` count.
211
+ rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
212
+ clock: systemScrapeClock,
213
+ timeoutMs: options.timeoutMs,
214
+ });
215
+ const page = session.page;
216
+ await page.goto(requestedUrl, { timeout: options.timeoutMs });
217
+ if (options.settleMs > 0) await Bun.sleep(options.settleMs);
218
+ // The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
219
+ // JS engine. `null` says so; a `0` would read as "the route renders no islands", which is a
220
+ // different and much more alarming claim.
221
+ const probe = (): Promise<IslandCount | null> =>
222
+ page
223
+ .evaluate(ISLAND_PROBE)
224
+ .then(parseIslandProbe)
225
+ .catch(() => null);
226
+ // The same budget again, and deliberately no new flag: `settleMs` is the deadline at which the
227
+ // runtime CALLS `import()`, so a mount gets exactly as long to settle as the runtime got to
228
+ // start it — and `--settle 0`, which asks for no wait, still gets none.
229
+ const islands = await settleIslands(probe, {
230
+ windowMs: options.settleMs,
231
+ pollMs: SETTLE_POLL_MS,
232
+ });
233
+ const bytes = await page.screenshot({ fullPage: options.fullPage });
234
+ // Read AFTER the capture, so an error logged while the page settled is in the verdict that
235
+ // ships with the picture it explains.
236
+ const verdict = buildVerdict({
237
+ route: options.route,
238
+ requestedUrl,
239
+ finalUrl: page.url(),
240
+ server: server.origin,
241
+ capturedAt: (options.now ?? (() => new Date()))().toISOString(),
242
+ screenshot: SHOT_IMAGE,
243
+ bytes,
244
+ console: page.console(),
245
+ pageErrors: page.pageErrors(),
246
+ pageErrorsDropped: page.pageErrorsDropped(),
247
+ network: page.network(),
248
+ networkDropped: page.networkDropped(),
249
+ islands,
250
+ });
251
+ mkdirSync(options.outDir, { recursive: true });
252
+ const image = join(options.outDir, SHOT_IMAGE);
253
+ const verdictFile = join(options.outDir, SHOT_VERDICT);
254
+ await Bun.write(image, bytes);
255
+ await Bun.write(verdictFile, `${JSON.stringify(verdictJson(verdict), null, 2)}\n`);
256
+ return { verdict, image, verdictFile };
257
+ } finally {
258
+ // Bound to a const: narrowing a `let` does not survive into the closure below, and the session
259
+ // has to be closed from inside one so a teardown throw cannot replace the real failure.
260
+ const open = session;
261
+ if (open !== undefined) await quietly(() => open.close());
262
+ await quietly(() => server.stop());
263
+ }
264
+ }
265
+
266
+ /**
267
+ * One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
268
+ * Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
269
+ * on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
270
+ */
271
+ export async function devServerFor(
272
+ root: string,
273
+ env: Readonly<Record<string, string | undefined>>,
274
+ port: number,
275
+ ): Promise<ShotServer> {
276
+ const services = resolveServices(root, env);
277
+ const file = Bun.file(lockPath(services.stateDir));
278
+ if (await file.exists()) {
279
+ const lock = parseLock(await file.text());
280
+ if (lock !== null && isProcessAlive(lock.pid)) {
281
+ return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
282
+ }
283
+ }
284
+ await preflight({
285
+ stateDir: services.stateDir,
286
+ port,
287
+ hostname: DEV_BINDING.hostname,
288
+ embeddedDb: services.db.mode === 'embedded',
289
+ });
290
+ const dev = await startDev({ root, port, env });
291
+ await writeLock(services.stateDir, {
292
+ pid: process.pid,
293
+ port,
294
+ url: dev.url,
295
+ startedAt: new Date().toISOString(),
296
+ });
297
+ return {
298
+ url: dev.url,
299
+ origin: 'booted',
300
+ async stop() {
301
+ clearLock(services.stateDir);
302
+ await dev.stop();
303
+ },
304
+ };
305
+ }
306
+
307
+ /** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
308
+ export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
309
+ const named = (extra ?? '')
310
+ .split(',')
311
+ .map((host) => host.trim())
312
+ .filter((host) => host.length > 0);
313
+ return [new URL(url).hostname, ...named];
314
+ };
315
+
316
+ export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
317
+ ok: artifacts.verdict.ok,
318
+ command: 'shot',
319
+ summary: shotSummary(artifacts.verdict),
320
+ lines: shotLines(artifacts),
321
+ data: {
322
+ image: artifacts.image,
323
+ verdictFile: artifacts.verdictFile,
324
+ verdict: verdictJson(artifacts.verdict),
325
+ },
326
+ });
327
+
328
+ export const shotCommand: CliCommand = {
329
+ spec: {
330
+ name: 'shot',
331
+ summary: 'photograph one route from a real browser, with a verdict a picture cannot carry',
332
+ usage: 'x shot <route> [--port 0] [--out <dir>] [--no-full] [--settle 2000] [--json]',
333
+ requiresApp: true,
334
+ flags: [
335
+ { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
336
+ { name: 'out', type: 'string', summary: 'where shot.png and verdict.json are written' },
337
+ { name: 'full', type: 'boolean', summary: 'whole page, not the fold', default: true },
338
+ { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
339
+ { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
340
+ { name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
341
+ { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
342
+ ],
343
+ },
344
+ async run(ctx: CommandContext): Promise<CommandResult> {
345
+ const root = requireAppRoot('shot', ctx.cwd).dir;
346
+ // Every value read before anything boots: a typo must not cost a browser and a dev server to
347
+ // report, which is the rule `x routes` and `x mcp` already follow.
348
+ const route = readRoute(ctx.args.positionals[0]);
349
+ const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
350
+ const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
351
+ const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
352
+ const executablePath = executablePathFrom(flagString(ctx.args, 'browser'), ctx.env);
353
+ if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
354
+ throw new BadFlagError({
355
+ flag: 'browser',
356
+ command: 'shot',
357
+ reason: `no executable at "${executablePath}"`,
358
+ fix: `x shot ${route} --browser /usr/bin/chromium`,
359
+ });
360
+ }
361
+ const out = flagString(ctx.args, 'out');
362
+ const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
363
+ // Resolved before the boot for the same reason: an app with no browser installed must not pay
364
+ // an embedded Postgres to be told to run `bun add -d puppeteer-core`.
365
+ const driver = await appBrowser({
366
+ root,
367
+ ...(executablePath === undefined ? {} : { executablePath }),
368
+ });
369
+ return shotResult(
370
+ await runShot({
371
+ route,
372
+ outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(route)) : resolve(root, out),
373
+ driver,
374
+ boot,
375
+ settleMs,
376
+ timeoutMs,
377
+ fullPage: flagBool(ctx.args, 'full'),
378
+ extraHosts: flagString(ctx.args, 'allow-hosts'),
379
+ }),
380
+ );
381
+ },
382
+ };
package/src/cmd-tasks.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // an agent reading `0 3 * * *` and guessing. CLI wiring only; the pure computation lives in
4
4
  // `tasks-facts.ts` — the same split `cmd-jobs.ts` makes against `jobs-report.ts`.
5
5
 
6
- import { systemClock } from '@ultimat3/core';
6
+ import { nearestName, systemClock } from '@ultimat3/core';
7
7
  import type { TaskHandle } from '@ultimat3/jobs';
8
8
  import type { CronPhrases } from '@ultimat3/time';
9
9
  import { loadApp } from './app-load';
@@ -12,7 +12,7 @@ import type { CliCommand, CommandContext } from './command';
12
12
  import { BadFlagError, DeclarationUnknownError } from './errors';
13
13
  import { msg } from './messages';
14
14
  import type { CommandResult, Finding, JsonValue } from './output';
15
- import { flagString, nearest } from './parse';
15
+ import { flagString } from './parse';
16
16
  import { renderTable } from './table';
17
17
  import {
18
18
  findTaskHandle,
@@ -92,7 +92,7 @@ function requireHandle(ctx: CommandContext): TaskHandle {
92
92
  const handle = findTaskHandle(name);
93
93
  if (handle !== undefined) return handle;
94
94
  const known = knownTaskNames();
95
- const suggestion = nearest(name, known);
95
+ const suggestion = nearestName(name, known);
96
96
  throw new DeclarationUnknownError(
97
97
  suggestion === undefined
98
98
  ? { kind: 'tasks', singular: 'task', name, known, verb: 'show' }
@@ -138,7 +138,12 @@ export const tasksCommand: CliCommand = {
138
138
  subcommands: ['list', 'show'],
139
139
  defaultSubcommand: 'list',
140
140
  flags: [
141
- { name: 'count', type: 'string', summary: 'show: how many upcoming occurrences to list' },
141
+ {
142
+ name: 'count',
143
+ type: 'string',
144
+ summary: 'show: how many upcoming occurrences to list',
145
+ subcommands: ['show'],
146
+ },
142
147
  ],
143
148
  },
144
149
  async run(ctx: CommandContext): Promise<CommandResult> {
package/src/cmd-test.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  // `x test`'s command surface: the flags and the one positional it accepts, and the refusals that
2
2
  // happen before a single process starts. Which files run is test-select.ts, how they are split and
3
3
  // spawned is test-shards.ts — this file only turns argv into their inputs, so a parsing bug can
4
- // never be read as a sharding one.
4
+ // never be read as a sharding one. `--affected` is the one narrowing decided here rather than
5
+ // there, because it is a fact about a git diff and not about a path: what the diff touches is
6
+ // `affected.ts`, and this file only maps that answer onto the paths discovery yields.
5
7
 
8
+ import type { AffectedScope } from './affected';
9
+ import { affectedScope, affectedScopeJson, DEFAULT_BASE, inScope } from './affected';
6
10
  import type { CliCommand, CommandContext } from './command';
11
+ import { ok } from './command';
7
12
  import { BadFlagError, NoTestFilesError } from './errors';
8
13
  import { readIntFlag } from './flag-number';
9
- import type { CommandResult } from './output';
14
+ import { msg } from './messages';
15
+ import type { CommandResult, JsonValue } from './output';
10
16
  import type { ParsedArgs } from './parse';
11
- import { flagString } from './parse';
17
+ import { flagBool, flagString } from './parse';
12
18
  import { quoteArg } from './shell-quote';
13
19
  import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
14
20
  import { runShards } from './test-shards';
@@ -53,12 +59,52 @@ function readOnlyType(positionals: readonly string[]): TestType | undefined {
53
59
  });
54
60
  }
55
61
 
62
+ /**
63
+ * `--affected`, and the two flags that only mean something with it. The scope itself is
64
+ * `affected.ts`'s — `x affected` reports exactly what this narrows to, or the two commands would
65
+ * be two answers to one question and only one of them would be the one an agent trusts.
66
+ */
67
+ async function readAffectedScope(ctx: CommandContext): Promise<AffectedScope | undefined> {
68
+ if (flagBool(ctx.args, 'affected')) {
69
+ return affectedScope({ runner: ctx.runner, cwd: ctx.cwd, args: ctx.args, command: 'test' });
70
+ }
71
+ // A flag that parses and changes nothing is a promise `x help test` cannot keep: without
72
+ // `--affected` the whole suite runs, and a `--base` on the line would read as if it had not.
73
+ const idle = flagString(ctx.args, 'base') !== undefined ? 'base' : 'dirty';
74
+ if (flagString(ctx.args, 'base') !== undefined || flagBool(ctx.args, 'dirty')) {
75
+ throw new BadFlagError({
76
+ flag: idle,
77
+ command: 'test',
78
+ reason: 'only narrows a run together with --affected, and on its own it changes nothing',
79
+ fix: `x test --affected --${idle}${idle === 'base' ? ` ${DEFAULT_BASE}` : ''}`,
80
+ });
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ // The cast is guarded by the three lines above it and is the narrowing TS will not do on its own:
86
+ // `Array.isArray` is declared `value is any[]`, which does not remove `readonly JsonValue[]` from
87
+ // the union, so every branch here still carries the array arm however the check is written.
88
+ const asObject = (value: JsonValue | undefined): Readonly<Record<string, JsonValue>> =>
89
+ typeof value === 'object' && value !== null && !Array.isArray(value)
90
+ ? (value as Readonly<Record<string, JsonValue>>)
91
+ : {};
92
+
93
+ /**
94
+ * The scope, carried onto whatever the shards reported. `--json` is what an agent reads, and a
95
+ * narrowed run that does not say what it narrowed to is indistinguishable from a full one.
96
+ */
97
+ const withScope = (result: CommandResult, scope: AffectedScope): CommandResult => ({
98
+ ...result,
99
+ data: { ...asObject(result.data), affected: affectedScopeJson(scope) },
100
+ });
101
+
56
102
  export const testCommand: CliCommand = {
57
103
  spec: {
58
104
  name: 'test',
59
105
  summary:
60
106
  'run one test type — or the whole suite — across N processes, one isolated database per worker',
61
- usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
107
+ usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
62
108
  positionalChoices: TEST_TYPES,
63
109
  flags: [
64
110
  {
@@ -78,17 +124,53 @@ export const testCommand: CliCommand = {
78
124
  summary:
79
125
  'run at most N files of the selected type — a fast signal for the eval loop, never a gate',
80
126
  },
127
+ {
128
+ name: 'affected',
129
+ type: 'boolean',
130
+ summary: 'only the workspaces a diff touches, and everything that depends on one of them',
131
+ },
132
+ {
133
+ name: 'base',
134
+ type: 'string',
135
+ summary: `--affected: git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
136
+ },
137
+ {
138
+ name: 'dirty',
139
+ type: 'boolean',
140
+ summary:
141
+ '--affected: also count uncommitted work, whichever agent in this checkout made it',
142
+ },
81
143
  ],
82
144
  },
83
145
  async run(ctx: CommandContext): Promise<CommandResult> {
84
146
  const type = readOnlyType(ctx.args.positionals);
85
147
  const filter = flagString(ctx.args, 'filter');
86
148
  const sample = readSample(ctx.args);
149
+ const scope = await readAffectedScope(ctx);
87
150
  const discovered = await discoverTests(ctx.cwd, filter, type);
88
151
  if (discovered.length === 0) {
89
152
  throw new NoTestFilesError({ root: ctx.cwd, ...missingSelection(type, filter) });
90
153
  }
91
- const files = sample === undefined ? discovered : sampleFiles(discovered, sample);
154
+ const selected =
155
+ scope === undefined
156
+ ? discovered
157
+ : discovered.filter((file) => inScope(file.path, scope.prefixes));
158
+ if (scope !== undefined && selected.length === 0) {
159
+ // Green, and it spawns nothing — a `.md`-only diff genuinely re-checks nothing, and failing
160
+ // a build for editing a doc is the wrong answer. It never reads as "the suite passed": the
161
+ // summary counts the files that ran (zero) and `data.affected` names the diff it asked about,
162
+ // so a caller can always tell "green because nothing is affected" from "green because
163
+ // everything passed". Nothing reaches `runShards`, whose empty file list would be a
164
+ // `bun test` with no arguments — that is, the whole suite.
165
+ return ok('test', msg('cli.test.affected.none', { base: scope.selection.base }), {
166
+ data: {
167
+ ...(type === undefined ? {} : { type }),
168
+ files: 0,
169
+ affected: affectedScopeJson(scope),
170
+ },
171
+ });
172
+ }
173
+ const files = sample === undefined ? selected : sampleFiles(selected, sample);
92
174
  const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
93
175
  const workers = Math.max(1, Math.min(requested, files.length));
94
176
  const only = readIndex(ctx.args, 'worker', 0);
@@ -99,7 +181,7 @@ export const testCommand: CliCommand = {
99
181
  reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
100
182
  });
101
183
  }
102
- return runShards({
184
+ const result = await runShards({
103
185
  root: ctx.cwd,
104
186
  runner: ctx.runner,
105
187
  files,
@@ -108,7 +190,14 @@ export const testCommand: CliCommand = {
108
190
  ...(filter === undefined ? {} : { filter }),
109
191
  ...(type === undefined ? {} : { type }),
110
192
  // `kept` is the corpus the split saw; a `--worker` rerun must name it, not its own shard.
111
- ...(sample === undefined ? {} : { sample: { kept: files.length, total: discovered.length } }),
193
+ // `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
194
+ // set, and reporting the whole tree as its total would name a corpus no run ever had.
195
+ ...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
196
+ // The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
197
+ // corpus, so its shard 2 is a different shard 2 — reproducing nothing, which is the one
198
+ // thing `reproduceFor` exists to prevent.
199
+ ...(scope === undefined ? {} : { affected: scope.selection }),
112
200
  });
201
+ return scope === undefined ? result : withScope(result, scope);
113
202
  },
114
203
  };
package/src/cmd-verify.ts CHANGED
@@ -1,17 +1,25 @@
1
1
  // `x verify` — the contract. Every check is a named step with its own pass/fail and duration, the
2
2
  // same list in the terminal and in --json, and a non-zero exit if any step fails. Green means
3
- // shippable (axiom 5): one step list, no second checklist, no CI-only step, and no way to narrow
4
- // the run — `--only` and `--skip` would make "green" mean whatever the caller chose.
3
+ // shippable (axiom 5): one step list, no second checklist, no CI-only step.
4
+ //
5
+ // `--only <step>` is the ONE narrowing, decided as D6, and it does not weaken that: the GATE is
6
+ // the no-flag run, and a narrowed run says `NOT A GATE RUN` in the summary and in `--json` so no
7
+ // reader of either can take it for one. `--skip` stays refused — it would let a caller drop the
8
+ // step that was going to fail and still read the output as a whole-tree verdict.
5
9
 
10
+ import { nearestName } from '@ultimat3/core';
6
11
  import { requireAppRoot } from './app-root';
7
12
  import type { CliCommand, CommandContext } from './command';
13
+ import { BadFlagError } from './errors';
8
14
  import { readIntFlag } from './flag-number';
9
15
  import type { CommandResult } from './output';
10
16
  import type { ParsedArgs } from './parse';
17
+ import { flagString } from './parse';
11
18
  import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
12
19
  import { VERIFY_STEPS } from './verify-checks';
13
20
  import { runVerify } from './verify-run';
14
21
  import type { VerifyStepName } from './verify-step';
22
+ import { VERIFY_STEP_NAMES } from './verify-step';
15
23
 
16
24
  // One import path for the gate, unchanged by the split: `index.ts`, `x build` and the MCP host all
17
25
  // reach the list and the runner through this module, and a second path to either would be the
@@ -23,30 +31,63 @@ export const verifyCommand: CliCommand = {
23
31
  spec: {
24
32
  name: 'verify',
25
33
  summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
26
- usage: 'x verify [--workers N] [--json]',
34
+ usage: 'x verify [--only <step>] [--workers N] [--json]',
27
35
  requiresApp: true,
28
- // The only flag, and it is not `--only`/`--skip` in disguise: it changes how wide the test
29
- // steps spread, never which steps run. Every step still runs, so "green" still means the
30
- // same thing at `--workers 1` as at `--workers 8`.
36
+ // Two flags, and only one of them narrows. `--workers` changes how wide the test steps
37
+ // spread, never which steps run. `--only` runs one step and says so in both renderers —
38
+ // never silently, which is the whole of what makes it safe to have.
31
39
  flags: [
32
40
  {
33
41
  name: 'workers',
34
42
  type: 'string',
35
43
  summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
36
44
  },
45
+ {
46
+ name: 'only',
47
+ type: 'string',
48
+ summary:
49
+ 'run ONE step by name — an iteration loop, NOT A GATE RUN; the gate is this command with no flag',
50
+ },
37
51
  ],
38
52
  },
39
53
  async run(ctx: CommandContext): Promise<CommandResult> {
40
54
  const root = requireAppRoot('verify', ctx.cwd).dir;
55
+ // Both readers before the run: an unrunnable flag must be refused in milliseconds, not after
56
+ // `tsc -b` has spent fourteen seconds on a run the caller cannot use.
41
57
  const workers = readWorkers(ctx.args);
58
+ const only = readOnlyStep(ctx.args);
42
59
  return runVerify(VERIFY_STEPS, {
43
60
  root,
44
61
  runner: ctx.runner,
45
62
  ...(workers === undefined ? {} : { workers }),
63
+ ...(only === undefined ? {} : { only }),
46
64
  });
47
65
  },
48
66
  };
49
67
 
68
+ /**
69
+ * The step `--only` names, or nothing. Refused against `VERIFY_STEP_NAMES` — the same constant the
70
+ * runner's list is built from — so a typo can never be read as "narrow to no steps at all", which
71
+ * is a run that passes by checking nothing.
72
+ *
73
+ * A near miss leads with the step it is near; a word near NOTHING gets the gate itself rather than
74
+ * an invented lead, which is the rule `parse.ts` already follows for a command that resembles
75
+ * none. Both arms are commands that run.
76
+ */
77
+ export const readOnlyStep = (args: ParsedArgs): VerifyStepName | undefined => {
78
+ const raw = flagString(args, 'only');
79
+ if (raw === undefined) return undefined;
80
+ const found = VERIFY_STEP_NAMES.find((name) => name === raw);
81
+ if (found !== undefined) return found;
82
+ const suggestion = nearestName(raw, VERIFY_STEP_NAMES);
83
+ throw new BadFlagError({
84
+ flag: 'only',
85
+ command: 'verify',
86
+ reason: `"${raw}" is not a gate step (${VERIFY_STEP_NAMES.join(', ')})`,
87
+ fix: suggestion === undefined ? 'x verify --json' : `x verify --only ${suggestion} --json`,
88
+ });
89
+ };
90
+
50
91
  /**
51
92
  * Both bounds are the constants the flag summary already names, so `x help verify` and the reader
52
93
  * cannot disagree. Exported for the test that pins them: the command's `run` reaches this only