@ultimat3/cli 6.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
package/src/parse.ts CHANGED
@@ -2,7 +2,19 @@
2
2
  // same way and `--json` / `--help` behave identically everywhere. Pure: no I/O, no process
3
3
  // access, so the parser is unit-testable and the dispatcher owns all side effects.
4
4
 
5
+ import { nearestName } from '@ultimat3/core';
5
6
  import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './errors';
7
+ // `shell-quote.ts` is a leaf — it imports nothing — so the parser stays pure and importable from
8
+ // anywhere while still refusing with a fix line a shell reads as one argument.
9
+ import { quoteArg } from './shell-quote';
10
+
11
+ /**
12
+ * The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
13
+ * package's exported surface and removing it would be a major for a rename. One implementation
14
+ * behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
15
+ * used to carry. New callers import `nearestName` from core.
16
+ */
17
+ export const nearest = nearestName;
6
18
 
7
19
  export type FlagValue = string | boolean;
8
20
 
@@ -12,6 +24,15 @@ export interface FlagSpec {
12
24
  readonly summary: string;
13
25
  readonly short?: string;
14
26
  readonly default?: FlagValue;
27
+ /**
28
+ * The subcommands that READ this flag, where it is not command-wide. Absent means every one —
29
+ * opt-in, because most flags really are. Declared from the same fact the summary states, and
30
+ * enforced: `x db gen --dry-run` parsed, ran the generator and WROTE the migration, because the
31
+ * parser validates a flag against the COMMAND and nothing then validates it against the word
32
+ * that decides what runs. A dry run that writes a file is the direction a mistake may never
33
+ * fail in. `parse.test.ts` pins that every entry names a subcommand its command declares.
34
+ */
35
+ readonly subcommands?: readonly string[];
15
36
  }
16
37
 
17
38
  export interface CommandSpec {
@@ -87,41 +108,11 @@ export const wantsJson = (argv: readonly string[]): boolean =>
87
108
  const HELP_ALIASES = new Set(['--help', '-h', 'help']);
88
109
  const VERSION_ALIASES = new Set(['--version', '-v', '-V']);
89
110
 
90
- function distance(a: string, b: string): number {
91
- const rows = a.length + 1;
92
- const cols = b.length + 1;
93
- const grid: number[] = new Array<number>(rows * cols).fill(0);
94
- const at = (r: number, c: number): number => grid[r * cols + c] ?? 0;
95
- for (let r = 0; r < rows; r += 1) grid[r * cols] = r;
96
- for (let c = 0; c < cols; c += 1) grid[c] = c;
97
- for (let r = 1; r < rows; r += 1) {
98
- for (let c = 1; c < cols; c += 1) {
99
- const cost = a[r - 1] === b[c - 1] ? 0 : 1;
100
- grid[r * cols + c] = Math.min(at(r - 1, c) + 1, at(r, c - 1) + 1, at(r - 1, c - 1) + cost);
101
- }
102
- }
103
- return at(rows - 1, cols - 1);
104
- }
105
-
106
- /** Nearest known name within an edit distance of 3, so the error can suggest a retry. */
107
- export function nearest(input: string, candidates: readonly string[]): string | undefined {
108
- let best: string | undefined;
109
- let bestScore = 4;
110
- for (const candidate of candidates) {
111
- const score = distance(input, candidate);
112
- if (score < bestScore) {
113
- best = candidate;
114
- bestScore = score;
115
- }
116
- }
117
- return best;
118
- }
119
-
120
111
  function resolveCommand(token: string, specs: readonly CommandSpec[]): CommandSpec {
121
112
  const found = specs.find((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
122
113
  if (found !== undefined) return found;
123
114
  const names = specs.map((spec) => spec.name);
124
- const suggestion = nearest(token, names);
115
+ const suggestion = nearestName(token, names);
125
116
  throw new UnknownCommandError(
126
117
  suggestion === undefined
127
118
  ? { path: token, known: names }
@@ -168,6 +159,9 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
168
159
  const spec = resolveCommand(first, specs);
169
160
  const flags = defaults(spec);
170
161
  const positionals: string[] = [];
162
+ // What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
163
+ // and refusing a flag the caller never typed would refuse the command itself.
164
+ const given = new Set<string>();
171
165
  let index = 1;
172
166
 
173
167
  while (index < tokens.length) {
@@ -183,7 +177,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
183
177
  const flag = findFlag(name, spec);
184
178
  if (flag === undefined) {
185
179
  const known = [...GLOBAL_FLAGS, ...(spec.flags ?? [])].map((entry) => entry.name);
186
- const suggestion = nearest(name, known);
180
+ const suggestion = nearestName(name, known);
187
181
  throw new BadFlagError({
188
182
  flag: name,
189
183
  command: spec.name,
@@ -193,6 +187,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
193
187
  : `unknown flag — did you mean --${suggestion}?`,
194
188
  });
195
189
  }
190
+ given.add(flag.name);
196
191
  if (flag.type === 'boolean') {
197
192
  if (inlineValue !== undefined) {
198
193
  throw new BadFlagError({
@@ -204,30 +199,79 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
204
199
  flags.set(flag.name, !negated);
205
200
  continue;
206
201
  }
202
+ // `--no-<string flag>` used to fall through to the value read below, so `--no-name feat` set
203
+ // `name` to `feat`: the caller asked for the flag to be OFF and argv's next token became its
204
+ // value. There is nothing a string flag can be negated to, so this is a refusal.
205
+ if (negated) {
206
+ throw new BadFlagError({
207
+ flag: flag.name,
208
+ command: spec.name,
209
+ reason: `--no- negates a boolean flag, and --${flag.name} takes a value`,
210
+ });
211
+ }
207
212
  const value = inlineValue ?? tokens[index];
208
- if (value === undefined || value.startsWith('--')) {
213
+ if (value === undefined) {
214
+ throw new BadFlagError({ flag: flag.name, command: spec.name, reason: 'expects a value' });
215
+ }
216
+ // A value beginning `--` is a flag as far as this loop can tell, and the caller who really
217
+ // meant it as a value has one form available — so the refusal names it rather than leaving
218
+ // `--filter --json` looking like a parser that cannot express the input.
219
+ if (value.startsWith('--')) {
209
220
  throw new BadFlagError({
210
221
  flag: flag.name,
211
222
  command: spec.name,
212
- reason: 'expects a value',
223
+ reason: `expects a value, and "${value}" is a flag — write --${flag.name}=${value} to pass it as the value`,
213
224
  });
214
225
  }
215
226
  if (inlineValue === undefined) index += 1;
216
227
  flags.set(flag.name, value);
217
228
  }
218
229
 
219
- const subcommand = readSubcommand(spec, positionals);
230
+ // Before `readSubcommand`, which THROWS on a missing or unknown one: `x db --help`, `x mcp
231
+ // --help` and `x pr --help` all exited 1 with `X_CLI_BAD_FLAG` — usage refused to the caller
232
+ // asking what the usage is, on every command that takes a subcommand. Help is answered by
233
+ // `dispatch`, which needs only the command name.
234
+ const help = flags.get('help') === true;
235
+ const subcommand = help ? undefined : readSubcommand(spec, positionals);
236
+ if (subcommand !== undefined) assertFlagsApply(spec, subcommand, given, flags);
220
237
  return {
221
238
  command: spec.name,
222
239
  subcommand,
223
240
  positionals: subcommand === undefined ? positionals : positionals.slice(1),
224
241
  flags,
225
242
  json: flags.get('json') === true,
226
- help: flags.get('help') === true,
243
+ help,
227
244
  passthrough,
228
245
  };
229
246
  }
230
247
 
248
+ /**
249
+ * A flag the resolved subcommand does not read is refused, never ignored. Only what argv SET is
250
+ * judged, and only against a flag that declared a scope: an undeclared flag stays command-wide.
251
+ *
252
+ * The fix carries the caller's own value through `quoteArg`, because it is pasted into a shell
253
+ * verbatim — `x db backfill --status 'a b'` runs, `--status a b` runs something else.
254
+ */
255
+ function assertFlagsApply(
256
+ spec: CommandSpec,
257
+ subcommand: string,
258
+ given: ReadonlySet<string>,
259
+ flags: ReadonlyMap<string, FlagValue>,
260
+ ): void {
261
+ for (const flag of spec.flags ?? []) {
262
+ const only = flag.subcommands;
263
+ if (only === undefined || only.includes(subcommand) || !given.has(flag.name)) continue;
264
+ const value = flags.get(flag.name);
265
+ const argument = typeof value === 'string' ? ` ${quoteArg(value)}` : '';
266
+ throw new BadFlagError({
267
+ flag: flag.name,
268
+ command: `${spec.name} ${subcommand}`,
269
+ reason: `read by ${only.map((word) => `x ${spec.name} ${word}`).join(' / ')} only — "${subcommand}" would ignore it`,
270
+ fix: `x ${spec.name} ${only[0]} --${flag.name}${argument}`,
271
+ });
272
+ }
273
+ }
274
+
231
275
  function splitInline(raw: string): [string, string | undefined] {
232
276
  const eq = raw.indexOf('=');
233
277
  if (eq === -1) return [raw, undefined];
@@ -243,7 +287,7 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): stri
243
287
  throw new MissingSubcommandError({ command: spec.name, known: allowed });
244
288
  }
245
289
  if (allowed.includes(token)) return token;
246
- const suggestion = nearest(token, allowed);
290
+ const suggestion = nearestName(token, allowed);
247
291
  throw new UnknownCommandError(
248
292
  suggestion === undefined
249
293
  ? { path: `${spec.name} ${token}`, known: allowed }
@@ -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`/`spa` need a process, so writing any of them
20
- * to disk would publish a page whose staleness nothing can correct — and the route already
21
- * declared which of the five it is.
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 => entry.config.render === 'static';
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
- /** Routes that exist and are not static. Reported, so "only 2 pages" is never a mystery. */
51
- readonly skipped: readonly string[];
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: string[] = [];
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
- if (!isPrerenderable(entry)) {
119
- skipped.push(entry.path);
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({ path: artifact.path, file: artifact.outputPath, hash: artifact.hash, bytes });
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
  }
@@ -0,0 +1,9 @@
1
+ // The browser island `wiki/Realtime.md` promises: one live hook and nothing else. It is a real
2
+ // module rather than a string a test writes to a temp path, because module resolution is the thing
3
+ // under test — `@ultimat3/realtime`'s client entry must reach neither the bus nor the WAL decoder.
4
+ // Bundled AND imported by `realtime-browser-barrel.test.ts` — the import is what gives it an lcov
5
+ // record, since `Bun.build()` reads this file without evaluating it.
6
+
7
+ import { useLive } from '@ultimat3/realtime';
8
+
9
+ export const probeUseLive = useLive;
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
  /**