speccle 0.11.0 → 0.15.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 (41) hide show
  1. package/README.md +290 -3
  2. package/dist/calibration.js +156 -0
  3. package/dist/changeset.js +141 -0
  4. package/dist/claims.js +5 -1
  5. package/dist/cli.js +596 -4
  6. package/dist/config.js +117 -0
  7. package/dist/doctor.js +141 -0
  8. package/dist/init.js +5 -4
  9. package/dist/lenses.js +68 -0
  10. package/dist/remedy.js +133 -0
  11. package/dist/render.js +323 -2
  12. package/dist/reviewinit.js +139 -0
  13. package/dist/reviewrun.js +516 -0
  14. package/dist/risk.js +228 -0
  15. package/dist/skills.js +63 -0
  16. package/dist/update.js +48 -0
  17. package/dist/verify.js +92 -0
  18. package/lenses/accessibility.md +39 -0
  19. package/lenses/architecture.md +38 -0
  20. package/lenses/correctness.md +36 -0
  21. package/lenses/house-conventions.md +37 -0
  22. package/lenses/performance.md +40 -0
  23. package/lenses/risk.md +45 -0
  24. package/lenses/security.md +42 -0
  25. package/lenses/test-quality.md +39 -0
  26. package/package.json +7 -5
  27. package/skills/carve-feature/SKILL.md +183 -0
  28. package/skills/carve-feature/references/convention.md +239 -0
  29. package/skills/conform/SKILL.md +129 -0
  30. package/skills/conform/references/convention.md +239 -0
  31. package/skills/feature/SKILL.md +103 -0
  32. package/skills/implement-feature/SKILL.md +152 -0
  33. package/skills/implement-feature/references/convention.md +239 -0
  34. package/skills/plan-feature/SKILL.md +125 -0
  35. package/skills/plan-feature/references/convention.md +239 -0
  36. package/skills/review/SKILL.md +190 -0
  37. package/skills/spec-feature/SKILL.md +163 -0
  38. package/skills/spec-feature/references/convention.md +239 -0
  39. package/skills/strengthen/SKILL.md +184 -0
  40. package/skills/strengthen/references/heatmap.md +39 -0
  41. package/skills/strengthen/references/stack.md +23 -0
@@ -0,0 +1,516 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { messageOf } from "./changeset.js";
4
+ import { LENSES_DIR, TEMPLATE_LENS } from "./lenses.js";
5
+ import { REMEDY_ROUTES } from "./remedy.js";
6
+ import { risk } from "./risk.js";
7
+ /**
8
+ * The CI driver's runner: the one module in this package that calls a model (ADR-0047). Every
9
+ * other command here is deterministic, and the boundary is worth keeping visible — nothing
10
+ * outside this module imports an API.
11
+ *
12
+ * It finds and comments. It never edits the tree, never commits, and never pushes: a fix has to
13
+ * pass the checks-gate and be revertible, which is the local driver's job, not CI's.
14
+ */
15
+ /** Marks a review as this driver's, so a second run can recognise its own work. */
16
+ export const REVIEW_MARKER = "<!-- speccle-review -->";
17
+ /** The lens that decides fix authority rather than reporting findings — never part of the panel. */
18
+ const RISK_LENS = "risk.md";
19
+ /** An unauthored house-conventions lens carries this; running it would report nothing. */
20
+ const TEMPLATE_MARKER = "speccle:lens-template";
21
+ /** Per-PR cost is why this driver is opt-in, so the default is the cheaper capable model. */
22
+ const DEFAULT_MODEL = "claude-sonnet-5";
23
+ const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
24
+ const ANTHROPIC_VERSION = "2023-06-01";
25
+ const GITHUB_API = "https://api.github.com";
26
+ /**
27
+ * Caps on the diff handed to a lens. A generated lockfile teaches a lens nothing and would
28
+ * crowd out the code, so a single oversized patch is dropped rather than truncated mid-hunk.
29
+ * Whatever is dropped is named in the summary — a silent cap reads as full coverage.
30
+ */
31
+ const MAX_FILE_PATCH_BYTES = 24_000;
32
+ const MAX_TOTAL_PATCH_BYTES = 160_000;
33
+ const MAX_FILE_PAGES = 10;
34
+ export async function reviewRun(target, options) {
35
+ const root = resolve(target);
36
+ const doFetch = options.fetch ?? ((url, init) => fetch(url, init));
37
+ const repo = options.repo ?? process.env.GITHUB_REPOSITORY;
38
+ if (repo === undefined || repo === "") {
39
+ throw new Error("no repository — pass --repo <owner/name> or set GITHUB_REPOSITORY");
40
+ }
41
+ const token = options.githubToken ?? process.env.GITHUB_TOKEN;
42
+ if (token === undefined || token === "") {
43
+ throw new Error("no GitHub token — set GITHUB_TOKEN (Actions provides it as a secret)");
44
+ }
45
+ const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
46
+ if (apiKey === undefined || apiKey === "") {
47
+ throw new Error("no ANTHROPIC_API_KEY — the CI driver calls a model, so it needs a metered key as a repo secret");
48
+ }
49
+ const github = githubClient(doFetch, token);
50
+ const pull = await pullRequest(github, repo, options.pr);
51
+ const base = options.base ?? `origin/${pull.base}`;
52
+ // Recognise our own earlier review before spending a token on a second one. The rerun path
53
+ // sets --force, which is the only way past this.
54
+ if (options.force !== true && (await alreadyReviewed(github, repo, options.pr))) {
55
+ return {
56
+ repo,
57
+ pr: options.pr,
58
+ headSha: pull.headSha,
59
+ base,
60
+ lenses: [],
61
+ skippedLenses: [],
62
+ skippedFiles: [],
63
+ findings: [],
64
+ comments: 0,
65
+ unplaced: 0,
66
+ risk: null,
67
+ outcome: "already-reviewed",
68
+ };
69
+ }
70
+ // Two diff sources, each the right one for its job. The lenses read the patches the API
71
+ // reports, because those are the hunks GitHub validates an inline comment's line against —
72
+ // anchoring off a locally-computed diff invites a 422. `risk` reads the local git range,
73
+ // because its signals need the spec files' content, not just their names.
74
+ const { files, skipped: skippedFiles, truncated } = await changedFiles(github, repo, options.pr);
75
+ const diff = renderDiff(files);
76
+ if (truncated) {
77
+ skippedFiles.push({ name: `beyond ${MAX_FILE_PAGES * 100} files`, reason: "file-count cap" });
78
+ }
79
+ const { lenses, skipped: skippedLenses } = await panel(root);
80
+ const findings = [];
81
+ const ran = [];
82
+ for (const lens of lenses) {
83
+ try {
84
+ const found = await applyLens(doFetch, apiKey, options.model, lens, diff);
85
+ findings.push(...found);
86
+ ran.push({ name: lens.name, findings: found.length });
87
+ }
88
+ catch (err) {
89
+ // A model or network failure must not lose the rest of the panel, and must not take the
90
+ // risk gate down with it: the verdict is deterministic and has to survive a bad API day.
91
+ skippedLenses.push({ name: lens.name, reason: messageOf(err) });
92
+ }
93
+ }
94
+ const verdict = await riskVerdict(root, base);
95
+ const { comments, unplaced } = normalise(findings, files);
96
+ const shared = { verdict, base, ran, skippedLenses, skippedFiles, headSha: pull.headSha };
97
+ const summary = renderSummary({ ...shared, unplaced });
98
+ // The fallback body has to name every finding, not just the unplaced ones: an anchored finding
99
+ // exists only in its inline comment, and those are exactly what a rejected review loses.
100
+ const unanchored = renderSummary({ ...shared, unplaced: findings, anchorsRejected: true });
101
+ const outcome = options.dryRun === true ? "dry-run" : "posted";
102
+ if (options.dryRun !== true) {
103
+ await postReview(github, repo, options.pr, pull.headSha, summary, comments, unanchored);
104
+ }
105
+ return {
106
+ repo,
107
+ pr: options.pr,
108
+ headSha: pull.headSha,
109
+ base,
110
+ lenses: ran,
111
+ skippedLenses,
112
+ skippedFiles,
113
+ findings,
114
+ comments: comments.length,
115
+ unplaced: unplaced.length,
116
+ risk: verdict,
117
+ outcome,
118
+ };
119
+ }
120
+ /** The panel: every lens but the two the local driver skips for the same reasons (ADR-0043). */
121
+ export async function panel(root) {
122
+ const dir = join(root, LENSES_DIR);
123
+ let entries;
124
+ try {
125
+ entries = await readdir(dir);
126
+ }
127
+ catch {
128
+ throw new Error(`no ${LENSES_DIR}/ — this repo was never initialized for review; run \`speccle init\``);
129
+ }
130
+ const lenses = [];
131
+ const skipped = [];
132
+ for (const name of entries.filter((entry) => entry.endsWith(".md")).sort()) {
133
+ if (name === RISK_LENS) {
134
+ skipped.push({ name, reason: "escalates fix authority, not a finding lens" });
135
+ continue;
136
+ }
137
+ const body = await readFile(join(dir, name), "utf8");
138
+ if (name === TEMPLATE_LENS && body.includes(TEMPLATE_MARKER)) {
139
+ skipped.push({ name, reason: "still the shipped template — this repo has not authored it" });
140
+ continue;
141
+ }
142
+ lenses.push({ name, body });
143
+ }
144
+ return { lenses, skipped };
145
+ }
146
+ /** Runs one lens over the change set, with the reported shape forced by a tool schema. */
147
+ async function applyLens(doFetch, apiKey, model, lens, diff) {
148
+ const response = await doFetch(ANTHROPIC_MESSAGES_URL, {
149
+ method: "POST",
150
+ headers: {
151
+ "content-type": "application/json",
152
+ "x-api-key": apiKey,
153
+ "anthropic-version": ANTHROPIC_VERSION,
154
+ },
155
+ body: JSON.stringify({
156
+ model: model ?? process.env.SPECCLE_REVIEW_MODEL ?? DEFAULT_MODEL,
157
+ max_tokens: 8192,
158
+ system: SYSTEM_PROMPT,
159
+ // Forcing the tool is what makes the shape reliable; asking for JSON in prose does not.
160
+ tools: [reportTool()],
161
+ tool_choice: { type: "tool", name: REPORT_TOOL_NAME },
162
+ messages: [{ role: "user", content: lensPrompt(lens, diff) }],
163
+ }),
164
+ });
165
+ if (!response.ok) {
166
+ throw new Error(`Anthropic API ${String(response.status)}: ${await errorText(response)}`);
167
+ }
168
+ return findingsFrom(asUnknown(await response.json()), lens.name);
169
+ }
170
+ const SYSTEM_PROMPT = `You are one lens in a code review panel. The lens below is your whole brief:
171
+ adopt its stance, look only for what it names, and report at its bar. Every finding must anchor to
172
+ a line the change set actually touched — you are reviewing a change, not auditing a repository.
173
+ Report nothing rather than padding: an empty list is the common, correct result. Report through the
174
+ report_findings tool.`;
175
+ export function lensPrompt(lens, diff) {
176
+ return `# Your lens
177
+
178
+ ${lens.body}
179
+
180
+ # The change set
181
+
182
+ ${diff}`;
183
+ }
184
+ const REPORT_TOOL_NAME = "report_findings";
185
+ function reportTool() {
186
+ return {
187
+ name: REPORT_TOOL_NAME,
188
+ description: "Report every finding, or an empty list when the change set breaches nothing.",
189
+ input_schema: {
190
+ type: "object",
191
+ properties: {
192
+ findings: {
193
+ type: "array",
194
+ items: {
195
+ type: "object",
196
+ properties: {
197
+ path: { type: "string", description: "Repo-relative path of the changed file." },
198
+ line: {
199
+ type: "integer",
200
+ description: "The line as numbered after the change for RIGHT, before it for LEFT. It must be a line this change set touched.",
201
+ },
202
+ side: {
203
+ type: "string",
204
+ enum: ["RIGHT", "LEFT"],
205
+ description: "RIGHT for the changed file as it now reads; LEFT for a removed line.",
206
+ },
207
+ severity: { type: "string", enum: ["high", "medium", "low"] },
208
+ what: { type: "string", description: "The finding, in one sentence." },
209
+ why: { type: "string", description: "Why it matters here." },
210
+ fix: { type: "string", description: "The one correct fix, in a line." },
211
+ remedy: {
212
+ type: "string",
213
+ enum: [...REMEDY_ROUTES],
214
+ description: "The durable artefact that would stop this class recurring: a deterministic check, an acceptance criterion, a sharpened lens, or none for a genuine one-off.",
215
+ },
216
+ },
217
+ required: ["path", "line", "side", "severity", "what", "why", "fix", "remedy"],
218
+ },
219
+ },
220
+ },
221
+ required: ["findings"],
222
+ },
223
+ };
224
+ }
225
+ /** The findings out of a Messages response's forced tool call; a malformed entry is dropped. */
226
+ export function findingsFrom(body, lens) {
227
+ const content = asArray(asRecord(body)?.content) ?? [];
228
+ const findings = [];
229
+ for (const block of content) {
230
+ const record = asRecord(block);
231
+ if (record?.type !== "tool_use" || record.name !== REPORT_TOOL_NAME)
232
+ continue;
233
+ for (const raw of asArray(asRecord(record.input)?.findings) ?? []) {
234
+ const finding = asFinding(raw, lens);
235
+ if (finding !== undefined)
236
+ findings.push(finding);
237
+ }
238
+ }
239
+ return findings;
240
+ }
241
+ function asFinding(raw, lens) {
242
+ const record = asRecord(raw);
243
+ if (record === undefined)
244
+ return undefined;
245
+ const path = asString(record.path);
246
+ const line = asNumber(record.line);
247
+ const what = asString(record.what);
248
+ if (path === undefined || line === undefined || what === undefined)
249
+ return undefined;
250
+ return {
251
+ lens,
252
+ path,
253
+ line,
254
+ side: record.side === "LEFT" ? "LEFT" : "RIGHT",
255
+ severity: asString(record.severity) ?? "medium",
256
+ what,
257
+ why: asString(record.why) ?? "",
258
+ fix: asString(record.fix) ?? "",
259
+ remedy: asString(record.remedy) ?? "none",
260
+ };
261
+ }
262
+ /**
263
+ * Splits findings into inline comments and unplaced ones. GitHub rejects the whole review if any
264
+ * comment names a line outside the diff, so a finding that cannot anchor moves to the summary —
265
+ * dropping it would lose a real finding to a formatting rule.
266
+ */
267
+ export function normalise(findings, files) {
268
+ const anchors = new Map(files.map((file) => [file.path, anchorableLines(file.patch ?? "")]));
269
+ const comments = [];
270
+ const unplaced = [];
271
+ for (const finding of findings) {
272
+ const lines = anchors.get(finding.path);
273
+ const side = finding.side === "LEFT" ? lines?.left : lines?.right;
274
+ if (side?.has(finding.line) === true) {
275
+ comments.push({
276
+ path: finding.path,
277
+ line: finding.line,
278
+ side: finding.side,
279
+ body: commentBody(finding),
280
+ });
281
+ }
282
+ else
283
+ unplaced.push(finding);
284
+ }
285
+ return { comments, unplaced };
286
+ }
287
+ /** The lines of a unified patch a comment may anchor to, by side. */
288
+ export function anchorableLines(patch) {
289
+ const left = new Set();
290
+ const right = new Set();
291
+ let oldLine = 0;
292
+ let newLine = 0;
293
+ for (const line of patch.split("\n")) {
294
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
295
+ if (hunk !== null) {
296
+ oldLine = Number(hunk[1]);
297
+ newLine = Number(hunk[2]);
298
+ continue;
299
+ }
300
+ if (line.startsWith("\\"))
301
+ continue; // "" — not a line of content.
302
+ if (line.startsWith("+"))
303
+ right.add(newLine++);
304
+ else if (line.startsWith("-"))
305
+ left.add(oldLine++);
306
+ else if (line.startsWith(" ")) {
307
+ left.add(oldLine++);
308
+ right.add(newLine++);
309
+ }
310
+ }
311
+ return { left, right };
312
+ }
313
+ function commentBody(finding) {
314
+ const lines = [`**${finding.severity}** — ${finding.what}`];
315
+ if (finding.why !== "")
316
+ lines.push("", finding.why);
317
+ if (finding.fix !== "")
318
+ lines.push("", `**Fix**: ${finding.fix}`);
319
+ lines.push("", `_${finding.lens} · proposed remedy: ${finding.remedy}_`);
320
+ return lines.join("\n");
321
+ }
322
+ /**
323
+ * The review body. The risk verdict leads, because it is the one thing here that is
324
+ * deterministic and the thing that decides whether a human is required.
325
+ */
326
+ export function renderSummary(input) {
327
+ const lines = ["## Speccle review", ""];
328
+ const verdict = input.verdict;
329
+ if (verdict === null) {
330
+ lines.push(`**Risk** — not computed: no shared history with \`${input.base}\`.`, "");
331
+ }
332
+ else {
333
+ const gate = verdict.humanRequired
334
+ ? `at or above the review threshold — **a human is required**`
335
+ : `below the review threshold`;
336
+ lines.push(`**Risk** — score ${verdict.score} vs threshold ${verdict.threshold}, ${gate}.`, "");
337
+ for (const signal of verdict.signals) {
338
+ lines.push(`- \`${signal.id}\` +${signal.weight} — ${signal.reason}: ${signal.evidence.join(", ")}`);
339
+ }
340
+ if (verdict.signals.length > 0)
341
+ lines.push("");
342
+ lines.push("This score is a floor. Escalating it is free; only a human lowers it, and only on calibration evidence.", "");
343
+ }
344
+ const total = input.ran.reduce((sum, lens) => sum + lens.findings, 0);
345
+ lines.push(total === 0
346
+ ? `**Findings** — none, across ${String(input.ran.length)} lenses.`
347
+ : `**Findings** — ${String(total)} across ${String(input.ran.length)} lenses, inline below.`, "");
348
+ for (const lens of input.ran) {
349
+ lines.push(`- \`${lens.name}\` — ${String(lens.findings)}`);
350
+ }
351
+ lines.push("");
352
+ if (input.unplaced.length > 0) {
353
+ lines.push(input.anchorsRejected === true
354
+ ? "**Findings in full** — GitHub rejected the inline anchors on this review, so every finding is here instead:"
355
+ : "**Unplaced findings** — these could not anchor to a line in the diff, so they are here rather than lost:", "");
356
+ for (const finding of input.unplaced) {
357
+ lines.push(`- \`${finding.path}:${String(finding.line)}\` (${finding.lens}) — ${finding.what}`);
358
+ }
359
+ lines.push("");
360
+ }
361
+ // Announce every cap: silence would read as full coverage of a change set that was trimmed.
362
+ for (const [label, skips] of [
363
+ ["Lenses skipped", input.skippedLenses],
364
+ ["Files not reviewed", input.skippedFiles],
365
+ ]) {
366
+ if (skips.length === 0)
367
+ continue;
368
+ lines.push(`**${label}**`, "");
369
+ for (const skip of skips)
370
+ lines.push(`- \`${skip.name}\` — ${skip.reason}`);
371
+ lines.push("");
372
+ }
373
+ lines.push(`Fixes come back through the local \`review\` skill, which re-runs the checks-gate and reverts what goes red.`, "", `<sub>${input.headSha.slice(0, 7)} · comment \`@review\` to run again</sub>`, REVIEW_MARKER);
374
+ return lines.join("\n");
375
+ }
376
+ /** The deterministic verdict, or null when git cannot measure the range (a shallow clone). */
377
+ async function riskVerdict(root, base) {
378
+ try {
379
+ return await risk(root, { base });
380
+ }
381
+ catch {
382
+ return null;
383
+ }
384
+ }
385
+ function githubClient(doFetch, token) {
386
+ const headers = {
387
+ accept: "application/vnd.github+json",
388
+ authorization: `Bearer ${token}`,
389
+ "x-github-api-version": "2022-11-28",
390
+ "user-agent": "speccle-review",
391
+ };
392
+ return {
393
+ get: async (path) => {
394
+ const response = await doFetch(`${GITHUB_API}${path}`, { headers });
395
+ if (!response.ok) {
396
+ throw new Error(`GitHub API ${String(response.status)} on ${path}: ${await errorText(response)}`);
397
+ }
398
+ return asUnknown(await response.json());
399
+ },
400
+ post: async (path, body) => {
401
+ const response = await doFetch(`${GITHUB_API}${path}`, {
402
+ method: "POST",
403
+ headers: { ...headers, "content-type": "application/json" },
404
+ body: JSON.stringify(body),
405
+ });
406
+ return { ok: response.ok, status: response.status, text: await errorText(response) };
407
+ },
408
+ };
409
+ }
410
+ async function pullRequest(github, repo, pr) {
411
+ const body = asRecord(await github.get(`/repos/${repo}/pulls/${String(pr)}`));
412
+ const base = asString(asRecord(body?.base)?.ref);
413
+ const headSha = asString(asRecord(body?.head)?.sha);
414
+ if (base === undefined || headSha === undefined) {
415
+ throw new Error(`could not read the base ref and head sha of ${repo}#${String(pr)}`);
416
+ }
417
+ return { base, headSha };
418
+ }
419
+ /** Whether this driver already reviewed the PR — recognised by the marker it leaves. */
420
+ async function alreadyReviewed(github, repo, pr) {
421
+ const reviews = asArray(await github.get(`/repos/${repo}/pulls/${String(pr)}/reviews?per_page=100`));
422
+ return (reviews ?? []).some((review) => (asString(asRecord(review)?.body) ?? "").includes(REVIEW_MARKER));
423
+ }
424
+ async function changedFiles(github, repo, pr) {
425
+ const files = [];
426
+ const skipped = [];
427
+ let budget = MAX_TOTAL_PATCH_BYTES;
428
+ let truncated = false;
429
+ for (let page = 1; page <= MAX_FILE_PAGES; page++) {
430
+ const path = `/repos/${repo}/pulls/${String(pr)}/files?per_page=100&page=${String(page)}`;
431
+ const batch = asArray(await github.get(path)) ?? [];
432
+ for (const entry of batch) {
433
+ const record = asRecord(entry);
434
+ const filename = asString(record?.filename);
435
+ if (filename === undefined)
436
+ continue;
437
+ const patch = asString(record?.patch);
438
+ if (patch === undefined) {
439
+ skipped.push({
440
+ name: filename,
441
+ reason: "no textual patch (binary or too large for the API)",
442
+ });
443
+ continue;
444
+ }
445
+ if (patch.length > MAX_FILE_PATCH_BYTES) {
446
+ skipped.push({
447
+ name: filename,
448
+ reason: `patch over ${String(MAX_FILE_PATCH_BYTES)} bytes`,
449
+ });
450
+ continue;
451
+ }
452
+ if (patch.length > budget) {
453
+ skipped.push({ name: filename, reason: "change-set size cap reached" });
454
+ continue;
455
+ }
456
+ budget -= patch.length;
457
+ files.push({ path: filename, patch });
458
+ }
459
+ if (batch.length < 100)
460
+ return { files, skipped, truncated };
461
+ truncated = page === MAX_FILE_PAGES;
462
+ }
463
+ return { files, skipped, truncated };
464
+ }
465
+ export function renderDiff(files) {
466
+ return files.map((file) => `--- ${file.path}\n${file.patch ?? ""}`).join("\n\n");
467
+ }
468
+ /**
469
+ * One review, with the inline comments attached. A 422 means GitHub rejected an anchor — it
470
+ * validates every comment's line against the diff and refuses the whole review over one bad
471
+ * one — so the retry carries the findings in the body instead of losing them to a line number.
472
+ */
473
+ async function postReview(github, repo, pr, headSha, summary, comments, unanchored) {
474
+ const path = `/repos/${repo}/pulls/${String(pr)}/reviews`;
475
+ const review = { commit_id: headSha, body: summary, event: "COMMENT", comments };
476
+ const first = await github.post(path, review);
477
+ if (first.ok)
478
+ return;
479
+ if (first.status !== 422 || comments.length === 0) {
480
+ throw new Error(`GitHub API ${String(first.status)} posting the review: ${first.text}`);
481
+ }
482
+ const retry = await github.post(path, {
483
+ commit_id: headSha,
484
+ body: unanchored,
485
+ event: "COMMENT",
486
+ });
487
+ if (!retry.ok) {
488
+ throw new Error(`GitHub API ${String(retry.status)} posting the review: ${retry.text}`);
489
+ }
490
+ }
491
+ async function errorText(response) {
492
+ try {
493
+ return (await response.text()).slice(0, 500);
494
+ }
495
+ catch {
496
+ return "<no body>";
497
+ }
498
+ }
499
+ // The API payloads are untrusted input, so they are narrowed rather than asserted into shape.
500
+ function asUnknown(value) {
501
+ return value;
502
+ }
503
+ function asRecord(value) {
504
+ return typeof value === "object" && value !== null && !Array.isArray(value)
505
+ ? value
506
+ : undefined;
507
+ }
508
+ function asArray(value) {
509
+ return Array.isArray(value) ? value : undefined;
510
+ }
511
+ function asString(value) {
512
+ return typeof value === "string" ? value : undefined;
513
+ }
514
+ function asNumber(value) {
515
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
516
+ }