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/README.md +321 -9
- package/dist/calibration.js +156 -0
- package/dist/changeset.js +136 -0
- package/dist/claims.js +24 -12
- package/dist/cli.js +497 -10
- package/dist/config.js +13 -10
- package/dist/doctor.js +40 -9
- package/dist/git.js +23 -0
- package/dist/init.js +12 -0
- package/dist/lenses.js +68 -0
- package/dist/remedy.js +133 -0
- package/dist/render.js +277 -16
- package/dist/reviewinit.js +139 -0
- package/dist/reviewrun.js +516 -0
- package/dist/risk.js +243 -0
- package/dist/update.js +23 -6
- package/dist/verify.js +92 -0
- package/lenses/accessibility.md +39 -0
- package/lenses/architecture.md +38 -0
- package/lenses/correctness.md +36 -0
- package/lenses/house-conventions.md +37 -0
- package/lenses/performance.md +40 -0
- package/lenses/risk.md +45 -0
- package/lenses/security.md +42 -0
- package/lenses/test-quality.md +39 -0
- package/package.json +5 -4
- package/skills/review/SKILL.md +190 -0
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 ${
|
|
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
|
-
|
|
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
|
|
60
|
-
switch (
|
|
117
|
+
function describePayload(payload, verb) {
|
|
118
|
+
switch (payload.status) {
|
|
61
119
|
case "current":
|
|
62
|
-
return `current (${
|
|
120
|
+
return `current (${payload.bundled})`;
|
|
63
121
|
case "stale":
|
|
64
|
-
return `stale — committed ${
|
|
122
|
+
return `stale — committed ${payload.recorded}, this CLI ships ${payload.bundled}`;
|
|
65
123
|
case "ahead":
|
|
66
|
-
return `ahead — committed ${
|
|
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
|
|
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 ${
|
|
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 = `${
|
|
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(
|
|
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} ${
|
|
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
|
+
}
|