@supersuit/hyperspec 0.1.0 → 0.3.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 (48) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +105 -4
  3. package/SPEC.md +205 -13
  4. package/WRITING.md +426 -0
  5. package/bin/hyperspec.mjs +322 -2
  6. package/examples/minimal.hyperspec.md +2 -2
  7. package/examples/recipe/doctor.mjs +11 -0
  8. package/examples/recipe/essay.hyperspec.md +50 -0
  9. package/examples/recipe/factory.mjs +29 -0
  10. package/examples/recipe/materials/call-2.md +2 -0
  11. package/examples/recipe/materials/call.md +3 -0
  12. package/examples/recipe/materials/notes.md +3 -0
  13. package/examples/recipe/runner.mjs +20 -0
  14. package/examples/recipe/runs.jsonl +0 -0
  15. package/examples/recipe/stages.mjs +31 -0
  16. package/examples/writing/essay/goldens/close.md +2 -0
  17. package/examples/writing/essay/goldens/opening.md +2 -0
  18. package/examples/writing/essay/materials/interview-notes.md +12 -0
  19. package/examples/writing/essay/materials/team-survey.md +7 -0
  20. package/examples/writing/essay/materials/voice-memo.md +18 -0
  21. package/examples/writing/essay/runs.jsonl +0 -0
  22. package/examples/writing/essay.hyperspec.md +217 -0
  23. package/examples/writing/story/goldens/dialogue.md +3 -0
  24. package/examples/writing/story/goldens/opening.md +3 -0
  25. package/examples/writing/story/materials/bakery-visit.md +8 -0
  26. package/examples/writing/story/materials/notes.md +14 -0
  27. package/examples/writing/story/materials/scene-list.md +7 -0
  28. package/examples/writing/story/runs.jsonl +0 -0
  29. package/examples/writing/story.hyperspec.md +285 -0
  30. package/examples/writing/style-rules.md +19 -0
  31. package/package.json +8 -2
  32. package/runs.jsonl +0 -0
  33. package/src/blobs.mjs +77 -0
  34. package/src/compare.mjs +189 -0
  35. package/src/fsutil.mjs +33 -0
  36. package/src/hash.mjs +26 -0
  37. package/src/placeholder.mjs +20 -0
  38. package/src/profiles.mjs +50 -0
  39. package/src/recipe.mjs +164 -0
  40. package/src/regenerate.mjs +318 -0
  41. package/src/reproduce.mjs +156 -0
  42. package/src/rules.mjs +58 -19
  43. package/src/score.mjs +7 -1
  44. package/src/template.mjs +15 -3
  45. package/src/writer.mjs +125 -0
  46. package/src/writing-fields.mjs +317 -0
  47. package/src/writing-template.mjs +192 -0
  48. package/src/writing.mjs +181 -0
@@ -0,0 +1,318 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { sha256 } from "./hash.mjs";
6
+ import { getBlob, putBlob, storeRoot } from "./blobs.mjs";
7
+ import { insideDir, writeFileAtomic } from "./fsutil.mjs";
8
+ import { readRecipe, resolveReads, stageKey, writeRecipe } from "./recipe.mjs";
9
+
10
+ const present = (v) => typeof v === "string" && v.trim().length > 0;
11
+ const CHANGE_KEYS = ["addInput", "swapInput", "factoryVersion"];
12
+ const RUNNER_MAX_BUFFER = 512 * 1024 * 1024;
13
+
14
+ // Spec requirement `regenerate`: a recipe plus exactly ONE named change (a new input, a swapped
15
+ // input, or a newer factory version) makes a child recipe that names its parent and the change.
16
+ // Only the stages downstream of the change rerun; every other stage reuses its recorded output.
17
+ //
18
+ // Reuse is PROVEN, never assumed: a stage is reused only when its key, recomputed from the
19
+ // child's hashes, equals the key the parent recorded for it, and only once its recorded output
20
+ // blob re-hashes to what the parent claims, and only if the parent's recorded key matches the
21
+ // parent's own record. A stage whose key differs reruns. With a runner that is decided per stage
22
+ // at run time, after upstream reruns have produced their real bytes, so a reader whose key is
23
+ // unchanged is reused. Without one, every transitive reader of a rerun stage is pending,
24
+ // since its reads are unknown (transitive readers, not every later stage).
25
+ //
26
+ // hyperspec never calls a model. Rerun stages are run by `run`, a shell command the caller
27
+ // supplies; without one the child is written with those stages pending.
28
+ //
29
+ // Nothing is written until the outcome is known: new input bytes and rerun outputs are held in
30
+ // memory, and only land in the blob store (with the output file and the child recipe) once every
31
+ // stage has run, or, with no runner, once the pending plan is settled. The parent recipe, its
32
+ // output and its blobs are only ever read.
33
+ export function regenerate(parentRecipePath, { out, clicker, change, run, changeText, store } = {}) {
34
+ const usage = (error) => ({ ok: false, usage: true, error, childRecipe: null, plan: [], pending: false, failedVerdicts: [] });
35
+ const failed = (error, extra = {}) => ({ ok: false, error, childRecipe: null, plan: [], pending: false, failedVerdicts: [], ...extra });
36
+
37
+ // ---- Options -------------------------------------------------------------------------------
38
+ const changeKeys = change && typeof change === "object" ? Object.keys(change).filter((k) => change[k] !== undefined) : [];
39
+ if (changeKeys.length !== 1 || !CHANGE_KEYS.includes(changeKeys[0])) {
40
+ return usage("exactly one change is required: an added input, a swapped input, or a factory version");
41
+ }
42
+ if (!present(clicker)) return usage("clicker is required");
43
+ if (!present(out)) return usage("out is required");
44
+
45
+ // ---- Parent ----------------------------------------------------------------------------------
46
+ const parentAbs = resolve(process.cwd(), parentRecipePath);
47
+ const loaded = readRecipe(parentAbs);
48
+ if (loaded.error) return usage(loaded.error);
49
+ const parentBytes = readFileSync(parentAbs);
50
+ const { data: parent, dir: parentDir } = loaded;
51
+ const parentStages = Array.isArray(parent.stages) ? parent.stages : [];
52
+ const parentInputs = Array.isArray(parent.inputs) ? parent.inputs : [];
53
+ if (parentStages.length === 0) return usage("the parent recipe has no stages");
54
+ if (!present(parent.spec?.sha256)) return usage("the parent recipe has no spec.sha256");
55
+
56
+ // The parent recipe is a plain JSON file, so its output.path is not trusted blind: one naming a
57
+ // place outside its own directory is refused rather than compared against (same rule as reproduce).
58
+ const parentOutputPath = parent.output?.path;
59
+ if (present(parentOutputPath) && !insideDir(parentDir, parentOutputPath)) {
60
+ return usage("the parent recipe's output path escapes its recipe directory");
61
+ }
62
+
63
+ // ---- Out -------------------------------------------------------------------------------------
64
+ const outAbs = resolve(process.cwd(), out);
65
+ const childRecipePath = `${outAbs}.recipe.json`;
66
+ const childDir = dirname(outAbs);
67
+ if (present(parentOutputPath) && resolve(parentDir, parentOutputPath) === outAbs) {
68
+ return usage(`${out} is the parent's output; the child needs its own path`);
69
+ }
70
+ if (existsSync(outAbs)) return usage(`${out} already exists; refusing to overwrite it`);
71
+ if (existsSync(childRecipePath)) return usage(`${childRecipePath} already exists; refusing to overwrite it`);
72
+ if (!isDir(childDir)) return usage(`directory ${childDir} does not exist`);
73
+
74
+ const childRoot = storeRoot({ from: childDir, store });
75
+ const parentRoot = storeRoot({ from: parentDir, store });
76
+ const held = new Map(); // hex -> Buffer, written to the store only once the outcome is known
77
+
78
+ // ---- The child, with the change applied ------------------------------------------------------
79
+ const rel = (abs) => relative(childDir, abs);
80
+ const rebase = (p) => (present(p) ? rel(resolve(parentDir, p)) : p);
81
+ const child = {
82
+ recipe: "0.1",
83
+ created: new Date().toISOString(),
84
+ output: { path: rel(outAbs), sha256: null },
85
+ factory: { ...(parent.factory ?? {}) },
86
+ spec: { ...parent.spec, path: rebase(parent.spec?.path) },
87
+ inputs: parentInputs.map((i) => ({ ...i, path: rebase(i.path) })),
88
+ stages: parentStages.map((s) => structuredClone(s)),
89
+ clicker,
90
+ approver: null,
91
+ parent: { path: rel(parentAbs), sha256: sha256(parentBytes) },
92
+ change: null,
93
+ };
94
+
95
+ let defaultChange;
96
+ const kind = changeKeys[0];
97
+ if (kind === "addInput") {
98
+ const { name, path, reads = [] } = change.addInput ?? {};
99
+ if (!present(name) || !present(path)) return usage("an added input needs a name and a path");
100
+ if (child.inputs.some((i) => i.name === name)) return usage(`input ${name} already exists; swap it instead`);
101
+ if (!Array.isArray(reads)) return usage("reads must be a list of stage ids");
102
+ for (const id of reads) {
103
+ if (!child.stages.some((s) => s.id === id)) return usage(`no stage ${id} to read input ${name}`);
104
+ }
105
+ const abs = resolve(process.cwd(), path);
106
+ const bytes = readBytes(abs);
107
+ if (!bytes) return usage(`cannot read ${path}`);
108
+ const hex = hold(held, bytes);
109
+ const order = child.inputs.reduce((m, i) => Math.max(m, i.order ?? 0), 0) + 1;
110
+ child.inputs.push({ name, path: rel(abs), sha256: hex, order });
111
+ const ref = `input:${name}`;
112
+ for (const id of reads) {
113
+ const stage = child.stages.find((s) => s.id === id);
114
+ // A stage with an empty reads already reads every input; making its reads explicit here
115
+ // would silently stop it reading everything else, so it is left as it is.
116
+ if (Array.isArray(stage.reads) && stage.reads.length && !stage.reads.includes(ref)) stage.reads = [...stage.reads, ref];
117
+ }
118
+ // An input no stage reads changes nothing: every stage would be reused, the child's output
119
+ // would be the parent's bytes, and the recipe would claim an input was used that nothing read.
120
+ // A regeneration must change something, so this is refused the way a no-op swap is.
121
+ const read = child.stages.some((s) => !Array.isArray(s.reads) || s.reads.length === 0 || s.reads.includes(ref));
122
+ if (!read) return usage(`no stage reads input ${name}; add --reads <stage>`);
123
+ defaultChange = `added input ${name} (${rel(abs)})`;
124
+ } else if (kind === "swapInput") {
125
+ const { name, path } = change.swapInput ?? {};
126
+ if (!present(name) || !present(path)) return usage("a swapped input needs a name and a path");
127
+ const input = child.inputs.find((i) => i.name === name);
128
+ if (!input) return usage(`no input ${name} to swap`);
129
+ const abs = resolve(process.cwd(), path);
130
+ const bytes = readBytes(abs);
131
+ if (!bytes) return usage(`cannot read ${path}`);
132
+ if (sha256(bytes) === input.sha256) return usage(`swap does not change input ${name}: ${path} has the same bytes`);
133
+ input.sha256 = hold(held, bytes);
134
+ input.path = rel(abs);
135
+ defaultChange = `swapped input ${name} to ${rel(abs)}`;
136
+ } else {
137
+ const version = change.factoryVersion;
138
+ if (!present(version)) return usage("factory version must be a non-empty string");
139
+ if (version === parent.factory?.version) return usage(`factory is already ${version}; that is not a change`);
140
+ child.factory.version = version;
141
+ defaultChange = `factory ${parent.factory?.version} to ${version}`;
142
+ }
143
+ child.change = present(changeText) ? changeText : defaultChange;
144
+
145
+ // Every blob the child names must be verified and reachable from the child's own store, or a
146
+ // reproduce of the child could not stand on its own. A blob found only in the parent's store
147
+ // (the child lives under a different root) is carried over, re-hashed on the way.
148
+ const fetch = (hex) => {
149
+ if (held.has(hex)) return held.get(hex);
150
+ for (const root of childRoot === parentRoot ? [childRoot] : [childRoot, parentRoot]) {
151
+ const bytes = getBlob(root, hex);
152
+ if (bytes !== null && sha256(bytes) === hex) {
153
+ if (root !== childRoot) held.set(hex, bytes);
154
+ return bytes;
155
+ }
156
+ }
157
+ return null;
158
+ };
159
+ for (const input of child.inputs) {
160
+ if (!present(input.sha256) || !fetch(input.sha256)) return failed(`input ${input.name}: blob missing or does not match its recorded hash`);
161
+ }
162
+ if (!fetch(child.spec.sha256)) return failed("spec: blob missing or does not match its recorded hash");
163
+
164
+ // ---- Plan and run ----------------------------------------------------------------------------
165
+ // Each stage is decided in order, once everything it reads is known. With a runner, an upstream
166
+ // rerun has already produced its real bytes by the time its readers are decided, so a reader is
167
+ // reused whenever its key still matches (a deterministic upstream that reproduced its parent
168
+ // bytes changes nothing downstream). Without a runner, a reader of a pending stage cannot be
169
+ // keyed, so it is pending too.
170
+ const plan = [];
171
+ const pendingIds = new Set();
172
+ const failedVerdicts = [];
173
+ for (let i = 0; i < child.stages.length; i++) {
174
+ const stage = child.stages[i];
175
+ let refs;
176
+ try {
177
+ refs = resolveReads(child, i).map(([ref]) => ref);
178
+ } catch (e) {
179
+ return failed(`stage ${stage.id}: ${e.message}`, { plan });
180
+ }
181
+ if (refs.some((ref) => ref.startsWith("stage:") && pendingIds.has(ref.slice("stage:".length)))) {
182
+ markPending(stage, null); // reads a stage not yet run, so its key cannot be known
183
+ pendingIds.add(stage.id);
184
+ plan.push({ id: stage.id, action: "rerun" });
185
+ continue;
186
+ }
187
+
188
+ const key = stageKey(child, i);
189
+ const decision = reusable(parent, i, key);
190
+ if (decision.reuse) {
191
+ if (!fetch(parentStages[i].output.sha256)) {
192
+ return failed(`stage ${stage.id}: recorded output blob missing or does not match its hash; cannot reuse an unverifiable stage`, { plan });
193
+ }
194
+ // The clone already carries the parent's output, verdict and (equal) key.
195
+ plan.push({ id: stage.id, action: "reuse" });
196
+ continue;
197
+ }
198
+
199
+ const entry = { id: stage.id, action: "rerun" };
200
+ if (decision.reason) entry.reason = decision.reason;
201
+ plan.push(entry);
202
+ if (!present(run)) {
203
+ markPending(stage, key);
204
+ pendingIds.add(stage.id);
205
+ continue;
206
+ }
207
+ const result = runStage(run, child, i, fetch);
208
+ if (result.error) return failed(`stage ${stage.id}: ${result.error}`, { failedStage: stage.id, plan });
209
+ delete stage.pending;
210
+ stage.output = { sha256: hold(held, result.output) };
211
+ stage.verdict = result.verdict;
212
+ stage.key = stageKey(child, i);
213
+ if (result.verdict.pass === false) failedVerdicts.push(stage.id);
214
+ }
215
+
216
+ // ---- Write -----------------------------------------------------------------------------------
217
+ const last = child.stages[child.stages.length - 1];
218
+ child.output.sha256 = last.output?.sha256 ?? null;
219
+ const pending = pendingIds.size > 0;
220
+
221
+ // A runner may take minutes, and anything may have appeared at out meanwhile (the runner itself
222
+ // included). Check again right before the first write, and refuse with nothing written.
223
+ if (existsSync(outAbs)) return usage(`${out} already exists; refusing to overwrite it`);
224
+ if (existsSync(childRecipePath)) return usage(`${childRecipePath} already exists; refusing to overwrite it`);
225
+
226
+ for (const bytes of held.values()) putBlob(childRoot, bytes);
227
+ if (!pending) {
228
+ // The child's output is the last stage's blob, written atomically and read back.
229
+ writeFileAtomic(outAbs, fetch(child.output.sha256));
230
+ if (sha256(readFileSync(outAbs)) !== child.output.sha256) {
231
+ try { unlinkSync(outAbs); } catch { /* nothing to remove */ }
232
+ return failed(`${out} does not hash to the child's output.sha256`, { plan });
233
+ }
234
+ }
235
+ writeRecipe(childRecipePath, child);
236
+ return { ok: true, childRecipe: childRecipePath, output: pending ? null : outAbs, plan, pending, failedVerdicts };
237
+ }
238
+
239
+ // Reuse is proven twice over: the key recomputed from the child's hashes equals the key the parent
240
+ // recorded, AND the parent's recorded key equals the key recomputed from the parent's own recorded
241
+ // hashes. Without the second check, a parent key edited to match the child would pass an old
242
+ // output off as made under conditions it never was, and a reproduce of the child could not tell.
243
+ function reusable(parent, index, childKey) {
244
+ const ps = parent.stages[index];
245
+ if (!ps || ps.pending === true || !present(ps.output?.sha256) || !present(ps.key)) return { reuse: false };
246
+ let selfKey;
247
+ try { selfKey = stageKey(parent, index); } catch { selfKey = null; }
248
+ if (selfKey !== ps.key) return { reuse: false, reason: "parent key does not match its record" };
249
+ return { reuse: childKey === ps.key };
250
+ }
251
+
252
+ function markPending(stage, key) {
253
+ stage.key = key;
254
+ stage.output = null;
255
+ stage.verdict = null;
256
+ stage.pending = true;
257
+ }
258
+
259
+ // Runs one stage through the caller's runner. The runner gets, on stdin, the stage id, its
260
+ // resolved reads (each materialized from its blob into a temp file) and its model; its stdout,
261
+ // byte for byte, is the stage's output. A final `VERDICT {...}` line on stderr is the verdict.
262
+ // A runner that reports no verdict gets a failing one: silence is not a verdict, and a recipe
263
+ // must never claim a station checked something that nothing checked.
264
+ function runStage(run, recipe, index, fetch) {
265
+ const stage = recipe.stages[index];
266
+ const tmp = mkdtempSync(join(tmpdir(), "hyperspec-run-"));
267
+ try {
268
+ const reads = resolveReads(recipe, index).map(([ref, hex], n) => {
269
+ const bytes = fetch(hex);
270
+ if (!bytes) throw new Error(`read ${ref}: blob ${hex} missing`);
271
+ const path = join(tmp, `${n}-${ref.replace(/[^A-Za-z0-9._-]/g, "_")}`);
272
+ writeFileSync(path, bytes);
273
+ return { ref, sha256: hex, path };
274
+ });
275
+ const job = { stage: stage.id, reads, model: stage.model ?? null };
276
+ const res = spawnSync("/bin/sh", ["-c", run], { input: JSON.stringify(job), maxBuffer: RUNNER_MAX_BUFFER });
277
+ const stderr = res.stderr ? res.stderr.toString("utf8") : "";
278
+ if (res.error) return { error: `runner could not run: ${res.error.message}` };
279
+ if (res.status !== 0) {
280
+ const how = res.signal ? `was killed by ${res.signal}` : `exited ${res.status}`;
281
+ const tail = stderr.trim().split("\n").slice(-5).join("\n");
282
+ return { error: `runner ${how}${tail ? `: ${tail}` : ""}` };
283
+ }
284
+ const verdict = parseVerdict(stderr);
285
+ if (verdict.error) return { error: verdict.error };
286
+ return { output: res.stdout, verdict: verdict.value };
287
+ } catch (e) {
288
+ return { error: e.message };
289
+ } finally {
290
+ rmSync(tmp, { recursive: true, force: true });
291
+ }
292
+ }
293
+
294
+ function parseVerdict(stderr) {
295
+ const lines = stderr.split(/\r?\n/).filter((l) => l.startsWith("VERDICT "));
296
+ if (!lines.length) return { value: { station: "runner", pass: false, note: "runner reported no verdict" } };
297
+ const text = lines[lines.length - 1].slice("VERDICT ".length).trim();
298
+ let value;
299
+ try { value = JSON.parse(text); } catch (e) { return { error: `invalid VERDICT line from the runner: ${e.message}` }; }
300
+ if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.pass !== "boolean") {
301
+ return { error: "invalid VERDICT line from the runner: it must be an object with a boolean pass" };
302
+ }
303
+ return { value: { station: "runner", note: "", ...value } };
304
+ }
305
+
306
+ function hold(held, bytes) {
307
+ const hex = sha256(bytes);
308
+ if (!held.has(hex)) held.set(hex, bytes);
309
+ return hex;
310
+ }
311
+
312
+ function readBytes(abs) {
313
+ try { return readFileSync(abs); } catch { return null; }
314
+ }
315
+
316
+ function isDir(p) {
317
+ try { return statSync(p).isDirectory(); } catch { return false; }
318
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { sha256 } from "./hash.mjs";
4
+ import { getBlob, isSha256, storeRoot, verifyBlob } from "./blobs.mjs";
5
+ import { insideDir, writeFileAtomic } from "./fsutil.mjs";
6
+ import { readRecipe, stageKey } from "./recipe.mjs";
7
+
8
+ const present = (v) => typeof v === "string" && v.trim().length > 0;
9
+ const MALFORMED = "recorded hash is not a SHA-256 hash (64 lowercase hex characters)";
10
+ // A recorded hash that is not well formed names no blob at all; say so rather than "missing".
11
+ const blobWhy = (hex, why) => (present(hex) && !isSha256(hex) ? MALFORMED : why);
12
+
13
+ // Spec requirement `reproduce`: replay the record and hash-check it. Never invokes a model,
14
+ // never runs a command, never regenerates a byte of content — every blob this looks at already
15
+ // exists in the store, and this only confirms the recipe's own claims about it still hold.
16
+ //
17
+ // Checks, in order (and every one is reported, nothing stops the walk early): every input blob
18
+ // verifies; the spec blob verifies; for each stage, its output blob verifies AND its recorded
19
+ // key equals the recomputed key; the output blob equals output.sha256; the output file on disk,
20
+ // if present, hashes to output.sha256.
21
+ export function reproduce(recipePath, { store, restore = false } = {}) {
22
+ const loaded = readRecipe(recipePath);
23
+ if (loaded.error) return { ok: false, error: loaded.error, steps: [], firstMismatch: null };
24
+ const { data: recipe, dir } = loaded;
25
+ const root = storeRoot({ from: dir, store });
26
+
27
+ const steps = [];
28
+ // Every step's ok/why is decided through setStep, including its own creation (addStep). One
29
+ // path for every mutation, so a step is never left with a stale why after a later change.
30
+ function setStep(step, ok, why) {
31
+ step.ok = ok;
32
+ if (ok || !why) delete step.why;
33
+ else step.why = why;
34
+ }
35
+ function addStep(ref, sha256Value, ok, why) {
36
+ const step = { ref, sha256: sha256Value ?? null, ok: true };
37
+ steps.push(step);
38
+ setStep(step, ok, why);
39
+ return step;
40
+ }
41
+
42
+ // 1. Every input blob verifies.
43
+ const inputs = Array.isArray(recipe.inputs) ? recipe.inputs : [];
44
+ for (const input of inputs) {
45
+ const hex = input?.sha256;
46
+ const ok = present(hex) && verifyBlob(root, hex);
47
+ addStep(`input:${input?.name}`, hex, ok, blobWhy(hex, "input blob missing or does not match its recorded hash"));
48
+ }
49
+
50
+ // 2. The spec blob verifies.
51
+ {
52
+ const hex = recipe.spec?.sha256;
53
+ const ok = present(hex) && verifyBlob(root, hex);
54
+ addStep("spec", hex, ok, blobWhy(hex, "spec blob missing or does not match its recorded hash"));
55
+ }
56
+
57
+ // 3. For each stage: its output blob verifies, and its recorded key equals the recomputed key.
58
+ const stages = Array.isArray(recipe.stages) ? recipe.stages : [];
59
+ stages.forEach((stage, index) => {
60
+ const id = stage?.id ?? `#${index}`;
61
+ const pending = stage?.pending === true || stage?.output == null || !present(stage.output?.sha256);
62
+ if (pending) {
63
+ addStep(`stage:${id}`, stage?.output?.sha256, false, "stage is pending");
64
+ } else {
65
+ const hex = stage.output.sha256;
66
+ const ok = verifyBlob(root, hex);
67
+ addStep(`stage:${id}`, hex, ok, blobWhy(hex, "stage output blob missing or does not match its recorded hash"));
68
+ }
69
+
70
+ let keyOk = false;
71
+ let keyWhy = "stage key could not be recomputed";
72
+ try {
73
+ const recomputed = stageKey(recipe, index);
74
+ keyOk = present(stage?.key) && stage.key === recomputed;
75
+ if (!keyOk) keyWhy = "recorded key does not match the recomputed key";
76
+ } catch (e) {
77
+ keyWhy = `stage key could not be recomputed: ${e.message}`;
78
+ }
79
+ addStep(`stage:${id}#key`, stage?.key, keyOk, keyWhy);
80
+ });
81
+
82
+ // 4. The output blob equals output.sha256.
83
+ const outputSha = recipe.output?.sha256;
84
+ const lastStage = stages[stages.length - 1];
85
+ let outputBlobOk = false;
86
+ let outputBlobWhy = "output.sha256 is missing";
87
+ if (present(outputSha)) {
88
+ if (!lastStage || lastStage.output?.sha256 !== outputSha) {
89
+ outputBlobWhy = "output.sha256 does not match the last stage's output";
90
+ } else if (!verifyBlob(root, outputSha)) {
91
+ outputBlobWhy = blobWhy(outputSha, "output blob missing or does not match output.sha256");
92
+ } else {
93
+ outputBlobOk = true;
94
+ }
95
+ }
96
+ addStep("output:blob", outputSha, outputBlobOk, outputBlobWhy);
97
+
98
+ // 5. The output file on disk, if present, hashes to output.sha256. Absent is vacuously fine:
99
+ // reproduce doesn't require the file to already exist, only that it agrees when it does.
100
+ //
101
+ // The recipe is a plain JSON file on disk — exactly the kind of claim reproduce exists to
102
+ // distrust — so output.path is never trusted blind. A hand-edited or corrupted recipe could
103
+ // name a path outside the recipe's own directory (a `../` climb, or an absolute path); refuse
104
+ // before touching disk at all, rather than reading from or (worse, under --restore) writing to
105
+ // wherever it points.
106
+ const outputPath = recipe.output?.path;
107
+ let restored = false;
108
+ if (present(outputPath) && !insideDir(dir, outputPath)) {
109
+ addStep("output:file", outputSha, false, "output path escapes the recipe directory");
110
+ } else {
111
+ const outputAbs = present(outputPath) ? resolve(dir, outputPath) : null;
112
+ const fileStep = addStep("output:file", outputSha, true);
113
+ if (outputAbs) {
114
+ const fileExists = existsSync(outputAbs);
115
+ let matches = true;
116
+ if (fileExists) {
117
+ const bytes = readFileSync(outputAbs);
118
+ matches = present(outputSha) && sha256(bytes) === outputSha;
119
+ if (!matches) setStep(fileStep, false, "output file does not match output.sha256");
120
+ }
121
+
122
+ // restore: true writes the output file from its blob, but only when the output blob
123
+ // itself verified (step 4) — restoring from an unverified blob would just write
124
+ // different wrong bytes. Covers both a lost file (never existed / deleted) and an
125
+ // edited one. The write is atomic (temp file + rename, same pattern as putBlob), so a
126
+ // crash mid-write never leaves the file in a state that is neither the old nor the new
127
+ // content, and a write failure is caught and reported as a failing step rather than
128
+ // thrown out of reproduce() — this function always returns a structured result.
129
+ const needsRestore = !fileExists || !matches;
130
+ if (restore && needsRestore && outputBlobOk) {
131
+ const blob = getBlob(root, outputSha);
132
+ if (blob !== null) {
133
+ try {
134
+ writeFileAtomic(outputAbs, blob);
135
+ restored = true;
136
+ // Re-evaluate for real: read back what actually landed on disk and hash it, rather
137
+ // than assuming the write did what it intended.
138
+ const writtenBytes = readFileSync(outputAbs);
139
+ const writtenOk = present(outputSha) && sha256(writtenBytes) === outputSha;
140
+ setStep(fileStep, writtenOk, "restored output file does not match output.sha256");
141
+ } catch (e) {
142
+ setStep(fileStep, false, `failed to restore the output file: ${e.message}`);
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ const firstFail = steps.find((s) => !s.ok);
150
+ return {
151
+ ok: steps.every((s) => s.ok),
152
+ steps,
153
+ firstMismatch: firstFail ? firstFail.ref : null,
154
+ restored,
155
+ };
156
+ }
package/src/rules.mjs CHANGED
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { lintProfile } from "./profiles.mjs";
4
+ import { str } from "./placeholder.mjs";
3
5
 
4
6
  export const TESTS = Object.freeze([
5
7
  { n: 1, name: "every decision is accounted for" },
@@ -25,22 +27,36 @@ const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![
25
27
  const INLINE_CODE = /(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g;
26
28
  const prose = (body) => String(body || "").replace(FENCE, "").replace(INLINE_CODE, "");
27
29
  const list = (v) => (Array.isArray(v) ? v : []);
28
- // A value that is only null or ~ is a placeholder: the reader (@supersuit/superskill/yaml) keeps
29
- // these as the literal strings "null" and "~" rather than resolving them to YAML's own null, so
30
- // they must never count as present. A value that is only a YAML comment (source: # TODO) is
31
- // handled upstream since superskill 0.2.1: the reader returns "" for it, same as any other blank
32
- // scalar, so it already fails str()'s own emptiness check and needs no rule here. A QUOTED value
33
- // that happens to start with "#" (source: "# literal") is real text and must count as present.
34
- // Every presence check goes through str().
35
- const PLACEHOLDER = /^(null|~)$/is;
36
- const str = (v) => { const t = typeof v === "string" ? v.trim() : ""; return PLACEHOLDER.test(t) ? "" : t; };
30
+ // str() (a value that is null/~/todo/tbd/fixme/xxx/placeholder never counts as present) is
31
+ // shared, from src/placeholder.mjs: writing.mjs and writing-fields.mjs import the same function,
32
+ // so a placeholder word closes every presence check in the linter at once. A value that is only a
33
+ // YAML comment (source: # TODO) is handled upstream since superskill 0.2.1: the reader returns ""
34
+ // for it, same as any other blank scalar, so it already fails str()'s own emptiness check and
35
+ // needs no rule here. A QUOTED value that happens to start with "#" (source: "# literal") is real
36
+ // text and must count as present.
37
37
  const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
38
+ // The versions of the format this linter knows. A spec naming any other may follow rules it
39
+ // cannot check, so it is warned about rather than failed.
40
+ export const KNOWN_VERSIONS = Object.freeze(["0.1"]);
41
+ // "file", "other" (a directory or a device), or null when nothing is there.
42
+ const kind = (p) => { try { return statSync(p).isFile() ? "file" : "other"; } catch { return null; } };
43
+ const UNIQUE_IDS = "Ids must be unique across decisions and requirements; rename one.";
38
44
 
39
- export function lintSpec(spec, { exists = existsSync } = {}) {
45
+ export function lintSpec(spec) {
40
46
  const d = spec.data || {};
41
47
  const out = [];
42
48
  const here = (p) => resolve(spec.dir || ".", p);
43
49
 
50
+ // The format version. Not one of the nine tests: a stranger resuming the spec (test 7) needs to
51
+ // know which standard it was written against, so an unknown one is a warning there.
52
+ const version = str(d.hyperspec);
53
+ if (!KNOWN_VERSIONS.includes(version)) out.push(f(7, "hyperspec-version", "warn", `hyperspec version "${version}" is not one this linter knows (${KNOWN_VERSIONS.join(", ")})`, "Set hyperspec: to a version this linter knows, or upgrade @supersuit/hyperspec."));
54
+
55
+ // A spec with no profile: runs no profile rules at all, so it lints exactly as it always has.
56
+ // One declared runs that profile's own rules (writing.mjs for profile: writing), every finding
57
+ // still reported under one of the nine tests below; an unknown profile name is a warning here.
58
+ out.push(...lintProfile(spec));
59
+
44
60
  // 1 and 4, decisions
45
61
  const decisions = list(d.decisions);
46
62
  if (!decisions.length) out.push(f(1, "decisions", "fail", "no decisions are listed", "List every decision this kind of work has under decisions:, each decided, delegated or open."));
@@ -48,7 +64,7 @@ export function lintSpec(spec, { exists = existsSync } = {}) {
48
64
  decisions.forEach((x, i) => {
49
65
  const id = str(x?.id) || `#${i + 1}`;
50
66
  if (!str(x?.id)) out.push(f(1, "decision-id", "fail", `decision ${id} has no id`, "Give it a short id."));
51
- else if (seen.has(id)) out.push(f(1, "decision-id", "fail", `decision id "${id}" is used twice`, "Make every id unique."));
67
+ else if (seen.has(id)) out.push(f(1, "decision-id", "fail", `decision id "${id}" is used twice`, UNIQUE_IDS));
52
68
  seen.add(id);
53
69
  const st = str(x?.state);
54
70
  if (!["decided", "delegated", "open"].includes(st)) out.push(f(1, "decision-state", "fail", `decision "${id}" has state "${st || "(none)"}"`, "Set state to decided, delegated or open."));
@@ -63,8 +79,17 @@ export function lintSpec(spec, { exists = existsSync } = {}) {
63
79
  // 2, 3 and 4, requirements
64
80
  const reqs = list(d.requirements);
65
81
  if (!reqs.length) out.push(f(2, "requirements", "fail", "no requirements are listed", "List what the finished work must meet under requirements:."));
82
+ // One id namespace across decisions and requirements: a recipe maps every id to its author, so
83
+ // a shared id would silently lose one of them. Reported under test 1.
84
+ const decisionIds = new Set(decisions.map((x) => str(x?.id)).filter(Boolean));
85
+ const reqSeen = new Set();
66
86
  reqs.forEach((r, i) => {
67
87
  const id = str(r?.id) || `#${i + 1}`;
88
+ if (str(r?.id)) {
89
+ if (reqSeen.has(id)) out.push(f(1, "requirement-id", "fail", `requirement id "${id}" is used twice`, UNIQUE_IDS));
90
+ else if (decisionIds.has(id)) out.push(f(1, "requirement-id", "fail", `id "${id}" is used by a decision and a requirement`, UNIQUE_IDS));
91
+ reqSeen.add(id);
92
+ }
68
93
  if (!str(r?.text)) out.push(f(2, "requirement-text", "fail", `requirement "${id}" has no text`, "Write the requirement."));
69
94
  const fw = str(r?.fails_when);
70
95
  if (!fw) out.push(f(2, "fails-when", "fail", `requirement "${id}" does not say what would show it failed`, "Add fails_when: something a person or a check could observe."));
@@ -89,15 +114,27 @@ export function lintSpec(spec, { exists = existsSync } = {}) {
89
114
  ex.forEach((e, i) => {
90
115
  const p = str(e?.path);
91
116
  if (!p) out.push(f(6, "example-path", "fail", `example ${i + 1} has no path`, "Add path: to the example."));
92
- else if (!/^https?:\/\//.test(p) && !exists(here(p))) out.push(f(6, "example-missing", "fail", `example "${p}" does not exist`, "Fix the path, or add the example file."));
117
+ else if (!/^https?:\/\//.test(p)) {
118
+ const k = kind(here(p));
119
+ if (!k) out.push(f(6, "example-missing", "fail", `example "${p}" does not exist`, "Fix the path, or add the example file."));
120
+ else if (k !== "file") out.push(f(6, "example-not-file", "fail", `example "${p}" is not a file`, "Point path: at one example file, not a folder."));
121
+ else if (spec.path && here(p) === resolve(spec.path)) out.push(f(6, "example-self", "fail", `example "${p}" is this spec itself`, "Point at a real example of the work, not the spec that describes it."));
122
+ }
93
123
  if (!str(e?.why)) out.push(f(6, "example-why", "fail", `example ${p || i + 1} does not say why it is an example`, "Add why: what it shows that an adjective could not."));
94
124
  });
95
125
 
96
126
  // 7
97
- const next = str(d.resume?.next_action);
98
- if (!next) out.push(f(7, "next-action", "fail", "no resume.next_action", "Write the single concrete step that starts the next session."));
99
- else if (NO_ACTION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" names no action`, "Name the concrete step."));
100
- else if (CONVERSATION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" points into a conversation the next reader cannot see`, "State the step itself."));
127
+ // NO_ACTION is checked against the RAW trimmed value, ahead of str()'s placeholder-blanking:
128
+ // two of its own words (tbd, todo) are now also placeholder words str() treats as blank, and
129
+ // "next action names no action" is the more specific, more correct message for those than "no
130
+ // resume.next_action" would be (something WAS written; it just names no action).
131
+ const rawNext = typeof d.resume?.next_action === "string" ? d.resume.next_action.trim() : "";
132
+ if (rawNext && NO_ACTION.test(rawNext)) out.push(f(7, "next-action-vague", "fail", `next action "${rawNext}" names no action`, "Name the concrete step."));
133
+ else {
134
+ const next = str(d.resume?.next_action);
135
+ if (!next) out.push(f(7, "next-action", "fail", "no resume.next_action", "Write the single concrete step that starts the next session."));
136
+ else if (CONVERSATION.test(next)) out.push(f(7, "next-action-vague", "fail", `next action "${next}" points into a conversation the next reader cannot see`, "State the step itself."));
137
+ }
101
138
  if (CONVERSATION.test(prose(spec.body))) out.push(f(7, "conversation-pointer", "warn", "the body points into a conversation the next reader cannot see", "State the thing itself."));
102
139
 
103
140
  // 8
@@ -108,11 +145,13 @@ export function lintSpec(spec, { exists = existsSync } = {}) {
108
145
  const led = str(d.improvement?.ledger);
109
146
  if (!led) out.push(f(9, "ledger", "fail", "no improvement.ledger", "Name the file each run writes its verdict to."));
110
147
  else {
111
- // A declared ledger not yet written is fine. One that exists must be a readable file.
148
+ // A declared ledger not yet written is a warning: the first run may create it. One that
149
+ // exists must be a readable file.
112
150
  let st = null;
113
151
  try { st = statSync(here(led)); } catch { /* not written yet */ }
114
152
  let text = null;
115
- if (st && !st.isFile()) out.push(f(9, "ledger-not-file", "fail", `ledger path ${led} is not a file`, "Point improvement.ledger at a file, one JSON object per line."));
153
+ if (!st) out.push(f(9, "ledger-missing", "warn", `ledger ${led} does not exist yet`, "Create it as an empty file, so the first run has somewhere to write its verdict."));
154
+ else if (!st.isFile()) out.push(f(9, "ledger-not-file", "fail", `ledger path ${led} is not a file`, "Point improvement.ledger at a file, one JSON object per line."));
116
155
  else if (st) {
117
156
  try { text = readFileSync(here(led), "utf8"); } catch (e) { out.push(f(9, "ledger-unreadable", "fail", `ledger ${led} cannot be read (${e.code || e.message})`, "Make the ledger file readable.")); }
118
157
  }
package/src/score.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TESTS } from "./rules.mjs";
2
+ import { profileStatus } from "./profiles.mjs";
2
3
 
3
4
  export function score(findings, data = {}) {
4
5
  const failed = new Set(findings.filter((x) => x.severity === "fail").map((x) => x.test));
@@ -6,7 +7,12 @@ export function score(findings, data = {}) {
6
7
  const open = (Array.isArray(data.decisions) ? data.decisions : [])
7
8
  .filter((x) => String(x?.state || "").trim() === "open").map((x) => String(x.id || "").trim());
8
9
  const status = failed.size ? "fail" : open.length ? "blocked" : "pass";
9
- return { tests, passed: tests.filter((t) => t.pass).length, open, status };
10
+ const out = { tests, passed: tests.filter((t) => t.pass).length, open, status };
11
+ // Only when a known profile ran: profileStatus returns undefined for no profile: at all, and
12
+ // for one this linter does not know (nothing to count when its rules were never checked).
13
+ const profile = profileStatus(data, findings);
14
+ if (profile) out.profile = profile;
15
+ return out;
10
16
  }
11
17
 
12
18
  export const exitCode = (status) => ({ pass: 0, fail: 1, blocked: 3 })[status] ?? 2;