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.
Files changed (41) hide show
  1. package/README.md +290 -3
  2. package/dist/calibration.js +156 -0
  3. package/dist/changeset.js +141 -0
  4. package/dist/claims.js +5 -1
  5. package/dist/cli.js +596 -4
  6. package/dist/config.js +117 -0
  7. package/dist/doctor.js +141 -0
  8. package/dist/init.js +5 -4
  9. package/dist/lenses.js +68 -0
  10. package/dist/remedy.js +133 -0
  11. package/dist/render.js +323 -2
  12. package/dist/reviewinit.js +139 -0
  13. package/dist/reviewrun.js +516 -0
  14. package/dist/risk.js +228 -0
  15. package/dist/skills.js +63 -0
  16. package/dist/update.js +48 -0
  17. package/dist/verify.js +92 -0
  18. package/lenses/accessibility.md +39 -0
  19. package/lenses/architecture.md +38 -0
  20. package/lenses/correctness.md +36 -0
  21. package/lenses/house-conventions.md +37 -0
  22. package/lenses/performance.md +40 -0
  23. package/lenses/risk.md +45 -0
  24. package/lenses/security.md +42 -0
  25. package/lenses/test-quality.md +39 -0
  26. package/package.json +7 -5
  27. package/skills/carve-feature/SKILL.md +183 -0
  28. package/skills/carve-feature/references/convention.md +239 -0
  29. package/skills/conform/SKILL.md +129 -0
  30. package/skills/conform/references/convention.md +239 -0
  31. package/skills/feature/SKILL.md +103 -0
  32. package/skills/implement-feature/SKILL.md +152 -0
  33. package/skills/implement-feature/references/convention.md +239 -0
  34. package/skills/plan-feature/SKILL.md +125 -0
  35. package/skills/plan-feature/references/convention.md +239 -0
  36. package/skills/review/SKILL.md +190 -0
  37. package/skills/spec-feature/SKILL.md +163 -0
  38. package/skills/spec-feature/references/convention.md +239 -0
  39. package/skills/strengthen/SKILL.md +184 -0
  40. package/skills/strengthen/references/heatmap.md +39 -0
  41. package/skills/strengthen/references/stack.md +23 -0
package/dist/config.js ADDED
@@ -0,0 +1,117 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
4
+ import { detectPackageManager } from "./init.js";
5
+ /** Where Speccle reads a repo's facts (ADR-0040). */
6
+ export const CONFIG_DIR = ".speccle";
7
+ export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
8
+ /** Reads `.speccle/config.json`; undefined when the repo has no config yet. */
9
+ export async function readConfig(root) {
10
+ let raw;
11
+ try {
12
+ raw = await readFile(join(root, CONFIG_FILE), "utf8");
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ let config;
18
+ try {
19
+ config = JSON.parse(raw);
20
+ }
21
+ catch (err) {
22
+ throw new Error(`${CONFIG_FILE} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
23
+ }
24
+ // A wrong dialect must be a loud, visible failure — that is the whole point of writing
25
+ // it down rather than guessing every time (ADR-0040).
26
+ assertDialect(config.dialect);
27
+ for (const override of config.overrides ?? []) {
28
+ if (override.dialect !== undefined)
29
+ assertDialect(override.dialect);
30
+ }
31
+ return config;
32
+ }
33
+ /**
34
+ * The dialect and suite in force at a root-relative path. Every override whose path is a
35
+ * prefix of `path` applies, most-specific last, so a longer override wins per field and a
36
+ * shorter one still fills a field the longer omits.
37
+ */
38
+ export function resolveFacts(config, path) {
39
+ let { dialect, suite } = config;
40
+ const matching = (config.overrides ?? [])
41
+ .filter((override) => underPath(path, override.path))
42
+ .sort((a, b) => a.path.length - b.path.length);
43
+ for (const override of matching) {
44
+ if (override.dialect !== undefined)
45
+ dialect = override.dialect;
46
+ if (override.suite !== undefined)
47
+ suite = override.suite;
48
+ }
49
+ return { dialect, suite };
50
+ }
51
+ /** Speccle's first guess at a repo's facts — written down, then corrected by hand. */
52
+ export async function detectConfig(root) {
53
+ const dialect = await detectDialect(root);
54
+ return { dialect, suite: await detectSuite(root, dialect) };
55
+ }
56
+ /**
57
+ * Writes `.speccle/config.json` from detection, or keeps an existing one untouched — the
58
+ * written record is the source of truth, never the detection (ADR-0040), the same posture
59
+ * `strength init` takes with the stack it provisions.
60
+ *
61
+ * When `payloadVersion` is given, it is (re)stamped onto both the skills and the lenses
62
+ * anchors even on the kept path: `init`/`update` materialize both from the same tarball, so
63
+ * the two anchors must move with them or a re-run would leave a recorded version behind the
64
+ * files it just wrote. The facts themselves stay kept — only the stamps follow the payload.
65
+ */
66
+ export async function initConfig(root, payloadVersion) {
67
+ const stamped = (config) => payloadVersion === undefined
68
+ ? config
69
+ : { ...config, skillsVersion: payloadVersion, lensesVersion: payloadVersion };
70
+ const existing = await readConfig(root);
71
+ if (existing !== undefined) {
72
+ const config = stamped(existing);
73
+ const moved = payloadVersion !== undefined &&
74
+ (existing.skillsVersion !== payloadVersion || existing.lensesVersion !== payloadVersion);
75
+ if (moved)
76
+ await writeConfig(root, config);
77
+ return { root, file: CONFIG_FILE, action: "kept", config };
78
+ }
79
+ const config = stamped(await detectConfig(root));
80
+ await writeConfig(root, config);
81
+ return { root, file: CONFIG_FILE, action: "written", config };
82
+ }
83
+ export async function writeConfig(root, config) {
84
+ await mkdir(join(root, CONFIG_DIR), { recursive: true });
85
+ await writeFile(join(root, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n");
86
+ }
87
+ async function detectDialect(root) {
88
+ if (await exists(join(root, "Package.swift")))
89
+ return "swift";
90
+ // package.json or nothing: the TypeScript stack is the default dialect.
91
+ return DEFAULT_DIALECT;
92
+ }
93
+ // The default suite runs the repo's own `test` script, except a swift repo, whose
94
+ // SwiftPM default cannot know an xcodebuild scheme (ADR-0040) — the human corrects it.
95
+ async function detectSuite(root, dialect) {
96
+ if (dialect === "swift")
97
+ return "swift test";
98
+ const packageManager = await detectPackageManager(root);
99
+ return packageManager === "bun" ? "bun run test" : `${packageManager} test`;
100
+ }
101
+ function assertDialect(name) {
102
+ if (!DIALECT_NAMES.includes(name)) {
103
+ throw new Error(`${CONFIG_FILE}: unknown test dialect: ${name} — known dialects: ${DIALECT_NAMES.join(", ")}`);
104
+ }
105
+ }
106
+ function underPath(path, prefix) {
107
+ return path === prefix || path.startsWith(`${prefix}/`);
108
+ }
109
+ async function exists(path) {
110
+ try {
111
+ await access(path);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,141 @@
1
+ import { access, readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { readConfig } from "./config.js";
4
+ import { ownVersion, STRENGTH_DEPS, STRYKER_CONFIG_NAMES } from "./init.js";
5
+ import { LENSES_DIR } from "./lenses.js";
6
+ import { SKILLS_DIR } from "./skills.js";
7
+ /**
8
+ * Reports the truth about the three things that drift in a Speccle consumer — the CLI, the
9
+ * committed skills, and the strength stack — and never mutates a byte (#182, ADR-0046).
10
+ * Offline and deterministic: it compares the repo against the installed CLI, not against
11
+ * the npm registry — "is there a newer release" is `update`'s network job, not this one's.
12
+ */
13
+ export async function doctor(target) {
14
+ const root = resolve(target);
15
+ if (!(await isDirectory(root)))
16
+ throw new Error(`path not found: ${target}`);
17
+ const cli = await ownVersion();
18
+ const config = await readConfig(root);
19
+ const skillsRecorded = config?.skillsVersion ?? null;
20
+ const skillsStatus = derivePayloadStatus(await hasSkills(root), skillsRecorded, cli);
21
+ const lensesRecorded = config?.lensesVersion ?? null;
22
+ const lensesStatus = derivePayloadStatus(await hasLenses(root), lensesRecorded, cli);
23
+ const provisioned = await anyPresent(root, STRYKER_CONFIG_NAMES);
24
+ const declared = await declaredDeps(root);
25
+ const deps = STRENGTH_DEPS.map((spec) => checkDep(spec, declared));
26
+ const stackStatus = !provisioned
27
+ ? "absent"
28
+ : deps.some((dep) => dep.status !== "ok")
29
+ ? "drift"
30
+ : "current";
31
+ const current = (status) => status === "current" || status === "absent";
32
+ const ok = current(skillsStatus) && current(lensesStatus) && stackStatus !== "drift";
33
+ return {
34
+ root,
35
+ cli,
36
+ skills: { recorded: skillsRecorded, bundled: cli, status: skillsStatus },
37
+ lenses: { recorded: lensesRecorded, bundled: cli, status: lensesStatus },
38
+ stack: { provisioned, deps, status: stackStatus },
39
+ ok,
40
+ };
41
+ }
42
+ function derivePayloadStatus(present, recorded, bundled) {
43
+ if (!present)
44
+ return "absent";
45
+ if (recorded === null)
46
+ return "unstamped";
47
+ const order = compareVersions(recorded, bundled);
48
+ return order === 0 ? "current" : order < 0 ? "stale" : "ahead";
49
+ }
50
+ function checkDep(spec, declared) {
51
+ const at = spec.lastIndexOf("@");
52
+ const name = spec.slice(0, at);
53
+ const wantedMajor = majorOf(spec.slice(at + 1)) ?? 0;
54
+ const range = declared.get(name);
55
+ if (range === undefined)
56
+ return { name, wantedMajor, declared: null, status: "missing" };
57
+ const declaredMajor = majorOf(range);
58
+ // A repo ahead of the preset is the consumer's choice, not drift to nag about; only a
59
+ // major below the preset is the "bump never propagated" bug this surface exists to catch.
60
+ const status = declaredMajor !== null && declaredMajor < wantedMajor ? "behind" : "ok";
61
+ return { name, wantedMajor, declared: range, status };
62
+ }
63
+ /** The union of a package.json's dependencies and devDependencies; empty when absent. */
64
+ async function declaredDeps(root) {
65
+ let raw;
66
+ try {
67
+ raw = await readFile(join(root, "package.json"), "utf8");
68
+ }
69
+ catch {
70
+ return new Map();
71
+ }
72
+ let pkg;
73
+ try {
74
+ pkg = JSON.parse(raw);
75
+ }
76
+ catch {
77
+ return new Map();
78
+ }
79
+ return new Map(Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }));
80
+ }
81
+ async function hasSkills(root) {
82
+ try {
83
+ const entries = await readdir(join(root, SKILLS_DIR), { withFileTypes: true });
84
+ return entries.some((entry) => entry.isDirectory());
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ async function hasLenses(root) {
91
+ try {
92
+ const entries = await readdir(join(root, LENSES_DIR));
93
+ return entries.some((entry) => entry.endsWith(".md"));
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ async function anyPresent(root, names) {
100
+ for (const name of names) {
101
+ if (await exists(join(root, name)))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ /** The leading integer of a version or range — 4 from "^4", "4.1.0", ">=4.0.0". */
107
+ function majorOf(range) {
108
+ const digits = /\d+/.exec(range);
109
+ return digits === null ? null : Number(digits[0]);
110
+ }
111
+ function compareVersions(a, b) {
112
+ const [pa, pb] = [parseVersion(a), parseVersion(b)];
113
+ for (let i = 0; i < 3; i++) {
114
+ if (pa[i] !== pb[i])
115
+ return pa[i] < pb[i] ? -1 : 1;
116
+ }
117
+ return 0;
118
+ }
119
+ /** [major, minor, patch] of a clean semver, prerelease and build metadata dropped. */
120
+ function parseVersion(version) {
121
+ const core = version.split(/[-+]/)[0] ?? "";
122
+ const parts = core.split(".").map((part) => Number(part) || 0);
123
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
124
+ }
125
+ async function isDirectory(path) {
126
+ try {
127
+ return (await stat(path)).isDirectory();
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
133
+ async function exists(path) {
134
+ try {
135
+ await access(path);
136
+ return true;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
package/dist/init.js CHANGED
@@ -17,7 +17,7 @@ const LOCKFILES = [
17
17
  ["bun.lock", "bun"],
18
18
  ["bun.lockb", "bun"],
19
19
  ];
20
- const STRYKER_CONFIG_NAMES = ["stryker.config", "stryker.conf"].flatMap((base) => ["json", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
20
+ export const STRYKER_CONFIG_NAMES = ["stryker.config", "stryker.conf"].flatMap((base) => ["json", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
21
21
  const VITEST_CONFIG_NAMES = ["vitest.config", "vite.config"].flatMap((base) => ["ts", "mts", "cts", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
22
22
  export function strykerConfig(packageManager, mutate) {
23
23
  return {
@@ -109,7 +109,8 @@ export async function detectDoubleLoad(root, settingsFile = join(homedir(), ".cl
109
109
  return false;
110
110
  }
111
111
  }
112
- async function ownVersion() {
112
+ /** The installed CLI's own version — the `speccle@X` that ships this bin and its skills. */
113
+ export async function ownVersion() {
113
114
  const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
114
115
  return JSON.parse(raw).version;
115
116
  }
@@ -123,7 +124,7 @@ async function readPackageJson(root) {
123
124
  }
124
125
  return JSON.parse(raw);
125
126
  }
126
- async function detectPackageManager(root) {
127
+ export async function detectPackageManager(root) {
127
128
  for (const [lockfile, manager] of LOCKFILES) {
128
129
  if (await exists(join(root, lockfile)))
129
130
  return manager;
@@ -138,7 +139,7 @@ async function provision(root, existingNames, writeName, content) {
138
139
  await writeFile(join(root, writeName), content());
139
140
  return { file: writeName, action: "written" };
140
141
  }
141
- function installCommandFor(manager, deps) {
142
+ export function installCommandFor(manager, deps) {
142
143
  const subcommand = manager === "npm" ? "install -D" : manager === "bun" ? "add -d" : "add -D";
143
144
  return `${manager} ${subcommand} ${deps.join(" ")}`;
144
145
  }
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
+ }