@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.
- package/CLAUDE.md +65 -5
- package/README.md +8 -3
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +359 -0
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +382 -0
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +21 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +38 -1
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +76 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/registry.ts +8 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +360 -0
- package/src/static-report.ts +219 -0
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +4 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +130 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +15 -2
- package/src/templates/scaffold-app.ts +13 -78
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +46 -7
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/workspace-graph.ts +241 -0
- package/src/write-line.ts +23 -5
package/src/cmd-shot.ts
ADDED
|
@@ -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
|
|
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 =
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
//
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|