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/doctor.js
CHANGED
|
@@ -2,12 +2,15 @@ import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { readConfig } from "./config.js";
|
|
4
4
|
import { ownVersion, STRENGTH_DEPS, STRYKER_CONFIG_NAMES } from "./init.js";
|
|
5
|
+
import { LENSES_DIR } from "./lenses.js";
|
|
6
|
+
import { pinnedVersion, WORKFLOW_FILE } from "./reviewinit.js";
|
|
5
7
|
import { SKILLS_DIR } from "./skills.js";
|
|
6
8
|
/**
|
|
7
|
-
* Reports the truth about
|
|
8
|
-
* committed skills, and the strength stack — and never
|
|
9
|
-
* Offline and deterministic: it compares the repo against
|
|
10
|
-
* the npm registry — "is there a newer release" is `update`'s
|
|
9
|
+
* Reports the truth about everything that drifts in a Speccle consumer — the CLI, the
|
|
10
|
+
* committed skills and lenses, the CI driver's pin, and the strength stack — and never
|
|
11
|
+
* mutates a byte (#182, ADR-0046). Offline and deterministic: it compares the repo against
|
|
12
|
+
* the installed CLI, not against the npm registry — "is there a newer release" is `update`'s
|
|
13
|
+
* network job, not this one's.
|
|
11
14
|
*/
|
|
12
15
|
export async function doctor(target) {
|
|
13
16
|
const root = resolve(target);
|
|
@@ -15,8 +18,13 @@ export async function doctor(target) {
|
|
|
15
18
|
throw new Error(`path not found: ${target}`);
|
|
16
19
|
const cli = await ownVersion();
|
|
17
20
|
const config = await readConfig(root);
|
|
18
|
-
const
|
|
19
|
-
const skillsStatus =
|
|
21
|
+
const skillsRecorded = config?.skillsVersion ?? null;
|
|
22
|
+
const skillsStatus = derivePayloadStatus(await hasSkills(root), skillsRecorded, cli);
|
|
23
|
+
const lensesRecorded = config?.lensesVersion ?? null;
|
|
24
|
+
const lensesStatus = derivePayloadStatus(await hasLenses(root), lensesRecorded, cli);
|
|
25
|
+
const workflow = await readMaybe(join(root, WORKFLOW_FILE));
|
|
26
|
+
const driverPin = workflow === undefined ? null : (pinnedVersion(workflow) ?? null);
|
|
27
|
+
const driverStatus = derivePayloadStatus(workflow !== undefined, driverPin, cli);
|
|
20
28
|
const provisioned = await anyPresent(root, STRYKER_CONFIG_NAMES);
|
|
21
29
|
const declared = await declaredDeps(root);
|
|
22
30
|
const deps = STRENGTH_DEPS.map((spec) => checkDep(spec, declared));
|
|
@@ -25,16 +33,22 @@ export async function doctor(target) {
|
|
|
25
33
|
: deps.some((dep) => dep.status !== "ok")
|
|
26
34
|
? "drift"
|
|
27
35
|
: "current";
|
|
28
|
-
const
|
|
36
|
+
const current = (status) => status === "current" || status === "absent";
|
|
37
|
+
const ok = current(skillsStatus) &&
|
|
38
|
+
current(lensesStatus) &&
|
|
39
|
+
current(driverStatus) &&
|
|
40
|
+
stackStatus !== "drift";
|
|
29
41
|
return {
|
|
30
42
|
root,
|
|
31
43
|
cli,
|
|
32
|
-
skills: { recorded, bundled: cli, status: skillsStatus },
|
|
44
|
+
skills: { recorded: skillsRecorded, bundled: cli, status: skillsStatus },
|
|
45
|
+
lenses: { recorded: lensesRecorded, bundled: cli, status: lensesStatus },
|
|
46
|
+
driver: { recorded: driverPin, bundled: cli, status: driverStatus },
|
|
33
47
|
stack: { provisioned, deps, status: stackStatus },
|
|
34
48
|
ok,
|
|
35
49
|
};
|
|
36
50
|
}
|
|
37
|
-
function
|
|
51
|
+
function derivePayloadStatus(present, recorded, bundled) {
|
|
38
52
|
if (!present)
|
|
39
53
|
return "absent";
|
|
40
54
|
if (recorded === null)
|
|
@@ -82,6 +96,23 @@ async function hasSkills(root) {
|
|
|
82
96
|
return false;
|
|
83
97
|
}
|
|
84
98
|
}
|
|
99
|
+
async function hasLenses(root) {
|
|
100
|
+
try {
|
|
101
|
+
const entries = await readdir(join(root, LENSES_DIR));
|
|
102
|
+
return entries.some((entry) => entry.endsWith(".md"));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function readMaybe(path) {
|
|
109
|
+
try {
|
|
110
|
+
return await readFile(path, "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
85
116
|
async function anyPresent(root, names) {
|
|
86
117
|
for (const name of names) {
|
|
87
118
|
if (await exists(join(root, name)))
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Spawning by name makes every call walk PATH, and the misses are what cost — measured at ~8ms
|
|
6
|
+
* each on macOS, against ~2ms for git itself. Under `npx`/`pnpm` the caller's PATH puts a dozen
|
|
7
|
+
* or more entries ahead of git's, so `risk`, which reads a baseline once per changed spec file,
|
|
8
|
+
* paid that walk per file. Resolved once here instead: the binary is fixed at module load rather
|
|
9
|
+
* than per call, which suits a short-lived CLI but means a PATH edit mid-process is not seen.
|
|
10
|
+
*/
|
|
11
|
+
const GIT = (process.env.PATH ?? "")
|
|
12
|
+
.split(delimiter)
|
|
13
|
+
.map((dir) => join(dir, "git"))
|
|
14
|
+
.find((candidate) => existsSync(candidate)) ?? "git";
|
|
15
|
+
/** Run one git command in `root`, with the binary resolved once. */
|
|
16
|
+
export function git(root, args) {
|
|
17
|
+
return spawnSync(GIT, args, { cwd: root, encoding: "utf8" });
|
|
18
|
+
}
|
|
19
|
+
/** Git's stdout, or undefined when the command could not run or failed. */
|
|
20
|
+
export function gitStdout(root, args) {
|
|
21
|
+
const result = git(root, args);
|
|
22
|
+
return result.error === undefined && result.status === 0 ? result.stdout : undefined;
|
|
23
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -10,6 +10,10 @@ export const STRENGTH_DEPS = [
|
|
|
10
10
|
"@stryker-mutator/core@^9",
|
|
11
11
|
"@stryker-mutator/vitest-runner@^9",
|
|
12
12
|
];
|
|
13
|
+
// Names this CLI has published under before (ADR-0045). A repo provisioned under an old name
|
|
14
|
+
// keeps that package beside the current one, and `node_modules/.bin/` still exposes its stale
|
|
15
|
+
// binary — dead weight the ladder never picks, but weight the repo should know it is carrying.
|
|
16
|
+
export const SUPERSEDED_DEPS = ["speccle-oracle"];
|
|
13
17
|
const LOCKFILES = [
|
|
14
18
|
["pnpm-lock.yaml", "pnpm"],
|
|
15
19
|
["package-lock.json", "npm"],
|
|
@@ -67,6 +71,8 @@ export async function init(root, options = {}) {
|
|
|
67
71
|
const wantedDeps = [`speccle@^${await ownVersion()}`, ...STRENGTH_DEPS];
|
|
68
72
|
const missingDeps = wantedDeps.filter((spec) => !declared.has(withoutVersion(spec)));
|
|
69
73
|
const installCommand = missingDeps.length > 0 ? installCommandFor(packageManager, missingDeps) : null;
|
|
74
|
+
const supersededDeps = SUPERSEDED_DEPS.filter((name) => declared.has(name));
|
|
75
|
+
const removeCommand = supersededDeps.length > 0 ? removeCommandFor(packageManager, supersededDeps) : null;
|
|
70
76
|
const files = [
|
|
71
77
|
await provision(root, STRYKER_CONFIG_NAMES, "stryker.config.json", () => JSON.stringify(strykerConfig(packageManager, mutate), null, 2) + "\n"),
|
|
72
78
|
await provision(root, VITEST_CONFIG_NAMES, "vitest.config.ts", () => vitestConfig(mutate)),
|
|
@@ -83,6 +89,8 @@ export async function init(root, options = {}) {
|
|
|
83
89
|
missingDeps,
|
|
84
90
|
installCommand,
|
|
85
91
|
installRan,
|
|
92
|
+
supersededDeps,
|
|
93
|
+
removeCommand,
|
|
86
94
|
mutate,
|
|
87
95
|
files,
|
|
88
96
|
doubleLoad,
|
|
@@ -143,6 +151,10 @@ export function installCommandFor(manager, deps) {
|
|
|
143
151
|
const subcommand = manager === "npm" ? "install -D" : manager === "bun" ? "add -d" : "add -D";
|
|
144
152
|
return `${manager} ${subcommand} ${deps.join(" ")}`;
|
|
145
153
|
}
|
|
154
|
+
export function removeCommandFor(manager, deps) {
|
|
155
|
+
const subcommand = manager === "npm" ? "uninstall" : "remove";
|
|
156
|
+
return `${manager} ${subcommand} ${deps.join(" ")}`;
|
|
157
|
+
}
|
|
146
158
|
function runInstall(root, command) {
|
|
147
159
|
const [manager, ...args] = command.split(" ");
|
|
148
160
|
const result = spawnSync(manager, args, { cwd: root, encoding: "utf8" });
|
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
|
+
}
|