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.
- package/README.md +290 -3
- package/dist/calibration.js +156 -0
- package/dist/changeset.js +141 -0
- package/dist/claims.js +5 -1
- package/dist/cli.js +596 -4
- package/dist/config.js +117 -0
- package/dist/doctor.js +141 -0
- package/dist/init.js +5 -4
- package/dist/lenses.js +68 -0
- package/dist/remedy.js +133 -0
- package/dist/render.js +323 -2
- package/dist/reviewinit.js +139 -0
- package/dist/reviewrun.js +516 -0
- package/dist/risk.js +228 -0
- package/dist/skills.js +63 -0
- package/dist/update.js +48 -0
- 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 +7 -5
- package/skills/carve-feature/SKILL.md +183 -0
- package/skills/carve-feature/references/convention.md +239 -0
- package/skills/conform/SKILL.md +129 -0
- package/skills/conform/references/convention.md +239 -0
- package/skills/feature/SKILL.md +103 -0
- package/skills/implement-feature/SKILL.md +152 -0
- package/skills/implement-feature/references/convention.md +239 -0
- package/skills/plan-feature/SKILL.md +125 -0
- package/skills/plan-feature/references/convention.md +239 -0
- package/skills/review/SKILL.md +190 -0
- package/skills/spec-feature/SKILL.md +163 -0
- package/skills/spec-feature/references/convention.md +239 -0
- package/skills/strengthen/SKILL.md +184 -0
- package/skills/strengthen/references/heatmap.md +39 -0
- package/skills/strengthen/references/stack.md +23 -0
package/dist/render.js
CHANGED
|
@@ -1,3 +1,174 @@
|
|
|
1
|
+
export function renderConfigInit(report) {
|
|
2
|
+
const lines = [
|
|
3
|
+
report.action === "written"
|
|
4
|
+
? `wrote ${report.file}`
|
|
5
|
+
: `kept ${report.file} — already present, left untouched`,
|
|
6
|
+
` dialect ${report.config.dialect}`,
|
|
7
|
+
` suite ${report.config.suite}`,
|
|
8
|
+
];
|
|
9
|
+
for (const override of report.config.overrides ?? []) {
|
|
10
|
+
const facts = [
|
|
11
|
+
override.dialect !== undefined ? `dialect ${override.dialect}` : undefined,
|
|
12
|
+
override.suite !== undefined ? `suite ${override.suite}` : undefined,
|
|
13
|
+
].filter((fact) => fact !== undefined);
|
|
14
|
+
lines.push(` ${override.path}: ${facts.join(", ")}`);
|
|
15
|
+
}
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push("these are facts about your repo, not judgement — edit .speccle/config.json by hand");
|
|
18
|
+
if (report.config.dialect === "swift" && report.config.suite === "swift test") {
|
|
19
|
+
lines.push("swift: set suite to your xcodebuild scheme if this is not a SwiftPM package");
|
|
20
|
+
}
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
export function renderSkillsInit(report) {
|
|
24
|
+
const lines = [`materialized ${plural(report.skills.length, "skill")} into ${report.dir}/`];
|
|
25
|
+
for (const { name, action } of report.skills) {
|
|
26
|
+
lines.push(` ${(action === "written" ? "wrote" : "refreshed").padEnd(9)} ${name}`);
|
|
27
|
+
}
|
|
28
|
+
lines.push("");
|
|
29
|
+
lines.push("these are generated files — commit them; re-run `speccle init` to refresh");
|
|
30
|
+
return lines.join("\n");
|
|
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
|
+
}
|
|
84
|
+
export function renderDoctor(report) {
|
|
85
|
+
const lines = [
|
|
86
|
+
`speccle ${report.cli}`,
|
|
87
|
+
"",
|
|
88
|
+
`skills ${describePayload(report.skills, "materialized")}`,
|
|
89
|
+
`lenses ${describePayload(report.lenses, "vendored")}`,
|
|
90
|
+
`stack ${describeStack(report.stack)}`,
|
|
91
|
+
];
|
|
92
|
+
if (report.stack.status === "drift") {
|
|
93
|
+
const width = Math.max(...report.stack.deps.map((dep) => dep.name.length));
|
|
94
|
+
for (const dep of report.stack.deps) {
|
|
95
|
+
if (dep.status === "ok")
|
|
96
|
+
continue;
|
|
97
|
+
lines.push(` ${dep.name.padEnd(width)} ${describeDep(dep)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
lines.push("");
|
|
101
|
+
const allAbsent = report.skills.status === "absent" &&
|
|
102
|
+
report.lenses.status === "absent" &&
|
|
103
|
+
report.stack.status === "absent";
|
|
104
|
+
if (allAbsent) {
|
|
105
|
+
lines.push("Speccle is not set up here — run `speccle init`");
|
|
106
|
+
}
|
|
107
|
+
else if (report.ok) {
|
|
108
|
+
lines.push("up to date");
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
lines.push("out of date — run `speccle update`");
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
function describePayload(payload, verb) {
|
|
116
|
+
switch (payload.status) {
|
|
117
|
+
case "current":
|
|
118
|
+
return `current (${payload.bundled})`;
|
|
119
|
+
case "stale":
|
|
120
|
+
return `stale — committed ${payload.recorded}, this CLI ships ${payload.bundled}`;
|
|
121
|
+
case "ahead":
|
|
122
|
+
return `ahead — committed ${payload.recorded} is newer than this CLI (${payload.bundled})`;
|
|
123
|
+
case "unstamped":
|
|
124
|
+
return "present but unversioned — re-run `speccle init` to record the version";
|
|
125
|
+
case "absent":
|
|
126
|
+
return `not ${verb} — run \`speccle init\``;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function describeStack(stack) {
|
|
130
|
+
if (stack.status === "absent")
|
|
131
|
+
return "not provisioned — run `speccle strength init`";
|
|
132
|
+
if (stack.status === "current")
|
|
133
|
+
return "current";
|
|
134
|
+
return "drift — the stack is behind the current preset";
|
|
135
|
+
}
|
|
136
|
+
function describeDep(dep) {
|
|
137
|
+
return dep.status === "missing"
|
|
138
|
+
? `missing — preset wants ^${dep.wantedMajor}`
|
|
139
|
+
: `behind — has ${dep.declared}, preset wants ^${dep.wantedMajor}`;
|
|
140
|
+
}
|
|
141
|
+
export function renderUpdate(report) {
|
|
142
|
+
const lines = [];
|
|
143
|
+
if (report.skills.from === report.skills.to) {
|
|
144
|
+
lines.push(`skills already at ${report.skills.to} — refreshed in place; review the diff`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
lines.push(`skills ${report.skills.from ?? "unversioned"} → ${report.skills.to} — review & commit the diff`);
|
|
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
|
+
}
|
|
158
|
+
if (report.stack.status === "absent") {
|
|
159
|
+
lines.push("stack not provisioned — run `speccle strength init`");
|
|
160
|
+
}
|
|
161
|
+
else if (report.stack.status === "current") {
|
|
162
|
+
lines.push("stack up to date");
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
lines.push("stack behind the preset — run:");
|
|
166
|
+
lines.push(` ${report.stack.fixCommand}`);
|
|
167
|
+
}
|
|
168
|
+
lines.push(`cli ${report.cli.version} installed — update the binary with:`);
|
|
169
|
+
lines.push(` ${report.cli.command}`);
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
1
172
|
export function renderCheck(report) {
|
|
2
173
|
const lines = [
|
|
3
174
|
`mutation ${describeReport(report.mutation)}`,
|
|
@@ -22,6 +193,143 @@ function describeReport(check) {
|
|
|
22
193
|
return `${check.path} — stale (${check.staleAgainst} is newer)`;
|
|
23
194
|
return `${check.path} — fresh`;
|
|
24
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
|
+
}
|
|
25
333
|
export function renderClaims(report) {
|
|
26
334
|
if (report.features.length === 0)
|
|
27
335
|
return "No SPEC.md files found.";
|
|
@@ -204,6 +512,19 @@ function ansi(text, code) {
|
|
|
204
512
|
function bold(text, color) {
|
|
205
513
|
return color ? ansi(text, "1") : text;
|
|
206
514
|
}
|
|
207
|
-
function plural(n, noun) {
|
|
208
|
-
return `${n} ${
|
|
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");
|
|
209
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
|
+
}
|