@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.
- package/CHANGELOG.md +102 -0
- package/README.md +105 -4
- package/SPEC.md +205 -13
- package/WRITING.md +426 -0
- package/bin/hyperspec.mjs +322 -2
- package/examples/minimal.hyperspec.md +2 -2
- 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/examples/writing/essay/goldens/close.md +2 -0
- package/examples/writing/essay/goldens/opening.md +2 -0
- package/examples/writing/essay/materials/interview-notes.md +12 -0
- package/examples/writing/essay/materials/team-survey.md +7 -0
- package/examples/writing/essay/materials/voice-memo.md +18 -0
- package/examples/writing/essay/runs.jsonl +0 -0
- package/examples/writing/essay.hyperspec.md +217 -0
- package/examples/writing/story/goldens/dialogue.md +3 -0
- package/examples/writing/story/goldens/opening.md +3 -0
- package/examples/writing/story/materials/bakery-visit.md +8 -0
- package/examples/writing/story/materials/notes.md +14 -0
- package/examples/writing/story/materials/scene-list.md +7 -0
- package/examples/writing/story/runs.jsonl +0 -0
- package/examples/writing/story.hyperspec.md +285 -0
- package/examples/writing/style-rules.md +19 -0
- package/package.json +8 -2
- 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/placeholder.mjs +20 -0
- package/src/profiles.mjs +50 -0
- package/src/recipe.mjs +164 -0
- package/src/regenerate.mjs +318 -0
- package/src/reproduce.mjs +156 -0
- package/src/rules.mjs +58 -19
- package/src/score.mjs +7 -1
- package/src/template.mjs +15 -3
- package/src/writer.mjs +125 -0
- package/src/writing-fields.mjs +317 -0
- package/src/writing-template.mjs +192 -0
- package/src/writing.mjs +181 -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A value only a human or an agent would recognize as "not actually written yet" never counts as
|
|
2
|
+
// present, wherever a presence check reads it:
|
|
3
|
+
//
|
|
4
|
+
// - null and ~ (the YAML reader hands these back as the literal strings "null" and "~" rather
|
|
5
|
+
// than resolving them to YAML's own null);
|
|
6
|
+
// - the placeholder words a scaffold or a hurried author leaves behind: todo, tbd, fixme, xxx,
|
|
7
|
+
// placeholder, <placeholder>, n/a;
|
|
8
|
+
// - a run of dashes, a run of question marks, or an ellipsis ("...", or the single character);
|
|
9
|
+
// - any of those followed by trailing ".", ":" or "!" (TODO., tbd:, FIXME!).
|
|
10
|
+
//
|
|
11
|
+
// Matched only against the WHOLE trimmed value, case-insensitive: "TODO: write the opening" is
|
|
12
|
+
// real text that happens to start with the word, and still counts as present. "none" is not on
|
|
13
|
+
// the list, because it is a legitimate decided value ("rejects: none of the above").
|
|
14
|
+
//
|
|
15
|
+
// This is the one place this pattern is defined. src/rules.mjs (the core tests), src/writing.mjs
|
|
16
|
+
// and src/writing-fields.mjs (the writing profile) all import str() from here rather than keeping
|
|
17
|
+
// their own copy, so a word added here closes every presence check in the linter at once. The
|
|
18
|
+
// rule exists because a scaffold that fills every field with "TODO" would otherwise lint clean.
|
|
19
|
+
export const PLACEHOLDER = /^(?:null|~|(?:todo|tbd|fixme|xxx|placeholder|<placeholder>|n\/a|-+|\?+|\.\.\.|…)[.:!]*)$/is;
|
|
20
|
+
export const str = (v) => { const t = typeof v === "string" ? v.trim() : ""; return PLACEHOLDER.test(t) ? "" : t; };
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A profile is an opt-in: a spec sets profile: <name> and gains a typed map of extra content
|
|
2
|
+
// (writing: for profile: writing) that this file's rules check, on top of everything the core
|
|
3
|
+
// format already requires. Every profile finding still reports under one of the nine tests; a
|
|
4
|
+
// profile adds no tenth test and no separate score.
|
|
5
|
+
//
|
|
6
|
+
// This is the one place that knows which profile names exist. Adding a profile means adding one
|
|
7
|
+
// entry here; rules.mjs and score.mjs both go through this registry rather than naming "writing"
|
|
8
|
+
// themselves, so a second profile needs no change to either.
|
|
9
|
+
import { lintWriting, blockStatus } from "./writing.mjs";
|
|
10
|
+
|
|
11
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
12
|
+
const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
|
|
13
|
+
|
|
14
|
+
export const PROFILES = Object.freeze({
|
|
15
|
+
writing: Object.freeze({
|
|
16
|
+
lint: lintWriting,
|
|
17
|
+
// Block completeness for the CLI's "<name>: k/n blocks complete" line and its --json twin.
|
|
18
|
+
// Reads the same findings lint just produced, so the count and the findings can never
|
|
19
|
+
// disagree with each other.
|
|
20
|
+
status: (data, findings) => blockStatus(data, findings),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Own-property lookup only: a profile named after something every object inherits
|
|
25
|
+
// (constructor, toString, __proto__) is an unknown profile, never a function to call.
|
|
26
|
+
export const knownProfile = (name) => (Object.hasOwn(PROFILES, name) ? PROFILES[name] : undefined);
|
|
27
|
+
|
|
28
|
+
// Runs the declared profile's rules against a loaded spec. A spec with no profile: at all runs no
|
|
29
|
+
// profile rules, so an unprofiled spec lints exactly as it always has. A profile: this linter does
|
|
30
|
+
// not know is a warning under test 7 (a stranger resuming the spec still needs to know its rules
|
|
31
|
+
// were not checked), not a failure: an unknown profile is not necessarily a wrong one, only one
|
|
32
|
+
// this version cannot yet check.
|
|
33
|
+
export function lintProfile(spec) {
|
|
34
|
+
const name = str(spec?.data?.profile);
|
|
35
|
+
if (!name) return [];
|
|
36
|
+
const profile = knownProfile(name);
|
|
37
|
+
if (!profile) {
|
|
38
|
+
return [f(7, "unknown-profile", "warn", `this linter does not know profile "${name}"; its rules were not checked`, "Set profile: to one this linter knows (writing), or remove it.")];
|
|
39
|
+
}
|
|
40
|
+
return profile.lint(spec);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The { name, complete, total } score.mjs merges into a passing profile's score, or undefined
|
|
44
|
+
// when no profile ran or the declared one is unknown (nothing to count).
|
|
45
|
+
export function profileStatus(data, findings) {
|
|
46
|
+
const name = str(data?.profile);
|
|
47
|
+
const profile = knownProfile(name);
|
|
48
|
+
if (!profile) return undefined;
|
|
49
|
+
return { name, ...profile.status(data, findings) };
|
|
50
|
+
}
|
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
|
+
}
|