@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/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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
}
|
|
@@ -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
|
/**
|