speccle 0.12.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.
package/dist/lenses.js ADDED
@@ -0,0 +1,68 @@
1
+ import { access, copyFile, mkdir, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** Where materialized lenses land in a target repo — the dimensions `review` fans out over. */
5
+ export const LENSES_DIR = ".speccle/lenses";
6
+ /**
7
+ * The one lens that ships as a template and is the repo's own to author (ADR-0043) — its
8
+ * house-conventions lens is the real IP Speccle cannot write. It is written once, when
9
+ * absent, and a refresh never overwrites it: clobbering an authored lens would be exactly
10
+ * the data loss the "with a lock" property exists to prevent.
11
+ */
12
+ export const TEMPLATE_LENS = "house-conventions.md";
13
+ /**
14
+ * Vendors the baseline lenses into the target's `.speccle/lenses/`, so `review` has a panel
15
+ * to fan out over on any repo day one (ADR-0043). The baseline lenses are Speccle's and are
16
+ * overwritten on every run — the same "the tarball is the source of truth" posture the skills
17
+ * take (ADR-0046) — but the house-conventions template is the repo's own and is only ever
18
+ * written when absent. Any lens the repo authored itself is left untouched: only the bundled
19
+ * names are considered, so a repo-added lens is never seen, let alone removed.
20
+ */
21
+ export async function materializeLenses(root, source) {
22
+ const from = source ?? bundledLensesDir();
23
+ const names = await lensNames(from);
24
+ const target = join(root, LENSES_DIR);
25
+ await mkdir(target, { recursive: true });
26
+ const lenses = [];
27
+ for (const name of names) {
28
+ const dest = join(target, name);
29
+ const present = await exists(dest);
30
+ if (name === TEMPLATE_LENS) {
31
+ if (!present)
32
+ await copyFile(join(from, name), dest);
33
+ lenses.push({ name, action: present ? "kept" : "written" });
34
+ continue;
35
+ }
36
+ await copyFile(join(from, name), dest);
37
+ lenses.push({ name, action: present ? "refreshed" : "written" });
38
+ }
39
+ return { root, dir: LENSES_DIR, source: from, lenses };
40
+ }
41
+ async function lensNames(source) {
42
+ let entries;
43
+ try {
44
+ entries = await readdir(source);
45
+ }
46
+ catch {
47
+ throw new Error(`no bundled lenses to materialize at ${source}`);
48
+ }
49
+ return entries.filter((entry) => entry.endsWith(".md")).sort();
50
+ }
51
+ /**
52
+ * The shipped lenses: a top-level `lenses/` beside `dist/` in the published tarball. Unlike
53
+ * the skills — copied in from the plugin at build time — the lens sources live in this
54
+ * package already, so the same relative path resolves whether the CLI runs from `dist/` or
55
+ * straight from `src/`; no build-time bundle and no source fallback are needed.
56
+ */
57
+ function bundledLensesDir() {
58
+ return fileURLToPath(new URL("../lenses", import.meta.url));
59
+ }
60
+ async function exists(path) {
61
+ try {
62
+ await access(path);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
package/dist/remedy.js ADDED
@@ -0,0 +1,133 @@
1
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { isDirectory, messageOf } from "./changeset.js";
4
+ /** Where the meta loop keeps its remedy record (ADR-0043): append-only, one entry per line. */
5
+ export const REMEDY_FILE = ".speccle/remedies.jsonl";
6
+ /**
7
+ * The prevention routes the remedy record owns — ADR-0043's routing table minus its risk-weight
8
+ * row, which is the calibration record's ({@link ./calibration.ts}, ADR-0042). `none` is the honest
9
+ * one-off: a finding whose class is not worth preventing, so it names no artefact.
10
+ */
11
+ export const REMEDY_ROUTES = ["check", "criterion", "lens", "none"];
12
+ /** Where each route's prevention artefact lives — named in the error when one is missing. */
13
+ const ARTEFACT_HOME = {
14
+ check: "a .speccle/checks/ path",
15
+ criterion: "a SPEC.md criterion id",
16
+ lens: "a .speccle/lenses/ path",
17
+ };
18
+ /**
19
+ * Appends one remedy: the finding, the fix applied, and the prevention route with its artefact.
20
+ * Every route but `none` must name its artefact — the record is only worth consulting if it points
21
+ * at the prevention it chose; a `none` remedy names nothing, because a one-off prevents nothing.
22
+ */
23
+ export async function recordRemedy(target, input, options = {}) {
24
+ const root = resolve(target);
25
+ if (!(await isDirectory(root)))
26
+ throw new Error(`path not found: ${target}`);
27
+ const classHandle = input.class.trim();
28
+ if (classHandle === "")
29
+ throw new Error("a remedy needs a --class (the finding's class handle)");
30
+ if (input.finding.trim() === "")
31
+ throw new Error("a remedy needs a --finding");
32
+ if (input.fix.trim() === "")
33
+ throw new Error("a remedy needs a --fix");
34
+ if (!REMEDY_ROUTES.includes(input.route)) {
35
+ throw new Error(`unknown route "${input.route}": one of ${REMEDY_ROUTES.join(", ")}`);
36
+ }
37
+ const artefact = (input.artefact ?? "").trim();
38
+ if (input.route === "none" && artefact !== "") {
39
+ throw new Error("a none remedy prevents nothing — it names no --artefact");
40
+ }
41
+ if (input.route !== "none" && artefact === "") {
42
+ throw new Error(`a ${input.route} remedy needs an --artefact (${ARTEFACT_HOME[input.route]})`);
43
+ }
44
+ const entry = {
45
+ at: (options.now ?? (() => new Date().toISOString()))(),
46
+ class: classHandle,
47
+ finding: input.finding.trim(),
48
+ fix: input.fix.trim(),
49
+ route: input.route,
50
+ ...(artefact !== "" && { artefact }),
51
+ ...(input.note !== undefined && input.note.trim() !== "" && { note: input.note.trim() }),
52
+ };
53
+ const file = join(root, REMEDY_FILE);
54
+ await mkdir(dirname(file), { recursive: true });
55
+ await appendFile(file, JSON.stringify(entry) + "\n");
56
+ return { root, file: REMEDY_FILE, entry, count: (await readEntries(root)).length };
57
+ }
58
+ /**
59
+ * Looks up the known-correct remedy for a finding's class so a repeat gets the same answer. Matches
60
+ * deterministically on the class handle's tokens (lowercased alphanumeric runs): an entry matches
61
+ * when one side's tokens are a subset of the other's, so `missing-roundtrip` still recalls
62
+ * `missing-model-roundtrip-test`. Most-recent first — the latest answer to a class wins.
63
+ */
64
+ export async function recallRemedy(target, query) {
65
+ const root = resolve(target);
66
+ if (!(await isDirectory(root)))
67
+ throw new Error(`path not found: ${target}`);
68
+ const entries = await readEntries(root);
69
+ const wanted = classTokens(query);
70
+ const matches = wanted.size === 0
71
+ ? []
72
+ : entries
73
+ .filter((entry) => {
74
+ const have = classTokens(entry.class);
75
+ return have.size > 0 && (isSubset(wanted, have) || isSubset(have, wanted));
76
+ })
77
+ .reverse();
78
+ return { root, file: REMEDY_FILE, query, matches, count: entries.length };
79
+ }
80
+ /** A class handle's comparison tokens: lowercased alphanumeric runs, e.g. `model`, `roundtrip`. */
81
+ function classTokens(handle) {
82
+ return new Set(handle
83
+ .toLowerCase()
84
+ .split(/[^a-z0-9]+/)
85
+ .filter(Boolean));
86
+ }
87
+ function isSubset(a, b) {
88
+ for (const token of a)
89
+ if (!b.has(token))
90
+ return false;
91
+ return true;
92
+ }
93
+ async function readEntries(root) {
94
+ let raw;
95
+ try {
96
+ raw = await readFile(join(root, REMEDY_FILE), "utf8");
97
+ }
98
+ catch {
99
+ return []; // no record yet: nothing has been remedied.
100
+ }
101
+ const entries = [];
102
+ const lines = raw.split("\n");
103
+ for (let i = 0; i < lines.length; i++) {
104
+ const line = lines[i].trim();
105
+ if (line === "")
106
+ continue;
107
+ let entry;
108
+ try {
109
+ entry = JSON.parse(line);
110
+ }
111
+ catch (err) {
112
+ throw new Error(`${REMEDY_FILE}:${i + 1} is not valid JSON: ${messageOf(err)}`);
113
+ }
114
+ assertEntry(entry, i + 1);
115
+ entries.push(entry);
116
+ }
117
+ return entries;
118
+ }
119
+ // A malformed entry must fail loudly, naming the line — a silently-dropped remedy is a hole in the
120
+ // memory the meta loop consults to fix consistently, exactly what ADR-0043 keeps in one place.
121
+ function assertEntry(entry, line) {
122
+ const at = `${REMEDY_FILE}:${line}`;
123
+ const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
124
+ if (!nonEmpty(entry.class) || !nonEmpty(entry.finding) || !nonEmpty(entry.fix)) {
125
+ throw new Error(`${at}: "class", "finding", and "fix" must be non-empty strings`);
126
+ }
127
+ if (!REMEDY_ROUTES.includes(entry.route)) {
128
+ throw new Error(`${at}: "route" must be one of ${REMEDY_ROUTES.join(", ")}`);
129
+ }
130
+ if (entry.route === "none" ? entry.artefact !== undefined : !nonEmpty(entry.artefact)) {
131
+ throw new Error(`${at}: a ${entry.route} remedy ${entry.route === "none" ? "names no" : "needs an"} "artefact"`);
132
+ }
133
+ }
package/dist/render.js CHANGED
@@ -29,11 +29,64 @@ 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")}`,
37
90
  `stack ${describeStack(report.stack)}`,
38
91
  ];
39
92
  if (report.stack.status === "drift") {
@@ -45,7 +98,10 @@ export function renderDoctor(report) {
45
98
  }
46
99
  }
47
100
  lines.push("");
48
- if (report.skills.status === "absent" && report.stack.status === "absent") {
101
+ const allAbsent = report.skills.status === "absent" &&
102
+ report.lenses.status === "absent" &&
103
+ report.stack.status === "absent";
104
+ if (allAbsent) {
49
105
  lines.push("Speccle is not set up here — run `speccle init`");
50
106
  }
51
107
  else if (report.ok) {
@@ -56,18 +112,18 @@ export function renderDoctor(report) {
56
112
  }
57
113
  return lines.join("\n");
58
114
  }
59
- function describeSkills(skills) {
60
- switch (skills.status) {
115
+ function describePayload(payload, verb) {
116
+ switch (payload.status) {
61
117
  case "current":
62
- return `current (${skills.bundled})`;
118
+ return `current (${payload.bundled})`;
63
119
  case "stale":
64
- return `stale — committed ${skills.recorded}, this CLI ships ${skills.bundled}`;
120
+ return `stale — committed ${payload.recorded}, this CLI ships ${payload.bundled}`;
65
121
  case "ahead":
66
- return `ahead — committed ${skills.recorded} is newer than this CLI (${skills.bundled})`;
122
+ return `ahead — committed ${payload.recorded} is newer than this CLI (${payload.bundled})`;
67
123
  case "unstamped":
68
124
  return "present but unversioned — re-run `speccle init` to record the version";
69
125
  case "absent":
70
- return "not materialized — run `speccle init`";
126
+ return `not ${verb} — run \`speccle init\``;
71
127
  }
72
128
  }
73
129
  function describeStack(stack) {
@@ -90,6 +146,15 @@ export function renderUpdate(report) {
90
146
  else {
91
147
  lines.push(`skills ${report.skills.from ?? "unversioned"} → ${report.skills.to} — review & commit the diff`);
92
148
  }
149
+ if (report.lenses.from === null) {
150
+ lines.push(`lenses vendored at ${report.lenses.to} — new; review & commit the diff`);
151
+ }
152
+ else if (report.lenses.from === report.lenses.to) {
153
+ lines.push(`lenses already at ${report.lenses.to} — refreshed in place; review the diff`);
154
+ }
155
+ else {
156
+ lines.push(`lenses ${report.lenses.from} → ${report.lenses.to} — review & commit the diff`);
157
+ }
93
158
  if (report.stack.status === "absent") {
94
159
  lines.push("stack not provisioned — run `speccle strength init`");
95
160
  }
@@ -128,6 +193,143 @@ function describeReport(check) {
128
193
  return `${check.path} — stale (${check.staleAgainst} is newer)`;
129
194
  return `${check.path} — fresh`;
130
195
  }
196
+ import { API_KEY_SECRET } from "./reviewinit.js";
197
+ export function renderVerify(report) {
198
+ const enforced = report.checks.filter((check) => check.status !== "inactive");
199
+ if (enforced.length === 0) {
200
+ const scanned = plural(report.changed.length, "changed file");
201
+ const authored = report.checks.length === 0 ? "no checks authored" : "no check applied";
202
+ return join(changeSetLine(report), `${authored} — ${scanned} scanned`);
203
+ }
204
+ const lines = [];
205
+ const header = changeSetLine(report);
206
+ if (header !== undefined)
207
+ lines.push(header, "");
208
+ for (const check of enforced.filter((check) => check.status === "breach")) {
209
+ lines.push(renderBreach(check));
210
+ }
211
+ if (lines.length > 0)
212
+ lines.push("");
213
+ const breaches = report.breaches;
214
+ const checks = plural(enforced.length, "check");
215
+ lines.push(report.clean ? `${checks}, clean` : `${checks}, ${plural(breaches, "breach", "breaches")}`);
216
+ return lines.join("\n");
217
+ }
218
+ function renderBreach(check) {
219
+ const lines = [`${check.id} ${check.message}`];
220
+ for (const offender of check.offenders ?? [])
221
+ lines.push(` ${offender}`);
222
+ if (check.because !== undefined)
223
+ lines.push(` because ${check.because}`);
224
+ return lines.join("\n");
225
+ }
226
+ export function renderRisk(report) {
227
+ const lines = [];
228
+ const header = changeSetLine(report);
229
+ if (header !== undefined)
230
+ lines.push(header, "");
231
+ for (const signal of report.signals) {
232
+ lines.push(`${signal.id} +${signal.weight} ${signal.reason}`);
233
+ for (const item of signal.evidence)
234
+ lines.push(` ${item}`);
235
+ if (signal.because !== undefined)
236
+ lines.push(` because ${signal.because}`);
237
+ }
238
+ if (report.signals.length > 0)
239
+ lines.push("");
240
+ else
241
+ lines.push(`no risk signals fired — ${plural(report.changed.length, "changed file")} scanned`);
242
+ lines.push(report.humanRequired
243
+ ? `score ${report.score} ≥ threshold ${report.threshold} — human required, review stops at findings`
244
+ : `score ${report.score} < threshold ${report.threshold} — review may fix and report`);
245
+ // Legibility (ADR-0041): the computed score is a floor a risk lens may raise, never lower.
246
+ lines.push("this score is a floor — a risk lens may escalate it, never lower it");
247
+ return lines.join("\n");
248
+ }
249
+ export function renderCalibrateRecord(report) {
250
+ const entry = report.entry;
251
+ const floor = `score ${entry.score} vs threshold ${entry.threshold}`;
252
+ const gate = entry.humanRequired ? " — floor required a human" : "";
253
+ const escalated = entry.escalated ? " — a lens escalated" : "";
254
+ const verdict = `${entry.verdict.neededHuman ? "needed a human" : "no human needed"}, ${entry.verdict.foundReal ? "found something real" : "found nothing real"}`;
255
+ const lines = [
256
+ `recorded ${report.file} — ${plural(report.count, "entry", "entries")}`,
257
+ ` ${floor}${gate}${escalated}`,
258
+ ` verdict: ${verdict}`,
259
+ ];
260
+ if (entry.signals.length > 0)
261
+ lines.push(` signals: ${entry.signals.join(", ")}`);
262
+ return lines.join("\n");
263
+ }
264
+ export function renderCalibrateReport(report) {
265
+ if (report.count === 0) {
266
+ return "no calibration entries yet — review some changes to build the record";
267
+ }
268
+ const changes = plural(report.count, "reviewed change");
269
+ const lines = [
270
+ `${changes} — ${report.neededByHuman} needed a human, floor gated ${report.gatedByFloor}`,
271
+ ];
272
+ if (report.floorMisses > 0) {
273
+ lines.push(` ${plural(report.floorMisses, "floor miss", "floor misses")} — needed a human the floor did not catch`);
274
+ }
275
+ if (report.overSupervised > 0) {
276
+ lines.push(` ${report.overSupervised} over-supervised — floor required a human the verdict did not`);
277
+ }
278
+ if (report.signals.length > 0) {
279
+ lines.push("");
280
+ const width = Math.max(...report.signals.map((signal) => signal.id.length));
281
+ for (const signal of report.signals) {
282
+ const note = signal.neverUseful
283
+ ? " — never on a change that mattered"
284
+ : signal.firedOnEveryNeeded
285
+ ? " — on every change that needed a human"
286
+ : "";
287
+ lines.push(` ${signal.id.padEnd(width)} ${signal.firedOnMattered}/${signal.fired} mattered${note}`);
288
+ }
289
+ }
290
+ lines.push("");
291
+ lines.push("proposals — evidence, not instructions (only a human reduces supervision):");
292
+ for (const proposal of report.proposals)
293
+ lines.push(` ${proposal}`);
294
+ return lines.join("\n");
295
+ }
296
+ /** The route with its artefact, or the honest one-off — how a remedy's prevention reads on one line. */
297
+ function remedyDestination(route, artefact) {
298
+ return route === "none" ? "one-off — no prevention artefact" : `${route} → ${artefact}`;
299
+ }
300
+ export function renderRemedyRecord(report) {
301
+ const entry = report.entry;
302
+ const lines = [
303
+ `recorded ${report.file} — ${plural(report.count, "remedy", "remedies")}`,
304
+ ` ${entry.class}: ${entry.finding}`,
305
+ ` fix: ${entry.fix}`,
306
+ ` remedy: ${remedyDestination(entry.route, entry.artefact)}`,
307
+ ];
308
+ if (entry.note !== undefined)
309
+ lines.push(` note: ${entry.note}`);
310
+ return lines.join("\n");
311
+ }
312
+ export function renderRemedyRecall(report) {
313
+ if (report.count === 0) {
314
+ return `no remedy record yet — nothing to recall for "${report.query}"`;
315
+ }
316
+ if (report.matches.length === 0) {
317
+ const onRecord = plural(report.count, "remedy", "remedies");
318
+ return `no prior remedy for "${report.query}" — route it fresh, then record it (${onRecord} on record)`;
319
+ }
320
+ const found = plural(report.matches.length, "prior remedy", "prior remedies");
321
+ const lines = [`${found} for "${report.query}" — reuse to fix consistently:`];
322
+ for (const entry of report.matches) {
323
+ lines.push("");
324
+ lines.push(` ${entry.class} (${entry.at})`);
325
+ lines.push(` finding: ${entry.finding}`);
326
+ lines.push(` fix: ${entry.fix}`);
327
+ lines.push(` remedy: ${remedyDestination(entry.route, entry.artefact)}`);
328
+ if (entry.note !== undefined)
329
+ lines.push(` note: ${entry.note}`);
330
+ }
331
+ return lines.join("\n");
332
+ }
131
333
  export function renderClaims(report) {
132
334
  if (report.features.length === 0)
133
335
  return "No SPEC.md files found.";
@@ -310,6 +512,19 @@ function ansi(text, code) {
310
512
  function bold(text, color) {
311
513
  return color ? ansi(text, "1") : text;
312
514
  }
313
- function plural(n, noun) {
314
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
515
+ function plural(n, noun, plural = `${noun}s`) {
516
+ return `${n} ${n === 1 ? noun : plural}`;
517
+ }
518
+ /**
519
+ * Names the change set a committed-range run was measured over — a score whose change set is
520
+ * invisible is not auditable, and the CI driver's whole output is read from a log. Absent for
521
+ * the working tree's pending change, where "what changed" is already in front of you.
522
+ */
523
+ function changeSetLine(report) {
524
+ if (report.base === undefined)
525
+ return undefined;
526
+ return `change set: ${report.base}...HEAD — ${plural(report.changed.length, "file")}`;
527
+ }
528
+ function join(...lines) {
529
+ return lines.filter((line) => line !== undefined).join("\n");
315
530
  }
@@ -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
+ }