create-pathfinder 4.1.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
- package/src/activation.mjs +199 -0
- package/src/cli.mjs +260 -6
- package/src/harnesses/adapter.mjs +42 -11
- package/src/harnesses/hook.mjs +142 -0
- package/src/harnesses/index.mjs +24 -5
- package/src/hooks/session-orientation.mjs +183 -0
- package/src/install.mjs +191 -3
- package/src/outcome.mjs +88 -36
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON Schema validator covering exactly the keywords this engine's schemas
|
|
3
|
+
* use, and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Writing one rather than depending on one is not preference. The engine ships
|
|
6
|
+
* inside `skills/`, which is copied whole into every destination project, and
|
|
7
|
+
* `NOT_A_FRAMEWORK.md` promises those projects acquire no runtime and no
|
|
8
|
+
* package manager. A dependency here would be a dependency in every installed
|
|
9
|
+
* project. The schemas are ours, so the subset is knowable: adding a keyword to
|
|
10
|
+
* a schema means adding it here, and an unknown keyword is a hard error rather
|
|
11
|
+
* than something quietly ignored — a validator that silently skips the rule you
|
|
12
|
+
* just wrote is worse than no validator.
|
|
13
|
+
*
|
|
14
|
+
* Supported: $ref, $defs, type, const, enum, properties, required,
|
|
15
|
+
* additionalProperties (false only), items, minItems, maxItems, uniqueItems,
|
|
16
|
+
* minLength, maxLength, pattern, minimum, maximum, oneOf, if/then.
|
|
17
|
+
*
|
|
18
|
+
* Note what is absent: `else`. `if`/`then` are here because a schema uses them.
|
|
19
|
+
* `else` would be a keyword implemented on speculation, never executed and
|
|
20
|
+
* never tested, and the first schema to reach for it would be relying on a
|
|
21
|
+
* branch nobody had seen work. Adding it when a schema needs it is one small
|
|
22
|
+
* change; carrying it until then is a promise this file cannot evidence.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Keywords carrying documentation rather than constraint. */
|
|
26
|
+
const ANNOTATIONS = new Set(["$id", "$schema", "title", "description", "$defs"]);
|
|
27
|
+
|
|
28
|
+
const CONSTRAINTS = new Set([
|
|
29
|
+
"$ref", "type", "const", "enum", "properties", "required",
|
|
30
|
+
"additionalProperties", "items", "minItems", "maxItems", "uniqueItems",
|
|
31
|
+
"minLength", "maxLength", "pattern", "minimum", "maximum", "oneOf",
|
|
32
|
+
"if", "then",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} SchemaError
|
|
37
|
+
* @property {string} path dotted/bracketed path into the instance
|
|
38
|
+
* @property {string} keyword the keyword that rejected it
|
|
39
|
+
* @property {string} message what is wrong, in the reader's terms
|
|
40
|
+
* @property {string} [property] the offending property name, when there is one
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** JSON's own type names, as `typeof` cannot tell array from object from null. */
|
|
44
|
+
function jsonType(value) {
|
|
45
|
+
if (value === null) return "null";
|
|
46
|
+
if (Array.isArray(value)) return "array";
|
|
47
|
+
if (Number.isInteger(value)) return "integer";
|
|
48
|
+
return typeof value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function typeMatches(value, expected) {
|
|
52
|
+
const actual = jsonType(value);
|
|
53
|
+
if (expected === "number") return actual === "integer" || actual === "number";
|
|
54
|
+
if (expected === "integer") return actual === "integer";
|
|
55
|
+
return actual === expected;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Stable, locale-independent rendering of a value inside a diagnostic. */
|
|
59
|
+
function show(value) {
|
|
60
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
61
|
+
if (value === undefined) return "undefined";
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A registry of schemas by `$id`, able to resolve the two ref shapes the
|
|
67
|
+
* schemas use: `other.schema.json#/$defs/name` and `#/$defs/name`.
|
|
68
|
+
*/
|
|
69
|
+
export class SchemaRegistry {
|
|
70
|
+
constructor() {
|
|
71
|
+
/** @type {Map<string, object>} */
|
|
72
|
+
this.byId = new Map();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
add(schema) {
|
|
76
|
+
if (typeof schema?.$id !== "string") {
|
|
77
|
+
throw new Error("schema has no $id; refs could not resolve it");
|
|
78
|
+
}
|
|
79
|
+
this.byId.set(schema.$id, schema);
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} ref the `$ref` value
|
|
85
|
+
* @param {string} fromId the `$id` of the schema the ref appeared in
|
|
86
|
+
*/
|
|
87
|
+
resolve(ref, fromId) {
|
|
88
|
+
const hash = ref.indexOf("#");
|
|
89
|
+
if (hash < 0) throw new Error(`unsupported $ref without a fragment: ${ref}`);
|
|
90
|
+
const id = ref.slice(0, hash) || fromId;
|
|
91
|
+
const pointer = ref.slice(hash + 1);
|
|
92
|
+
const root = this.byId.get(id);
|
|
93
|
+
if (!root) throw new Error(`$ref names an unregistered schema: ${ref}`);
|
|
94
|
+
|
|
95
|
+
let node = root;
|
|
96
|
+
for (const rawSegment of pointer.split("/")) {
|
|
97
|
+
if (rawSegment === "") continue;
|
|
98
|
+
const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
99
|
+
node = node?.[segment];
|
|
100
|
+
if (node === undefined) throw new Error(`$ref does not resolve: ${ref}`);
|
|
101
|
+
}
|
|
102
|
+
return { schema: node, id };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate `instance` against the schema registered under `rootId`.
|
|
108
|
+
*
|
|
109
|
+
* Errors come back in a deterministic order: depth-first through the instance,
|
|
110
|
+
* and for object properties in the order the schema declares them, never the
|
|
111
|
+
* order the instance happens to carry or `Object.keys` happens to return.
|
|
112
|
+
*
|
|
113
|
+
* @returns {SchemaError[]}
|
|
114
|
+
*/
|
|
115
|
+
export function validateAgainstSchema(instance, rootId, registry) {
|
|
116
|
+
const errors = [];
|
|
117
|
+
const root = registry.byId.get(rootId);
|
|
118
|
+
if (!root) throw new Error(`no schema registered as ${rootId}`);
|
|
119
|
+
walk(instance, root, rootId, "", errors, registry);
|
|
120
|
+
return errors;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function push(errors, path, keyword, message, property) {
|
|
124
|
+
const error = { path: path || "(root)", keyword, message };
|
|
125
|
+
if (property !== undefined) error.property = property;
|
|
126
|
+
errors.push(error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function walk(value, schema, schemaId, path, errors, registry) {
|
|
130
|
+
for (const keyword of Object.keys(schema)) {
|
|
131
|
+
if (!ANNOTATIONS.has(keyword) && !CONSTRAINTS.has(keyword)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`schema ${schemaId} at ${path || "(root)"} uses unsupported keyword ` +
|
|
134
|
+
`\`${keyword}\`; add it to validate/jsonschema.mjs rather than ` +
|
|
135
|
+
`letting it be ignored`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (schema.$ref !== undefined) {
|
|
141
|
+
const { schema: target, id } = registry.resolve(schema.$ref, schemaId);
|
|
142
|
+
walk(value, target, id, path, errors, registry);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (schema.oneOf !== undefined) {
|
|
147
|
+
walkOneOf(value, schema.oneOf, schemaId, path, errors, registry);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (schema.const !== undefined && value !== schema.const) {
|
|
152
|
+
push(errors, path, "const", `must be ${show(schema.const)}, found ${show(value)}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (schema.enum !== undefined && !schema.enum.includes(value)) {
|
|
157
|
+
push(errors, path, "enum",
|
|
158
|
+
`must be one of ${schema.enum.map(show).join(", ")}, found ${show(value)}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (schema.type !== undefined && !typeMatches(value, schema.type)) {
|
|
163
|
+
push(errors, path, "type", `must be ${schema.type}, found ${jsonType(value)}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const type = jsonType(value);
|
|
168
|
+
if (type === "string") walkString(value, schema, path, errors);
|
|
169
|
+
if (type === "integer" || type === "number") walkNumber(value, schema, path, errors);
|
|
170
|
+
if (type === "array") walkArray(value, schema, schemaId, path, errors, registry);
|
|
171
|
+
if (type === "object") walkObject(value, schema, schemaId, path, errors, registry);
|
|
172
|
+
|
|
173
|
+
if (schema.if !== undefined) walkConditional(value, schema, schemaId, path, errors, registry);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `if` / `then` — one rule whose applicability depends on the instance.
|
|
178
|
+
*
|
|
179
|
+
* It exists for the diagram kind's provenance: a `derived` diagram must declare
|
|
180
|
+
* a `source`, and a `proposed` one need not. That is a conditional requirement,
|
|
181
|
+
* and writing it as a conditional keeps the contract in the schema where a
|
|
182
|
+
* reader looks for it, rather than leaving the schema silent and the rule
|
|
183
|
+
* somewhere in JavaScript.
|
|
184
|
+
*
|
|
185
|
+
* The `if` branch is evaluated for its *outcome*, not its diagnostics: its
|
|
186
|
+
* errors go to a throwaway array and are discarded, because "this instance is
|
|
187
|
+
* not a derived diagram" is not a problem to report. Only `then` contributes to
|
|
188
|
+
* `errors`, so a producer sees the rule that actually applied to what they
|
|
189
|
+
* wrote and never the one that did not.
|
|
190
|
+
*/
|
|
191
|
+
function walkConditional(value, schema, schemaId, path, errors, registry) {
|
|
192
|
+
const probe = [];
|
|
193
|
+
walk(value, schema.if, schemaId, path, probe, registry);
|
|
194
|
+
|
|
195
|
+
if (probe.length === 0 && schema.then !== undefined) {
|
|
196
|
+
walk(value, schema.then, schemaId, path, errors, registry);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `oneOf` here is always a discriminated union over `type`, so branch selection
|
|
202
|
+
* is by the discriminator rather than by counting how many branches passed.
|
|
203
|
+
* The difference matters to the reader: "unknown section type" and a precise
|
|
204
|
+
* complaint about the branch they meant beat six parallel rejections.
|
|
205
|
+
*/
|
|
206
|
+
function walkOneOf(value, branches, schemaId, path, errors, registry) {
|
|
207
|
+
const resolved = branches.map((branch) =>
|
|
208
|
+
branch.$ref ? registry.resolve(branch.$ref, schemaId) : { schema: branch, id: schemaId });
|
|
209
|
+
|
|
210
|
+
const discriminators = resolved.map(({ schema }) => schema?.properties?.type?.const);
|
|
211
|
+
const known = discriminators.filter((d) => typeof d === "string");
|
|
212
|
+
|
|
213
|
+
if (known.length !== resolved.length) {
|
|
214
|
+
throw new Error(`oneOf at ${path || "(root)"} is not discriminated by a \`type\` const`);
|
|
215
|
+
}
|
|
216
|
+
if (jsonType(value) !== "object") {
|
|
217
|
+
push(errors, path, "oneOf", `must be an object, found ${jsonType(value)}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const index = discriminators.indexOf(value.type);
|
|
222
|
+
if (index < 0) {
|
|
223
|
+
push(errors, `${path}.type`, "oneOf",
|
|
224
|
+
`must be one of ${known.map(show).join(", ")}, found ${show(value.type)}`,
|
|
225
|
+
"type");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const branch = resolved[index];
|
|
229
|
+
walk(value, branch.schema, branch.id, path, errors, registry);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function walkString(value, schema, path, errors) {
|
|
233
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
234
|
+
push(errors, path, "minLength",
|
|
235
|
+
`must be at least ${schema.minLength} character(s), found ${value.length}`);
|
|
236
|
+
}
|
|
237
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
238
|
+
push(errors, path, "maxLength",
|
|
239
|
+
`must be at most ${schema.maxLength} character(s), found ${value.length}`);
|
|
240
|
+
}
|
|
241
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern, "u").test(value)) {
|
|
242
|
+
push(errors, path, "pattern",
|
|
243
|
+
`must match ${schema.pattern}, found ${show(value)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function walkNumber(value, schema, path, errors) {
|
|
248
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
249
|
+
push(errors, path, "minimum", `must be at least ${schema.minimum}, found ${value}`);
|
|
250
|
+
}
|
|
251
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
252
|
+
push(errors, path, "maximum", `must be at most ${schema.maximum}, found ${value}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function walkArray(value, schema, schemaId, path, errors, registry) {
|
|
257
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
258
|
+
push(errors, path, "minItems",
|
|
259
|
+
`must have at least ${schema.minItems} item(s), found ${value.length}`);
|
|
260
|
+
}
|
|
261
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
262
|
+
push(errors, path, "maxItems",
|
|
263
|
+
`must have at most ${schema.maxItems} item(s), found ${value.length}`);
|
|
264
|
+
}
|
|
265
|
+
if (schema.uniqueItems === true) {
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
268
|
+
const key = JSON.stringify(value[index]);
|
|
269
|
+
if (seen.has(key)) {
|
|
270
|
+
push(errors, `${path}[${index}]`, "uniqueItems",
|
|
271
|
+
`duplicates an earlier item: ${show(value[index])}`);
|
|
272
|
+
}
|
|
273
|
+
seen.add(key);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (schema.items !== undefined) {
|
|
277
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
278
|
+
walk(value[index], schema.items, schemaId, `${path}[${index}]`, errors, registry);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function walkObject(value, schema, schemaId, path, errors, registry) {
|
|
284
|
+
for (const name of schema.required ?? []) {
|
|
285
|
+
if (!Object.prototype.hasOwnProperty.call(value, name)) {
|
|
286
|
+
push(errors, path ? `${path}.${name}` : name, "required",
|
|
287
|
+
`is required and is missing`, name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (schema.additionalProperties === false && schema.properties) {
|
|
292
|
+
const allowed = new Set(Object.keys(schema.properties));
|
|
293
|
+
// Instance key order is producer-controlled, so sort to keep diagnostics
|
|
294
|
+
// stable. Codepoint order, never `localeCompare`.
|
|
295
|
+
const extras = Object.keys(value).filter((name) => !allowed.has(name)).sort();
|
|
296
|
+
for (const name of extras) {
|
|
297
|
+
push(errors, path ? `${path}.${name}` : name, "additionalProperties",
|
|
298
|
+
`is not part of this contract`, name);
|
|
299
|
+
}
|
|
300
|
+
} else if (schema.additionalProperties !== undefined
|
|
301
|
+
&& schema.additionalProperties !== false) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`schema ${schemaId} at ${path || "(root)"} uses a non-false ` +
|
|
304
|
+
`additionalProperties, which this validator does not implement`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const [name, subschema] of Object.entries(schema.properties ?? {})) {
|
|
308
|
+
if (!Object.prototype.hasOwnProperty.call(value, name)) continue;
|
|
309
|
+
walk(value[name], subschema, schemaId, path ? `${path}.${name}` : name,
|
|
310
|
+
errors, registry);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1 — structural. The specification satisfies its schema.
|
|
3
|
+
*
|
|
4
|
+
* This is also where an unsupported `kind` is refused. A kind the renderer does
|
|
5
|
+
* not have is never permission to improvise: there is no fallback schema, no
|
|
6
|
+
* generic renderer, and no "render it as best you can" path out of here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { SCHEMA_VERSION } from "../version.mjs";
|
|
14
|
+
import { cellWidth } from "../render/graph/width.mjs";
|
|
15
|
+
import { SchemaRegistry, validateAgainstSchema } from "./jsonschema.mjs";
|
|
16
|
+
import { diagramEvidenceSites } from "./diagram-parts.mjs";
|
|
17
|
+
import { diagnostic, isPresentationControl } from "./diagnostics.mjs";
|
|
18
|
+
|
|
19
|
+
const SCHEMA_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "schemas");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The kind registry: the discriminator's only meaning.
|
|
23
|
+
*
|
|
24
|
+
* Two entries, and there is deliberately no placeholder for any future kind. A
|
|
25
|
+
* placeholder entry is a promise the renderer cannot keep, and the first thing
|
|
26
|
+
* it would do is turn a clean refusal into a half-render.
|
|
27
|
+
*/
|
|
28
|
+
export const KINDS = Object.freeze({
|
|
29
|
+
lesson: { schema: "lesson.schema.json" },
|
|
30
|
+
diagram: { schema: "diagram.schema.json" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The topologies the `diagram` kind supports, and the layout each selects.
|
|
35
|
+
*
|
|
36
|
+
* `graph` is the only one. A second topology is a second layout engine, not a
|
|
37
|
+
* value in a list, so an unsupported topology is refused here for the same
|
|
38
|
+
* reason an unsupported kind is: there is no generic layout to fall back on
|
|
39
|
+
* and no "draw it as best you can" path out.
|
|
40
|
+
*/
|
|
41
|
+
export const TOPOLOGIES = Object.freeze(["graph"]);
|
|
42
|
+
|
|
43
|
+
let registry = null;
|
|
44
|
+
|
|
45
|
+
/** Load and register the schemas once. Ordering is fixed by this list. */
|
|
46
|
+
export function schemaRegistry() {
|
|
47
|
+
if (registry) return registry;
|
|
48
|
+
const next = new SchemaRegistry();
|
|
49
|
+
for (const file of ["common.schema.json", "lesson.schema.json",
|
|
50
|
+
"diagram.schema.json"]) {
|
|
51
|
+
next.add(JSON.parse(readFileSync(join(SCHEMA_DIR, file), "utf8")));
|
|
52
|
+
}
|
|
53
|
+
registry = next;
|
|
54
|
+
return registry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {unknown} spec the parsed specification
|
|
59
|
+
* @returns {import("./diagnostics.mjs").Diagnostic[]}
|
|
60
|
+
*/
|
|
61
|
+
export function validateStructure(spec) {
|
|
62
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
63
|
+
return [diagnostic("structural", "specification_not_an_object", "(root)",
|
|
64
|
+
"a specification must be a JSON object")];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (spec.schema_version !== SCHEMA_VERSION) {
|
|
68
|
+
return [diagnostic("structural", "schema_version_unsupported", "schema_version",
|
|
69
|
+
`this engine implements schema_version ${JSON.stringify(SCHEMA_VERSION)}; ` +
|
|
70
|
+
`the specification declares ${JSON.stringify(spec.schema_version ?? null)}`)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kind = spec.kind;
|
|
74
|
+
if (!Object.prototype.hasOwnProperty.call(KINDS, kind)) {
|
|
75
|
+
const supported = Object.keys(KINDS).map((k) => `\`${k}\``).join(", ");
|
|
76
|
+
return [diagnostic("structural", "kind_unsupported", "kind",
|
|
77
|
+
`${JSON.stringify(kind ?? null)} is not an artifact kind this renderer ` +
|
|
78
|
+
`supports. Supported: ${supported}. A missing kind is a refusal, not a ` +
|
|
79
|
+
`reason to hand-author HTML.`)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The topology refusal comes before the schema for the same reason the kind
|
|
83
|
+
// refusal does: `enum` would reject it too, but "must be one of \"graph\"" is
|
|
84
|
+
// a sentence about a list, and the thing that went wrong was asking for a
|
|
85
|
+
// layout this renderer does not have.
|
|
86
|
+
if (kind === "diagram") {
|
|
87
|
+
const topology = spec.diagram?.topology;
|
|
88
|
+
if (topology !== undefined && !TOPOLOGIES.includes(topology)) {
|
|
89
|
+
const supported = TOPOLOGIES.map((t) => `\`${t}\``).join(", ");
|
|
90
|
+
return [diagnostic("structural", "topology_unsupported", "diagram.topology",
|
|
91
|
+
`${JSON.stringify(topology)} is not a topology this renderer supports. ` +
|
|
92
|
+
`Supported: ${supported}. A topology is a layout, not a label, so a ` +
|
|
93
|
+
`missing one is a refusal rather than a reason to approximate it.`,
|
|
94
|
+
"topology")];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Ahead of the schema for the same reason the two refusals above are. The
|
|
98
|
+
// schema's own conditional says `source` is required when provenance is
|
|
99
|
+
// `derived`, and would reject this too — as "`source` is required and is
|
|
100
|
+
// missing", a sentence about a field. What went wrong is a claim: the
|
|
101
|
+
// specification says it was derived from a repository and does not say
|
|
102
|
+
// which, so there is nothing for a citation to resolve against.
|
|
103
|
+
if (spec.provenance === "derived"
|
|
104
|
+
&& !Object.prototype.hasOwnProperty.call(spec, "source")) {
|
|
105
|
+
return [diagnostic("structural", "source_required_for_derived", "source",
|
|
106
|
+
`a \`derived\` diagram maps what a repository asserts about itself at ` +
|
|
107
|
+
`a declared commit, so it must say which repository and which commit. ` +
|
|
108
|
+
`Without a source there is nothing for its citations to resolve ` +
|
|
109
|
+
`against, and nothing that makes it derived from anything. A diagram ` +
|
|
110
|
+
`describing a system with no repository behind it is \`proposed\`.`,
|
|
111
|
+
"source")];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const errors = validateAgainstSchema(spec, KINDS[kind].schema, schemaRegistry())
|
|
116
|
+
.map(toDiagnostic);
|
|
117
|
+
if (errors.length > 0) return errors;
|
|
118
|
+
|
|
119
|
+
// Label caps are counted in columns, which is why they are here rather than
|
|
120
|
+
// as a `maxLength`. A column is not a character: `漢` is one character and two
|
|
121
|
+
// columns, a Devanagari matra is one character and none, and `String.length`
|
|
122
|
+
// is not even a character count but a count of UTF-16 units. Capping on any
|
|
123
|
+
// of those would give a producer writing in one script a different allowance
|
|
124
|
+
// from a producer writing in another.
|
|
125
|
+
return kind === "diagram"
|
|
126
|
+
? [...provenanceErrors(spec), ...labelWidthErrors(spec)]
|
|
127
|
+
: [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The two provenance rules a schema cannot express, both refusals rather than
|
|
132
|
+
* anything the engine works around.
|
|
133
|
+
*
|
|
134
|
+
* **A citation with no source.** There is nothing to resolve it against: no
|
|
135
|
+
* repository, no commit, no history. Accepting it and moving on would be the
|
|
136
|
+
* engine holding an unchecked citation and saying nothing about it, and the
|
|
137
|
+
* artifact would then display a file path and a line range as though they had
|
|
138
|
+
* been verified. It is refused here, in the structural layer, because it is a
|
|
139
|
+
* contradiction in the specification's own shape rather than a citation that
|
|
140
|
+
* failed to resolve — the evidence layer never runs for such a specification,
|
|
141
|
+
* so a rule living there would never fire.
|
|
142
|
+
*
|
|
143
|
+
* **`artifact.summary` on a `derived` diagram.** Every other place prose can
|
|
144
|
+
* make a claim is covered by the evidence rules. `artifact.summary` is the one
|
|
145
|
+
* that is not addressed to any node, edge or group, so it has nothing to carry
|
|
146
|
+
* a citation of its own — which makes it the exit a claim would leave by. The
|
|
147
|
+
* subtitle stays available, and so does every node's `summary`; what is refused
|
|
148
|
+
* is an uncited assertion at the top of a document whose whole premise is that
|
|
149
|
+
* its assertions are cited.
|
|
150
|
+
*/
|
|
151
|
+
function provenanceErrors(spec) {
|
|
152
|
+
const out = [];
|
|
153
|
+
const hasSource = Object.prototype.hasOwnProperty.call(spec, "source");
|
|
154
|
+
|
|
155
|
+
if (!hasSource) {
|
|
156
|
+
for (const site of diagramEvidenceSites(spec.diagram)) {
|
|
157
|
+
if (site.evidence.length === 0) continue;
|
|
158
|
+
out.push(diagnostic("structural", "citation_without_source", site.path,
|
|
159
|
+
`${site.subject} cites \`${site.evidence[0].path}\`, and this ` +
|
|
160
|
+
`specification declares no source. A citation resolves against a ` +
|
|
161
|
+
`commit; with no repository and no commit there is nothing to resolve ` +
|
|
162
|
+
`it against, so it is refused rather than displayed as though it had ` +
|
|
163
|
+
`been checked. Either declare a source, or remove the citation and ` +
|
|
164
|
+
`let the diagram describe an intended system.`, site.subject));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (spec.provenance === "derived" && spec.artifact.summary !== undefined) {
|
|
169
|
+
out.push(diagnostic("structural", "artifact_summary_forbidden",
|
|
170
|
+
"artifact.summary",
|
|
171
|
+
`a \`derived\` diagram carries no \`artifact.summary\`. Every other ` +
|
|
172
|
+
`place prose asserts something — a node, an edge, a group, a path, a ` +
|
|
173
|
+
`view — carries evidence for it, and this one cannot: it belongs to the ` +
|
|
174
|
+
`document rather than to anything in the diagram, so there is nowhere ` +
|
|
175
|
+
`for its citation to go. Put the claim on the thing it is about and ` +
|
|
176
|
+
`cite it there, or use \`artifact.subtitle\`, which names the diagram ` +
|
|
177
|
+
`rather than asserting anything about the system.`, "summary"));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The column cap for each place a label appears. */
|
|
184
|
+
const LABEL_CELLS = Object.freeze({ node: 32, group: 32, path: 32, view: 32, edge: 24 });
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Refuse a label wider than its cap, naming the width in columns.
|
|
188
|
+
*
|
|
189
|
+
* Refused, never shortened. The renderer owns where a label goes and how it
|
|
190
|
+
* wraps; it owns none of the words, and a label that arrives too wide is a
|
|
191
|
+
* conversation with the producer rather than something to quietly trim.
|
|
192
|
+
*/
|
|
193
|
+
function labelWidthErrors(spec) {
|
|
194
|
+
const out = [];
|
|
195
|
+
const check = (label, cap, path, what) => {
|
|
196
|
+
if (label === undefined) return;
|
|
197
|
+
const width = cellWidth(label);
|
|
198
|
+
if (width <= cap) return;
|
|
199
|
+
out.push(diagnostic("structural", "label_too_long", path,
|
|
200
|
+
`${what} is ${width} columns wide and the cap is ${cap}. Columns, not ` +
|
|
201
|
+
`characters: a wide character counts two and a combining mark counts ` +
|
|
202
|
+
`none. Shorten the label — the renderer will not do it for you, because ` +
|
|
203
|
+
`the words are yours.`, label));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const { diagram } = spec;
|
|
207
|
+
diagram.nodes.forEach((node, i) =>
|
|
208
|
+
check(node.label, LABEL_CELLS.node, `diagram.nodes[${i}].label`, `node label "${node.label}"`));
|
|
209
|
+
(diagram.groups ?? []).forEach((group, i) =>
|
|
210
|
+
check(group.label, LABEL_CELLS.group, `diagram.groups[${i}].label`, `group label "${group.label}"`));
|
|
211
|
+
diagram.edges.forEach((edge, i) =>
|
|
212
|
+
check(edge.label, LABEL_CELLS.edge, `diagram.edges[${i}].label`, `edge label "${edge.label}"`));
|
|
213
|
+
(diagram.paths ?? []).forEach((path, i) =>
|
|
214
|
+
check(path.label, LABEL_CELLS.path, `diagram.paths[${i}].label`, `path label "${path.label}"`));
|
|
215
|
+
(diagram.views ?? []).forEach((view, i) =>
|
|
216
|
+
check(view.label, LABEL_CELLS.view, `diagram.views[${i}].label`, `view label "${view.label}"`));
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Turn a schema error into a diagnostic, upgrading the ones that are really a
|
|
222
|
+
* producer reaching for presentation control.
|
|
223
|
+
*/
|
|
224
|
+
function toDiagnostic(error) {
|
|
225
|
+
if (error.keyword === "additionalProperties" && isPresentationControl(error.property)) {
|
|
226
|
+
return diagnostic("structural", "presentation_control", error.path,
|
|
227
|
+
`\`${error.property}\` is presentation control and belongs to the ` +
|
|
228
|
+
`renderer, not to the specification. Rejected rather than ignored: a ` +
|
|
229
|
+
`field silently dropped is a producer believing it had an effect.`,
|
|
230
|
+
error.property);
|
|
231
|
+
}
|
|
232
|
+
if (error.keyword === "additionalProperties") {
|
|
233
|
+
return diagnostic("structural", "unknown_field", error.path,
|
|
234
|
+
`\`${error.property}\` is not part of this contract`, error.property);
|
|
235
|
+
}
|
|
236
|
+
if (error.keyword === "required") {
|
|
237
|
+
return diagnostic("structural", "missing_field", error.path,
|
|
238
|
+
`\`${error.property}\` is required and is missing`, error.property);
|
|
239
|
+
}
|
|
240
|
+
return diagnostic("structural", `schema_${error.keyword}`, error.path, error.message);
|
|
241
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof that this engine validated something, in a form a caller cannot fake.
|
|
3
|
+
*
|
|
4
|
+
* The rule this exists to enforce:
|
|
5
|
+
*
|
|
6
|
+
* render() does not prove validation
|
|
7
|
+
* validate() does not prove delivery
|
|
8
|
+
* deliver() may claim verification only after performing it
|
|
9
|
+
*
|
|
10
|
+
* An artifact carries a sentence in its provenance block saying its evidence
|
|
11
|
+
* was checked against the named commit. That sentence is worth exactly what it
|
|
12
|
+
* costs to obtain. Before this module it cost nothing: `render()` is a public
|
|
13
|
+
* export, so any caller could hand it an unvalidated specification and get back
|
|
14
|
+
* a page asserting that every citation had been verified.
|
|
15
|
+
*
|
|
16
|
+
* So the claim is now gated on an attestation, and an attestation can only be
|
|
17
|
+
* minted from a validation result this engine produced and that passed. The
|
|
18
|
+
* brand is a module-private symbol — not `Symbol.for`, so it is not reachable
|
|
19
|
+
* through the global registry, and not a string key a caller could guess and
|
|
20
|
+
* set. `attest()` refuses anything unbranded, which means the only route to a
|
|
21
|
+
* verification claim is to actually pass validation.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately importable by the render path: this file imports nothing at all,
|
|
24
|
+
* so it does not put a `node:` builtin anywhere near rendering.
|
|
25
|
+
*
|
|
26
|
+
* There is no producer-facing counterpart. No `verified`, no
|
|
27
|
+
* `validation_status`, no field of any name in the specification can influence
|
|
28
|
+
* this — a producer asserting its own work was checked is precisely the claim
|
|
29
|
+
* this module exists to make impossible.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const PASSED = Symbol("pathfinder.render-artifact.validated");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Brand a passing validation result. Called by the validator, and nowhere else.
|
|
36
|
+
* The brand is non-enumerable, so it never reaches JSON, a receipt, or a log.
|
|
37
|
+
*/
|
|
38
|
+
export function markPassed(result) {
|
|
39
|
+
Object.defineProperty(result, PASSED, { value: true, enumerable: false });
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mint an attestation from a validation this engine performed and that passed.
|
|
45
|
+
*
|
|
46
|
+
* The attestation carries *what* passed, not merely that something did, because
|
|
47
|
+
* the sentence an artifact is entitled to depends on it. `layers` says which
|
|
48
|
+
* layers ran — the evidence layer does not run for a specification with no
|
|
49
|
+
* source — and `resolvedCitations` says how many citations actually resolved at
|
|
50
|
+
* the declared commit.
|
|
51
|
+
*
|
|
52
|
+
* That count is the guard against a vacuous claim. A specification carrying no
|
|
53
|
+
* citations passes the evidence layer by having nothing to fail, and an artifact
|
|
54
|
+
* that said "every citation above was verified" on the strength of that would be
|
|
55
|
+
* making a stronger statement than anyone made. Zero is therefore a number the
|
|
56
|
+
* shell reads and declines to claim on, rather than a pass it cannot see.
|
|
57
|
+
*
|
|
58
|
+
* @throws when handed anything else — an unbranded object, a hand-built
|
|
59
|
+
* `{ ok: true }`, or a result that failed.
|
|
60
|
+
*/
|
|
61
|
+
export function attest(result) {
|
|
62
|
+
if (!result || result[PASSED] !== true) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"a verification claim can only be minted from a validation this engine " +
|
|
65
|
+
"performed and that passed; nothing else can vouch for an artifact");
|
|
66
|
+
}
|
|
67
|
+
return markPassed({
|
|
68
|
+
layers: Object.freeze([...result.ran]),
|
|
69
|
+
resolvedCitations: result.resolvedCitations ?? 0,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Is this a real attestation? Used by the shell to decide whether to claim. */
|
|
74
|
+
export function isAttestation(value) {
|
|
75
|
+
return Boolean(value) && value[PASSED] === true;
|
|
76
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The renderer's own version, and the single source of truth for it.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately not the Pathfinder release version and deliberately not
|
|
5
|
+
* carried by a `package.json`. Two reasons, both structural:
|
|
6
|
+
*
|
|
7
|
+
* 1. Everything under `skills/` is copied into every destination project. A
|
|
8
|
+
* manifest here would ship into other people's repositories and read as a
|
|
9
|
+
* dependency the kit does not have. `NOT_A_FRAMEWORK.md` is the promise
|
|
10
|
+
* that would break.
|
|
11
|
+
* 2. The determinism invariant is *same specification bytes + same renderer
|
|
12
|
+
* version -> same artifact bytes*. That makes this constant part of the
|
|
13
|
+
* compiler's input, so it has to move exactly when rendered output can
|
|
14
|
+
* move, and at no other time. A kit release that does not touch rendering
|
|
15
|
+
* must leave it alone; tying it to the kit version would make every release
|
|
16
|
+
* look like an intentional output change.
|
|
17
|
+
*
|
|
18
|
+
* Bump this in the same commit as any change that can alter rendered HTML --
|
|
19
|
+
* markup, CSS, inline behaviour, ordering, or escaping.
|
|
20
|
+
*/
|
|
21
|
+
export const RENDERER_VERSION = "0.5.0";
|
|
22
|
+
|
|
23
|
+
/** The specification `schema_version` this engine understands. */
|
|
24
|
+
export const SCHEMA_VERSION = "1.0";
|