@supersuit/hyperspec 0.1.0 → 0.2.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/CHANGELOG.md +42 -0
- package/README.md +74 -2
- package/SPEC.md +190 -10
- package/bin/hyperspec.mjs +277 -0
- package/examples/recipe/doctor.mjs +11 -0
- package/examples/recipe/essay.hyperspec.md +50 -0
- package/examples/recipe/factory.mjs +29 -0
- package/examples/recipe/materials/call-2.md +2 -0
- package/examples/recipe/materials/call.md +3 -0
- package/examples/recipe/materials/notes.md +3 -0
- package/examples/recipe/runner.mjs +20 -0
- package/examples/recipe/runs.jsonl +0 -0
- package/examples/recipe/stages.mjs +31 -0
- package/package.json +6 -1
- package/runs.jsonl +0 -0
- package/src/blobs.mjs +77 -0
- package/src/compare.mjs +189 -0
- package/src/fsutil.mjs +33 -0
- package/src/hash.mjs +26 -0
- package/src/recipe.mjs +164 -0
- package/src/regenerate.mjs +318 -0
- package/src/reproduce.mjs +156 -0
- package/src/rules.mjs +33 -6
- package/src/template.mjs +12 -3
- package/src/writer.mjs +125 -0
package/src/writer.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { sha256 } from "./hash.mjs";
|
|
4
|
+
import { writeFileAtomic } from "./fsutil.mjs";
|
|
5
|
+
import { storeRoot, putBlob, getBlob } from "./blobs.mjs";
|
|
6
|
+
import { checkRecipe, readRecipe, stageKey, writeRecipe } from "./recipe.mjs";
|
|
7
|
+
import { loadSpec } from "./load.mjs";
|
|
8
|
+
|
|
9
|
+
// What every outcome factory calls at the end of a run to record what it made, from what, and
|
|
10
|
+
// how. output and spec are paths, resolved against process.cwd(); every path stored inside the
|
|
11
|
+
// recipe (output.path, spec.path, each input's path) is relative to the recipe file's own
|
|
12
|
+
// directory, per the recipe file convention in SPEC.md.
|
|
13
|
+
export function startRecipe({ output, factory, spec, clicker, store } = {}) {
|
|
14
|
+
const outputAbs = resolve(process.cwd(), output);
|
|
15
|
+
const recipePath = `${outputAbs}.recipe.json`;
|
|
16
|
+
const recipeDir = dirname(recipePath);
|
|
17
|
+
const root = storeRoot({ from: recipeDir, store });
|
|
18
|
+
|
|
19
|
+
const specLoaded = loadSpec(spec);
|
|
20
|
+
if (specLoaded.error) throw new Error(specLoaded.error);
|
|
21
|
+
const specBytes = readFileSync(specLoaded.path);
|
|
22
|
+
const specHash = putBlob(root, specBytes);
|
|
23
|
+
// spec.authors is id -> author across decisions and requirements together; the two arrays
|
|
24
|
+
// share one id namespace here (every field is traced back to its source), so an id reused
|
|
25
|
+
// across them is a collision, not a silent overwrite, whether or not the authors agree.
|
|
26
|
+
const authors = {};
|
|
27
|
+
for (const d of [...(specLoaded.data.decisions ?? []), ...(specLoaded.data.requirements ?? [])]) {
|
|
28
|
+
if (!d || typeof d.id !== "string") continue;
|
|
29
|
+
if (Object.prototype.hasOwnProperty.call(authors, d.id)) {
|
|
30
|
+
throw new Error(`spec id ${d.id} is used twice; ids must be unique across decisions and requirements`);
|
|
31
|
+
}
|
|
32
|
+
// null rather than undefined, which JSON would drop: recipe check then names the id.
|
|
33
|
+
authors[d.id] = typeof d.author === "string" && d.author.trim() ? d.author : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const recipe = {
|
|
37
|
+
recipe: "0.1",
|
|
38
|
+
created: new Date().toISOString(),
|
|
39
|
+
output: { path: relative(recipeDir, outputAbs), sha256: null },
|
|
40
|
+
factory,
|
|
41
|
+
spec: { path: relative(recipeDir, specLoaded.path), sha256: specHash, authors },
|
|
42
|
+
inputs: [],
|
|
43
|
+
stages: [],
|
|
44
|
+
clicker,
|
|
45
|
+
approver: null,
|
|
46
|
+
parent: null,
|
|
47
|
+
change: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
// Stores the file's bytes as a blob now and records {name, path, sha256, order}. order is
|
|
52
|
+
// assigned by call order, starting at 1. A duplicate name throws.
|
|
53
|
+
input(name, path) {
|
|
54
|
+
if (recipe.inputs.some((i) => i.name === name)) throw new Error(`duplicate input name: ${name}`);
|
|
55
|
+
const abs = resolve(process.cwd(), path);
|
|
56
|
+
const bytes = readFileSync(abs);
|
|
57
|
+
const hex = putBlob(root, bytes);
|
|
58
|
+
const entry = { name, path: relative(recipeDir, abs), sha256: hex, order: recipe.inputs.length + 1 };
|
|
59
|
+
recipe.inputs.push(entry);
|
|
60
|
+
return entry;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Stores output (string or Buffer) as a blob, computes key with stageKey, and validates reads
|
|
64
|
+
// by delegating to stageKey/resolveReads (an unknown ref throws). A duplicate id throws too,
|
|
65
|
+
// since resolveReads resolves a stage: ref by id and a duplicate would make that ambiguous.
|
|
66
|
+
stage({ id, reads, model, output: stageOutput, verdict } = {}) {
|
|
67
|
+
if (!id) throw new Error("stage needs an id");
|
|
68
|
+
if (recipe.stages.some((s) => s.id === id)) throw new Error(`duplicate stage id: ${id}`);
|
|
69
|
+
const hex = putBlob(root, stageOutput);
|
|
70
|
+
const index = recipe.stages.length;
|
|
71
|
+
// The key must be computed from exactly what the recipe file will hold. A Date, an
|
|
72
|
+
// undefined, or anything else JSON writes differently from how it hashes would otherwise
|
|
73
|
+
// produce a recipe that fails its own key check the moment it is read back.
|
|
74
|
+
const entry = { id, reads: reads ?? [], model: asWritten(model ?? undefined), key: undefined, output: { sha256: hex }, verdict: asWritten(verdict) };
|
|
75
|
+
recipe.stages.push(entry);
|
|
76
|
+
try {
|
|
77
|
+
entry.key = stageKey(recipe, index);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
recipe.stages.pop();
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
return entry;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Writes the output file from the last stage's blob if it is absent, or checks the file on
|
|
86
|
+
// disk against it if present (throwing on a mismatch: the output on disk must be what the
|
|
87
|
+
// recipe says). Writes the recipe file and returns checkRecipe's findings; an unapproved
|
|
88
|
+
// recipe returning only the approver fail is expected, not an error. Refuses a stage-less
|
|
89
|
+
// recipe before writing anything (neither the output file nor the recipe file): a recipe
|
|
90
|
+
// with no stages has nothing to have made, so finishing one would write an "approved" recipe
|
|
91
|
+
// for content that does not exist.
|
|
92
|
+
finish({ approver = null } = {}) {
|
|
93
|
+
if (!recipe.stages.length) throw new Error("a recipe needs at least one stage");
|
|
94
|
+
recipe.approver = approver;
|
|
95
|
+
const last = recipe.stages[recipe.stages.length - 1];
|
|
96
|
+
recipe.output.sha256 = last.output.sha256;
|
|
97
|
+
if (existsSync(outputAbs)) {
|
|
98
|
+
const onDisk = readFileSync(outputAbs);
|
|
99
|
+
if (sha256(onDisk) !== last.output.sha256) {
|
|
100
|
+
throw new Error(`${output} does not match the recipe's last stage output`);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
writeFileAtomic(outputAbs, getBlob(root, last.output.sha256));
|
|
104
|
+
}
|
|
105
|
+
writeRecipe(recipePath, recipe);
|
|
106
|
+
return { path: recipePath, findings: checkRecipe(asWritten(recipe)) };
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Rereads the recipe, sets approver, rewrites it, and returns the new findings.
|
|
112
|
+
export function approve(recipePath, by) {
|
|
113
|
+
const { data, error } = readRecipe(recipePath);
|
|
114
|
+
if (error) throw new Error(error);
|
|
115
|
+
data.approver = by;
|
|
116
|
+
writeRecipe(recipePath, data);
|
|
117
|
+
return checkRecipe(data);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A value exactly as the recipe file will hold it: what JSON.stringify writes, read back.
|
|
121
|
+
function asWritten(value) {
|
|
122
|
+
if (value === undefined) return undefined;
|
|
123
|
+
const text = JSON.stringify(value);
|
|
124
|
+
return text === undefined ? undefined : JSON.parse(text);
|
|
125
|
+
}
|