speccle 0.12.0 → 0.16.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/dist/render.js CHANGED
@@ -29,11 +29,65 @@ export function renderSkillsInit(report) {
29
29
  lines.push("these are generated files — commit them; re-run `speccle init` to refresh");
30
30
  return lines.join("\n");
31
31
  }
32
+ export function renderLensesInit(report) {
33
+ const lines = [`vendored ${plural(report.lenses.length, "lens", "lenses")} into ${report.dir}/`];
34
+ for (const { name, action } of report.lenses) {
35
+ const verb = action === "written" ? "wrote" : action;
36
+ lines.push(` ${verb.padEnd(9)} ${name}`);
37
+ }
38
+ lines.push("");
39
+ lines.push("the baseline lenses are Speccle's — refreshed each run; house-conventions.md is yours to author");
40
+ return lines.join("\n");
41
+ }
42
+ export function renderReviewInit(report) {
43
+ const verb = report.action === "written" ? "wrote" : "refreshed";
44
+ const lines = [
45
+ `${verb} ${report.file} — pinned to speccle@${report.pin}`,
46
+ "",
47
+ `the driver is opt-in and needs a metered ${API_KEY_SECRET} repo secret; add it before the`,
48
+ "next pull request, or the review step fails with no key",
49
+ "",
50
+ "it finds and comments — fixes come back through the local `review` skill",
51
+ "whether the risk gate blocks a merge is branch protection: GitHub's setting, your call",
52
+ ];
53
+ if (report.action === "refreshed" && !report.movedPin) {
54
+ lines.splice(1, 0, " (unchanged — the workflow already pinned this version)");
55
+ }
56
+ return lines.join("\n");
57
+ }
58
+ export function renderReviewRun(report) {
59
+ if (report.outcome === "already-reviewed") {
60
+ return join(`${report.repo}#${report.pr} — already reviewed by this driver, so nothing was posted`, "comment `@review` on the pull request to run again");
61
+ }
62
+ const lines = [];
63
+ const findings = report.findings.length;
64
+ lines.push(`${report.repo}#${report.pr} at ${report.headSha.slice(0, 7)} — ${plural(report.lenses.length, "lens", "lenses")} ran, ${plural(findings, "finding")}`);
65
+ for (const lens of report.lenses) {
66
+ lines.push(` ${String(lens.findings).padStart(3)} ${lens.name}`);
67
+ }
68
+ for (const skip of [...report.skippedLenses, ...report.skippedFiles]) {
69
+ lines.push(` skipped ${skip.name} — ${skip.reason}`);
70
+ }
71
+ lines.push("");
72
+ if (findings > 0) {
73
+ lines.push(`${report.comments} inline, ${report.unplaced} unplaced in the summary`);
74
+ }
75
+ const verdict = report.risk;
76
+ lines.push(verdict === null
77
+ ? `risk not computed — no shared history with ${report.base}`
78
+ : verdict.humanRequired
79
+ ? `risk ${verdict.score} ≥ threshold ${verdict.threshold} — human required; the gate step fails`
80
+ : `risk ${verdict.score} < threshold ${verdict.threshold} — the gate step passes`);
81
+ lines.push(report.outcome === "dry-run" ? "dry run — nothing was posted" : "posted one review");
82
+ return lines.join("\n");
83
+ }
32
84
  export function renderDoctor(report) {
33
85
  const lines = [
34
86
  `speccle ${report.cli}`,
35
87
  "",
36
- `skills ${describeSkills(report.skills)}`,
88
+ `skills ${describePayload(report.skills, "materialized")}`,
89
+ `lenses ${describePayload(report.lenses, "vendored")}`,
90
+ `driver ${describeDriver(report.driver)}`,
37
91
  `stack ${describeStack(report.stack)}`,
38
92
  ];
39
93
  if (report.stack.status === "drift") {
@@ -45,7 +99,11 @@ export function renderDoctor(report) {
45
99
  }
46
100
  }
47
101
  lines.push("");
48
- if (report.skills.status === "absent" && report.stack.status === "absent") {
102
+ const allAbsent = report.skills.status === "absent" &&
103
+ report.lenses.status === "absent" &&
104
+ report.driver.status === "absent" &&
105
+ report.stack.status === "absent";
106
+ if (allAbsent) {
49
107
  lines.push("Speccle is not set up here — run `speccle init`");
50
108
  }
51
109
  else if (report.ok) {
@@ -56,18 +114,37 @@ export function renderDoctor(report) {
56
114
  }
57
115
  return lines.join("\n");
58
116
  }
59
- function describeSkills(skills) {
60
- switch (skills.status) {
117
+ function describePayload(payload, verb) {
118
+ switch (payload.status) {
61
119
  case "current":
62
- return `current (${skills.bundled})`;
120
+ return `current (${payload.bundled})`;
63
121
  case "stale":
64
- return `stale — committed ${skills.recorded}, this CLI ships ${skills.bundled}`;
122
+ return `stale — committed ${payload.recorded}, this CLI ships ${payload.bundled}`;
65
123
  case "ahead":
66
- return `ahead — committed ${skills.recorded} is newer than this CLI (${skills.bundled})`;
124
+ return `ahead — committed ${payload.recorded} is newer than this CLI (${payload.bundled})`;
67
125
  case "unstamped":
68
126
  return "present but unversioned — re-run `speccle init` to record the version";
69
127
  case "absent":
70
- return "not materialized — run `speccle init`";
128
+ return `not ${verb} — run \`speccle init\``;
129
+ }
130
+ }
131
+ /**
132
+ * The driver's own arm, because its two ends differ from a vendored payload's: absent means
133
+ * the repo never opted into a metered CI driver — a choice, not a gap to fix — and every
134
+ * remedy is `review init`, which is also the only thing that moves the pin.
135
+ */
136
+ function describeDriver(driver) {
137
+ switch (driver.status) {
138
+ case "current":
139
+ return `current (${driver.bundled})`;
140
+ case "stale":
141
+ return `stale — the workflow pins ${driver.recorded}, this CLI is ${driver.bundled}; re-run \`speccle review init\``;
142
+ case "ahead":
143
+ return `ahead — the workflow pins ${driver.recorded}, newer than this CLI (${driver.bundled})`;
144
+ case "unstamped":
145
+ return "present but unpinned — re-run `speccle review init` to pin a version";
146
+ case "absent":
147
+ return "not installed — opt in with `speccle review init`";
71
148
  }
72
149
  }
73
150
  function describeStack(stack) {
@@ -90,6 +167,24 @@ export function renderUpdate(report) {
90
167
  else {
91
168
  lines.push(`skills ${report.skills.from ?? "unversioned"} → ${report.skills.to} — review & commit the diff`);
92
169
  }
170
+ if (report.lenses.from === null) {
171
+ lines.push(`lenses vendored at ${report.lenses.to} — new; review & commit the diff`);
172
+ }
173
+ else if (report.lenses.from === report.lenses.to) {
174
+ lines.push(`lenses already at ${report.lenses.to} — refreshed in place; review the diff`);
175
+ }
176
+ else {
177
+ lines.push(`lenses ${report.lenses.from} → ${report.lenses.to} — review & commit the diff`);
178
+ }
179
+ if (report.driver.to === null) {
180
+ lines.push("driver not installed — opt in with `speccle review init`");
181
+ }
182
+ else if (report.driver.from === report.driver.to) {
183
+ lines.push(`driver already pinned to ${report.driver.to} — rewritten in place`);
184
+ }
185
+ else {
186
+ lines.push(`driver ${report.driver.from ?? "unpinned"} → ${report.driver.to} — review & commit the diff`);
187
+ }
93
188
  if (report.stack.status === "absent") {
94
189
  lines.push("stack not provisioned — run `speccle strength init`");
95
190
  }
@@ -128,21 +223,162 @@ function describeReport(check) {
128
223
  return `${check.path} — stale (${check.staleAgainst} is newer)`;
129
224
  return `${check.path} — fresh`;
130
225
  }
226
+ import { API_KEY_SECRET } from "./reviewinit.js";
227
+ export function renderVerify(report) {
228
+ const enforced = report.checks.filter((check) => check.status !== "inactive");
229
+ if (enforced.length === 0) {
230
+ const scanned = plural(report.changed.length, "changed file");
231
+ const authored = report.checks.length === 0 ? "no checks authored" : "no check applied";
232
+ return join(changeSetLine(report), `${authored} — ${scanned} scanned`);
233
+ }
234
+ const lines = [];
235
+ const header = changeSetLine(report);
236
+ if (header !== undefined)
237
+ lines.push(header, "");
238
+ for (const check of enforced.filter((check) => check.status === "breach")) {
239
+ lines.push(renderBreach(check));
240
+ }
241
+ if (lines.length > 0)
242
+ lines.push("");
243
+ const breaches = report.breaches;
244
+ const checks = plural(enforced.length, "check");
245
+ lines.push(report.clean ? `${checks}, clean` : `${checks}, ${plural(breaches, "breach", "breaches")}`);
246
+ return lines.join("\n");
247
+ }
248
+ function renderBreach(check) {
249
+ const lines = [`${check.id} ${check.message}`];
250
+ for (const offender of check.offenders ?? [])
251
+ lines.push(` ${offender}`);
252
+ if (check.because !== undefined)
253
+ lines.push(` because ${check.because}`);
254
+ return lines.join("\n");
255
+ }
256
+ export function renderRisk(report) {
257
+ const lines = [];
258
+ const header = changeSetLine(report);
259
+ if (header !== undefined)
260
+ lines.push(header, "");
261
+ for (const signal of report.signals) {
262
+ lines.push(`${signal.id} +${signal.weight} ${signal.reason}`);
263
+ for (const item of signal.evidence)
264
+ lines.push(` ${item}`);
265
+ if (signal.because !== undefined)
266
+ lines.push(` because ${signal.because}`);
267
+ }
268
+ if (report.signals.length > 0)
269
+ lines.push("");
270
+ else
271
+ lines.push(`no risk signals fired — ${plural(report.changed.length, "changed file")} scanned`);
272
+ lines.push(report.humanRequired
273
+ ? `score ${report.score} ≥ threshold ${report.threshold} — human required, review stops at findings`
274
+ : `score ${report.score} < threshold ${report.threshold} — review may fix and report`);
275
+ // Legibility (ADR-0041): the computed score is a floor a risk lens may raise, never lower.
276
+ lines.push("this score is a floor — a risk lens may escalate it, never lower it");
277
+ return lines.join("\n");
278
+ }
279
+ export function renderCalibrateRecord(report) {
280
+ const entry = report.entry;
281
+ const floor = `score ${entry.score} vs threshold ${entry.threshold}`;
282
+ const gate = entry.humanRequired ? " — floor required a human" : "";
283
+ const escalated = entry.escalated ? " — a lens escalated" : "";
284
+ const verdict = `${entry.verdict.neededHuman ? "needed a human" : "no human needed"}, ${entry.verdict.foundReal ? "found something real" : "found nothing real"}`;
285
+ const lines = [
286
+ `recorded ${report.file} — ${plural(report.count, "entry", "entries")}`,
287
+ ` ${floor}${gate}${escalated}`,
288
+ ` verdict: ${verdict}`,
289
+ ];
290
+ if (entry.signals.length > 0)
291
+ lines.push(` signals: ${entry.signals.join(", ")}`);
292
+ return lines.join("\n");
293
+ }
294
+ export function renderCalibrateReport(report) {
295
+ if (report.count === 0) {
296
+ return "no calibration entries yet — review some changes to build the record";
297
+ }
298
+ const changes = plural(report.count, "reviewed change");
299
+ const lines = [
300
+ `${changes} — ${report.neededByHuman} needed a human, floor gated ${report.gatedByFloor}`,
301
+ ];
302
+ if (report.floorMisses > 0) {
303
+ lines.push(` ${plural(report.floorMisses, "floor miss", "floor misses")} — needed a human the floor did not catch`);
304
+ }
305
+ if (report.overSupervised > 0) {
306
+ lines.push(` ${report.overSupervised} over-supervised — floor required a human the verdict did not`);
307
+ }
308
+ if (report.signals.length > 0) {
309
+ lines.push("");
310
+ const width = Math.max(...report.signals.map((signal) => signal.id.length));
311
+ for (const signal of report.signals) {
312
+ const note = signal.neverUseful
313
+ ? " — never on a change that mattered"
314
+ : signal.firedOnEveryNeeded
315
+ ? " — on every change that needed a human"
316
+ : "";
317
+ lines.push(` ${signal.id.padEnd(width)} ${signal.firedOnMattered}/${signal.fired} mattered${note}`);
318
+ }
319
+ }
320
+ lines.push("");
321
+ lines.push("proposals — evidence, not instructions (only a human reduces supervision):");
322
+ for (const proposal of report.proposals)
323
+ lines.push(` ${proposal}`);
324
+ return lines.join("\n");
325
+ }
326
+ /** The route with its artefact, or the honest one-off — how a remedy's prevention reads on one line. */
327
+ function remedyDestination(route, artefact) {
328
+ return route === "none" ? "one-off — no prevention artefact" : `${route} → ${artefact}`;
329
+ }
330
+ export function renderRemedyRecord(report) {
331
+ const entry = report.entry;
332
+ const lines = [
333
+ `recorded ${report.file} — ${plural(report.count, "remedy", "remedies")}`,
334
+ ` ${entry.class}: ${entry.finding}`,
335
+ ` fix: ${entry.fix}`,
336
+ ` remedy: ${remedyDestination(entry.route, entry.artefact)}`,
337
+ ];
338
+ if (entry.note !== undefined)
339
+ lines.push(` note: ${entry.note}`);
340
+ return lines.join("\n");
341
+ }
342
+ export function renderRemedyRecall(report) {
343
+ if (report.count === 0) {
344
+ return `no remedy record yet — nothing to recall for "${report.query}"`;
345
+ }
346
+ if (report.matches.length === 0) {
347
+ const onRecord = plural(report.count, "remedy", "remedies");
348
+ return `no prior remedy for "${report.query}" — route it fresh, then record it (${onRecord} on record)`;
349
+ }
350
+ const found = plural(report.matches.length, "prior remedy", "prior remedies");
351
+ const lines = [`${found} for "${report.query}" — reuse to fix consistently:`];
352
+ for (const entry of report.matches) {
353
+ lines.push("");
354
+ lines.push(` ${entry.class} (${entry.at})`);
355
+ lines.push(` finding: ${entry.finding}`);
356
+ lines.push(` fix: ${entry.fix}`);
357
+ lines.push(` remedy: ${remedyDestination(entry.route, entry.artefact)}`);
358
+ if (entry.note !== undefined)
359
+ lines.push(` note: ${entry.note}`);
360
+ }
361
+ return lines.join("\n");
362
+ }
131
363
  export function renderClaims(report) {
132
364
  if (report.features.length === 0)
133
365
  return "No SPEC.md files found.";
134
366
  const idWidth = Math.max(...report.features.flatMap((f) => f.criteria.map((c) => c.id.length)));
367
+ // Only a mixed pass needs to say which dialect read which slice; naming the one dialect
368
+ // above every spec in the common case is noise the footer already carries.
369
+ const mixed = report.dialects.length > 1;
135
370
  const lines = [];
136
371
  for (const feature of report.features) {
137
- lines.push(feature.spec);
372
+ lines.push(mixed ? `${feature.spec} (${feature.dialect})` : feature.spec);
138
373
  for (const c of feature.criteria) {
139
374
  const status = c.claimed ? plural(c.tests.length, "test name") : "unclaimed";
140
375
  lines.push(` ${c.id.padEnd(idWidth)} ${status.padEnd(13)} ${c.statement}`);
141
376
  }
142
377
  lines.push("");
143
378
  }
379
+ const dialects = report.dialects.join(", ");
144
380
  if (report.testFiles.length === 0) {
145
- lines.push(`no test files matched the ${report.dialect} dialect`);
381
+ lines.push(`no test files matched the ${dialects} dialect${mixed ? "s" : ""}`);
146
382
  lines.push("");
147
383
  }
148
384
  if (report.unclaimed.length > 0) {
@@ -162,7 +398,7 @@ export function renderClaims(report) {
162
398
  const claimed = report.features.reduce((n, f) => n + f.criteria.filter((c) => c.claimed).length, 0);
163
399
  const criteria = `${total} ${total === 1 ? "criterion" : "criteria"}`;
164
400
  const specs = plural(report.features.length, "spec file");
165
- const counts = `${report.dialect} — ${specs}, ${criteria}, ${claimed} claimed`;
401
+ const counts = `${dialects} — ${specs}, ${criteria}, ${claimed} claimed`;
166
402
  lines.push(report.clean ? `${counts}, clean` : counts);
167
403
  return lines.join("\n");
168
404
  }
@@ -186,14 +422,26 @@ export function renderInit(report) {
186
422
  lines.push(' stryker config: coverageAnalysis "perTest", the json reporter');
187
423
  lines.push(" vitest config: istanbul provider, json-summary reporter");
188
424
  }
425
+ if (report.supersededDeps.length > 0) {
426
+ lines.push("");
427
+ lines.push(`superseded devDependency: ${report.supersededDeps.join(", ")}`);
428
+ lines.push("this CLI publishes as speccle now — the old package is a stale binary beside");
429
+ lines.push(`the current one. Remove it yourself: ${report.removeCommand}`);
430
+ }
189
431
  if (report.doubleLoad) {
190
432
  lines.push("");
191
- lines.push("warning: this repo vendors the speccle skills project-level AND the");
192
- lines.push("speccle plugin is enabled user-level — two copies of every skill will");
193
- lines.push("load. Disable one: /plugin (user-level) or remove .claude/skills/ here.");
433
+ lines.push(renderDoubleLoad());
194
434
  }
195
435
  return lines.join("\n");
196
436
  }
437
+ /**
438
+ * One wording for both entry points. `speccle init` vendors the skills and `speccle strength
439
+ * init` provisions the stack; either run is a fair place to discover the double-load, so
440
+ * neither stays quiet about it (#183).
441
+ */
442
+ export function renderDoubleLoad() {
443
+ return join("warning: this repo vendors the speccle skills project-level AND the", "speccle plugin is enabled user-level — two copies of every skill will", "load. Disable one: /plugin (user-level) or remove .claude/skills/ here.");
444
+ }
197
445
  export function renderHuman(report) {
198
446
  if (report.files.length === 0)
199
447
  return "No SPEC.md files found.";
@@ -310,6 +558,19 @@ function ansi(text, code) {
310
558
  function bold(text, color) {
311
559
  return color ? ansi(text, "1") : text;
312
560
  }
313
- function plural(n, noun) {
314
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
561
+ function plural(n, noun, plural = `${noun}s`) {
562
+ return `${n} ${n === 1 ? noun : plural}`;
563
+ }
564
+ /**
565
+ * Names the change set a committed-range run was measured over — a score whose change set is
566
+ * invisible is not auditable, and the CI driver's whole output is read from a log. Absent for
567
+ * the working tree's pending change, where "what changed" is already in front of you.
568
+ */
569
+ function changeSetLine(report) {
570
+ if (report.base === undefined)
571
+ return undefined;
572
+ return `change set: ${report.base}...HEAD — ${plural(report.changed.length, "file")}`;
573
+ }
574
+ function join(...lines) {
575
+ return lines.filter((line) => line !== undefined).join("\n");
315
576
  }
@@ -0,0 +1,139 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { ownVersion } from "./init.js";
4
+ /** Where the scaffolded workflow lands. One file: the driver itself ships in the tarball. */
5
+ export const WORKFLOW_FILE = ".github/workflows/speccle-review.yml";
6
+ /** The repo secret the CI driver cannot run without — and the reason the driver is opt-in. */
7
+ export const API_KEY_SECRET = "ANTHROPIC_API_KEY";
8
+ /**
9
+ * Scaffolds the review workflow — the CI driver's one artefact in a consumer repo (ADR-0047).
10
+ * The runner itself is not vendored: the workflow pins `speccle@<version>` and npm serves it, so
11
+ * the code doing the reviewing never comes from the branch being reviewed, and there is nothing
12
+ * committed here to drift from the CLI. Re-running is how a repo moves the pin.
13
+ */
14
+ export async function scaffoldReviewWorkflow(root, version) {
15
+ const pin = version ?? (await ownVersion());
16
+ const path = join(root, WORKFLOW_FILE);
17
+ const existing = await readMaybe(path);
18
+ const workflow = renderWorkflow(pin);
19
+ await mkdir(dirname(path), { recursive: true });
20
+ await writeFile(path, workflow);
21
+ return {
22
+ root,
23
+ file: WORKFLOW_FILE,
24
+ action: existing === undefined ? "written" : "refreshed",
25
+ pin,
26
+ movedPin: existing !== undefined && existing !== workflow,
27
+ };
28
+ }
29
+ /** The pinned version an already-scaffolded workflow names, or undefined when it names none. */
30
+ export function pinnedVersion(workflow) {
31
+ return /\bspeccle@(\d+\.\d+\.\d+[^\s]*)/.exec(workflow)?.[1];
32
+ }
33
+ /**
34
+ * The workflow, with every hardening decision stated in a comment beside the line that enforces
35
+ * it: a scaffolded file is read by people deciding whether to trust it, and a guard whose reason
36
+ * is undocumented is a guard someone deletes.
37
+ */
38
+ function renderWorkflow(pin) {
39
+ return `# Speccle review — the outer loop, running in CI. Written by \`speccle review init\`.
40
+ #
41
+ # It fans this repo's \`.speccle/lenses/\` over a pull request's change set, posts what they find
42
+ # as one review with inline comments, and reports the deterministic risk verdict as a check.
43
+ # It finds and comments; it never pushes a fix — fixes come back through the local \`review\`
44
+ # skill, which can re-run the checks-gate and revert a fix that goes red.
45
+ #
46
+ # Before this can run:
47
+ # 1. Add a metered ${API_KEY_SECRET} repo secret. This driver calls a model once per lens per
48
+ # pull request, which is why it is opt-in; the local \`review\` skill needs no key.
49
+ # 2. Protect this file. Anyone who can edit a workflow can read the secrets it uses — put
50
+ # \`.github/\` behind CODEOWNERS or a branch protection rule.
51
+ #
52
+ # Re-run \`speccle review init\` to move the pinned version below.
53
+
54
+ name: Speccle review
55
+
56
+ on:
57
+ pull_request:
58
+ types: [opened, synchronize, reopened]
59
+ # The rerun path: an \`@review\` comment on the pull request, gated on write access below.
60
+ issue_comment:
61
+ types: [created]
62
+
63
+ # Least privilege: post a review, read the tree, nothing else.
64
+ permissions:
65
+ contents: read
66
+ pull-requests: write
67
+
68
+ concurrency:
69
+ group: speccle-review-\${{ github.event.pull_request.number || github.event.issue.number }}
70
+ cancel-in-progress: true
71
+
72
+ jobs:
73
+ review:
74
+ runs-on: ubuntu-latest
75
+ # Two ways in, each gated. A pull request must come from a branch of this repo, never a
76
+ # fork: a fork's pull request must not reach the API key. A comment must come from someone
77
+ # who can already write here, or \`@review\` would be a way to spend the key from outside.
78
+ if: >-
79
+ (github.event_name == 'pull_request' &&
80
+ github.event.pull_request.head.repo.full_name == github.repository) ||
81
+ (github.event_name == 'issue_comment' &&
82
+ github.event.issue.pull_request != null &&
83
+ startsWith(github.event.comment.body, '@review') &&
84
+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
85
+ steps:
86
+ # The comment event carries no refs, so resolve them for both paths the same way.
87
+ - name: Resolve the pull request
88
+ id: pr
89
+ env:
90
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
91
+ run: |
92
+ number=\${{ github.event.pull_request.number || github.event.issue.number }}
93
+ pr=$(gh api "repos/\${{ github.repository }}/pulls/$number")
94
+ {
95
+ echo "number=$number"
96
+ echo "base=$(echo "$pr" | jq -r .base.ref)"
97
+ echo "head=$(echo "$pr" | jq -r .head.sha)"
98
+ } >> "$GITHUB_OUTPUT"
99
+
100
+ - uses: actions/checkout@v5
101
+ with:
102
+ # The head commit itself, not the synthetic merge commit: the change set under review
103
+ # is what the branch says, and the comment path would otherwise land on the default
104
+ # branch. The head is data here — the reviewing code comes from npm, pinned below.
105
+ ref: \${{ steps.pr.outputs.head }}
106
+ # \`risk\` measures from the merge base, which a shallow clone has no history to find.
107
+ fetch-depth: 0
108
+
109
+ - uses: actions/setup-node@v4
110
+ with:
111
+ node-version: 24
112
+
113
+ # \`origin/\` prefixes the base ref because checkout leaves it as a remote-tracking ref;
114
+ # a bare branch name would not resolve.
115
+ - name: Review the change set
116
+ env:
117
+ ${API_KEY_SECRET}: \${{ secrets.${API_KEY_SECRET} }}
118
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
119
+ run: >-
120
+ npx -y speccle@${pin} review run
121
+ --pr \${{ steps.pr.outputs.number }}
122
+ --base origin/\${{ steps.pr.outputs.base }}
123
+ \${{ github.event_name == 'issue_comment' && '--force' || '' }}
124
+
125
+ # The status check. \`risk\` exits 1 at or above the review threshold, so a failing step is
126
+ # the failing check. Whether that blocks the merge is branch protection — GitHub's, and
127
+ # this repo's call to make, not Speccle's. It runs last so the findings post either way.
128
+ - name: Risk gate
129
+ run: npx -y speccle@${pin} risk --base origin/\${{ steps.pr.outputs.base }}
130
+ `;
131
+ }
132
+ async function readMaybe(path) {
133
+ try {
134
+ return await readFile(path, "utf8");
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }