@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
@@ -8,7 +8,7 @@ import type { PurgeDriver } from '@ultimat3/cache';
8
8
  import type { Middleware, RateLimitStore } from '@ultimat3/http';
9
9
  import type { JobDriver } from '@ultimat3/jobs';
10
10
  import type { MailDriver } from '@ultimat3/mail';
11
- import type { SyncAuthenticator, Transport } from '@ultimat3/realtime';
11
+ import type { SyncAuthenticator, Transport } from '@ultimat3/realtime/server';
12
12
  import type { IsrStore } from '@ultimat3/render';
13
13
  import type { ImageTransformDriver } from '@ultimat3/seo';
14
14
  import type { Storage } from '@ultimat3/storage';
@@ -45,6 +45,11 @@ export interface RuntimeOverrides {
45
45
  * Where the HTTP rate limiter keeps its counters. It also DECIDES `rateLimit.scope`: a store
46
46
  * that says `'shared'` is a deployment declaring fleet-wide numbers, and `assertRateLimitScope`
47
47
  * holds the two halves together rather than a literal in the boot contradicting the store.
48
+ *
49
+ * Omitted, the boot installs `postgresRateLimitStore` over the pool it already opened
50
+ * (`startServices`) — so this replaces a SHARED default, not an absent one. A store whose scope
51
+ * is `'process'` is legal and warned about: it is every declared limit enforced once per
52
+ * replica, and `docker/helm/values.yaml` runs three.
48
53
  */
49
54
  readonly rateLimitStore?: RateLimitStore;
50
55
  /**
@@ -59,8 +64,11 @@ export interface RuntimeOverrides {
59
64
  readonly images?: ImageTransformDriver;
60
65
  /**
61
66
  * Who is dialling the `sync` node. Omitted, the app's own `configureAuthenticator()` is adapted
62
- * — this field exists because that adapter can only ever answer `{ actor }`, and a real
63
- * deployment's token has an `expiresAt` and a `refresh`, which is the whole of re-authorization.
67
+ * — and that adapter now carries `expiresAt` and `refresh` of its own (`SYNC_GRANT_TTL_MS`),
68
+ * re-asking the app's resolver with the upgrade's own `cookie`/`authorization`. So this field is
69
+ * no longer the only way to get re-authorization; it is how a deployment states a window the
70
+ * credential itself declares (a token's `exp`), or resolves identity from a header the adapter
71
+ * deliberately does not retain per socket.
64
72
  */
65
73
  readonly syncAuthenticate?: SyncAuthenticator;
66
74
  }
@@ -0,0 +1,57 @@
1
+ // When a shot may be TAKEN: the two rules that decide whether the page has finished hydrating,
2
+ // separated from both the command that drives a browser and the verdict that judges what came
3
+ // back. Plain values and an injected sleep, so the whole loop is proved with neither.
4
+
5
+ import type { IslandCount } from './shot-verdict';
6
+
7
+ /**
8
+ * When there is nothing left to wait for. `booted` counts the islands whose chunk the runtime
9
+ * ASKED for; `mounted` and `failed` are the two ways that request can end — so the outcome of
10
+ * every boot exists exactly when they add up to it. An island that never booted is not something
11
+ * to wait for (`visible` with nothing scrolled to it, `never` by declaration), and `null` is "the
12
+ * page answered no probe", which no amount of waiting turns into an answer.
13
+ */
14
+ export const islandsSettled = (islands: IslandCount | null): boolean =>
15
+ islands === null || islands.mounted + islands.failed >= islands.booted;
16
+
17
+ /**
18
+ * How often the island probe is re-read while the page settles. Short enough that a page whose
19
+ * mounts have already resolved pays one extra read and nothing else.
20
+ */
21
+ export const SETTLE_POLL_MS = 100;
22
+
23
+ export interface SettleOptions {
24
+ /** The extra budget a mount gets AFTER the boot deadline. Bounded: a picture is still owed. */
25
+ readonly windowMs: number;
26
+ readonly pollMs: number;
27
+ /** Injected by the test, so the poll is proved without spending its own window in real time. */
28
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
29
+ }
30
+
31
+ /**
32
+ * Read the probe until every booted island has settled, or the window runs out.
33
+ *
34
+ * `DEFAULT_SETTLE_MS` is the deadline at which the hydration runtime CALLS `import()` — `mounted`
35
+ * and `failed` land after it — so a single read at that instant reports `mounted: 0` for a page
36
+ * that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
37
+ * is the only shape that ends EARLY on a fast page and still bounds a slow one.
38
+ *
39
+ * A `null` answer never overwrites a real count: `null` means "not counted", and a probe that
40
+ * fails once would otherwise turn a page with islands into a page reported to have none.
41
+ */
42
+ export async function settleIslands(
43
+ probe: () => Promise<IslandCount | null>,
44
+ options: SettleOptions,
45
+ ): Promise<IslandCount | null> {
46
+ const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
47
+ let answer = await probe();
48
+ let waited = 0;
49
+ while (!islandsSettled(answer) && waited < options.windowMs) {
50
+ // At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
51
+ const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
52
+ await sleep(step);
53
+ waited += step;
54
+ answer = (await probe()) ?? answer;
55
+ }
56
+ return answer;
57
+ }
@@ -0,0 +1,360 @@
1
+ // What a shot CLAIMS, and what it refuses to claim: the verdict `x shot` writes beside the
2
+ // picture, built from plain values so every rule here is testable with no browser, no dev server
3
+ // and no `ParsedArgs` — the `cmd-jobs.ts` / `jobs-report.ts` split, repeated.
4
+
5
+ import { probeImage } from '@ultimat3/core';
6
+ import { ISLAND_FAILED_ATTRIBUTE, ISLAND_MOUNTED_ATTRIBUTE } from '@ultimat3/render';
7
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
8
+ import { t, validate } from '@ultimat3/schema';
9
+ import type { ConsoleLine, NetworkEntry, PageError } from '@ultimat3/scraping';
10
+ import { msg } from './messages';
11
+ import type { JsonValue } from './output';
12
+
13
+ /**
14
+ * The one expression that counts islands, and it counts TWO different facts because they are two
15
+ * different facts. `data-x-island` is emitted by `emitIslandAttributes` for every island on the
16
+ * page including `hydrate: 'never'`, so its presence means "the server rendered an island here"
17
+ * and nothing about the client. `el.__x` is the boot promise `hydrateRuntime`'s prelude assigns
18
+ * (`el.__x=import(e).then(…)`), so it means "the runtime asked for this island's chunk" — still
19
+ * not "mount() resolved", which nothing in the DOM records today.
20
+ *
21
+ * An expression, never a closure: `CdpPageLike.evaluate` takes the string form only.
22
+ */
23
+ /** Every key this command renders. `msg()` answers `⟦key⟧` for a miss, which no build can see. */
24
+ export const SHOT_MESSAGE_KEYS = [
25
+ 'cli.shot.ok',
26
+ 'cli.shot.errors',
27
+ 'cli.shot.redirected',
28
+ 'cli.shot.picture',
29
+ 'cli.shot.verdict',
30
+ 'cli.shot.server.booted',
31
+ 'cli.shot.server.reused',
32
+ 'cli.shot.canvas',
33
+ 'cli.shot.canvasUnreadable',
34
+ 'cli.shot.islands',
35
+ 'cli.shot.islandsUnknown',
36
+ 'cli.shot.islandFailed',
37
+ 'cli.shot.network',
38
+ 'cli.shot.console',
39
+ 'cli.shot.threw',
40
+ 'cli.shot.pageError',
41
+ 'cli.shot.blind.status',
42
+ ] as const;
43
+
44
+ /**
45
+ * Four facts, not one, because they are genuinely different and conflating them is what makes a
46
+ * screenshot tool lie. `declared` is server-rendered and says nothing about the client.
47
+ * `booted` means the runtime CALLED `import()` — set before `mount()` settles, and legitimately
48
+ * absent for a `visible` or `interaction` island nothing has scrolled to or clicked. `mounted` and
49
+ * `failed` are the outcome, marked by the prelude itself when the mount promise settles.
50
+ *
51
+ * The attribute names are INTERPOLATED from `@ultimat3/render`'s own exports rather than typed
52
+ * here: this probe and the runtime that writes the markers have to agree, and a second spelling of
53
+ * `data-x-mounted` would read as "no island mounted" — a clean-looking answer that is wrong.
54
+ */
55
+ export const ISLAND_PROBE =
56
+ '(function(){var els=document.querySelectorAll("[data-x-island]");var by={};var booted=0;' +
57
+ 'var mounted=0;var failed=0;var failures=[];' +
58
+ 'for(var i=0;i<els.length;i+=1){var el=els[i];' +
59
+ 'var s=el.getAttribute("data-x-hydrate")||"unknown";by[s]=(by[s]||0)+1;' +
60
+ 'if(el.__x!==undefined)booted+=1;' +
61
+ `if(el.hasAttribute("${ISLAND_MOUNTED_ATTRIBUTE}"))mounted+=1;` +
62
+ `if(el.hasAttribute("${ISLAND_FAILED_ATTRIBUTE}")){failed+=1;failures.push({` +
63
+ 'island:el.getAttribute("data-x-island")||"",' +
64
+ `message:el.getAttribute("${ISLAND_FAILED_ATTRIBUTE}")||''});}}` +
65
+ 'return{declared:els.length,booted:booted,mounted:mounted,failed:failed,' +
66
+ 'byStrategy:by,failures:failures};})()';
67
+
68
+ export interface IslandCount {
69
+ /** `[data-x-island]` in the served DOM — server-rendered, whatever the client then did. */
70
+ readonly declared: number;
71
+ /** Islands whose chunk the hydration runtime asked for. See `ISLAND_PROBE` for the distance. */
72
+ readonly booted: number;
73
+ /** Islands whose `mount()` RESOLVED. This is the one that answers "does the page work". */
74
+ readonly mounted: number;
75
+ /** Islands whose `mount()` REJECTED — the case a picture can never show. */
76
+ readonly failed: number;
77
+ /** `data-x-hydrate` value → count, so a `never` island is never read as a failure to boot. */
78
+ readonly byStrategy: Readonly<Record<string, number>>;
79
+ /** Which island threw, and what it said. Empty when `failed` is 0. */
80
+ readonly failures: readonly IslandFailure[];
81
+ }
82
+
83
+ export interface IslandFailure {
84
+ readonly island: string;
85
+ readonly message: string;
86
+ }
87
+
88
+ const islandProbeSchema: StandardSchemaV1<unknown, IslandCount> = t.object({
89
+ declared: t.number,
90
+ booted: t.number,
91
+ mounted: t.number,
92
+ failed: t.number,
93
+ byStrategy: t.record(t.number),
94
+ failures: t.array(t.object({ island: t.string, message: t.string })),
95
+ }) as unknown as StandardSchemaV1<unknown, IslandCount>;
96
+
97
+ /**
98
+ * `evaluate()` answers `unknown` on every driver — a page can return anything at all — so the
99
+ * probe's result is PARSED and never cast. `null` for anything that does not fit, because a
100
+ * malformed probe must not be able to take a capture down after the picture was already taken.
101
+ */
102
+ export function parseIslandProbe(value: unknown): IslandCount | null {
103
+ const result = validate(islandProbeSchema, value);
104
+ return result.issues === undefined ? result.value : null;
105
+ }
106
+
107
+ export interface ShotCanvas {
108
+ readonly width: number;
109
+ readonly height: number;
110
+ readonly format: string;
111
+ }
112
+
113
+ /**
114
+ * The picture's own pixel size, read from the header bytes by `@ultimat3/core`'s one image probe —
115
+ * never a viewport number the tool asked for and cannot prove it got. `null` when the bytes are
116
+ * not a decodable image, which is a fact worth reporting rather than an exception worth throwing:
117
+ * the offline drivers answer a PNG signature with no IHDR behind it.
118
+ */
119
+ export function canvasOf(bytes: Uint8Array): ShotCanvas | null {
120
+ try {
121
+ const info = probeImage(bytes);
122
+ return { width: info.width, height: info.height, format: info.format };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ export interface ShotInput {
129
+ readonly route: string;
130
+ readonly requestedUrl: string;
131
+ readonly finalUrl: string;
132
+ readonly server: 'booted' | 'reused';
133
+ readonly capturedAt: string;
134
+ /** The picture's filename, beside the verdict — a sibling, so the pair moves as one directory. */
135
+ readonly screenshot: string;
136
+ readonly bytes: Uint8Array;
137
+ readonly console: readonly ConsoleLine[];
138
+ /**
139
+ * Uncaught exceptions, which are NOT console lines: a throw calls no console method, so a page
140
+ * whose island exploded can have `console: []`. This is the field the whole command was for —
141
+ * "a picture cannot tell you the island threw".
142
+ */
143
+ readonly pageErrors: readonly PageError[];
144
+ /** Page errors the ring evicted, so `pageErrors.length` reads as a floor and not a total. */
145
+ readonly pageErrorsDropped: number;
146
+ readonly network: readonly NetworkEntry[];
147
+ readonly networkDropped: number;
148
+ /** `null` when the probe could not run or did not parse. Never a zero standing in for unknown. */
149
+ readonly islands: IslandCount | null;
150
+ }
151
+
152
+ export interface ShotVerdict extends ShotInput {
153
+ readonly ok: boolean;
154
+ /** The route asked for is not the document photographed. An `auth: 'required'` route's default. */
155
+ readonly redirected: boolean;
156
+ readonly errors: number;
157
+ readonly warnings: number;
158
+ readonly canvas: ShotCanvas | null;
159
+ readonly refused: number;
160
+ /** What this verdict cannot see, stated every time — a `0` whose blind spots are named. */
161
+ readonly blind: readonly string[];
162
+ }
163
+
164
+ /**
165
+ * What a shot is blind to, each naming the mechanism rather than apologising. Constant because
166
+ * these are properties of the browser port, not of a run — and in the artifact because `errors: 0`
167
+ * read without them is a claim the tool cannot support.
168
+ *
169
+ * It was three. `pageErrors` and `hydration` both left on 2026-08-21, when `@ultimat3/scraping`
170
+ * learned to capture `pageerror` and the hydration prelude learned to mark a mount's outcome. A
171
+ * blind spot is worth stating while it is true and worth DELETING the moment it is not: a stale
172
+ * one teaches an agent to distrust an answer the tool can now give.
173
+ */
174
+ export const BLIND_SPOTS = ['cli.shot.blind.status'] as const;
175
+
176
+ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']): number =>
177
+ lines.filter((line) => line.level === level).length;
178
+
179
+ /**
180
+ * `ok` is four conditions, and every one is something a picture cannot show: nothing on the page
181
+ * logged an error, nothing THREW, no island's `mount()` REJECTED, and the document photographed is
182
+ * the route that was asked for.
183
+ *
184
+ * The throw is its own clause rather than folded into `errors` because an uncaught exception calls
185
+ * no console method — a page whose island died can log nothing at all, and `errors === 0` would
186
+ * then pass it. A rejected mount is a third silent one and was read by NOTHING until 2026-08-22:
187
+ * the prelude pays 129 B an island to write `data-x-failed`, the probe counted it into the
188
+ * artifact, and every island on a page could reject while the run reported "clean". `?? 0` keeps
189
+ * an uncounted probe (`null`) out of the verdict — "not counted" is not "none failed".
190
+ * A redirect is a failure of the CAPTURE rather than of the app: an agent that
191
+ * photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
192
+ */
193
+ export function buildVerdict(input: ShotInput): ShotVerdict {
194
+ const errors = levelCount(input.console, 'error');
195
+ return {
196
+ ...input,
197
+ ok:
198
+ errors === 0 &&
199
+ input.pageErrors.length === 0 &&
200
+ (input.islands?.failed ?? 0) === 0 &&
201
+ input.requestedUrl === input.finalUrl,
202
+ redirected: input.requestedUrl !== input.finalUrl,
203
+ errors,
204
+ warnings: levelCount(input.console, 'warn'),
205
+ canvas: canvasOf(input.bytes),
206
+ refused: input.network.filter((entry) => entry.refused !== undefined).length,
207
+ blind: BLIND_SPOTS.map((key) => msg(key)),
208
+ };
209
+ }
210
+
211
+ const consoleJson = (lines: readonly ConsoleLine[]): JsonValue =>
212
+ lines.map((line) => ({ level: line.level, text: line.text, at: line.at }));
213
+
214
+ const islandJson = (islands: IslandCount | null): JsonValue =>
215
+ islands === null
216
+ ? null
217
+ : {
218
+ declared: islands.declared,
219
+ booted: islands.booted,
220
+ mounted: islands.mounted,
221
+ failed: islands.failed,
222
+ byStrategy: islands.byStrategy,
223
+ failures: islands.failures.map((failure) => ({
224
+ island: failure.island,
225
+ message: failure.message,
226
+ })),
227
+ };
228
+
229
+ /** The artifact, and the same object `--json` carries under `data.verdict`. One shape, two files. */
230
+ export function verdictJson(verdict: ShotVerdict): JsonValue {
231
+ return {
232
+ ok: verdict.ok,
233
+ route: verdict.route,
234
+ requestedUrl: verdict.requestedUrl,
235
+ finalUrl: verdict.finalUrl,
236
+ redirected: verdict.redirected,
237
+ server: verdict.server,
238
+ capturedAt: verdict.capturedAt,
239
+ screenshot: verdict.screenshot,
240
+ bytes: verdict.bytes.byteLength,
241
+ canvas:
242
+ verdict.canvas === null
243
+ ? null
244
+ : {
245
+ width: verdict.canvas.width,
246
+ height: verdict.canvas.height,
247
+ format: verdict.canvas.format,
248
+ },
249
+ console: {
250
+ total: verdict.console.length,
251
+ errors: verdict.errors,
252
+ warnings: verdict.warnings,
253
+ lines: consoleJson(verdict.console),
254
+ },
255
+ // Its own object, never merged into `console`: an uncaught exception calls no console method,
256
+ // and a reader who finds throws under `console` will look for them in the wrong stream.
257
+ pageErrors: {
258
+ total: verdict.pageErrors.length,
259
+ dropped: verdict.pageErrorsDropped,
260
+ thrown: verdict.pageErrors.map((error) => ({
261
+ message: error.message,
262
+ stack: error.stack ?? null,
263
+ at: error.at,
264
+ })),
265
+ },
266
+ islands: islandJson(verdict.islands),
267
+ network: {
268
+ requests: verdict.network.length,
269
+ refused: verdict.refused,
270
+ dropped: verdict.networkDropped,
271
+ },
272
+ blind: [...verdict.blind],
273
+ };
274
+ }
275
+
276
+ export interface ShotArtifacts {
277
+ readonly verdict: ShotVerdict;
278
+ /** Absolute path of the picture. */
279
+ readonly image: string;
280
+ /** Absolute path of this verdict on disk. */
281
+ readonly verdictFile: string;
282
+ }
283
+
284
+ const consoleLines = (verdict: ShotVerdict): readonly string[] =>
285
+ verdict.console
286
+ .filter((line) => line.level === 'error' || line.level === 'warn')
287
+ .map((line) => msg('cli.shot.console', { level: line.level, text: line.text.slice(0, 200) }));
288
+
289
+ /** Human lines. Every fact here is a fact `--json` carries under `data.verdict`. */
290
+ export function shotLines(artifacts: ShotArtifacts): readonly string[] {
291
+ const verdict = artifacts.verdict;
292
+ const islands = verdict.islands;
293
+ const canvas = verdict.canvas;
294
+ return [
295
+ msg(`cli.shot.server.${verdict.server}`, { url: verdict.finalUrl }),
296
+ canvas === null
297
+ ? msg('cli.shot.canvasUnreadable', { bytes: verdict.bytes.byteLength })
298
+ : msg('cli.shot.canvas', { width: canvas.width, height: canvas.height }),
299
+ islands === null
300
+ ? msg('cli.shot.islandsUnknown')
301
+ : msg('cli.shot.islands', {
302
+ booted: islands.mounted,
303
+ declared: islands.declared,
304
+ strategies: Object.entries(islands.byStrategy)
305
+ .map(([name, count]) => `${name}=${count}`)
306
+ .join(' '),
307
+ }),
308
+ msg('cli.shot.network', {
309
+ requests: verdict.network.length,
310
+ refused: verdict.refused,
311
+ dropped: verdict.networkDropped,
312
+ }),
313
+ ...verdict.pageErrors.map((error) =>
314
+ msg('cli.shot.pageError', {
315
+ message: error.message,
316
+ // The frame that names the island module and line. `message` alone says what went wrong
317
+ // and never where, which is the difference between a report and a lead.
318
+ at: error.stack?.split('\n')[1]?.trim() ?? '',
319
+ }),
320
+ ),
321
+ ...consoleLines(verdict),
322
+ msg('cli.shot.picture', { path: artifacts.image }),
323
+ msg('cli.shot.verdict', { path: artifacts.verdictFile }),
324
+ ];
325
+ }
326
+
327
+ /** The one line a reader sees first, and it names the gating fact rather than the file count. */
328
+ export const shotSummary = (verdict: ShotVerdict): string => {
329
+ if (verdict.redirected) {
330
+ return msg('cli.shot.redirected', { route: verdict.route, url: verdict.finalUrl });
331
+ }
332
+ // Ahead of the console count, because a throw is the more severe fact AND the quieter one: an
333
+ // island that died can log nothing at all, so `errors` would report a clean page.
334
+ if (verdict.pageErrors.length > 0) {
335
+ return msg('cli.shot.threw', {
336
+ route: verdict.route,
337
+ thrown: verdict.pageErrors.length,
338
+ first: verdict.pageErrors[0]?.message ?? '',
339
+ });
340
+ }
341
+ // Ahead of the console count for the same reason, and it is the same silence: a rejected mount
342
+ // promise calls no console method either, so a page whose every island died can read `errors: 0`.
343
+ // The first failure is NAMED — "1 island failed" sends a reader back to the artifact for the one
344
+ // fact they need to start.
345
+ const failure = verdict.islands?.failures[0];
346
+ if (failure !== undefined) {
347
+ return msg('cli.shot.islandFailed', {
348
+ route: verdict.route,
349
+ failed: verdict.islands?.failed ?? 0,
350
+ island: failure.island,
351
+ message: failure.message,
352
+ });
353
+ }
354
+ if (verdict.errors > 0) {
355
+ return msg('cli.shot.errors', { route: verdict.route, errors: verdict.errors });
356
+ }
357
+ // `mounted`, not `booted`: "the runtime asked for the chunk" is not the claim worth making when
358
+ // the DOM can now say the mount RESOLVED.
359
+ return msg('cli.shot.ok', { route: verdict.route, islands: verdict.islands?.mounted ?? 0 });
360
+ };
@@ -0,0 +1,219 @@
1
+ // The inventory of one `x build --target static`: which declared route reached the artifact, which
2
+ // did not, and WHY for each. Written by the prerenderer, read back by `x build`, so both renderers
3
+ // state the same thing. A skipped route with no reason is what turned "the island did not mount"
4
+ // into a bug report about a page that had never been in `.x/static/` at all (#242).
5
+
6
+ // `node:` twice, and only where Bun answers nothing: Bun ships no path API, and `rm(…, { force })`
7
+ // deletes a report that may not be there without a branch. `existsSync` was a third and is gone —
8
+ // `Bun.file(path).json()` already rejects on a missing file, into the catch that answers `undefined`.
9
+ import { rm } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import type { RenderMode } from '@ultimat3/core';
12
+ import { RENDER_MODES } from '@ultimat3/core';
13
+ import type { Surface } from '@ultimat3/render';
14
+ import { SURFACE_SPECS, SURFACES, surfaceAllows } from '@ultimat3/render';
15
+ import type { JsonValue } from './output';
16
+
17
+ /** Beside `.x/build-stats.json`, and written by the same call — see `readStaticReport` below. */
18
+ export const STATIC_REPORT_FILE = join('.x', 'static-report.json');
19
+
20
+ export const SKIP_REASONS = [
21
+ 'surface-forbids-static',
22
+ 'mode-revalidates',
23
+ 'mode-per-request',
24
+ 'no-prerender-paths',
25
+ ] as const;
26
+
27
+ export type SkipReason = (typeof SKIP_REASONS)[number];
28
+
29
+ /**
30
+ * Type ALIASES, never interfaces — every shape below is JSON on both sides of a file, and only an
31
+ * alias carries the implicit index signature that makes it assignable to `JsonValue`. As
32
+ * interfaces, `data: { emitted, skipped }` in `cmd-build.ts` was TS2322 (`not assignable to type
33
+ * 'JsonValue'`) with nothing wrong in either file, and the obvious fixes — a cast, a widened
34
+ * annotation — are the two this repo refuses. Same rule `RouteContext` in `@ultimat3/render` is an
35
+ * alias for.
36
+ */
37
+
38
+ /** The two declarations a skip decision reads. Narrower than `RouteEntry` so a test can state one. */
39
+ export type RouteFacts = {
40
+ readonly surface: Surface;
41
+ readonly render: RenderMode;
42
+ };
43
+
44
+ export type SkippedRoute = RouteFacts & {
45
+ /** The DECLARED path, `/blog/:slug` — never a filled one, so the author can grep for it. */
46
+ readonly route: string;
47
+ readonly reason: SkipReason;
48
+ readonly why: string;
49
+ };
50
+
51
+ /** One HTML file in the artifact, and the declared route that produced it. */
52
+ export type EmittedPage = {
53
+ readonly route: string;
54
+ readonly path: string;
55
+ /** Relative to the build output root, POSIX — the file a screenshot tool actually opens. */
56
+ readonly file: string;
57
+ };
58
+
59
+ export type StaticReport = {
60
+ readonly target: 'static';
61
+ readonly out: string;
62
+ readonly buildId: string;
63
+ readonly emitted: readonly EmittedPage[];
64
+ readonly skipped: readonly SkippedRoute[];
65
+ };
66
+
67
+ /**
68
+ * The ONE decision about what lands on a CDN — `isPrerenderable` is derived from it, so the answer
69
+ * and the reason can never disagree.
70
+ *
71
+ * Surface is asked FIRST, and that ordering is the whole finding. `app/` allows `stream | ssr` and
72
+ * nothing else, so no `render:` edit can put an app/ route into the artifact: the surface is the
73
+ * cause, and naming the mode would send an author to change the one thing that cannot help. On a
74
+ * surface that DOES allow `static`, the mode is the cause and the edit is real — which is why the
75
+ * two produce different sentences below rather than one flat "not prerendered".
76
+ */
77
+ export function skipReasonFor(route: RouteFacts): SkipReason | null {
78
+ if (!surfaceAllows(route.surface, 'static')) return 'surface-forbids-static';
79
+ if (route.render === 'static') return null;
80
+ return route.render === 'isr' ? 'mode-revalidates' : 'mode-per-request';
81
+ }
82
+
83
+ /**
84
+ * `Object.freeze<Record<…>>`, never `const X: Readonly<Record<…>> = Object.freeze({…})`: the second
85
+ * form infers from the literal and accepts an EXTRA key in silence (`bun run frozen-records`).
86
+ *
87
+ * English rather than `t()`, for the reason every `cause:`/`fix:` in this repo is English: this is
88
+ * a diagnostic an agent pastes into a report, and `wiki/Error-Codes.md` is the same surface.
89
+ */
90
+ const WHY = Object.freeze<Record<SkipReason, (route: RouteFacts) => string>>({
91
+ 'surface-forbids-static': (route) =>
92
+ `${route.surface}/ surface — server-rendered, not prerendered; ` +
93
+ `${route.surface}/ allows ${SURFACE_SPECS[route.surface].allowedModes.join(' | ') || 'no render mode'}, ` +
94
+ 'so no route on it can ever reach the artifact',
95
+ 'mode-revalidates': (route) =>
96
+ `render: '${route.render}' regenerates on a tag or ttl and a published file cannot, ` +
97
+ "so it is served by the app — change it to render: 'static' to emit it",
98
+ 'mode-per-request': (route) =>
99
+ `render: '${route.render}' is rendered per request, so it is served by the app — ` +
100
+ "change it to render: 'static' to emit it",
101
+ 'no-prerender-paths': (route) =>
102
+ `render: '${route.render}' with dynamic params and no prerender() paths, so the build ` +
103
+ 'enumerated nothing to write — add prerender() to the route',
104
+ });
105
+
106
+ /** The reason as a row: the code a machine keys off, and the sentence a human reads. */
107
+ export function skippedRoute(
108
+ input: RouteFacts & { readonly route: string },
109
+ reason: SkipReason,
110
+ ): SkippedRoute {
111
+ return {
112
+ route: input.route,
113
+ surface: input.surface,
114
+ render: input.render,
115
+ reason,
116
+ why: WHY[reason](input),
117
+ };
118
+ }
119
+
120
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
121
+ typeof value === 'object' && value !== null;
122
+
123
+ /**
124
+ * A closed vocabulary, checked against the vocabulary itself. `typeof value === 'string'` is what
125
+ * `surface` and `render` were held to, and a string is not a `Surface`: the parse then handed back
126
+ * a `SkippedRoute` whose declared type says `'site' | 'app' | 'api' | 'shared'` while it holds
127
+ * whatever the file said, so `SURFACE_SPECS[route.surface]` on the read side is `undefined` at the
128
+ * first reader that indexes it. `SKIP_REASONS` was already checked this way; these two are the same
129
+ * kind of field and get the same rule.
130
+ */
131
+ const inDomain = (domain: readonly string[], value: unknown): boolean =>
132
+ typeof value === 'string' && domain.includes(value);
133
+
134
+ const isSkipped = (value: unknown): value is SkippedRoute =>
135
+ isRecord(value) &&
136
+ typeof value['route'] === 'string' &&
137
+ inDomain(SURFACES, value['surface']) &&
138
+ inDomain(RENDER_MODES, value['render']) &&
139
+ typeof value['why'] === 'string' &&
140
+ inDomain(SKIP_REASONS, value['reason']);
141
+
142
+ const isEmitted = (value: unknown): value is EmittedPage =>
143
+ isRecord(value) &&
144
+ typeof value['route'] === 'string' &&
145
+ typeof value['path'] === 'string' &&
146
+ typeof value['file'] === 'string';
147
+
148
+ /**
149
+ * Parsed, never cast. The file is this build's own output, but `x build` reads it back off disk
150
+ * after a subprocess — a truncated write or a hand-edit must read as "no report", which the caller
151
+ * already handles, rather than as a report with a `skipped` nobody can render.
152
+ */
153
+ export function parseStaticReport(value: unknown): StaticReport | undefined {
154
+ if (!isRecord(value)) return undefined;
155
+ const { target, out, buildId, emitted, skipped } = value;
156
+ if (target !== 'static' || typeof out !== 'string' || typeof buildId !== 'string') {
157
+ return undefined;
158
+ }
159
+ if (!Array.isArray(emitted) || !emitted.every(isEmitted)) return undefined;
160
+ if (!Array.isArray(skipped) || !skipped.every(isSkipped)) return undefined;
161
+ return { target, out, buildId, emitted, skipped };
162
+ }
163
+
164
+ export async function writeStaticReport(root: string, report: StaticReport): Promise<string> {
165
+ const path = join(root, STATIC_REPORT_FILE);
166
+ await Bun.write(path, `${JSON.stringify(report, null, 2)}\n`);
167
+ return path;
168
+ }
169
+
170
+ /**
171
+ * `undefined` means no report — an app whose `apps/web/prerender.ts` does not call `prerenderSite`.
172
+ * That app also writes no `.x/build-stats.json` (one call writes both), so `x verify`'s `budgets`
173
+ * step already reds it with `X_BUDGET_UNMEASURED`; this side stays quiet rather than adding a
174
+ * second code for one cause.
175
+ */
176
+ export async function readStaticReport(root: string): Promise<StaticReport | undefined> {
177
+ try {
178
+ return parseStaticReport(await Bun.file(join(root, STATIC_REPORT_FILE)).json());
179
+ } catch {
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ /** Deleted before the builder is spawned, so a failed build can never report the last one's list. */
185
+ export async function removeStaticReport(root: string): Promise<void> {
186
+ await rm(join(root, STATIC_REPORT_FILE), { force: true });
187
+ }
188
+
189
+ /**
190
+ * The `--json` half, beside `renderStaticReport`'s human one — and a `Record<string, JsonValue>`
191
+ * rather than a `JsonValue`, because `x build` SPREADS it into `data` beside `target` and
192
+ * `artifact`: a bare `JsonValue` admits a string, so the spread is TS2698 (`spread types may only
193
+ * be created from object types`) and no annotation at the call site can rescue it.
194
+ *
195
+ * A field the compiler cannot prove is JSON reds HERE, at the projection, rather than at whichever
196
+ * caller happens to hand the report to a renderer.
197
+ */
198
+ export function staticReportData(report: StaticReport | undefined): Record<string, JsonValue> {
199
+ // Never `{ ...report }`: `out` is an absolute build path and `buildId` is this run's, neither of
200
+ // which the inventory is about — `data` already carries `artifact` and the build's own id.
201
+ return report === undefined ? {} : { emitted: report.emitted, skipped: report.skipped };
202
+ }
203
+
204
+ /**
205
+ * The human half. Fixed-width columns headed by the JSON's own field names, exactly as
206
+ * `renderRouteTable` is — the renderer invents no prose, so `--json` and the terminal cannot drift.
207
+ */
208
+ export function renderStaticReport(report: StaticReport): readonly string[] {
209
+ const rows: readonly (readonly string[])[] = [
210
+ ...report.emitted.map((page) => ['emitted', page.route, page.file]),
211
+ ...report.skipped.map((route) => ['skipped', route.route, route.why]),
212
+ ];
213
+ const widths = [0, 1].map((index) =>
214
+ Math.max(...rows.map((row) => (row[index] ?? '').length), 0),
215
+ );
216
+ return rows.map((row) =>
217
+ ` ${row.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' ')}`.trimEnd(),
218
+ );
219
+ }