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,395 @@
1
+ /**
2
+ * Layer 2 — composition. The specification is internally coherent.
3
+ *
4
+ * Everything here is a rule a schema cannot express: uniqueness across the
5
+ * document, references that resolve, graphs without orphans or cycles, and an
6
+ * answer that indexes its own options. A structurally perfect specification can
7
+ * still describe a lesson whose navigation lands nowhere, and that is what this
8
+ * layer is for.
9
+ *
10
+ * Every check walks the specification in document order, so diagnostics come
11
+ * back in the order a reader would meet the problems.
12
+ */
13
+
14
+ import { diagnostic } from "./diagnostics.mjs";
15
+
16
+ /**
17
+ * @param {object} spec a specification that passed the structural layer
18
+ * @returns {import("./diagnostics.mjs").Diagnostic[]}
19
+ */
20
+ export function validateComposition(spec) {
21
+ return spec.kind === "diagram"
22
+ ? validateDiagramComposition(spec)
23
+ : validateLessonComposition(spec);
24
+ }
25
+
26
+ /**
27
+ * @param {object} spec a `lesson` specification that passed the structural layer
28
+ * @returns {import("./diagnostics.mjs").Diagnostic[]}
29
+ */
30
+ function validateLessonComposition(spec) {
31
+ const out = [];
32
+ const modules = spec.lesson.modules;
33
+
34
+ /** Anchors the renderer will emit. Collisions would make two links one link. */
35
+ const anchors = new Map();
36
+
37
+ modules.forEach((module, m) => {
38
+ const modulePath = `lesson.modules[${m}]`;
39
+ claimAnchor(anchors, out, module.id, `${modulePath}.id`, `module "${module.title}"`);
40
+
41
+ if (module.sections.length === 0) {
42
+ out.push(diagnostic("composition", "module_empty", `${modulePath}.sections`,
43
+ "a module must carry at least one section", module.title));
44
+ }
45
+
46
+ module.sections.forEach((section, s) => {
47
+ const sectionPath = `${modulePath}.sections[${s}]`;
48
+ claimAnchor(anchors, out, section.id, `${sectionPath}.id`,
49
+ `${section.type} section in module "${module.title}"`);
50
+
51
+ if (section.type === "flow") checkFlow(section, sectionPath, out);
52
+ if (section.type === "quiz") checkQuiz(section, sectionPath, out);
53
+ });
54
+ });
55
+
56
+ checkModuleGraph(modules, out);
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * The `graph` topology's coherence rules.
62
+ *
63
+ * Everything here is a rule the schema cannot express, and nothing here is a
64
+ * rule the schema already made unrepresentable. A node belongs to at most one
65
+ * group because `group` is a single identifier, so there is no
66
+ * multiple-membership check below: preventing the state beats detecting it.
67
+ *
68
+ * Deliberately legal, and each for a reason about real systems:
69
+ *
70
+ * self-edges a service calling itself, a state retrying itself
71
+ * cycles retry loops, bidirectional calls
72
+ * isolated nodes something real that is not wired up yet
73
+ *
74
+ * Group nesting is the one invalid state a flat array can still represent, and
75
+ * a single rule covers all three of its shapes: depth greater than one, a
76
+ * two-group cycle, and a group parented to itself. Each of them is a group
77
+ * whose parent is not a root.
78
+ */
79
+ function validateDiagramComposition(spec) {
80
+ const out = [];
81
+ const { nodes, edges } = spec.diagram;
82
+ const groups = spec.diagram.groups ?? [];
83
+ const paths = spec.diagram.paths ?? [];
84
+ const views = spec.diagram.views ?? [];
85
+
86
+ /** Every identifier the renderer turns into a DOM id, across the artifact. */
87
+ const anchors = new Map();
88
+ const nodeIds = new Map();
89
+ const groupIds = new Map();
90
+ const edgeIds = new Map();
91
+
92
+ groups.forEach((group, g) => {
93
+ claimAnchor(anchors, out, group.id, `diagram.groups[${g}].id`,
94
+ `group "${group.label}"`);
95
+ groupIds.set(group.id, g);
96
+ });
97
+
98
+ nodes.forEach((node, n) => {
99
+ claimAnchor(anchors, out, node.id, `diagram.nodes[${n}].id`, `node "${node.label}"`);
100
+ nodeIds.set(node.id, n);
101
+ });
102
+
103
+ edges.forEach((edge, e) => {
104
+ claimAnchor(anchors, out, edge.id, `diagram.edges[${e}].id`, `edge \`${edge.id}\``);
105
+ edgeIds.set(edge.id, e);
106
+ });
107
+
108
+ paths.forEach((path, p) => {
109
+ claimAnchor(anchors, out, path.id, `diagram.paths[${p}].id`, `path "${path.label}"`);
110
+ });
111
+
112
+ views.forEach((view, v) => {
113
+ claimAnchor(anchors, out, view.id, `diagram.views[${v}].id`, `view "${view.label}"`);
114
+ });
115
+
116
+ // Group membership and nesting.
117
+ nodes.forEach((node, n) => {
118
+ if (node.group !== undefined && !groupIds.has(node.group)) {
119
+ out.push(diagnostic("composition", "unresolved_reference",
120
+ `diagram.nodes[${n}].group`,
121
+ `node "${node.label}" belongs to \`${node.group}\`, which is not a group ` +
122
+ `of this diagram`, node.label));
123
+ }
124
+ });
125
+
126
+ groups.forEach((group, g) => {
127
+ if (group.parent === undefined) return;
128
+ if (!groupIds.has(group.parent)) {
129
+ out.push(diagnostic("composition", "unresolved_reference",
130
+ `diagram.groups[${g}].parent`,
131
+ `group "${group.label}" is inside \`${group.parent}\`, which is not a ` +
132
+ `group of this diagram`, group.label));
133
+ return;
134
+ }
135
+ const parent = groups[groupIds.get(group.parent)];
136
+ if (parent.parent !== undefined) {
137
+ out.push(diagnostic("composition", "group_parent_not_root",
138
+ `diagram.groups[${g}].parent`,
139
+ `group "${group.label}" is inside "${parent.label}", which is itself ` +
140
+ `inside \`${parent.parent}\`. Nesting is one level: a parent must be a ` +
141
+ `root. This is also what a group cycle and a self-parented group look ` +
142
+ `like from here.`, group.label));
143
+ }
144
+ });
145
+
146
+ // Edges.
147
+ const seenRelations = new Map();
148
+ edges.forEach((edge, e) => {
149
+ for (const [end, id] of [["from", edge.from], ["to", edge.to]]) {
150
+ if (!nodeIds.has(id)) {
151
+ out.push(diagnostic("composition", "unresolved_reference",
152
+ `diagram.edges[${e}].${end}`,
153
+ `edge \`${edge.id}\` runs ${end} \`${id}\`, which is not a node of this ` +
154
+ `diagram`, edge.id));
155
+ }
156
+ }
157
+
158
+ // The same relationship asserted twice is a modelling slip, not a second
159
+ // fact. Two *different* relations between one pair stay legal: an API that
160
+ // both calls and publishes to a thing is two claims.
161
+ const key = `${edge.from}\u0000${edge.to}\u0000${edge.relation}`;
162
+ const previous = seenRelations.get(key);
163
+ if (previous !== undefined) {
164
+ out.push(diagnostic("composition", "duplicate_edge", `diagram.edges[${e}]`,
165
+ `\`${edge.relation}\` from \`${edge.from}\` to \`${edge.to}\` is already ` +
166
+ `asserted by edge \`${previous}\`; the same relationship twice is one ` +
167
+ `fact written twice`, edge.id));
168
+ } else {
169
+ seenRelations.set(key, edge.id);
170
+ }
171
+ });
172
+
173
+ // Paths address edges, so the walk is checkable rather than inferred.
174
+ paths.forEach((path, p) => {
175
+ let previous = null;
176
+ path.edges.forEach((id, i) => {
177
+ if (!edgeIds.has(id)) {
178
+ out.push(diagnostic("composition", "unresolved_reference",
179
+ `diagram.paths[${p}].edges[${i}]`,
180
+ `path "${path.label}" walks \`${id}\`, which is not an edge of this ` +
181
+ `diagram`, path.label));
182
+ previous = null;
183
+ return;
184
+ }
185
+ const edge = edges[edgeIds.get(id)];
186
+ if (previous !== null && previous.to !== edge.from) {
187
+ out.push(diagnostic("composition", "path_discontinuous",
188
+ `diagram.paths[${p}].edges[${i}]`,
189
+ `path "${path.label}" goes \`${previous.id}\`, which ends at ` +
190
+ `\`${previous.to}\`, then \`${edge.id}\`, which starts at \`${edge.from}\`. ` +
191
+ `A path is a walk: each edge begins where the last one ended.`,
192
+ path.label));
193
+ }
194
+ previous = edge;
195
+ });
196
+ });
197
+
198
+ // Views.
199
+ views.forEach((view, v) => {
200
+ view.focus.forEach((id, i) => {
201
+ if (!nodeIds.has(id)) {
202
+ out.push(diagnostic("composition", "unresolved_reference",
203
+ `diagram.views[${v}].focus[${i}]`,
204
+ `view "${view.label}" focuses \`${id}\`, which is not a node of this ` +
205
+ `diagram`, view.label));
206
+ }
207
+ });
208
+ });
209
+
210
+ return out;
211
+ }
212
+
213
+ /** Identifiers become DOM ids and link targets, so they are unique document-wide. */
214
+ function claimAnchor(anchors, out, id, path, subject) {
215
+ const previous = anchors.get(id);
216
+ if (previous) {
217
+ out.push(diagnostic("composition", "duplicate_identifier", path,
218
+ `\`${id}\` is already used by ${previous.subject} at ${previous.path}; ` +
219
+ `identifiers become link targets and must be unique across the artifact`,
220
+ subject));
221
+ return;
222
+ }
223
+ anchors.set(id, { path, subject });
224
+ }
225
+
226
+ /**
227
+ * A flow's first step is its entry. `next` is optional and defaults to the
228
+ * following step, which is what a linear flow means without saying so.
229
+ */
230
+ function checkFlow(section, path, out) {
231
+ const steps = section.steps;
232
+ const index = new Map();
233
+
234
+ steps.forEach((step, i) => {
235
+ if (index.has(step.id)) {
236
+ out.push(diagnostic("composition", "duplicate_step_identifier",
237
+ `${path}.steps[${i}].id`,
238
+ `\`${step.id}\` is used twice in flow "${section.title}"`, section.title));
239
+ return;
240
+ }
241
+ index.set(step.id, i);
242
+ });
243
+
244
+ const edges = steps.map((step, i) => {
245
+ if (step.next === undefined) return i + 1 < steps.length ? [i + 1] : [];
246
+ return step.next.map((target) => index.get(target)).filter((t) => t !== undefined);
247
+ });
248
+
249
+ steps.forEach((step, i) => {
250
+ for (const target of step.next ?? []) {
251
+ if (!index.has(target)) {
252
+ out.push(diagnostic("composition", "unresolved_reference",
253
+ `${path}.steps[${i}].next`,
254
+ `step "${step.title}" leads to \`${target}\`, which is not a step of ` +
255
+ `flow "${section.title}"`, step.title));
256
+ } else if (index.get(target) === i) {
257
+ out.push(diagnostic("composition", "graph_cycle", `${path}.steps[${i}].next`,
258
+ `step "${step.title}" leads to itself`, step.title));
259
+ }
260
+ }
261
+ });
262
+
263
+ if (findCycle(edges)) {
264
+ out.push(diagnostic("composition", "graph_cycle", `${path}.steps`,
265
+ `flow "${section.title}" contains a cycle; a flow a reader can follow ` +
266
+ `has an end`, section.title));
267
+ return;
268
+ }
269
+
270
+ const reached = reachableFrom(edges, 0);
271
+ steps.forEach((step, i) => {
272
+ if (!reached.has(i)) {
273
+ out.push(diagnostic("composition", "orphan_step", `${path}.steps[${i}]`,
274
+ `step "${step.title}" is not reachable from the flow's first step`,
275
+ step.title));
276
+ }
277
+ });
278
+ }
279
+
280
+ function checkQuiz(section, path, out) {
281
+ const seen = new Set();
282
+ section.questions.forEach((question, q) => {
283
+ const questionPath = `${path}.questions[${q}]`;
284
+ if (seen.has(question.id)) {
285
+ out.push(diagnostic("composition", "duplicate_question_identifier",
286
+ `${questionPath}.id`,
287
+ `\`${question.id}\` is used twice in this quiz`, question.prompt));
288
+ }
289
+ seen.add(question.id);
290
+
291
+ if (question.answer >= question.options.length) {
292
+ out.push(diagnostic("composition", "answer_out_of_range",
293
+ `${questionPath}.answer`,
294
+ `answer is ${question.answer} but the question has ` +
295
+ `${question.options.length} option(s), indexed 0 to ` +
296
+ `${question.options.length - 1}`, question.prompt));
297
+ }
298
+ });
299
+ }
300
+
301
+ /**
302
+ * The module graph. `requires` is optional, so a flat list of modules is a
303
+ * legal graph with no edges — which is exactly the single-module case, and must
304
+ * stay legal. Orphans are therefore defined against the roots: with no edges
305
+ * every module is a root, so nothing is orphaned. Once edges exist, a module
306
+ * unreachable from every root is a module the reader can never legitimately
307
+ * arrive at.
308
+ */
309
+ function checkModuleGraph(modules, out) {
310
+ const index = new Map(modules.map((module, i) => [module.id, i]));
311
+ const edges = modules.map(() => []);
312
+
313
+ modules.forEach((module, m) => {
314
+ for (const required of module.requires ?? []) {
315
+ if (!index.has(required)) {
316
+ out.push(diagnostic("composition", "unresolved_reference",
317
+ `lesson.modules[${m}].requires`,
318
+ `module "${module.title}" requires \`${required}\`, which is not a ` +
319
+ `module of this lesson`, module.title));
320
+ continue;
321
+ }
322
+ if (index.get(required) === m) {
323
+ out.push(diagnostic("composition", "graph_cycle",
324
+ `lesson.modules[${m}].requires`,
325
+ `module "${module.title}" requires itself`, module.title));
326
+ continue;
327
+ }
328
+ edges[index.get(required)].push(m);
329
+ }
330
+ });
331
+
332
+ if (findCycle(edges)) {
333
+ out.push(diagnostic("composition", "graph_cycle", "lesson.modules",
334
+ "the module graph contains a cycle; prerequisites that lead back to " +
335
+ "themselves cannot be satisfied in any order"));
336
+ return;
337
+ }
338
+
339
+ const roots = modules
340
+ .map((module, m) => ({ module, m }))
341
+ .filter(({ module }) => (module.requires ?? []).length === 0)
342
+ .map(({ m }) => m);
343
+
344
+ const reached = new Set();
345
+ for (const root of roots) for (const node of reachableFrom(edges, root)) reached.add(node);
346
+
347
+ modules.forEach((module, m) => {
348
+ if (!reached.has(m)) {
349
+ out.push(diagnostic("composition", "orphan_module", `lesson.modules[${m}]`,
350
+ `module "${module.title}" is not reachable from any module without ` +
351
+ `prerequisites`, module.title));
352
+ }
353
+ });
354
+ }
355
+
356
+ /** Depth-first reachability. Node order is the adjacency order, never a Set's. */
357
+ function reachableFrom(edges, start) {
358
+ const seen = new Set();
359
+ const stack = [start];
360
+ while (stack.length > 0) {
361
+ const node = stack.pop();
362
+ if (seen.has(node)) continue;
363
+ seen.add(node);
364
+ for (const next of edges[node]) stack.push(next);
365
+ }
366
+ return seen;
367
+ }
368
+
369
+ /** Iterative depth-first cycle detection over an adjacency list. */
370
+ function findCycle(edges) {
371
+ const WHITE = 0, GREY = 1, BLACK = 2;
372
+ const colour = edges.map(() => WHITE);
373
+
374
+ for (let start = 0; start < edges.length; start += 1) {
375
+ if (colour[start] !== WHITE) continue;
376
+ const stack = [{ node: start, cursor: 0 }];
377
+ colour[start] = GREY;
378
+ while (stack.length > 0) {
379
+ const frame = stack[stack.length - 1];
380
+ if (frame.cursor >= edges[frame.node].length) {
381
+ colour[frame.node] = BLACK;
382
+ stack.pop();
383
+ continue;
384
+ }
385
+ const next = edges[frame.node][frame.cursor];
386
+ frame.cursor += 1;
387
+ if (colour[next] === GREY) return true;
388
+ if (colour[next] === WHITE) {
389
+ colour[next] = GREY;
390
+ stack.push({ node: next, cursor: 0 });
391
+ }
392
+ }
393
+ }
394
+ return false;
395
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The one diagnostic shape, and the layers that produce it.
3
+ *
4
+ * Four layers, each supporting a distinct claim and reported distinctly. A
5
+ * caller that collapses them into "valid / invalid" throws away the only thing
6
+ * that makes the result honest: *which* claim was checked. Delivery validation
7
+ * proves the artifact was checked; it never proves the artifact looks correct.
8
+ */
9
+
10
+ /** The layers, in the order they run. Later layers assume earlier ones passed. */
11
+ export const LAYERS = Object.freeze(["structural", "composition", "evidence", "delivery"]);
12
+
13
+ /** What each layer's passing result does and does not entitle a caller to say. */
14
+ export const LAYER_CLAIMS = Object.freeze({
15
+ structural: "the specification satisfies its schema",
16
+ composition: "identifiers, references, graphs, and answers are coherent",
17
+ evidence: "every citation resolves at the declared commit",
18
+ delivery: "the artifact was rendered, digested, and committed atomically",
19
+ });
20
+
21
+ /**
22
+ * @typedef {object} Diagnostic
23
+ * @property {"structural"|"composition"|"evidence"|"delivery"} layer
24
+ * @property {string} code stable, greppable, never localised
25
+ * @property {string} path where in the specification, in reader terms
26
+ * @property {string} message what is wrong
27
+ * @property {string} [subject] the thing being talked about, named by title or id
28
+ */
29
+
30
+ /** @returns {Diagnostic} */
31
+ export function diagnostic(layer, code, path, message, subject) {
32
+ const result = { layer, code, path, message };
33
+ if (subject !== undefined) result.subject = subject;
34
+ return result;
35
+ }
36
+
37
+ /**
38
+ * Property names that are presentation control, rejected rather than ignored.
39
+ *
40
+ * `additionalProperties: false` already rejects every one of these — this list
41
+ * exists to change the *diagnostic*, not the outcome. "`color` is not part of
42
+ * this contract" is true but unhelpful; a producer who wrote it believed
43
+ * presentation was theirs to set, and the error should say so. Matched on the
44
+ * property name at any depth, because that is the level at which the mistake
45
+ * is made.
46
+ */
47
+ export const PRESENTATION_CONTROLS = Object.freeze(new Set([
48
+ "align", "background", "background_color", "backgroundColor", "border",
49
+ "class", "class_name", "className", "color", "colors", "colour", "column",
50
+ "coordinates", "css", "font", "font_family", "font_size", "fontSize",
51
+ "gap", "grid", "height", "html", "icon", "layout", "margin", "padding",
52
+ "position", "preset", "size", "spacing", "style", "styles", "template",
53
+ "theme", "theme_default", "variant", "width", "x", "y", "z_index", "zIndex",
54
+
55
+ // The drawing controls. A producer describing a diagram reaches for these
56
+ // first, and every one of them is the renderer deciding where something goes
57
+ // rather than the producer saying what it is. `emphasis` belongs here for a
58
+ // subtler reason than the rest: it is not a coordinate, but it is the
59
+ // producer setting how much ink a thing gets. Emphasis is derived from
60
+ // authored paths — say why it matters, and the renderer decides how loud.
61
+ "anchor", "animation", "col", "cols", "dot", "edge_style", "emphasis",
62
+ "importance", "lane", "lanes", "offset", "orientation", "pos", "rank",
63
+ "route", "routing", "rx", "ry", "shape", "side", "stage", "stroke", "svg",
64
+ "viewbox", "viewBox", "weight", "x1", "x2", "y1", "y2", "zoom",
65
+ ]));
66
+
67
+ /** Is this property name one a producer must never control? */
68
+ export function isPresentationControl(name) {
69
+ return PRESENTATION_CONTROLS.has(name);
70
+ }
71
+
72
+ /**
73
+ * Group diagnostics by layer, preserving order within each.
74
+ * Returned as an array in `LAYERS` order so reporting never depends on
75
+ * insertion or hash order.
76
+ */
77
+ export function byLayer(diagnostics) {
78
+ return LAYERS.map((layer) => ({
79
+ layer,
80
+ claim: LAYER_CLAIMS[layer],
81
+ diagnostics: diagnostics.filter((d) => d.layer === layer),
82
+ }));
83
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Every place a diagram may carry evidence, in document order, named the way a
3
+ * reader would name it.
4
+ *
5
+ * Two layers need this list and they need it to agree. The structural layer
6
+ * refuses a citation in a specification that has no source to resolve it
7
+ * against; the evidence layer requires citations of a `derived` diagram's nodes,
8
+ * edges and claim-bearing prose. If each walked the specification itself the two
9
+ * would drift — one would learn about a new place evidence can live and the
10
+ * other would not — and the drift would show up as a rule that silently stopped
11
+ * applying to part of the document.
12
+ *
13
+ * So the walk is defined once, here, and both layers read it.
14
+ *
15
+ * `claim` is the distinction the provenance contract rests on for a group, path
16
+ * or view: a label is a name, and a name is not a factual assertion that needs
17
+ * backing. `summary` and `note` are prose that asserts something, so they are
18
+ * what makes one of these claim-bearing. A node or an edge is claim-bearing by
19
+ * existing at all — saying a component is there, or that two things relate, is
20
+ * the assertion — which is why `claim` is not what decides their requirement.
21
+ *
22
+ * Imports nothing. It is walked by a layer that spawns Git and by one that does
23
+ * not, and it has no business knowing which.
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} EvidenceSite
28
+ * @property {"group"|"node"|"edge"|"path"|"view"} role what kind of thing it is
29
+ * @property {string} subject the thing, named for a diagnostic
30
+ * @property {string} path where its evidence lives, in specification terms
31
+ * @property {object[]} evidence the citations present, possibly none
32
+ * @property {boolean} claim whether it asserts something beyond its own name
33
+ */
34
+
35
+ /**
36
+ * @param {object} diagram a `diagram` body that passed schema validation
37
+ * @returns {EvidenceSite[]} in document order
38
+ */
39
+ export function diagramEvidenceSites(diagram) {
40
+ const sites = [];
41
+
42
+ const site = (role, subject, path, holder, claim) => {
43
+ sites.push({ role, subject, path, evidence: holder.evidence ?? [], claim });
44
+ };
45
+
46
+ // Groups first, then nodes, edges, paths and views. This order is the order
47
+ // citations are reported in, so it is fixed rather than convenient.
48
+ (diagram.groups ?? []).forEach((group, g) => {
49
+ site("group", `group "${group.label}"`, `diagram.groups[${g}].evidence`,
50
+ group, group.summary !== undefined);
51
+ });
52
+ diagram.nodes.forEach((node, n) => {
53
+ site("node", `node "${node.label}"`, `diagram.nodes[${n}].evidence`, node, true);
54
+ });
55
+ diagram.edges.forEach((edge, e) => {
56
+ site("edge", `edge \`${edge.id}\``, `diagram.edges[${e}].evidence`, edge, true);
57
+ });
58
+ (diagram.paths ?? []).forEach((path, p) => {
59
+ site("path", `path "${path.label}"`, `diagram.paths[${p}].evidence`,
60
+ path, path.note !== undefined);
61
+ });
62
+ (diagram.views ?? []).forEach((view, v) => {
63
+ site("view", `view "${view.label}"`, `diagram.views[${v}].evidence`,
64
+ view, view.note !== undefined);
65
+ });
66
+
67
+ return sites;
68
+ }