@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/runtime-overrides.ts
CHANGED
|
@@ -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
|
-
* —
|
|
63
|
-
*
|
|
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
|
+
}
|