@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.
- package/CLAUDE.md +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- 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 +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- package/types/babel-modules.d.ts +31 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The GraphQL a review lives behind, and the rows it becomes. `gh pr view --comments` shows ISSUE
|
|
2
|
+
// comments; the findings a reviewer anchored to a line are `reviewThreads`, and the thread id that
|
|
3
|
+
// resolves one exists in no REST response and in no web URL — this file is where that query stops
|
|
4
|
+
// being folklore.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/schema';
|
|
7
|
+
import type { GhHost } from './gh';
|
|
8
|
+
import { GhResponseInvalidError, ghGraphql } from './gh';
|
|
9
|
+
import type { GhRepo } from './gh-target';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How many threads one page holds. The query is built from it, so the report and the request can
|
|
13
|
+
* never disagree about what "the first page" was.
|
|
14
|
+
*/
|
|
15
|
+
export const THREAD_PAGE = 100;
|
|
16
|
+
|
|
17
|
+
/** And how many comments of each. A long argument still shows the finding that opened it. */
|
|
18
|
+
export const COMMENT_PAGE = 10;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One document, because the two hazards are only visible together: a `reviewDecision` survives
|
|
22
|
+
* later pushes, so `CHANGES_REQUESTED` may predate the commits that addressed it, and the only
|
|
23
|
+
* thing that settles it is the head oid — asking for the threads and then asking for the head is
|
|
24
|
+
* two answers about two moments.
|
|
25
|
+
*/
|
|
26
|
+
export const REVIEW_QUERY =
|
|
27
|
+
'query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){' +
|
|
28
|
+
'pullRequest(number:$n){number url headRefOid reviewDecision ' +
|
|
29
|
+
'commits(last:1){nodes{commit{oid committedDate}}} ' +
|
|
30
|
+
'latestOpinionatedReviews(first:20){nodes{state submittedAt author{login} commit{oid}}} ' +
|
|
31
|
+
`reviewThreads(first:${THREAD_PAGE}){pageInfo{hasNextPage} nodes{id isResolved isOutdated ` +
|
|
32
|
+
`path line originalLine comments(first:${COMMENT_PAGE}){totalCount ` +
|
|
33
|
+
'nodes{author{login} createdAt body}}}}}}}';
|
|
34
|
+
|
|
35
|
+
const AUTHOR = t.nullable(t.object({ login: t.string }));
|
|
36
|
+
|
|
37
|
+
const REVIEW_RESPONSE = t.object({
|
|
38
|
+
data: t.object({
|
|
39
|
+
repository: t.nullable(
|
|
40
|
+
t.object({
|
|
41
|
+
pullRequest: t.nullable(
|
|
42
|
+
t.object({
|
|
43
|
+
number: t.number,
|
|
44
|
+
url: t.string,
|
|
45
|
+
headRefOid: t.string,
|
|
46
|
+
reviewDecision: t.nullable(t.string),
|
|
47
|
+
commits: t.object({
|
|
48
|
+
nodes: t.array(
|
|
49
|
+
t.object({ commit: t.object({ oid: t.string, committedDate: t.string }) }),
|
|
50
|
+
),
|
|
51
|
+
}),
|
|
52
|
+
latestOpinionatedReviews: t.object({
|
|
53
|
+
nodes: t.array(
|
|
54
|
+
t.object({
|
|
55
|
+
state: t.string,
|
|
56
|
+
submittedAt: t.nullable(t.string),
|
|
57
|
+
author: AUTHOR,
|
|
58
|
+
commit: t.nullable(t.object({ oid: t.string })),
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
reviewThreads: t.object({
|
|
63
|
+
pageInfo: t.object({ hasNextPage: t.boolean }),
|
|
64
|
+
nodes: t.array(
|
|
65
|
+
t.object({
|
|
66
|
+
id: t.string,
|
|
67
|
+
isResolved: t.boolean,
|
|
68
|
+
isOutdated: t.boolean,
|
|
69
|
+
path: t.string,
|
|
70
|
+
line: t.nullable(t.number),
|
|
71
|
+
originalLine: t.nullable(t.number),
|
|
72
|
+
comments: t.object({
|
|
73
|
+
totalCount: t.number,
|
|
74
|
+
nodes: t.array(
|
|
75
|
+
t.object({ author: AUTHOR, createdAt: t.string, body: t.string }),
|
|
76
|
+
),
|
|
77
|
+
}),
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
}),
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export interface PrComment {
|
|
89
|
+
readonly author: string;
|
|
90
|
+
readonly createdAt: string;
|
|
91
|
+
readonly body: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PrThread {
|
|
95
|
+
readonly id: string;
|
|
96
|
+
readonly path: string;
|
|
97
|
+
/** The line the comment is anchored to NOW; `null` once the diff has moved under it. */
|
|
98
|
+
readonly line: number | null;
|
|
99
|
+
/** Where it was anchored when it was written — the only locator an outdated thread still has. */
|
|
100
|
+
readonly originalLine: number | null;
|
|
101
|
+
readonly isResolved: boolean;
|
|
102
|
+
readonly isOutdated: boolean;
|
|
103
|
+
readonly comments: readonly PrComment[];
|
|
104
|
+
/** Every comment on the thread, against the ten this page carries. */
|
|
105
|
+
readonly commentCount: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PrReview {
|
|
109
|
+
readonly state: string;
|
|
110
|
+
readonly author: string;
|
|
111
|
+
readonly submittedAt: string | null;
|
|
112
|
+
readonly commit: string | null;
|
|
113
|
+
/**
|
|
114
|
+
* The review was submitted against a commit that is no longer the head. A verdict about the
|
|
115
|
+
* COMMIT, not about the clock: two reviews seconds apart can straddle a push, and a timestamp
|
|
116
|
+
* comparison would call one of them current.
|
|
117
|
+
*/
|
|
118
|
+
readonly stale: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface PrReviewReport {
|
|
122
|
+
readonly repo: string;
|
|
123
|
+
readonly number: number;
|
|
124
|
+
readonly url: string;
|
|
125
|
+
readonly headSha: string;
|
|
126
|
+
readonly headCommittedAt: string | null;
|
|
127
|
+
readonly reviewDecision: string | null;
|
|
128
|
+
/** The review the decision came from, when one of the opinionated reviews states it. */
|
|
129
|
+
readonly decidingReview: PrReview | null;
|
|
130
|
+
readonly threads: readonly PrThread[];
|
|
131
|
+
/** More threads exist than one page holds, so `threads` is the first page and not the set. */
|
|
132
|
+
readonly truncated: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** GitHub answers `null` for a deleted account. A row with no author still carries its finding. */
|
|
136
|
+
const loginOf = (author: { readonly login: string } | null): string => author?.login ?? 'ghost';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The review a `reviewDecision` came from. GitHub derives the decision from every opinionated
|
|
140
|
+
* review at once, so the one that STATES it is the one to date — falling back to the newest, which
|
|
141
|
+
* is the only defensible guess when none of them spells the decision out.
|
|
142
|
+
*/
|
|
143
|
+
export function decidingReview(
|
|
144
|
+
reviews: readonly PrReview[],
|
|
145
|
+
decision: string | null,
|
|
146
|
+
): PrReview | null {
|
|
147
|
+
const stating = reviews.filter((review) => review.state === decision);
|
|
148
|
+
const pool = stating.length > 0 ? stating : reviews;
|
|
149
|
+
return pool.reduce<PrReview | null>(
|
|
150
|
+
(newest, review) =>
|
|
151
|
+
newest === null || (review.submittedAt ?? '') > (newest.submittedAt ?? '') ? review : newest,
|
|
152
|
+
null,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Unresolved first, then by file and line: the order an agent works the list in. */
|
|
157
|
+
export function orderThreads(threads: readonly PrThread[]): readonly PrThread[] {
|
|
158
|
+
return [...threads].sort((left, right) => {
|
|
159
|
+
if (left.isResolved !== right.isResolved) return left.isResolved ? 1 : -1;
|
|
160
|
+
if (left.path !== right.path) return left.path < right.path ? -1 : 1;
|
|
161
|
+
return (left.line ?? left.originalLine ?? 0) - (right.line ?? right.originalLine ?? 0);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One round trip, one report. `repository` and `pullRequest` are both nullable in the schema
|
|
167
|
+
* because GraphQL answers `null` for either — but gh exits non-zero on the `errors` block that
|
|
168
|
+
* accompanies it, so reaching here with a `null` means GitHub answered a shape nobody has seen,
|
|
169
|
+
* and `X_GH_RESPONSE_INVALID` is a better sentence for that than a `TypeError` two frames later.
|
|
170
|
+
*/
|
|
171
|
+
export async function fetchReviewReport(
|
|
172
|
+
host: GhHost,
|
|
173
|
+
repo: GhRepo,
|
|
174
|
+
number: number,
|
|
175
|
+
): Promise<PrReviewReport | null> {
|
|
176
|
+
const response = await ghGraphql(
|
|
177
|
+
host,
|
|
178
|
+
REVIEW_QUERY,
|
|
179
|
+
{ owner: repo.owner, name: repo.name, n: number },
|
|
180
|
+
REVIEW_RESPONSE,
|
|
181
|
+
{
|
|
182
|
+
label: `gh api graphql (review threads on ${repo.slug}#${number})`,
|
|
183
|
+
fix: `gh pr view ${number} --repo ${repo.slug} --json number # confirm the pull request is visible to this token`,
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
const pull = response.data.repository?.pullRequest;
|
|
187
|
+
if (pull === undefined || pull === null) return null;
|
|
188
|
+
const head = pull.commits.nodes[0]?.commit ?? null;
|
|
189
|
+
const reviews: readonly PrReview[] = pull.latestOpinionatedReviews.nodes.map((review) => ({
|
|
190
|
+
state: review.state,
|
|
191
|
+
author: loginOf(review.author),
|
|
192
|
+
submittedAt: review.submittedAt,
|
|
193
|
+
commit: review.commit?.oid ?? null,
|
|
194
|
+
stale: review.commit?.oid !== pull.headRefOid,
|
|
195
|
+
}));
|
|
196
|
+
return {
|
|
197
|
+
repo: repo.slug,
|
|
198
|
+
number: pull.number,
|
|
199
|
+
url: pull.url,
|
|
200
|
+
headSha: pull.headRefOid,
|
|
201
|
+
headCommittedAt: head?.committedDate ?? null,
|
|
202
|
+
reviewDecision: pull.reviewDecision,
|
|
203
|
+
decidingReview: decidingReview(reviews, pull.reviewDecision),
|
|
204
|
+
truncated: pull.reviewThreads.pageInfo.hasNextPage,
|
|
205
|
+
threads: orderThreads(
|
|
206
|
+
pull.reviewThreads.nodes.map((thread) => ({
|
|
207
|
+
id: thread.id,
|
|
208
|
+
path: thread.path,
|
|
209
|
+
line: thread.line,
|
|
210
|
+
originalLine: thread.originalLine,
|
|
211
|
+
isResolved: thread.isResolved,
|
|
212
|
+
isOutdated: thread.isOutdated,
|
|
213
|
+
commentCount: thread.comments.totalCount,
|
|
214
|
+
comments: thread.comments.nodes.map((comment) => ({
|
|
215
|
+
author: loginOf(comment.author),
|
|
216
|
+
createdAt: comment.createdAt,
|
|
217
|
+
body: comment.body,
|
|
218
|
+
})),
|
|
219
|
+
})),
|
|
220
|
+
),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const RESOLVE_MUTATION =
|
|
225
|
+
'mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{id isResolved}}}';
|
|
226
|
+
|
|
227
|
+
// Both levels nullable, because both are nullable in GitHub's schema (`ResolveReviewThreadPayload.
|
|
228
|
+
// thread` is an OBJECT, not a NON_NULL one) — and a payload that does not carry the thread it
|
|
229
|
+
// claims to have resolved is exactly the answer this command must not read as a success.
|
|
230
|
+
const RESOLVE_RESPONSE = t.object({
|
|
231
|
+
data: t.object({
|
|
232
|
+
resolveReviewThread: t.nullable(
|
|
233
|
+
t.object({ thread: t.nullable(t.object({ id: t.string, isResolved: t.boolean })) }),
|
|
234
|
+
),
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Marks the CONVERSATION resolved, and says only that. Whether the finding is addressed is a fact
|
|
240
|
+
* about the code, which no GitHub mutation can observe — the summary this returns feeds a line
|
|
241
|
+
* that refuses to conflate the two.
|
|
242
|
+
*/
|
|
243
|
+
export async function resolveThread(host: GhHost, threadId: string): Promise<string> {
|
|
244
|
+
const fix = 'x pr review --json # the id column is the thread id this mutation takes';
|
|
245
|
+
const label = `gh api graphql (resolve ${threadId})`;
|
|
246
|
+
const response = await ghGraphql(host, RESOLVE_MUTATION, { t: threadId }, RESOLVE_RESPONSE, {
|
|
247
|
+
label,
|
|
248
|
+
fix,
|
|
249
|
+
});
|
|
250
|
+
const thread = response.data.resolveReviewThread?.thread;
|
|
251
|
+
// A mutation that exited 0 and did not come back saying the thread is resolved has not told us
|
|
252
|
+
// it worked, and reporting `resolved` off the exit code alone is how a command claims an effect
|
|
253
|
+
// it never observed.
|
|
254
|
+
if (thread?.isResolved !== true) {
|
|
255
|
+
throw new GhResponseInvalidError({
|
|
256
|
+
label,
|
|
257
|
+
detail: 'the mutation returned no resolved thread',
|
|
258
|
+
fix,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// The id GitHub echoed, not the one that was sent: they are the same string on every success,
|
|
262
|
+
// and reporting the one we sent would make a summary that cannot tell a hit from a miss.
|
|
263
|
+
return thread.id;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const REPLY_MUTATION =
|
|
267
|
+
'mutation($t:ID!,$b:String!){addPullRequestReviewThreadReply(' +
|
|
268
|
+
'input:{pullRequestReviewThreadId:$t,body:$b}){comment{id url}}}';
|
|
269
|
+
|
|
270
|
+
const REPLY_RESPONSE = t.object({
|
|
271
|
+
data: t.object({
|
|
272
|
+
addPullRequestReviewThreadReply: t.nullable(
|
|
273
|
+
t.object({ comment: t.nullable(t.object({ id: t.string, url: t.string })) }),
|
|
274
|
+
),
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/** A reply lands IN the thread, which is the only place a reviewer reads it. Returns its URL. */
|
|
279
|
+
export async function replyToThread(host: GhHost, threadId: string, body: string): Promise<string> {
|
|
280
|
+
const fix = 'x pr review --json # the id column is the thread id this mutation takes';
|
|
281
|
+
const label = `gh api graphql (reply on ${threadId})`;
|
|
282
|
+
const response = await ghGraphql(host, REPLY_MUTATION, { t: threadId, b: body }, REPLY_RESPONSE, {
|
|
283
|
+
label,
|
|
284
|
+
fix,
|
|
285
|
+
});
|
|
286
|
+
const url = response.data.addPullRequestReviewThreadReply?.comment?.url;
|
|
287
|
+
if (url === undefined || url === null) {
|
|
288
|
+
throw new GhResponseInvalidError({ label, detail: 'the mutation posted no comment', fix });
|
|
289
|
+
}
|
|
290
|
+
return url;
|
|
291
|
+
}
|
package/src/prerender.ts
CHANGED
|
@@ -14,13 +14,19 @@ import { measureDocumentJs, writeBuildStats } from './budgets';
|
|
|
14
14
|
import { routeDocument } from './dev-render';
|
|
15
15
|
import type { IslandBundle } from './island-bundle';
|
|
16
16
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
17
|
+
import type { SkippedRoute } from './static-report';
|
|
18
|
+
import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
* `static` only. `isr` revalidates and `ssr`/`stream
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* `static` only. `isr` revalidates and `ssr`/`stream` need a process, so writing any of them to
|
|
22
|
+
* disk would publish a page whose staleness nothing can correct — and the route already declared
|
|
23
|
+
* which of the four it is.
|
|
24
|
+
*
|
|
25
|
+
* DERIVED from `skipReasonFor`, never a second `=== 'static'`: the answer and the reason reported
|
|
26
|
+
* beside it are one decision, and two copies of it is how a route came to be dropped silently.
|
|
22
27
|
*/
|
|
23
|
-
export const isPrerenderable = (entry: RouteEntry): boolean =>
|
|
28
|
+
export const isPrerenderable = (entry: RouteEntry): boolean =>
|
|
29
|
+
skipReasonFor({ surface: entry.surface, render: entry.config.render }) === null;
|
|
24
30
|
|
|
25
31
|
export interface PrerenderOptions {
|
|
26
32
|
readonly root: string;
|
|
@@ -30,6 +36,8 @@ export interface PrerenderOptions {
|
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export interface PrerenderedPage {
|
|
39
|
+
/** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
|
|
40
|
+
readonly route: string;
|
|
33
41
|
readonly path: string;
|
|
34
42
|
/** Relative to `out`, POSIX, as `renderStatic` computed it. */
|
|
35
43
|
readonly file: string;
|
|
@@ -47,8 +55,13 @@ export interface PrerenderReport {
|
|
|
47
55
|
readonly out: string;
|
|
48
56
|
readonly buildId: string;
|
|
49
57
|
readonly pages: readonly PrerenderedPage[];
|
|
50
|
-
/**
|
|
51
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
|
|
60
|
+
* #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
|
|
61
|
+
* screenshot tool pointed at the directory filed "the island did not mount" against a route that
|
|
62
|
+
* had never been in the artifact. The reason is what tells an author whether an edit exists.
|
|
63
|
+
*/
|
|
64
|
+
readonly skipped: readonly SkippedRoute[];
|
|
52
65
|
/**
|
|
53
66
|
* Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
|
|
54
67
|
* the gate then reports for each; this is the half that says WHY, which a per-route finding read
|
|
@@ -57,6 +70,8 @@ export interface PrerenderReport {
|
|
|
57
70
|
readonly unmeasured: readonly UnmeasuredRoute[];
|
|
58
71
|
/** Where the measured stats landed, for the `budgets` gate step to read. */
|
|
59
72
|
readonly stats: string;
|
|
73
|
+
/** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
|
|
74
|
+
readonly report: string;
|
|
60
75
|
/** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
|
|
61
76
|
readonly islands: readonly string[];
|
|
62
77
|
}
|
|
@@ -104,7 +119,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
104
119
|
const buildId = (await appManifest(options.root)).manifest.buildId;
|
|
105
120
|
const origin = options.origin ?? DEFAULT_ORIGIN;
|
|
106
121
|
const pages: PrerenderedPage[] = [];
|
|
107
|
-
const skipped:
|
|
122
|
+
const skipped: SkippedRoute[] = [];
|
|
108
123
|
const routes: RouteStats[] = [];
|
|
109
124
|
const unmeasured: UnmeasuredRoute[] = [];
|
|
110
125
|
|
|
@@ -115,8 +130,10 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
115
130
|
await writeIslands(islands, options.out);
|
|
116
131
|
|
|
117
132
|
for (const entry of routeEntries()) {
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
const facts = { surface: entry.surface, render: entry.config.render, route: entry.path };
|
|
134
|
+
const reason = skipReasonFor(facts);
|
|
135
|
+
if (reason !== null) {
|
|
136
|
+
skipped.push(skippedRoute(facts, reason));
|
|
120
137
|
if (!declaresBudget(entry)) continue;
|
|
121
138
|
// Non-fatal, and that is deliberate: an ssr page's `load` may want a request, a session or a
|
|
122
139
|
// database this build does not have, and a `x build --target static` that started failing on
|
|
@@ -152,10 +169,24 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
152
169
|
),
|
|
153
170
|
{ buildId },
|
|
154
171
|
);
|
|
172
|
+
// `enumeratePrerender` answers `[]` for a dynamic route with no `prerender()`, so a
|
|
173
|
+
// `render: 'static'` route with a param writes nothing and used to be reported NOWHERE — past
|
|
174
|
+
// the skip branch by its mode, absent from `pages` by its zero artifacts. A route in neither
|
|
175
|
+
// list is the defect this report exists to close, wearing its other shape.
|
|
176
|
+
if (artifacts.length === 0) {
|
|
177
|
+
skipped.push(skippedRoute(facts, 'no-prerender-paths'));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
155
180
|
for (const artifact of artifacts) {
|
|
156
181
|
const file = join(options.out, artifact.outputPath);
|
|
157
182
|
const bytes = await Bun.write(file, artifact.html);
|
|
158
|
-
pages.push({
|
|
183
|
+
pages.push({
|
|
184
|
+
route: entry.path,
|
|
185
|
+
path: artifact.path,
|
|
186
|
+
file: artifact.outputPath,
|
|
187
|
+
hash: artifact.hash,
|
|
188
|
+
bytes,
|
|
189
|
+
});
|
|
159
190
|
// Measured from the document that was just written, so the `budgets` step compares a
|
|
160
191
|
// declared budget against bytes that exist on disk rather than against a graph's estimate.
|
|
161
192
|
const measured = await measureDocumentJs(artifact.html, options.out);
|
|
@@ -168,6 +199,16 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
201
|
const stats = await writeBuildStats(options.root, { routes });
|
|
202
|
+
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
|
203
|
+
// not reach `prerenderSite` produces neither — and `x verify`'s `budgets` step already reds that
|
|
204
|
+
// app with `X_BUDGET_UNMEASURED`, which is why this side needs no second code of its own.
|
|
205
|
+
const report = await writeStaticReport(options.root, {
|
|
206
|
+
target: 'static',
|
|
207
|
+
out: options.out,
|
|
208
|
+
buildId,
|
|
209
|
+
emitted: pages.map((page) => ({ route: page.route, path: page.path, file: page.file })),
|
|
210
|
+
skipped,
|
|
211
|
+
});
|
|
171
212
|
return {
|
|
172
213
|
out: options.out,
|
|
173
214
|
buildId,
|
|
@@ -175,6 +216,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
175
216
|
skipped,
|
|
176
217
|
unmeasured,
|
|
177
218
|
stats,
|
|
219
|
+
report,
|
|
178
220
|
islands: islands.chunks.map((chunk) => chunk.file),
|
|
179
221
|
};
|
|
180
222
|
}
|
package/src/registry.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// The command registry: the one list the parser, the help catalogue and the dispatcher all read.
|
|
2
2
|
// A command that is not here does not exist — there is no second place to register one.
|
|
3
3
|
|
|
4
|
+
import { affectedCommand } from './cmd-affected';
|
|
4
5
|
import { buildCommand } from './cmd-build';
|
|
6
|
+
import { ciCommand } from './cmd-ci';
|
|
5
7
|
import { dbCommand } from './cmd-db';
|
|
6
8
|
import { deployCommand } from './cmd-deploy';
|
|
7
9
|
import { devCommand } from './cmd-dev';
|
|
@@ -19,9 +21,11 @@ import { mcpCommand } from './cmd-mcp';
|
|
|
19
21
|
import { newCommand } from './cmd-new';
|
|
20
22
|
import { plannedCommands } from './cmd-planned';
|
|
21
23
|
import { policyCommand } from './cmd-policy';
|
|
24
|
+
import { prCommand } from './cmd-pr';
|
|
22
25
|
import { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
|
|
23
26
|
import { routesCommand } from './cmd-routes';
|
|
24
27
|
import { secretsCommand } from './cmd-secrets';
|
|
28
|
+
import { shotCommand } from './cmd-shot';
|
|
25
29
|
import { tasksCommand } from './cmd-tasks';
|
|
26
30
|
import { testCommand } from './cmd-test';
|
|
27
31
|
import { verifyCommand } from './cmd-verify';
|
|
@@ -69,6 +73,10 @@ const CORE: readonly CliCommand[] = [
|
|
|
69
73
|
errorsCommand,
|
|
70
74
|
docsCommand,
|
|
71
75
|
fixCommand,
|
|
76
|
+
affectedCommand,
|
|
77
|
+
shotCommand,
|
|
78
|
+
prCommand,
|
|
79
|
+
ciCommand,
|
|
72
80
|
];
|
|
73
81
|
|
|
74
82
|
/**
|