@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/compare.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { dirname, relative, resolve } from "node:path";
|
|
4
|
+
import { sha256 } from "./hash.mjs";
|
|
5
|
+
import { insideDir } from "./fsutil.mjs";
|
|
6
|
+
import { loadSpec } from "./load.mjs";
|
|
7
|
+
import { readRecipe } from "./recipe.mjs";
|
|
8
|
+
|
|
9
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
10
|
+
const DOCTOR_MAX_BUFFER = 512 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
// Spec requirement `compare`: grade a regenerated output and its parent through ONE doctor
|
|
13
|
+
// against ONE spec, so "the new one should be better" becomes a number, and flag a regression
|
|
14
|
+
// naming the change as the suspect. hyperspec never calls a model: the doctor is a command the
|
|
15
|
+
// caller supplies, run once per output via /bin/sh -c, with no recipe-derived value ever
|
|
16
|
+
// interpolated into the command string itself (only passed on stdin).
|
|
17
|
+
export function compare(childRecipePath, { doctor, parent: parentOption, spec: specOption } = {}) {
|
|
18
|
+
const empty = () => ({ ok: false, parent: null, child: null, delta: null, regressed: null, suspect: null, specChanged: null, ledger: null, warnings: [] });
|
|
19
|
+
const usage = (error) => ({ ...empty(), usage: true, error });
|
|
20
|
+
const failed = (error, extra = {}) => ({ ...empty(), error, ...extra });
|
|
21
|
+
|
|
22
|
+
if (!present(doctor)) return usage("a doctor command is required");
|
|
23
|
+
if (!present(childRecipePath)) return usage("a child recipe path is required");
|
|
24
|
+
|
|
25
|
+
// ---- Child -------------------------------------------------------------------------------------
|
|
26
|
+
const childLoaded = readRecipe(childRecipePath);
|
|
27
|
+
if (childLoaded.error) return usage(childLoaded.error);
|
|
28
|
+
const { data: child, dir: childDir } = childLoaded;
|
|
29
|
+
const childRecipeAbs = resolve(childRecipePath);
|
|
30
|
+
|
|
31
|
+
// ---- Parent: defaults to the child's recorded parent.path -------------------------------------
|
|
32
|
+
let parentPath = parentOption;
|
|
33
|
+
if (!present(parentPath)) {
|
|
34
|
+
if (!present(child.parent?.path)) return usage("the child recipe has no parent recorded, and none was given");
|
|
35
|
+
parentPath = resolve(childDir, child.parent.path);
|
|
36
|
+
} else {
|
|
37
|
+
parentPath = resolve(process.cwd(), parentPath);
|
|
38
|
+
}
|
|
39
|
+
const parentLoaded = readRecipe(parentPath);
|
|
40
|
+
if (parentLoaded.error) return usage(parentLoaded.error);
|
|
41
|
+
const { data: parentRecipe, dir: parentDir } = parentLoaded;
|
|
42
|
+
const parentRecipeAbs = resolve(parentPath);
|
|
43
|
+
|
|
44
|
+
const warnings = [];
|
|
45
|
+
// The child's recorded parent.sha256 is what the parent recipe's bytes were when the child was
|
|
46
|
+
// made. Whatever parent recipe is actually being graded against here (the default or an explicit
|
|
47
|
+
// override) may have moved since, which is worth a warning but never a refusal to compare.
|
|
48
|
+
if (child.parent && present(child.parent.sha256)) {
|
|
49
|
+
const parentBytes = readFileSync(parentRecipeAbs);
|
|
50
|
+
if (sha256(parentBytes) !== child.parent.sha256) {
|
|
51
|
+
warnings.push("parent recipe changed since the child was made");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- Spec: defaults to the child's spec path, graded ONCE for both outputs --------------------
|
|
56
|
+
let specPath = specOption;
|
|
57
|
+
if (!present(specPath)) {
|
|
58
|
+
if (!present(child.spec?.path)) return usage("the child recipe has no spec path");
|
|
59
|
+
specPath = resolve(childDir, child.spec.path);
|
|
60
|
+
} else {
|
|
61
|
+
specPath = resolve(process.cwd(), specPath);
|
|
62
|
+
}
|
|
63
|
+
const specLoaded = loadSpec(specPath);
|
|
64
|
+
if (specLoaded.error) return usage(specLoaded.error);
|
|
65
|
+
const specAbs = specLoaded.path;
|
|
66
|
+
const specHash = sha256(readFileSync(specAbs));
|
|
67
|
+
// specChanged is about the PARENT's record, not the child's: it tells the caller the spec being
|
|
68
|
+
// graded against now differs from what the parent was made under. Both outputs still get graded
|
|
69
|
+
// against this one spec file either way — never each against its own.
|
|
70
|
+
const specChanged = parentRecipe.spec?.sha256 !== specHash;
|
|
71
|
+
if (specChanged) warnings.push("spec changed since the parent was made; both outputs graded against the current file");
|
|
72
|
+
|
|
73
|
+
// ---- Output files, resolved against each recipe's own directory --------------------------------
|
|
74
|
+
const childOutputPath = child.output?.path;
|
|
75
|
+
if (!present(childOutputPath)) return failed("the child recipe has no output path");
|
|
76
|
+
if (!insideDir(childDir, childOutputPath)) return failed("the child recipe's output path escapes its recipe directory");
|
|
77
|
+
const childOutputAbs = resolve(childDir, childOutputPath);
|
|
78
|
+
let childOutputBytes;
|
|
79
|
+
try { childOutputBytes = readFileSync(childOutputAbs); } catch { return failed(`cannot read the child's output: ${childOutputPath}`); }
|
|
80
|
+
|
|
81
|
+
const parentOutputPath = parentRecipe.output?.path;
|
|
82
|
+
if (!present(parentOutputPath)) return failed("the parent recipe has no output path");
|
|
83
|
+
if (!insideDir(parentDir, parentOutputPath)) return failed("the parent recipe's output path escapes its recipe directory");
|
|
84
|
+
const parentOutputAbs = resolve(parentDir, parentOutputPath);
|
|
85
|
+
let parentOutputBytes;
|
|
86
|
+
try { parentOutputBytes = readFileSync(parentOutputAbs); } catch { return failed(`cannot read the parent's output: ${parentOutputPath}`); }
|
|
87
|
+
|
|
88
|
+
// A score is attributed to a recipe, so the bytes graded must be the bytes that recipe records.
|
|
89
|
+
// A file edited by hand since (or never the recipe's output at all) would turn a hand edit into
|
|
90
|
+
// a regression or an improvement blamed on the change, and write that into the ledger. Refuse
|
|
91
|
+
// before the doctor runs; reproduce --restore puts the recorded bytes back.
|
|
92
|
+
const stale = (bytes, recipe) => sha256(bytes) !== recipe.output?.sha256;
|
|
93
|
+
if (stale(parentOutputBytes, parentRecipe)) return failed("parent output does not match its recipe; run hyperspec reproduce --restore");
|
|
94
|
+
if (stale(childOutputBytes, child)) return failed("child output does not match its recipe; run hyperspec reproduce --restore");
|
|
95
|
+
|
|
96
|
+
// ---- Grade, same doctor command for both, same spec for both -----------------------------------
|
|
97
|
+
const parentGraded = runDoctor(doctor, parentOutputAbs, specAbs);
|
|
98
|
+
if (parentGraded.error) return usage(`parent: ${parentGraded.error}`);
|
|
99
|
+
const childGraded = runDoctor(doctor, childOutputAbs, specAbs);
|
|
100
|
+
if (childGraded.error) return usage(`child: ${childGraded.error}`);
|
|
101
|
+
|
|
102
|
+
const delta = childGraded.score - parentGraded.score;
|
|
103
|
+
const regressed = childGraded.score < parentGraded.score;
|
|
104
|
+
const suspect = regressed ? (child.change ?? null) : null;
|
|
105
|
+
|
|
106
|
+
// ---- Ledger: one line of evidence for the self-upgrade loop, only if the spec declares one -----
|
|
107
|
+
let ledgerPath = null;
|
|
108
|
+
const ledgerDecl = specLoaded.data?.improvement?.ledger;
|
|
109
|
+
if (present(ledgerDecl)) {
|
|
110
|
+
const specDir = specLoaded.dir;
|
|
111
|
+
if (!insideDir(specDir, ledgerDecl)) {
|
|
112
|
+
warnings.push("improvement.ledger escapes the spec's directory; not appended");
|
|
113
|
+
} else {
|
|
114
|
+
const ledgerAbs = resolve(specDir, ledgerDecl);
|
|
115
|
+
const ledgerDir = dirname(ledgerAbs);
|
|
116
|
+
// The child's own `change` is null whenever it has no genealogical parent of its own (a root
|
|
117
|
+
// recipe compared against an explicit, unrelated --parent — compare's usage check allows
|
|
118
|
+
// this: it only requires *either* the child's recorded parent.path *or* an explicit
|
|
119
|
+
// override). Fall back to naming what was actually compared against, so `change` and the
|
|
120
|
+
// not-improved `reason` below are never "after null" — both a broken persisted record and,
|
|
121
|
+
// for the "improved" verdict, a lint failure (rules.mjs's verdict-change: `!str(v.change)`).
|
|
122
|
+
const childChange = child.change ?? `compared against ${relative(ledgerDir, parentRecipeAbs)}`;
|
|
123
|
+
// A compare line must satisfy test 9 ("it improves itself"), which only knows the
|
|
124
|
+
// verdict vocabulary one-shot/improved/not-improved — never a new "compare" verdict. A
|
|
125
|
+
// strictly higher child score is improved (and already carries change, which doubles as
|
|
126
|
+
// that verdict's required field). Equal or lower is not-improved, with a reason a later
|
|
127
|
+
// session can argue with; when it's a genuine regression (strictly lower, not merely tied)
|
|
128
|
+
// the reason is prefixed to say so.
|
|
129
|
+
const line = {
|
|
130
|
+
at: new Date().toISOString(),
|
|
131
|
+
kind: "compare",
|
|
132
|
+
parent: relative(ledgerDir, parentRecipeAbs),
|
|
133
|
+
child: relative(ledgerDir, childRecipeAbs),
|
|
134
|
+
scores: { parent: parentGraded.score, child: childGraded.score },
|
|
135
|
+
regressed,
|
|
136
|
+
change: childChange,
|
|
137
|
+
};
|
|
138
|
+
if (childGraded.score > parentGraded.score) {
|
|
139
|
+
line.verdict = "improved";
|
|
140
|
+
} else {
|
|
141
|
+
line.verdict = "not-improved";
|
|
142
|
+
const reasonBase = `compare: child scored ${childGraded.score} vs parent ${parentGraded.score} after ${childChange}`;
|
|
143
|
+
line.reason = regressed ? `regression: ${reasonBase}` : reasonBase;
|
|
144
|
+
}
|
|
145
|
+
appendFileSync(ledgerAbs, `${JSON.stringify(line)}\n`);
|
|
146
|
+
ledgerPath = ledgerAbs;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
parent: { recipe: parentRecipeAbs, output: parentOutputAbs, score: parentGraded.score, notes: parentGraded.notes },
|
|
153
|
+
child: { recipe: childRecipeAbs, output: childOutputAbs, score: childGraded.score, notes: childGraded.notes },
|
|
154
|
+
delta,
|
|
155
|
+
regressed,
|
|
156
|
+
suspect,
|
|
157
|
+
specChanged,
|
|
158
|
+
ledger: ledgerPath,
|
|
159
|
+
warnings,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Runs the doctor command once, via /bin/sh -c, over one output against the one spec. stdin is
|
|
164
|
+
// { output, spec } (absolute paths); the last non-empty stdout line must be JSON with a numeric
|
|
165
|
+
// score. Nothing recipe-derived is ever interpolated into the command string — only passed on
|
|
166
|
+
// stdin — so the same command string is reused verbatim for the parent and the child.
|
|
167
|
+
function runDoctor(doctorCmd, outputAbs, specAbs) {
|
|
168
|
+
const res = spawnSync("/bin/sh", ["-c", doctorCmd], {
|
|
169
|
+
input: JSON.stringify({ output: outputAbs, spec: specAbs }),
|
|
170
|
+
maxBuffer: DOCTOR_MAX_BUFFER,
|
|
171
|
+
});
|
|
172
|
+
if (res.error) return { error: `doctor could not run: ${res.error.message}` };
|
|
173
|
+
const stderr = res.stderr ? res.stderr.toString("utf8") : "";
|
|
174
|
+
if (res.status !== 0) {
|
|
175
|
+
const how = res.signal ? `was killed by ${res.signal}` : `exited ${res.status}`;
|
|
176
|
+
const tail = stderr.trim().split("\n").slice(-5).join("\n");
|
|
177
|
+
return { error: `doctor ${how}${tail ? `: ${tail}` : ""}` };
|
|
178
|
+
}
|
|
179
|
+
const stdout = res.stdout ? res.stdout.toString("utf8") : "";
|
|
180
|
+
const lines = stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
181
|
+
if (!lines.length) return { error: "doctor produced no output" };
|
|
182
|
+
const last = lines[lines.length - 1].trim();
|
|
183
|
+
let value;
|
|
184
|
+
try { value = JSON.parse(last); } catch (e) { return { error: `doctor's last line is not JSON: ${e.message}` }; }
|
|
185
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.score !== "number" || !Number.isFinite(value.score)) {
|
|
186
|
+
return { error: "doctor's last line must be an object with a numeric score" };
|
|
187
|
+
}
|
|
188
|
+
return { score: value.score, notes: typeof value.notes === "string" ? value.notes : "" };
|
|
189
|
+
}
|
package/src/fsutil.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
// True when `target`, resolved against `dir`, stays inside `dir` — refuses a `..`-escape and an
|
|
6
|
+
// absolute path pointing elsewhere. Both a relative `target` (including one that climbs out via
|
|
7
|
+
// `../`) and an already-absolute `target` are handled the same way, since `path.resolve(dir,
|
|
8
|
+
// target)` already treats an absolute second argument as overriding the first: either way, the
|
|
9
|
+
// resolved path is compared against `dir` by `path.relative`, and anything that needs a leading
|
|
10
|
+
// `..` segment to get there is outside.
|
|
11
|
+
export function insideDir(dir, target) {
|
|
12
|
+
const dirAbs = resolve(dir);
|
|
13
|
+
const targetAbs = resolve(dir, target);
|
|
14
|
+
const rel = relative(dirAbs, targetAbs);
|
|
15
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Writes `bytes` to `path` atomically: a temp file in the same directory, then a single
|
|
19
|
+
// renameSync (atomic on one filesystem) onto the final path. Same pattern as blobs.mjs's
|
|
20
|
+
// putBlob, minus putBlob's content-addressed dedup (this always writes/overwrites the given
|
|
21
|
+
// path; it isn't keyed by the bytes' own hash, so there's nothing to skip). Any failure along
|
|
22
|
+
// the way removes the temp file before rethrowing, so a crash mid-write never leaves the target
|
|
23
|
+
// path holding a partial write, and never leaves an orphaned temp file behind either.
|
|
24
|
+
export function writeFileAtomic(path, bytes) {
|
|
25
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(tmp, bytes);
|
|
28
|
+
renameSync(tmp, path);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
try { unlinkSync(tmp); } catch { /* nothing to clean up */ }
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/hash.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// SHA-256, lowercase hex, over exact bytes. Accepts a Buffer or a string.
|
|
4
|
+
export function sha256(bufferOrString) {
|
|
5
|
+
return createHash("sha256").update(bufferOrString).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Canonical JSON for anything about to be hashed: keys sorted recursively,
|
|
9
|
+
// no whitespace, undefined keys dropped. Array order is preserved, since
|
|
10
|
+
// order is meaningful there (inputs, stages, reads).
|
|
11
|
+
export function canonical(value) {
|
|
12
|
+
return JSON.stringify(sortKeys(value));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sortKeys(value) {
|
|
16
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
17
|
+
if (value && typeof value === "object") {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const key of Object.keys(value).sort()) {
|
|
20
|
+
if (value[key] === undefined) continue;
|
|
21
|
+
out[key] = sortKeys(value[key]);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
package/src/recipe.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { canonical, sha256 } from "./hash.mjs";
|
|
4
|
+
import { writeFileAtomic } from "./fsutil.mjs";
|
|
5
|
+
import { isSha256 } from "./blobs.mjs";
|
|
6
|
+
|
|
7
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
8
|
+
const NOT_HEX = "is not a SHA-256 hash (64 lowercase hex characters)";
|
|
9
|
+
|
|
10
|
+
export function readRecipe(path) {
|
|
11
|
+
const abs = resolve(path);
|
|
12
|
+
let text;
|
|
13
|
+
try { text = readFileSync(abs, "utf8"); } catch { return { error: `cannot read ${path}` }; }
|
|
14
|
+
let data;
|
|
15
|
+
try { data = JSON.parse(text); } catch (e) { return { error: `invalid JSON in ${path}: ${e.message}` }; }
|
|
16
|
+
return { data, dir: dirname(abs) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Pretty-printed (2-space indent) with a trailing newline, per the recipe file convention.
|
|
20
|
+
// Atomic (temp file + rename), so a crash mid-write never leaves a half-written recipe.
|
|
21
|
+
export function writeRecipe(path, data) {
|
|
22
|
+
writeFileAtomic(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A stage with a declared, non-empty `reads` resolves exactly those refs. An empty (or missing)
|
|
26
|
+
// `reads` means "read everything": every input in order, then every earlier stage in array
|
|
27
|
+
// order, then spec (spec decision downstream-rerun-detection).
|
|
28
|
+
export function resolveReads(recipe, stageIndex) {
|
|
29
|
+
const stages = Array.isArray(recipe.stages) ? recipe.stages : [];
|
|
30
|
+
const stage = stages[stageIndex];
|
|
31
|
+
if (!stage) throw new Error(`no stage at index ${stageIndex}`);
|
|
32
|
+
const declared = Array.isArray(stage.reads) && stage.reads.length ? stage.reads : expandReads(recipe, stageIndex);
|
|
33
|
+
return declared.map((ref) => [ref, resolveRef(recipe, stageIndex, ref)]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function expandReads(recipe, stageIndex) {
|
|
37
|
+
const inputs = [...(Array.isArray(recipe.inputs) ? recipe.inputs : [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
38
|
+
const stages = Array.isArray(recipe.stages) ? recipe.stages : [];
|
|
39
|
+
return [
|
|
40
|
+
...inputs.map((i) => `input:${i.name}`),
|
|
41
|
+
...stages.slice(0, stageIndex).map((s) => `stage:${s.id}`),
|
|
42
|
+
"spec",
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveRef(recipe, stageIndex, ref) {
|
|
47
|
+
if (ref === "spec") {
|
|
48
|
+
const hex = recipe.spec?.sha256;
|
|
49
|
+
if (!present(hex)) throw new Error(`stage ${stageIndex}: spec has no sha256`);
|
|
50
|
+
return hex;
|
|
51
|
+
}
|
|
52
|
+
if (typeof ref === "string" && ref.startsWith("input:")) {
|
|
53
|
+
const name = ref.slice("input:".length);
|
|
54
|
+
const input = (Array.isArray(recipe.inputs) ? recipe.inputs : []).find((i) => i.name === name);
|
|
55
|
+
if (!input) throw new Error(`stage ${stageIndex}: unknown read "${ref}"`);
|
|
56
|
+
return input.sha256;
|
|
57
|
+
}
|
|
58
|
+
if (typeof ref === "string" && ref.startsWith("stage:")) {
|
|
59
|
+
const id = ref.slice("stage:".length);
|
|
60
|
+
const stages = Array.isArray(recipe.stages) ? recipe.stages : [];
|
|
61
|
+
const idx = stages.findIndex((s) => s.id === id);
|
|
62
|
+
if (idx === -1) throw new Error(`stage ${stageIndex}: unknown read "${ref}"`);
|
|
63
|
+
if (idx >= stageIndex) throw new Error(`stage ${stageIndex}: "${ref}" is not an earlier stage`);
|
|
64
|
+
return stages[idx].output?.sha256;
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`stage ${stageIndex}: unknown read "${ref}"`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// sha256(canonical({ reads, spec, factory, model })) where reads is the resolved [ref, hex] list
|
|
70
|
+
// in declared (or expanded) order, spec is spec.sha256, factory is factory.version, and model is
|
|
71
|
+
// the stage's own model settings or null.
|
|
72
|
+
export function stageKey(recipe, stageIndex) {
|
|
73
|
+
const stages = Array.isArray(recipe.stages) ? recipe.stages : [];
|
|
74
|
+
const stage = stages[stageIndex];
|
|
75
|
+
if (!stage) throw new Error(`no stage at index ${stageIndex}`);
|
|
76
|
+
const reads = resolveReads(recipe, stageIndex);
|
|
77
|
+
return sha256(canonical({
|
|
78
|
+
reads,
|
|
79
|
+
spec: recipe.spec?.sha256,
|
|
80
|
+
factory: recipe.factory?.version,
|
|
81
|
+
model: stage.model ?? null,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Implements spec requirement recipe-completeness: every fail below is a field the recipe must
|
|
86
|
+
// carry for it to count as done; the one warn flags a stage that will rerun on every regeneration
|
|
87
|
+
// because it declared nothing.
|
|
88
|
+
export function checkRecipe(recipe) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const fail = (field, message) => out.push({ severity: "fail", field, message });
|
|
91
|
+
const warn = (field, message) => out.push({ severity: "warn", field, message });
|
|
92
|
+
|
|
93
|
+
if (!present(recipe.factory?.name)) fail("factory.name", "factory.name is missing");
|
|
94
|
+
if (!present(recipe.factory?.version)) fail("factory.version", "factory.version is missing");
|
|
95
|
+
if (!present(recipe.spec?.sha256)) fail("spec.sha256", "spec.sha256 is missing");
|
|
96
|
+
else if (!isSha256(recipe.spec.sha256)) fail("spec.sha256", `spec.sha256 ${NOT_HEX}`);
|
|
97
|
+
if (present(recipe.output?.sha256) && !isSha256(recipe.output.sha256)) fail("output.sha256", `output.sha256 ${NOT_HEX}`);
|
|
98
|
+
const authors = recipe.spec?.authors;
|
|
99
|
+
if (!authors || typeof authors !== "object" || Array.isArray(authors) || Object.keys(authors).length === 0) {
|
|
100
|
+
fail("spec.authors", "spec.authors is missing");
|
|
101
|
+
} else {
|
|
102
|
+
// The writer records null for a spec entry with no author rather than dropping the id, so the
|
|
103
|
+
// gap stays visible here instead of disappearing from the map.
|
|
104
|
+
for (const [id, author] of Object.entries(authors)) {
|
|
105
|
+
if (!present(author)) warn(`spec.authors.${id}`, `spec id "${id}" has no author`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!present(recipe.clicker)) fail("clicker", "clicker is missing");
|
|
109
|
+
if (!present(recipe.approver)) fail("approver", "approver is missing");
|
|
110
|
+
|
|
111
|
+
const parentSet = recipe.parent !== null && recipe.parent !== undefined;
|
|
112
|
+
const changeSet = recipe.change !== null && recipe.change !== undefined;
|
|
113
|
+
if (parentSet !== changeSet) fail("parent", "parent and change must both be null or both be set");
|
|
114
|
+
if (parentSet && present(recipe.parent?.sha256) && !isSha256(recipe.parent.sha256)) fail("parent.sha256", `parent.sha256 ${NOT_HEX}`);
|
|
115
|
+
|
|
116
|
+
const inputs = Array.isArray(recipe.inputs) ? recipe.inputs : [];
|
|
117
|
+
inputs.forEach((input, i) => {
|
|
118
|
+
if (!present(input?.sha256)) fail(`inputs[${i}].sha256`, `input "${input?.name ?? i}" has no sha256`);
|
|
119
|
+
else if (!isSha256(input.sha256)) fail(`inputs[${i}].sha256`, `input "${input?.name ?? i}" sha256 ${NOT_HEX}`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const stages = Array.isArray(recipe.stages) ? recipe.stages : [];
|
|
123
|
+
if (stages.length === 0) fail("stages", "a recipe needs at least one stage");
|
|
124
|
+
stages.forEach((stage, i) => {
|
|
125
|
+
const id = stage?.id ?? `#${i}`;
|
|
126
|
+
|
|
127
|
+
if (!stage?.verdict || typeof stage.verdict !== "object") {
|
|
128
|
+
fail(`stages[${i}].verdict`, `stage "${id}" has no verdict`);
|
|
129
|
+
} else if (typeof stage.verdict.pass !== "boolean") {
|
|
130
|
+
fail(`stages[${i}].verdict.pass`, `stage "${id}" verdict.pass is not a boolean`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// pending: true, or a null/incomplete output, makes the stage incomplete by definition.
|
|
134
|
+
if (stage?.pending === true || stage?.output == null || !present(stage.output?.sha256)) {
|
|
135
|
+
fail(`stages[${i}]`, `stage ${id} is pending`);
|
|
136
|
+
} else if (!isSha256(stage.output.sha256)) {
|
|
137
|
+
fail(`stages[${i}].output.sha256`, `stage "${id}" output.sha256 ${NOT_HEX}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!Array.isArray(stage?.reads) || stage.reads.length === 0) {
|
|
141
|
+
warn(`stages[${i}].reads`, `stage "${id}" declares no reads, so it reruns on every regeneration; declare what it reads`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const recomputed = stageKey(recipe, i);
|
|
146
|
+
if (stage?.key !== recomputed) fail(`stages[${i}].key`, `stage "${id}" key does not match its recomputed key`);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// A bad ref is why the key can't be recomputed, but this is still a key-completeness
|
|
149
|
+
// failure, not the reads-declaration warn: keep it on its own field so a caller grouping
|
|
150
|
+
// or deduping findings by field can't collapse a blocking fail into an informational warn.
|
|
151
|
+
fail(`stages[${i}].key`, `stage "${id}" key could not be recomputed: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (stages.length) {
|
|
156
|
+
const last = stages[stages.length - 1];
|
|
157
|
+
const lastHash = last?.output?.sha256;
|
|
158
|
+
if (present(lastHash) && lastHash !== recipe.output?.sha256) {
|
|
159
|
+
fail("output.sha256", "the last stage's output does not match output.sha256");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return out;
|
|
164
|
+
}
|