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/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 the three things that drift in a Speccle consumer — the CLI, the
8
- * committed skills, and the strength stack — and never mutates a byte (#182, ADR-0046).
9
- * Offline and deterministic: it compares the repo against the installed CLI, not against
10
- * the npm registry — "is there a newer release" is `update`'s network job, not this one'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 recorded = config?.skillsVersion ?? null;
19
- const skillsStatus = deriveSkillsStatus(await hasSkills(root), recorded, cli);
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 ok = (skillsStatus === "current" || skillsStatus === "absent") && stackStatus !== "drift";
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 deriveSkillsStatus(present, recorded, bundled) {
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
+ }