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.
- package/CLAUDE.md +2 -0
- package/package.json +1 -1
- package/skills/learn-codebase/SKILL.md +188 -17
- package/skills/learn-feature/SKILL.md +136 -15
- package/skills/map-system/SKILL.md +293 -0
- package/skills/render-artifact/SKILL.md +187 -0
- package/skills/render-artifact/engine/bin/render.mjs +225 -0
- package/skills/render-artifact/engine/deliver.mjs +197 -0
- package/skills/render-artifact/engine/doctor.mjs +96 -0
- package/skills/render-artifact/engine/examples/diagram.json +223 -0
- package/skills/render-artifact/engine/examples/lesson.json +242 -0
- package/skills/render-artifact/engine/references/determinism.md +71 -0
- package/skills/render-artifact/engine/references/specification.md +149 -0
- package/skills/render-artifact/engine/references/validation.md +268 -0
- package/skills/render-artifact/engine/render/behavior.mjs +128 -0
- package/skills/render-artifact/engine/render/diagram.mjs +342 -0
- package/skills/render-artifact/engine/render/escape.mjs +34 -0
- package/skills/render-artifact/engine/render/graph/behavior.mjs +394 -0
- package/skills/render-artifact/engine/render/graph/draw.mjs +204 -0
- package/skills/render-artifact/engine/render/graph/interaction.mjs +174 -0
- package/skills/render-artifact/engine/render/graph/layout.mjs +698 -0
- package/skills/render-artifact/engine/render/graph/style.mjs +200 -0
- package/skills/render-artifact/engine/render/graph/width.mjs +204 -0
- package/skills/render-artifact/engine/render/index.mjs +50 -0
- package/skills/render-artifact/engine/render/lesson.mjs +294 -0
- package/skills/render-artifact/engine/render/shell.mjs +275 -0
- package/skills/render-artifact/engine/render/theme.mjs +592 -0
- package/skills/render-artifact/engine/schemas/common.schema.json +101 -0
- package/skills/render-artifact/engine/schemas/diagram.schema.json +176 -0
- package/skills/render-artifact/engine/schemas/lesson.schema.json +210 -0
- package/skills/render-artifact/engine/validate/composition.mjs +395 -0
- package/skills/render-artifact/engine/validate/diagnostics.mjs +83 -0
- package/skills/render-artifact/engine/validate/diagram-parts.mjs +68 -0
- package/skills/render-artifact/engine/validate/evidence.mjs +302 -0
- package/skills/render-artifact/engine/validate/index.mjs +132 -0
- package/skills/render-artifact/engine/validate/jsonschema.mjs +312 -0
- package/skills/render-artifact/engine/validate/structural.mjs +241 -0
- package/skills/render-artifact/engine/verification.mjs +76 -0
- 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
|
+
}
|