create-pathfinder 4.2.0 → 4.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 (39) hide show
  1. package/CLAUDE.md +2 -0
  2. package/package.json +1 -1
  3. package/skills/learn-codebase/SKILL.md +188 -17
  4. package/skills/learn-feature/SKILL.md +136 -15
  5. package/skills/map-system/SKILL.md +293 -0
  6. package/skills/render-artifact/SKILL.md +187 -0
  7. package/skills/render-artifact/engine/bin/render.mjs +225 -0
  8. package/skills/render-artifact/engine/deliver.mjs +197 -0
  9. package/skills/render-artifact/engine/doctor.mjs +96 -0
  10. package/skills/render-artifact/engine/examples/diagram.json +223 -0
  11. package/skills/render-artifact/engine/examples/lesson.json +242 -0
  12. package/skills/render-artifact/engine/references/determinism.md +71 -0
  13. package/skills/render-artifact/engine/references/specification.md +149 -0
  14. package/skills/render-artifact/engine/references/validation.md +268 -0
  15. package/skills/render-artifact/engine/render/behavior.mjs +128 -0
  16. package/skills/render-artifact/engine/render/diagram.mjs +342 -0
  17. package/skills/render-artifact/engine/render/escape.mjs +34 -0
  18. package/skills/render-artifact/engine/render/graph/behavior.mjs +394 -0
  19. package/skills/render-artifact/engine/render/graph/draw.mjs +204 -0
  20. package/skills/render-artifact/engine/render/graph/interaction.mjs +174 -0
  21. package/skills/render-artifact/engine/render/graph/layout.mjs +698 -0
  22. package/skills/render-artifact/engine/render/graph/style.mjs +200 -0
  23. package/skills/render-artifact/engine/render/graph/width.mjs +204 -0
  24. package/skills/render-artifact/engine/render/index.mjs +50 -0
  25. package/skills/render-artifact/engine/render/lesson.mjs +294 -0
  26. package/skills/render-artifact/engine/render/shell.mjs +275 -0
  27. package/skills/render-artifact/engine/render/theme.mjs +592 -0
  28. package/skills/render-artifact/engine/schemas/common.schema.json +101 -0
  29. package/skills/render-artifact/engine/schemas/diagram.schema.json +176 -0
  30. package/skills/render-artifact/engine/schemas/lesson.schema.json +210 -0
  31. package/skills/render-artifact/engine/validate/composition.mjs +395 -0
  32. package/skills/render-artifact/engine/validate/diagnostics.mjs +83 -0
  33. package/skills/render-artifact/engine/validate/diagram-parts.mjs +68 -0
  34. package/skills/render-artifact/engine/validate/evidence.mjs +302 -0
  35. package/skills/render-artifact/engine/validate/index.mjs +132 -0
  36. package/skills/render-artifact/engine/validate/jsonschema.mjs +312 -0
  37. package/skills/render-artifact/engine/validate/structural.mjs +241 -0
  38. package/skills/render-artifact/engine/verification.mjs +76 -0
  39. package/skills/render-artifact/engine/version.mjs +24 -0
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `render-artifact` — validate a specification, or deliver it as an artifact.
4
+ *
5
+ * node bin/render.mjs validate <spec.json> [--repo <dir>] [--json]
6
+ * node bin/render.mjs deliver <spec.json> <out.html> [--repo <dir>] [--json]
7
+ * node bin/render.mjs doctor [--json]
8
+ *
9
+ * Exit codes are the contract, and a non-zero exit is never reported as
10
+ * success:
11
+ *
12
+ * 0 every layer that ran passed, and the artifact was committed
13
+ * 1 a validation or delivery layer failed; nothing was committed
14
+ * 2 the command line was wrong
15
+ *
16
+ * `--repo` names the repository whose history evidence resolves in. It defaults
17
+ * to the specification's own directory, which is right for a specification that
18
+ * lives in the repository it cites, and is exactly what needs overriding when
19
+ * it does not.
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { dirname, resolve } from "node:path";
24
+
25
+ import { RENDERER_VERSION } from "../version.mjs";
26
+ import { validateSpecification, formatReport } from "../validate/index.mjs";
27
+ import { deliver } from "../deliver.mjs";
28
+ import { doctor } from "../doctor.mjs";
29
+
30
+ const USAGE = `render-artifact ${RENDERER_VERSION}
31
+
32
+ validate <spec.json> [--repo <dir>] [--json]
33
+ Run the structural, composition and evidence layers. Writes nothing.
34
+
35
+ deliver <spec.json> <out.html> [--repo <dir>] [--json]
36
+ Validate, then render and commit the artifact atomically. Prints a
37
+ receipt naming the renderer version and the digests it produced.
38
+
39
+ doctor [--json]
40
+ Report whether this machine can render at all. Does not validate any
41
+ particular specification.
42
+ `;
43
+
44
+ function main(argv) {
45
+ const flags = { json: false, repo: null };
46
+ const positional = [];
47
+
48
+ for (let i = 0; i < argv.length; i += 1) {
49
+ const argument = argv[i];
50
+ if (argument === "--json") { flags.json = true; continue; }
51
+ if (argument === "--repo") {
52
+ i += 1;
53
+ if (i >= argv.length) return usageError("--repo needs a directory");
54
+ flags.repo = argv[i];
55
+ continue;
56
+ }
57
+ if (argument === "--help" || argument === "-h") { process.stdout.write(USAGE); return 0; }
58
+ if (argument.startsWith("-")) return usageError(`unknown option \`${argument}\``);
59
+ positional.push(argument);
60
+ }
61
+
62
+ const [command, ...rest] = positional;
63
+ if (command === undefined) return usageError("no command given");
64
+ if (command === "doctor") return runDoctor(flags);
65
+ if (command === "validate") return runValidate(rest, flags);
66
+ if (command === "deliver") return runDeliver(rest, flags);
67
+ return usageError(`unknown command \`${command}\``);
68
+ }
69
+
70
+ function runDoctor(flags) {
71
+ const result = doctor();
72
+ if (flags.json) {
73
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
74
+ } else {
75
+ process.stdout.write(`render-artifact ${RENDERER_VERSION} — capability check\n\n`);
76
+ for (const check of result.checks) {
77
+ process.stdout.write(` ${check.ok ? "ok " : "FAIL"} ${check.name}: ${check.detail}\n`);
78
+ }
79
+ process.stdout.write(
80
+ "\n This reports whether the engine can render here. It does not " +
81
+ "validate\n any specification, and the determinism check compares two " +
82
+ "renders in\n one process — cross-environment determinism is checked by " +
83
+ "rendering the\n same specification in different environments and " +
84
+ "comparing digests.\n");
85
+ }
86
+ return result.ok ? 0 : 1;
87
+ }
88
+
89
+ function runValidate([specPath], flags) {
90
+ if (!specPath) return usageError("validate needs a specification path");
91
+
92
+ const loaded = loadSpec(specPath, flags);
93
+ if (loaded.exitCode !== undefined) return loaded.exitCode;
94
+
95
+ const result = validateSpecification(loaded.spec, { repoDir: repoDirFor(specPath, flags) });
96
+
97
+ if (flags.json) {
98
+ // `not_run` is reported here for the same reason it is in the human-readable
99
+ // report: a caller that saw `ok: true` beside two layers, with nothing
100
+ // saying why the third is missing, would have to infer the difference
101
+ // between "everything passed" and "one layer did not apply" — and the point
102
+ // of keeping the layers apart is that nobody has to infer it.
103
+ process.stdout.write(`${JSON.stringify({
104
+ ok: result.ok,
105
+ renderer_version: RENDERER_VERSION,
106
+ ran: result.ran,
107
+ skipped: result.skipped,
108
+ not_run: result.notRun,
109
+ resolved_citations: result.resolvedCitations,
110
+ diagnostics: result.diagnostics,
111
+ }, null, 2)}\n`);
112
+ return result.ok ? 0 : 1;
113
+ }
114
+
115
+ process.stdout.write(`validating ${specPath}\n${formatReport(result)}\n`);
116
+ process.stdout.write(result.ok
117
+ ? "\nvalid. Nothing was written: `validate` never delivers.\n"
118
+ : "\ninvalid. Nothing was written.\n");
119
+ return result.ok ? 0 : 1;
120
+ }
121
+
122
+ function runDeliver([specPath, outPath], flags) {
123
+ if (!specPath || !outPath) return usageError("deliver needs a specification path and an output path");
124
+
125
+ // Read the bytes and hand only the bytes on. `deliver` parses, validates,
126
+ // renders and digests from that one source, so the receipt cannot end up
127
+ // describing a specification other than the one that was rendered.
128
+ let specBytes;
129
+ try {
130
+ specBytes = readFileSync(specPath);
131
+ } catch (error) {
132
+ return reportLoadFailure(flags, "specification_unreadable", specPath, error.message);
133
+ }
134
+
135
+ const result = deliver(specBytes, specPath, outPath, { repoDir: repoDirFor(specPath, flags) });
136
+
137
+ if (!result.ok) {
138
+ if (flags.json) {
139
+ process.stdout.write(`${JSON.stringify({
140
+ ok: false,
141
+ renderer_version: RENDERER_VERSION,
142
+ delivered: false,
143
+ diagnostics: result.diagnostics,
144
+ }, null, 2)}\n`);
145
+ } else if (result.validation) {
146
+ process.stderr.write(`validating ${specPath}\n${formatReport(result.validation)}\n`);
147
+ process.stderr.write(
148
+ `\ninvalid. Nothing was delivered, and any artifact already at ` +
149
+ `${outPath} is untouched.\n`);
150
+ } else {
151
+ for (const d of result.diagnostics) {
152
+ process.stderr.write(` FAIL delivery: [${d.code}] ${d.message}\n`);
153
+ }
154
+ }
155
+ return 1;
156
+ }
157
+
158
+ if (flags.json) {
159
+ process.stdout.write(`${JSON.stringify({
160
+ ok: true, delivered: true, ...result.receipt,
161
+ }, null, 2)}\n`);
162
+ return 0;
163
+ }
164
+
165
+ const { receipt } = result;
166
+ process.stdout.write([
167
+ `validating ${specPath}`,
168
+ formatReport(result.validation),
169
+ " ok delivery: rendered, digested, and committed atomically",
170
+ "",
171
+ "receipt",
172
+ ` renderer version ${receipt.renderer_version}`,
173
+ ` specification ${receipt.specification.path}`,
174
+ ` sha256 ${receipt.specification.sha256}`,
175
+ ` bytes ${receipt.specification.bytes}`,
176
+ ` artifact ${receipt.artifact.path}`,
177
+ ` sha256 ${receipt.artifact.sha256}`,
178
+ ` bytes ${receipt.artifact.bytes}`,
179
+ "",
180
+ " Delivered. This says the artifact was validated and written, not that it",
181
+ " looks right — open it in a browser to know that.",
182
+ "",
183
+ ].join("\n"));
184
+ return 0;
185
+ }
186
+
187
+ /** Where evidence resolves: `--repo` if given, otherwise the spec's directory. */
188
+ function repoDirFor(specPath, flags) {
189
+ return resolve(flags.repo ?? dirname(resolve(specPath)));
190
+ }
191
+
192
+ function loadSpec(specPath, flags) {
193
+ let bytes;
194
+ try {
195
+ bytes = readFileSync(specPath);
196
+ } catch (error) {
197
+ return { exitCode: reportLoadFailure(flags, "specification_unreadable", specPath, error.message) };
198
+ }
199
+
200
+ let spec;
201
+ try {
202
+ spec = JSON.parse(bytes.toString("utf8"));
203
+ } catch (error) {
204
+ return { exitCode: reportLoadFailure(flags, "specification_not_json", specPath, error.message) };
205
+ }
206
+
207
+ return { spec, bytes };
208
+ }
209
+
210
+ function reportLoadFailure(flags, code, path, message) {
211
+ const d = { layer: "structural", code, path, message };
212
+ if (flags.json) {
213
+ process.stdout.write(`${JSON.stringify({ ok: false, diagnostics: [d] }, null, 2)}\n`);
214
+ } else {
215
+ process.stderr.write(` FAIL structural: [${code}] ${path}\n ${message}\n`);
216
+ }
217
+ return 1;
218
+ }
219
+
220
+ function usageError(message) {
221
+ process.stderr.write(`render-artifact: ${message}\n\n${USAGE}`);
222
+ return 2;
223
+ }
224
+
225
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Layer 4 — delivery. Parse, validate, render, digest, and commit atomically.
3
+ *
4
+ * Four properties this file exists to guarantee:
5
+ *
6
+ * 1. **One source of truth: the bytes.** `deliver` takes the specification as
7
+ * bytes and nothing else. It copies them, parses that copy, validates the
8
+ * parsed value, renders that same value, and reports the digest of those
9
+ * same bytes. There is deliberately no parameter through which a caller
10
+ * could supply an object alongside unrelated bytes — the receipt would then
11
+ * describe a specification that was never rendered, and nothing downstream
12
+ * could tell. The absent parameter is the guarantee; a check would only be
13
+ * a second thing to get wrong.
14
+ * 2. **Verification is earned.** Delivery runs validation itself and mints its
15
+ * attestation from that result. The renderer emits a verification claim only
16
+ * against one, so an artifact that says its evidence was checked is an
17
+ * artifact whose evidence this process checked, moments earlier, from these
18
+ * bytes.
19
+ * 3. **Atomic commit.** The artifact is written to a temporary file beside the
20
+ * destination and renamed over it. A rename within a directory is atomic, so
21
+ * a reader never sees a half-written page, and a failure at any earlier step
22
+ * leaves the previously delivered artifact exactly as it was.
23
+ * 4. **A receipt that says what it checked.** Renderer version, and SHA-256 and
24
+ * byte count for both the specification and the artifact. The renderer
25
+ * version is in there because it is part of the deterministic input: the
26
+ * invariant is *same specification bytes and same renderer version*, so a
27
+ * receipt naming only the digests would describe half the compiler.
28
+ */
29
+
30
+ import { createHash } from "node:crypto";
31
+ import { closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
32
+ import { dirname, basename, join } from "node:path";
33
+
34
+ import { RENDERER_VERSION } from "./version.mjs";
35
+ import { render } from "./render/index.mjs";
36
+ import { diagnostic } from "./validate/diagnostics.mjs";
37
+ import { validateSpecification } from "./validate/index.mjs";
38
+ import { attest } from "./verification.mjs";
39
+
40
+ /**
41
+ * @typedef {object} Receipt
42
+ * @property {string} renderer_version
43
+ * @property {{ path: string, sha256: string, bytes: number }} specification
44
+ * @property {{ path: string, sha256: string, bytes: number }} artifact
45
+ */
46
+
47
+ /**
48
+ * Validate, render, and commit the specification held in `specBytes`.
49
+ *
50
+ * @param {Buffer} specBytes the specification exactly as it was read
51
+ * @param {string} specPath where it was read from, for the receipt
52
+ * @param {string} outPath where the artifact is committed
53
+ * @param {{ repoDir: string }} options where evidence resolves
54
+ * @returns {{ ok: true, receipt: Receipt, validation: object }
55
+ * | { ok: false, diagnostics: object[], validation?: object }}
56
+ */
57
+ export function deliver(specBytes, specPath, outPath, { repoDir }) {
58
+ // Copy first. Everything below — the parse, the render, the digest — reads
59
+ // this copy, so a caller mutating its own buffer afterwards cannot leave the
60
+ // receipt describing bytes that were never rendered.
61
+ const frozenBytes = Buffer.from(specBytes);
62
+
63
+ let spec;
64
+ try {
65
+ spec = JSON.parse(frozenBytes.toString("utf8"));
66
+ } catch (error) {
67
+ return fail("specification_not_json", specPath,
68
+ `the specification could not be parsed, and nothing was written: ${error.message}`);
69
+ }
70
+
71
+ const validation = validateSpecification(spec, { repoDir });
72
+ if (!validation.ok) {
73
+ return { ok: false, validation, diagnostics: validation.diagnostics };
74
+ }
75
+
76
+ let html;
77
+ try {
78
+ // The attestation is minted here, from the validation just performed on
79
+ // this parse of these bytes. It is the only thing that lets the artifact
80
+ // say its evidence was checked.
81
+ html = render(deepFreeze(structuredClone(spec)), attest(validation));
82
+ } catch (error) {
83
+ return fail("render_failed", outPath,
84
+ `rendering threw and nothing was written: ${error.message}`);
85
+ }
86
+
87
+ // The invariant is about bytes, so check the bytes rather than trusting that
88
+ // no template ever grew a `\r` or that no environment introduced a BOM.
89
+ if (html.includes("\r")) {
90
+ return fail("carriage_return_in_output", outPath,
91
+ "rendered HTML contains a carriage return; artifacts use \\n only, on " +
92
+ "every platform");
93
+ }
94
+ if (html.charCodeAt(0) === 0xFEFF) {
95
+ return fail("byte_order_mark_in_output", outPath,
96
+ "rendered HTML begins with a byte-order mark; artifacts are UTF-8 " +
97
+ "without one");
98
+ }
99
+
100
+ const artifactBytes = Buffer.from(html, "utf8");
101
+
102
+ try {
103
+ commitAtomically(outPath, artifactBytes);
104
+ } catch (error) {
105
+ return fail("commit_failed", outPath,
106
+ `the artifact could not be committed, and any previously delivered ` +
107
+ `artifact is untouched: ${error.message}`);
108
+ }
109
+
110
+ return {
111
+ ok: true,
112
+ validation,
113
+ receipt: {
114
+ renderer_version: RENDERER_VERSION,
115
+ specification: {
116
+ path: specPath,
117
+ sha256: sha256(frozenBytes),
118
+ bytes: frozenBytes.byteLength,
119
+ },
120
+ artifact: {
121
+ path: outPath,
122
+ sha256: sha256(artifactBytes),
123
+ bytes: artifactBytes.byteLength,
124
+ },
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Render without writing anything and without validating anything.
131
+ *
132
+ * No attestation is minted, so the artifact makes no claim to have been
133
+ * checked. Used by `doctor`, which is asking whether rendering works at all,
134
+ * and by anyone comparing two renders rather than keeping either.
135
+ */
136
+ export function renderOnly(spec) {
137
+ const html = render(deepFreeze(structuredClone(spec)));
138
+ const bytes = Buffer.from(html, "utf8");
139
+ return { html, sha256: sha256(bytes), bytes: bytes.byteLength };
140
+ }
141
+
142
+ export function sha256(bytes) {
143
+ return createHash("sha256").update(bytes).digest("hex");
144
+ }
145
+
146
+ function fail(code, path, message) {
147
+ return { ok: false, diagnostics: [diagnostic("delivery", code, path, message)] };
148
+ }
149
+
150
+ /**
151
+ * Write beside the destination, flush to disk, then rename over it.
152
+ *
153
+ * The `fsync` matters as much as the rename: without it the rename can be
154
+ * durable while the content behind it is not, which is how a crash leaves a
155
+ * correctly named, empty artifact. The temporary name is derived from the
156
+ * destination and the process id so two concurrent deliveries to one path do
157
+ * not stage over each other.
158
+ */
159
+ function commitAtomically(outPath, bytes) {
160
+ const directory = dirname(outPath);
161
+ const temporary = join(directory, `.${basename(outPath)}.${process.pid}.tmp`);
162
+
163
+ let handle;
164
+ try {
165
+ writeFileSync(temporary, bytes, { encoding: null, mode: 0o644 });
166
+ handle = openSync(temporary, "r+");
167
+ fsyncSync(handle);
168
+ } catch (error) {
169
+ safeUnlink(temporary);
170
+ throw error;
171
+ } finally {
172
+ if (handle !== undefined) closeSync(handle);
173
+ }
174
+
175
+ try {
176
+ renameSync(temporary, outPath);
177
+ } catch (error) {
178
+ safeUnlink(temporary);
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ function safeUnlink(path) {
184
+ try {
185
+ unlinkSync(path);
186
+ } catch {
187
+ /* Nothing to clean up, or nothing we can do about it. */
188
+ }
189
+ }
190
+
191
+ /** Freeze an object graph in place, so rendering cannot mutate its own input. */
192
+ function deepFreeze(value) {
193
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
194
+ Object.freeze(value);
195
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
196
+ return value;
197
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `doctor` — can this machine render at all?
3
+ *
4
+ * Deliberately not "does the shipped example validate here". Those are
5
+ * different questions and conflating them would make the answer useless. The
6
+ * example under `examples/` cites the Pathfinder source repository at a fixed
7
+ * commit; in an installed destination project that commit does not exist, and
8
+ * validating it there correctly fails. A `doctor` that reported that as a
9
+ * broken installation would be wrong about the only thing it is for.
10
+ *
11
+ * So: `doctor` checks capability — the runtime, the schemas, the kind registry,
12
+ * and that rendering is reproducible in this process. Whether a particular
13
+ * specimen validates is what `validate` is for.
14
+ */
15
+
16
+ import { RENDERER_VERSION, SCHEMA_VERSION } from "./version.mjs";
17
+ import { KINDS, schemaRegistry, validateStructure } from "./validate/structural.mjs";
18
+ import { validateComposition } from "./validate/composition.mjs";
19
+ import { renderOnly } from "./deliver.mjs";
20
+ import { RENDERERS } from "./render/index.mjs";
21
+
22
+ /** The Node the kit already requires. Nothing else is needed. */
23
+ const MINIMUM_NODE_MAJOR = 18;
24
+
25
+ /**
26
+ * A specimen that exercises the whole path without needing a repository: no
27
+ * citations, so no Git, so the check means the same thing on every machine.
28
+ */
29
+ const SELF_CHECK = Object.freeze({
30
+ schema_version: SCHEMA_VERSION,
31
+ kind: "lesson",
32
+ artifact: { title: "Renderer self-check" },
33
+ source: { repo: "pathfinder", commit: "0000000" },
34
+ lesson: {
35
+ modules: [{
36
+ id: "self-check",
37
+ title: "Self-check",
38
+ sections: [{ type: "prose", id: "self-check-prose", body: ["Rendered locally."] }],
39
+ }],
40
+ },
41
+ });
42
+
43
+ /**
44
+ * @returns {{ ok: boolean, checks: {name: string, ok: boolean, detail: string}[] }}
45
+ */
46
+ export function doctor() {
47
+ const checks = [];
48
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
49
+
50
+ const major = Number.parseInt(process.versions.node.split(".")[0], 10);
51
+ add("node", major >= MINIMUM_NODE_MAJOR,
52
+ `Node ${process.versions.node} (needs >= ${MINIMUM_NODE_MAJOR})`);
53
+
54
+ add("dependencies", true,
55
+ "zero runtime dependencies; the engine imports only node: builtins");
56
+
57
+ try {
58
+ const registry = schemaRegistry();
59
+ add("schemas", registry.byId.size >= 2,
60
+ `${[...registry.byId.keys()].sort().join(", ")} loaded`);
61
+ } catch (error) {
62
+ add("schemas", false, `schemas could not be loaded: ${error.message}`);
63
+ }
64
+
65
+ const kinds = Object.keys(KINDS).sort();
66
+ const rendered = Object.keys(RENDERERS).sort();
67
+ add("kinds", kinds.join(",") === rendered.join(","),
68
+ `schema kinds [${kinds.join(", ")}] and renderers [${rendered.join(", ")}] agree`);
69
+
70
+ try {
71
+ const structural = validateStructure(SELF_CHECK);
72
+ const composition = structural.length === 0 ? validateComposition(SELF_CHECK) : [];
73
+ add("validation", structural.length === 0 && composition.length === 0,
74
+ structural.length === 0 && composition.length === 0
75
+ ? "structural and composition layers ran on an internal specimen"
76
+ : "the internal specimen did not validate, which means the engine is broken");
77
+ } catch (error) {
78
+ add("validation", false, `validation threw: ${error.message}`);
79
+ }
80
+
81
+ try {
82
+ const first = renderOnly(SELF_CHECK);
83
+ const second = renderOnly(SELF_CHECK);
84
+ add("determinism", first.sha256 === second.sha256,
85
+ first.sha256 === second.sha256
86
+ ? `two renders of one specimen agreed (${first.sha256.slice(0, 12)}…, ` +
87
+ `${first.bytes} bytes)`
88
+ : `two renders of one specimen disagreed: ${first.sha256} vs ${second.sha256}`);
89
+ } catch (error) {
90
+ add("determinism", false, `rendering threw: ${error.message}`);
91
+ }
92
+
93
+ add("renderer", true, `render-artifact ${RENDERER_VERSION}, schema_version ${SCHEMA_VERSION}`);
94
+
95
+ return { ok: checks.every((check) => check.ok), checks };
96
+ }