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/README.md +280 -2
- package/dist/calibration.js +156 -0
- package/dist/changeset.js +141 -0
- package/dist/cli.js +486 -9
- package/dist/config.js +13 -10
- package/dist/doctor.js +19 -5
- package/dist/lenses.js +68 -0
- package/dist/remedy.js +133 -0
- package/dist/render.js +225 -10
- package/dist/reviewinit.js +139 -0
- package/dist/reviewrun.js +516 -0
- package/dist/risk.js +228 -0
- package/dist/update.js +15 -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/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 ${
|
|
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
|
-
|
|
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
|
|
60
|
-
switch (
|
|
115
|
+
function describePayload(payload, verb) {
|
|
116
|
+
switch (payload.status) {
|
|
61
117
|
case "current":
|
|
62
|
-
return `current (${
|
|
118
|
+
return `current (${payload.bundled})`;
|
|
63
119
|
case "stale":
|
|
64
|
-
return `stale — committed ${
|
|
120
|
+
return `stale — committed ${payload.recorded}, this CLI ships ${payload.bundled}`;
|
|
65
121
|
case "ahead":
|
|
66
|
-
return `ahead — committed ${
|
|
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
|
|
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} ${
|
|
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
|
+
}
|