@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.
@@ -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
+ }