@ultimat3/cli 5.0.1 → 7.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 (60) hide show
  1. package/CLAUDE.md +75 -6
  2. package/README.md +2 -2
  3. package/package.json +28 -24
  4. package/src/affected.ts +320 -0
  5. package/src/browser-launcher.ts +109 -0
  6. package/src/ci-log.ts +0 -0
  7. package/src/ci-runs.ts +179 -0
  8. package/src/cmd-affected.ts +109 -0
  9. package/src/cmd-build.ts +36 -3
  10. package/src/cmd-ci.ts +273 -0
  11. package/src/cmd-dev.ts +35 -2
  12. package/src/cmd-generate.ts +16 -348
  13. package/src/cmd-i18n.ts +32 -16
  14. package/src/cmd-pr.ts +308 -0
  15. package/src/cmd-shot.ts +320 -0
  16. package/src/cmd-test.ts +96 -7
  17. package/src/cmd-verify.ts +10 -427
  18. package/src/compile-externals.ts +34 -0
  19. package/src/dev-lock.ts +275 -0
  20. package/src/dev-render.ts +7 -17
  21. package/src/error-codes.ts +18 -0
  22. package/src/generate-files.ts +127 -0
  23. package/src/generate-write.ts +229 -0
  24. package/src/gh-target.ts +118 -0
  25. package/src/gh.ts +204 -0
  26. package/src/i18n-audit.ts +39 -1
  27. package/src/i18n-registration.ts +130 -0
  28. package/src/index.ts +37 -0
  29. package/src/island-bundle.ts +68 -2
  30. package/src/island-solid-production.ts +129 -0
  31. package/src/island-styles.ts +41 -0
  32. package/src/mcp-errors.ts +11 -0
  33. package/src/messages.ts +67 -0
  34. package/src/pr-threads.ts +291 -0
  35. package/src/prerender.ts +52 -10
  36. package/src/registry.ts +8 -0
  37. package/src/shot-verdict.ts +337 -0
  38. package/src/solid-loader.ts +127 -0
  39. package/src/static-report.ts +219 -0
  40. package/src/templates/admin-page.ts +46 -5
  41. package/src/templates/index.ts +1 -0
  42. package/src/templates/island-fixture.ts +76 -0
  43. package/src/templates/island.ts +129 -18
  44. package/src/templates/resource-form-island.ts +279 -0
  45. package/src/templates/resource.ts +52 -43
  46. package/src/templates/route.ts +45 -6
  47. package/src/templates/scaffold-app.ts +70 -19
  48. package/src/templates/scaffold-container.ts +2 -2
  49. package/src/templates/scaffold-db-package.ts +88 -39
  50. package/src/templates/scaffold-docs.ts +18 -1
  51. package/src/templates/scaffold-i18n.ts +9 -2
  52. package/src/templates/scaffold-mcp-package.ts +35 -2
  53. package/src/templates/scaffold-package-shape.ts +7 -2
  54. package/src/templates/scaffold-repo.ts +2 -2
  55. package/src/test-shards.ts +19 -3
  56. package/src/verify-checks.ts +349 -0
  57. package/src/verify-run.ts +122 -0
  58. package/src/verify-step.ts +7 -0
  59. package/src/workspace-graph.ts +241 -0
  60. package/types/babel-modules.d.ts +31 -0
@@ -0,0 +1,337 @@
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.network',
37
+ 'cli.shot.console',
38
+ 'cli.shot.threw',
39
+ 'cli.shot.pageError',
40
+ 'cli.shot.blind.status',
41
+ ] as const;
42
+
43
+ /**
44
+ * Four facts, not one, because they are genuinely different and conflating them is what makes a
45
+ * screenshot tool lie. `declared` is server-rendered and says nothing about the client.
46
+ * `booted` means the runtime CALLED `import()` — set before `mount()` settles, and legitimately
47
+ * absent for a `visible` or `interaction` island nothing has scrolled to or clicked. `mounted` and
48
+ * `failed` are the outcome, marked by the prelude itself when the mount promise settles.
49
+ *
50
+ * The attribute names are INTERPOLATED from `@ultimat3/render`'s own exports rather than typed
51
+ * here: this probe and the runtime that writes the markers have to agree, and a second spelling of
52
+ * `data-x-mounted` would read as "no island mounted" — a clean-looking answer that is wrong.
53
+ */
54
+ export const ISLAND_PROBE =
55
+ '(function(){var els=document.querySelectorAll("[data-x-island]");var by={};var booted=0;' +
56
+ 'var mounted=0;var failed=0;var failures=[];' +
57
+ 'for(var i=0;i<els.length;i+=1){var el=els[i];' +
58
+ 'var s=el.getAttribute("data-x-hydrate")||"unknown";by[s]=(by[s]||0)+1;' +
59
+ 'if(el.__x!==undefined)booted+=1;' +
60
+ `if(el.hasAttribute("${ISLAND_MOUNTED_ATTRIBUTE}"))mounted+=1;` +
61
+ `if(el.hasAttribute("${ISLAND_FAILED_ATTRIBUTE}")){failed+=1;failures.push({` +
62
+ 'island:el.getAttribute("data-x-island")||"",' +
63
+ `message:el.getAttribute("${ISLAND_FAILED_ATTRIBUTE}")||''});}}` +
64
+ 'return{declared:els.length,booted:booted,mounted:mounted,failed:failed,' +
65
+ 'byStrategy:by,failures:failures};})()';
66
+
67
+ export interface IslandCount {
68
+ /** `[data-x-island]` in the served DOM — server-rendered, whatever the client then did. */
69
+ readonly declared: number;
70
+ /** Islands whose chunk the hydration runtime asked for. See `ISLAND_PROBE` for the distance. */
71
+ readonly booted: number;
72
+ /** Islands whose `mount()` RESOLVED. This is the one that answers "does the page work". */
73
+ readonly mounted: number;
74
+ /** Islands whose `mount()` REJECTED — the case a picture can never show. */
75
+ readonly failed: number;
76
+ /** `data-x-hydrate` value → count, so a `never` island is never read as a failure to boot. */
77
+ readonly byStrategy: Readonly<Record<string, number>>;
78
+ /** Which island threw, and what it said. Empty when `failed` is 0. */
79
+ readonly failures: readonly IslandFailure[];
80
+ }
81
+
82
+ export interface IslandFailure {
83
+ readonly island: string;
84
+ readonly message: string;
85
+ }
86
+
87
+ const islandProbeSchema: StandardSchemaV1<unknown, IslandCount> = t.object({
88
+ declared: t.number,
89
+ booted: t.number,
90
+ mounted: t.number,
91
+ failed: t.number,
92
+ byStrategy: t.record(t.number),
93
+ failures: t.array(t.object({ island: t.string, message: t.string })),
94
+ }) as unknown as StandardSchemaV1<unknown, IslandCount>;
95
+
96
+ /**
97
+ * `evaluate()` answers `unknown` on every driver — a page can return anything at all — so the
98
+ * probe's result is PARSED and never cast. `null` for anything that does not fit, because a
99
+ * malformed probe must not be able to take a capture down after the picture was already taken.
100
+ */
101
+ export function parseIslandProbe(value: unknown): IslandCount | null {
102
+ const result = validate(islandProbeSchema, value);
103
+ return result.issues === undefined ? result.value : null;
104
+ }
105
+
106
+ export interface ShotCanvas {
107
+ readonly width: number;
108
+ readonly height: number;
109
+ readonly format: string;
110
+ }
111
+
112
+ /**
113
+ * The picture's own pixel size, read from the header bytes by `@ultimat3/core`'s one image probe —
114
+ * never a viewport number the tool asked for and cannot prove it got. `null` when the bytes are
115
+ * not a decodable image, which is a fact worth reporting rather than an exception worth throwing:
116
+ * the offline drivers answer a PNG signature with no IHDR behind it.
117
+ */
118
+ export function canvasOf(bytes: Uint8Array): ShotCanvas | null {
119
+ try {
120
+ const info = probeImage(bytes);
121
+ return { width: info.width, height: info.height, format: info.format };
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export interface ShotInput {
128
+ readonly route: string;
129
+ readonly requestedUrl: string;
130
+ readonly finalUrl: string;
131
+ readonly server: 'booted' | 'reused';
132
+ readonly capturedAt: string;
133
+ /** The picture's filename, beside the verdict — a sibling, so the pair moves as one directory. */
134
+ readonly screenshot: string;
135
+ readonly bytes: Uint8Array;
136
+ readonly console: readonly ConsoleLine[];
137
+ /**
138
+ * Uncaught exceptions, which are NOT console lines: a throw calls no console method, so a page
139
+ * whose island exploded can have `console: []`. This is the field the whole command was for —
140
+ * "a picture cannot tell you the island threw".
141
+ */
142
+ readonly pageErrors: readonly PageError[];
143
+ /** Page errors the ring evicted, so `pageErrors.length` reads as a floor and not a total. */
144
+ readonly pageErrorsDropped: number;
145
+ readonly network: readonly NetworkEntry[];
146
+ readonly networkDropped: number;
147
+ /** `null` when the probe could not run or did not parse. Never a zero standing in for unknown. */
148
+ readonly islands: IslandCount | null;
149
+ }
150
+
151
+ export interface ShotVerdict extends ShotInput {
152
+ readonly ok: boolean;
153
+ /** The route asked for is not the document photographed. An `auth: 'required'` route's default. */
154
+ readonly redirected: boolean;
155
+ readonly errors: number;
156
+ readonly warnings: number;
157
+ readonly canvas: ShotCanvas | null;
158
+ readonly refused: number;
159
+ /** What this verdict cannot see, stated every time — a `0` whose blind spots are named. */
160
+ readonly blind: readonly string[];
161
+ }
162
+
163
+ /**
164
+ * What a shot is blind to, each naming the mechanism rather than apologising. Constant because
165
+ * these are properties of the browser port, not of a run — and in the artifact because `errors: 0`
166
+ * read without them is a claim the tool cannot support.
167
+ *
168
+ * It was three. `pageErrors` and `hydration` both left on 2026-08-21, when `@ultimat3/scraping`
169
+ * learned to capture `pageerror` and the hydration prelude learned to mark a mount's outcome. A
170
+ * blind spot is worth stating while it is true and worth DELETING the moment it is not: a stale
171
+ * one teaches an agent to distrust an answer the tool can now give.
172
+ */
173
+ export const BLIND_SPOTS = ['cli.shot.blind.status'] as const;
174
+
175
+ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']): number =>
176
+ lines.filter((line) => line.level === level).length;
177
+
178
+ /**
179
+ * `ok` is three conditions, and every one is something a picture cannot show: nothing on the page
180
+ * logged an error, nothing THREW, and the document photographed is the route that was asked for.
181
+ *
182
+ * The throw is its own clause rather than folded into `errors` because an uncaught exception calls
183
+ * no console method — a page whose island died can log nothing at all, and `errors === 0` would
184
+ * then pass it. A redirect is a failure of the CAPTURE rather than of the app: an agent that
185
+ * photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
186
+ */
187
+ export function buildVerdict(input: ShotInput): ShotVerdict {
188
+ const errors = levelCount(input.console, 'error');
189
+ return {
190
+ ...input,
191
+ ok: errors === 0 && input.pageErrors.length === 0 && input.requestedUrl === input.finalUrl,
192
+ redirected: input.requestedUrl !== input.finalUrl,
193
+ errors,
194
+ warnings: levelCount(input.console, 'warn'),
195
+ canvas: canvasOf(input.bytes),
196
+ refused: input.network.filter((entry) => entry.refused !== undefined).length,
197
+ blind: BLIND_SPOTS.map((key) => msg(key)),
198
+ };
199
+ }
200
+
201
+ const consoleJson = (lines: readonly ConsoleLine[]): JsonValue =>
202
+ lines.map((line) => ({ level: line.level, text: line.text, at: line.at }));
203
+
204
+ const islandJson = (islands: IslandCount | null): JsonValue =>
205
+ islands === null
206
+ ? null
207
+ : {
208
+ declared: islands.declared,
209
+ booted: islands.booted,
210
+ mounted: islands.mounted,
211
+ failed: islands.failed,
212
+ byStrategy: islands.byStrategy,
213
+ failures: islands.failures.map((failure) => ({
214
+ island: failure.island,
215
+ message: failure.message,
216
+ })),
217
+ };
218
+
219
+ /** The artifact, and the same object `--json` carries under `data.verdict`. One shape, two files. */
220
+ export function verdictJson(verdict: ShotVerdict): JsonValue {
221
+ return {
222
+ ok: verdict.ok,
223
+ route: verdict.route,
224
+ requestedUrl: verdict.requestedUrl,
225
+ finalUrl: verdict.finalUrl,
226
+ redirected: verdict.redirected,
227
+ server: verdict.server,
228
+ capturedAt: verdict.capturedAt,
229
+ screenshot: verdict.screenshot,
230
+ bytes: verdict.bytes.byteLength,
231
+ canvas:
232
+ verdict.canvas === null
233
+ ? null
234
+ : {
235
+ width: verdict.canvas.width,
236
+ height: verdict.canvas.height,
237
+ format: verdict.canvas.format,
238
+ },
239
+ console: {
240
+ total: verdict.console.length,
241
+ errors: verdict.errors,
242
+ warnings: verdict.warnings,
243
+ lines: consoleJson(verdict.console),
244
+ },
245
+ // Its own object, never merged into `console`: an uncaught exception calls no console method,
246
+ // and a reader who finds throws under `console` will look for them in the wrong stream.
247
+ pageErrors: {
248
+ total: verdict.pageErrors.length,
249
+ dropped: verdict.pageErrorsDropped,
250
+ thrown: verdict.pageErrors.map((error) => ({
251
+ message: error.message,
252
+ stack: error.stack ?? null,
253
+ at: error.at,
254
+ })),
255
+ },
256
+ islands: islandJson(verdict.islands),
257
+ network: {
258
+ requests: verdict.network.length,
259
+ refused: verdict.refused,
260
+ dropped: verdict.networkDropped,
261
+ },
262
+ blind: [...verdict.blind],
263
+ };
264
+ }
265
+
266
+ export interface ShotArtifacts {
267
+ readonly verdict: ShotVerdict;
268
+ /** Absolute path of the picture. */
269
+ readonly image: string;
270
+ /** Absolute path of this verdict on disk. */
271
+ readonly verdictFile: string;
272
+ }
273
+
274
+ const consoleLines = (verdict: ShotVerdict): readonly string[] =>
275
+ verdict.console
276
+ .filter((line) => line.level === 'error' || line.level === 'warn')
277
+ .map((line) => msg('cli.shot.console', { level: line.level, text: line.text.slice(0, 200) }));
278
+
279
+ /** Human lines. Every fact here is a fact `--json` carries under `data.verdict`. */
280
+ export function shotLines(artifacts: ShotArtifacts): readonly string[] {
281
+ const verdict = artifacts.verdict;
282
+ const islands = verdict.islands;
283
+ const canvas = verdict.canvas;
284
+ return [
285
+ msg(`cli.shot.server.${verdict.server}`, { url: verdict.finalUrl }),
286
+ canvas === null
287
+ ? msg('cli.shot.canvasUnreadable', { bytes: verdict.bytes.byteLength })
288
+ : msg('cli.shot.canvas', { width: canvas.width, height: canvas.height }),
289
+ islands === null
290
+ ? msg('cli.shot.islandsUnknown')
291
+ : msg('cli.shot.islands', {
292
+ booted: islands.mounted,
293
+ declared: islands.declared,
294
+ strategies: Object.entries(islands.byStrategy)
295
+ .map(([name, count]) => `${name}=${count}`)
296
+ .join(' '),
297
+ }),
298
+ msg('cli.shot.network', {
299
+ requests: verdict.network.length,
300
+ refused: verdict.refused,
301
+ dropped: verdict.networkDropped,
302
+ }),
303
+ ...verdict.pageErrors.map((error) =>
304
+ msg('cli.shot.pageError', {
305
+ message: error.message,
306
+ // The frame that names the island module and line. `message` alone says what went wrong
307
+ // and never where, which is the difference between a report and a lead.
308
+ at: error.stack?.split('\n')[1]?.trim() ?? '',
309
+ }),
310
+ ),
311
+ ...consoleLines(verdict),
312
+ msg('cli.shot.picture', { path: artifacts.image }),
313
+ msg('cli.shot.verdict', { path: artifacts.verdictFile }),
314
+ ];
315
+ }
316
+
317
+ /** The one line a reader sees first, and it names the gating fact rather than the file count. */
318
+ export const shotSummary = (verdict: ShotVerdict): string => {
319
+ if (verdict.redirected) {
320
+ return msg('cli.shot.redirected', { route: verdict.route, url: verdict.finalUrl });
321
+ }
322
+ // Ahead of the console count, because a throw is the more severe fact AND the quieter one: an
323
+ // island that died can log nothing at all, so `errors` would report a clean page.
324
+ if (verdict.pageErrors.length > 0) {
325
+ return msg('cli.shot.threw', {
326
+ route: verdict.route,
327
+ thrown: verdict.pageErrors.length,
328
+ first: verdict.pageErrors[0]?.message ?? '',
329
+ });
330
+ }
331
+ if (verdict.errors > 0) {
332
+ return msg('cli.shot.errors', { route: verdict.route, errors: verdict.errors });
333
+ }
334
+ // `mounted`, not `booted`: "the runtime asked for the chunk" is not the claim worth making when
335
+ // the DOM can now say the mount RESOLVED.
336
+ return msg('cli.shot.ok', { route: verdict.route, islands: verdict.islands?.mounted ?? 0 });
337
+ };
@@ -0,0 +1,127 @@
1
+ // The JSX transform every island chunk is built with: `.tsx` → Solid's COMPILED DOM output, run by
2
+ // `babel-preset-solid` inside the island `Bun.build`'s own plugin. Solid's reactivity is a
3
+ // COMPILE-time contract, which is why no runtime factory can stand in for the compiler here.
4
+
5
+ /// <reference path="../types/babel-modules.d.ts" />
6
+ // The reference is load-bearing, not decorative: @ultimat3/cli ships SOURCE, so an APP's
7
+ // `tsc` compiles this file inside ITS program, where a `.d.ts` sitting in this directory is
8
+ // not included and its `declare module` never applies. `tsc -b` proves it in this repo.
9
+ import { transformAsync } from '@babel/core';
10
+ import { contentHash } from '@ultimat3/render';
11
+ import solidPreset from 'babel-preset-solid';
12
+ import type { BunPlugin } from 'bun';
13
+ import { IslandBuildFailedError } from './errors';
14
+
15
+ /**
16
+ * `generate: 'dom'` because an island runs in a browser and nowhere else; `hydratable: false`
17
+ * because an island MOUNTS over server markup through its own `mount(el, props)` rather than
18
+ * resuming a Solid hydration tree — there is no `renderToString` pass on the other side of it, so
19
+ * hydration markers would be per-node bytes with no reader.
20
+ */
21
+ const PRESET_OPTIONS = { generate: 'dom', hydratable: false } as const;
22
+
23
+ /**
24
+ * The parser plugins, given DIRECTLY rather than through `@babel/plugin-syntax-typescript`. That
25
+ * plugin does nothing but push this same array, and Babel 8 deleted its `isTSX` option — so the
26
+ * documented spelling silently stops parsing JSX and every `<` becomes a type parameter. Given
27
+ * here the option surface is Babel's parser, which has never moved.
28
+ *
29
+ * Order is inert here and is NOT inert via `plugins:` — as plugin entries, the JSX one must precede
30
+ * the TypeScript one or `<button` parses as a type-parameter list. One more reason to say it once,
31
+ * here.
32
+ */
33
+ const PARSER_PLUGINS = ['typescript', 'jsx'] as const;
34
+
35
+ interface CacheEntry {
36
+ /** `contentHash` of the source the code was compiled from. */
37
+ readonly hash: string;
38
+ readonly code: string;
39
+ }
40
+
41
+ /**
42
+ * Keyed by PATH, not by content hash: `x dev` re-runs `buildIslands` on every change, and a map
43
+ * keyed by content would grow one entry per keystroke for the life of the process. One entry per
44
+ * island file is bounded by the island count, which is the only quantity that should bound it.
45
+ *
46
+ * It never hits inside a single `x build` — `discoverIslands` yields unique paths and `buildOne`
47
+ * runs once each. The dev loop is the whole reason it exists: Babel is ~8.7ms a file against
48
+ * `Bun.Transpiler`'s 0.07ms, so an app with twenty islands re-compiles nineteen unchanged files on
49
+ * every rebuild without this.
50
+ */
51
+ const cache = new Map<string, CacheEntry>();
52
+
53
+ /** Test seam: the cache is process-global because the dev server it serves is too. */
54
+ export function clearIslandTransformCache(): void {
55
+ cache.clear();
56
+ }
57
+
58
+ /**
59
+ * `.tsx` source → Solid's compiled DOM expressions. Exported so the transform is testable as a
60
+ * function of its two inputs: the plugin below is the four lines of glue that hand it a file.
61
+ *
62
+ * The output is still TypeScript — Babel PARSES the annotations here and does not strip them,
63
+ * which is why the plugin declares `loader: 'ts'` and lets Bun remove them.
64
+ */
65
+ export async function transformIslandTsx(source: string, path: string): Promise<string> {
66
+ const hash = contentHash(source);
67
+ const hit = cache.get(path);
68
+ if (hit !== undefined && hit.hash === hash) return hit.code;
69
+
70
+ // Babel installs its own `prepareStackTrace` on the first TRANSFORM — not on import, which is
71
+ // where this guard was first put — and leaving it installed makes `Error.captureStackTrace`
72
+ // strict for every unrelated module loaded later in the same process.
73
+ const saved = Error.prepareStackTrace;
74
+ let code: string | null | undefined;
75
+ try {
76
+ // `transformAsync` and never `transformFileAsync`: the latter is gated behind `@babel/core`'s
77
+ // `browser` export condition and throws "Transforming files is not supported in browsers"
78
+ // under `bun --conditions=browser`, which is the condition Solid work runs in.
79
+ const result = await transformAsync(source, {
80
+ filename: path,
81
+ // The app's own Babel config is not this transform's business, and an app that happens to
82
+ // have one must not change what its islands compile to.
83
+ babelrc: false,
84
+ configFile: false,
85
+ parserOpts: { plugins: [...PARSER_PLUGINS] },
86
+ presets: [[solidPreset, PRESET_OPTIONS]],
87
+ });
88
+ code = result?.code;
89
+ } finally {
90
+ Error.prepareStackTrace = saved;
91
+ }
92
+ // A parse error is NOT caught here: Babel's own message already names the file, the line and the
93
+ // column ("a.island.tsx: Unexpected token (2:23)"), and `buildOne` wraps whatever escapes in
94
+ // `X_BUILD_FAILED` naming the island. Re-wrapping it here would only bury that.
95
+ if (code == null) {
96
+ throw new IslandBuildFailedError({
97
+ file: path,
98
+ logs: 'the Solid JSX transform emitted no code',
99
+ });
100
+ }
101
+ cache.set(path, { hash, code });
102
+ return code;
103
+ }
104
+
105
+ /**
106
+ * The plugin `island-bundle.ts` hands `Bun.build`. It carries no state of its own, so one frozen
107
+ * descriptor serves every concurrent island build — `Bun.build` calls `setup` once per build with
108
+ * that build's own builder.
109
+ *
110
+ * `.tsx`, NOT `.island.tsx`. The narrow filter looks like axiom 6 and is the opposite of it: an
111
+ * island that imports a plain `.tsx` component — the most ordinary thing an author does, and what
112
+ * `x g resource` generates — would have that component compiled by nobody, and the app's
113
+ * `jsx: "preserve"` tsconfig turns it straight back into `React.createElement("span", …)` and
114
+ * `React is not defined`. Axiom 6 is already satisfied by GRAPH SEPARATION: this plugin runs
115
+ * inside the island build, whose graph only ever holds islands and what they import, and a page
116
+ * names its island by SPECIFIER and never imports one. So `.tsx` here already means exactly the
117
+ * set that ships to a browser — the filter never had to do that work.
118
+ */
119
+ export const solidJsxPlugin: BunPlugin = {
120
+ name: 'ultimate-island-solid',
121
+ setup(build): void {
122
+ build.onLoad({ filter: /\.tsx$/ }, async ({ path }) => ({
123
+ contents: await transformIslandTsx(await Bun.file(path).text(), path),
124
+ loader: 'ts',
125
+ }));
126
+ },
127
+ };
@@ -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
+ }