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,342 @@
1
+ /**
2
+ * The `diagram` renderer.
3
+ *
4
+ * A diagram artifact is two readings of one set of facts. The canvas is the
5
+ * picture; everything below it is the same graph as text — every node with its
6
+ * role and description, every relationship, every authored walk, every view,
7
+ * and every citation. Both are emitted into the document, which is what lets
8
+ * the artifact be read with no scripting at all, and what the interactions in
9
+ * a later ticket will attach to rather than generate.
10
+ *
11
+ * Nothing here decides where anything goes. `layout.mjs` owns geometry,
12
+ * `draw.mjs` owns the markup for it, and this module owns the page around
13
+ * them — which sections exist, what they are called, and in what order. Every
14
+ * word naming a section is renderer interface language; every word inside one
15
+ * came from the specification.
16
+ */
17
+
18
+ import { esc, domId } from "./escape.mjs";
19
+ import { renderShell, renderNav } from "./shell.mjs";
20
+ import { layoutGraph } from "./graph/layout.mjs";
21
+ import { drawGraph } from "./graph/draw.mjs";
22
+ import { GRAPH_CSS } from "./graph/style.mjs";
23
+ import { graphBehavior } from "./graph/behavior.mjs";
24
+ import { interactionModel, serializeModel } from "./graph/interaction.mjs";
25
+
26
+ /** Renderer-owned interface language. The producer supplies none of this. */
27
+ const UI = Object.freeze({
28
+ eyebrow: "Pathfinder diagram",
29
+ navLabel: "In this diagram",
30
+ components: "Components",
31
+ ungrouped: "Not in a boundary",
32
+ relationships: "Relationships",
33
+ paths: "Paths",
34
+ views: "Views",
35
+ evidence: "Evidence",
36
+ walks: "Walks",
37
+ focuses: "Focuses on",
38
+ canvasLabel: "Diagram canvas. Arrow keys pan, plus and minus zoom, 0 fits.",
39
+ toolbar: "Diagram controls",
40
+ nothing: "Nothing selected. Choose a component to focus it.",
41
+ zoomIn: "Zoom in",
42
+ zoomOut: "Zoom out",
43
+ fit: "Fit",
44
+ reset: "Reset",
45
+ upstream: "Upstream",
46
+ downstream: "Downstream",
47
+ details: "Go to details",
48
+ clear: "Clear",
49
+ focusOne: "Focus",
50
+ highlight: "Highlight",
51
+ relations: {
52
+ calls: "calls",
53
+ reads: "reads",
54
+ writes: "writes",
55
+ publishes: "publishes to",
56
+ consumes: "consumes from",
57
+ depends_on: "depends on",
58
+ transitions_to: "becomes",
59
+ triggers: "triggers",
60
+ },
61
+ });
62
+
63
+ /**
64
+ * @param {object} spec a `diagram` specification
65
+ * @param {object} [verification] an attestation, passed through to the shell
66
+ * @returns {string} a complete HTML document
67
+ */
68
+ export function renderDiagram(spec, verification) {
69
+ const { diagram } = spec;
70
+ const layout = layoutGraph(diagram);
71
+
72
+ const groups = diagram.groups ?? [];
73
+ const views = diagram.views ?? [];
74
+ const nav = renderNav([
75
+ ...groups.map((group) => ({ id: domId("s", "g", group.id), label: group.label })),
76
+ ...views.map((view) => ({ id: domId("s", "v", view.id), label: view.label })),
77
+ ], UI.navLabel);
78
+
79
+ const body = [
80
+ renderLead(spec),
81
+ renderTools(),
82
+ `<div class="pf-canvas" data-pf-canvas tabindex="0" role="group" ` +
83
+ `aria-label="${esc(UI.canvasLabel)}">${drawGraph(diagram, layout)}</div>`,
84
+ renderComponents(diagram),
85
+ renderRelationships(diagram),
86
+ renderPaths(diagram),
87
+ renderViews(diagram),
88
+ ].filter((part) => part !== "").join("\n");
89
+
90
+ return renderShell({
91
+ lang: spec.artifact.locale ?? "en",
92
+ title: spec.artifact.title,
93
+ eyebrow: UI.eyebrow,
94
+ description: spec.artifact.summary ?? spec.artifact.subtitle,
95
+ style: GRAPH_CSS,
96
+ nav,
97
+ body,
98
+ source: spec.source,
99
+ // Handed over, never interpreted here. Which of the three things an
100
+ // artifact may say about its own provenance is the shell's decision and the
101
+ // shell's wording; this renderer's only part in it is reporting what the
102
+ // specification declared.
103
+ provenance: spec.provenance,
104
+ // The reading interactions run on this index and on nothing else. It is
105
+ // derived from the specification, carries no summary, detail or citation,
106
+ // and is therefore incapable of being the only place a fact lives.
107
+ behavior: graphBehavior(serializeModel(interactionModel(diagram))),
108
+ verification,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * The controls, and the line that says what is selected.
114
+ *
115
+ * `hidden` in the delivered document and revealed by the script, which is the
116
+ * same bargain the shell's theme toggle strikes: a control that cannot work is
117
+ * not offered. Without scripting a reader meets no dead buttons, and loses
118
+ * nothing they could have read — every fact these controls navigate to is
119
+ * already written out below the canvas.
120
+ */
121
+ function renderTools() {
122
+ const button = (action, text, extra = "") =>
123
+ `<button type="button" class="pf-tool" data-pf-act="${esc(action)}"${extra}>` +
124
+ `${esc(text)}</button>`;
125
+
126
+ return [
127
+ '<div class="pf-graph-tools" data-pf-controls hidden>',
128
+ `<div class="pf-toolbar" role="toolbar" aria-label="${esc(UI.toolbar)}">`,
129
+ button("zoom-out", UI.zoomOut),
130
+ button("zoom-in", UI.zoomIn),
131
+ button("fit", UI.fit),
132
+ button("reset", UI.reset),
133
+ '<span class="pf-tool-sep" aria-hidden="true"></span>',
134
+ button("upstream", UI.upstream, " disabled"),
135
+ button("downstream", UI.downstream, " disabled"),
136
+ button("details", UI.details, " disabled"),
137
+ button("clear", UI.clear, " disabled"),
138
+ "</div>",
139
+ `<p class="pf-graph-status" data-pf-status aria-live="polite">` +
140
+ `${esc(UI.nothing)}</p>`,
141
+ "</div>",
142
+ ].join("\n");
143
+ }
144
+
145
+ function renderLead(spec) {
146
+ const { artifact } = spec;
147
+ const out = ['<div class="pf-lead">', `<h1>${esc(artifact.title)}</h1>`];
148
+ if (artifact.subtitle) {
149
+ out.push(`<p class="pf-lead-sub">${esc(artifact.subtitle)}</p>`);
150
+ }
151
+ if (artifact.summary) {
152
+ out.push(`<div class="pf-lead-summary"><p>${esc(artifact.summary)}</p></div>`);
153
+ }
154
+ out.push("</div>");
155
+ return out.join("\n");
156
+ }
157
+
158
+ /**
159
+ * The nodes, under the boundary each belongs to.
160
+ *
161
+ * Groups come first in specification order, then whatever belongs to no group.
162
+ * A node appears exactly once, so the list is the graph's node set rather than
163
+ * a view of it.
164
+ */
165
+ function renderComponents(diagram) {
166
+ const groups = diagram.groups ?? [];
167
+ const blocks = [];
168
+
169
+ for (const group of groups) {
170
+ const members = diagram.nodes.filter((node) => node.group === group.id);
171
+
172
+ // A group with no members of its own is still written out when it has
173
+ // something to say. A parent group holds other groups rather than nodes,
174
+ // so skipping it for having no members dropped its summary and — worse —
175
+ // its citations: the evidence layer requires a claim-bearing group to cite
176
+ // its claim, and the reader was then never shown either. A label-only
177
+ // group with no members has nothing to write and is represented by its
178
+ // boundary on the canvas.
179
+ const hasSomethingToSay = group.summary !== undefined
180
+ || (group.evidence ?? []).length > 0;
181
+ if (members.length === 0 && !hasSomethingToSay) continue;
182
+
183
+ blocks.push(section(domId("s", "g", group.id), group.label, [
184
+ group.summary ? `<p class="pf-module-summary">${esc(group.summary)}</p>` : "",
185
+ renderEvidence(group.evidence),
186
+ members.map(renderNode).join("\n"),
187
+ ].filter(Boolean).join("\n")));
188
+ }
189
+
190
+ const loose = diagram.nodes.filter((node) =>
191
+ node.group === undefined || !groups.some((group) => group.id === node.group));
192
+ if (loose.length > 0) {
193
+ blocks.push(section(domId("s", "ungrouped"), UI.ungrouped,
194
+ loose.map(renderNode).join("\n")));
195
+ }
196
+
197
+ return blocks.length === 0 ? "" : heading(UI.components) + blocks.join("\n");
198
+ }
199
+
200
+ function renderNode(node) {
201
+ const out = [
202
+ `<div class="pf-card" id="${esc(domId("s", "n", node.id))}" ` +
203
+ `data-pf-entry="node" data-pf-for="${esc(node.id)}">`,
204
+ `<h4 class="pf-section-title">${esc(node.label)}` +
205
+ `<span class="pf-legend-role">${esc(node.role)}</span>` +
206
+ // Named for the component rather than "Focus" twenty times over: a
207
+ // reader listing the page's buttons has to be able to tell them apart.
208
+ `<span class="pf-pick" data-pf-controls hidden>` +
209
+ `<button type="button" class="pf-tool" data-pf-pick="${esc(node.id)}" ` +
210
+ `aria-label="${esc(`${UI.focusOne} ${node.label}`)}">${esc(UI.focusOne)}` +
211
+ `</button></span></h4>`,
212
+ ];
213
+ if (node.summary) out.push(`<p>${esc(node.summary)}</p>`);
214
+ for (const paragraph of node.detail ?? []) out.push(`<p>${esc(paragraph)}</p>`);
215
+ const evidence = renderEvidence(node.evidence);
216
+ if (evidence) out.push(evidence);
217
+ out.push("</div>");
218
+ return out.join("\n");
219
+ }
220
+
221
+ function renderRelationships(diagram) {
222
+ if (diagram.edges.length === 0) return "";
223
+ const labelOf = new Map(diagram.nodes.map((node) => [node.id, node.label]));
224
+
225
+ const rows = diagram.edges.map((edge) => {
226
+ const out = [
227
+ `<li id="${esc(domId("s", "e", edge.id))}" data-pf-entry="edge" ` +
228
+ `data-pf-for="${esc(edge.id)}">`,
229
+ `<span class="pf-step-title">${esc(labelOf.get(edge.from) ?? edge.from)}</span> `,
230
+ `<span class="pf-relation">${esc(UI.relations[edge.relation])}</span> `,
231
+ `<span class="pf-step-title">${esc(labelOf.get(edge.to) ?? edge.to)}</span>`,
232
+ ];
233
+ if (edge.label) out.push(`<p class="pf-step-detail">${esc(edge.label)}</p>`);
234
+ const evidence = renderEvidence(edge.evidence);
235
+ if (evidence) out.push(evidence);
236
+ out.push("</li>");
237
+ return out.join("");
238
+ });
239
+
240
+ return heading(UI.relationships)
241
+ + section(domId("s", "relationships"), "",
242
+ `<ul class="pf-legend">${rows.join("\n")}</ul>`);
243
+ }
244
+
245
+ /**
246
+ * The authored walks, expanded from the edge ids they name.
247
+ *
248
+ * A path names edges rather than nodes so the claim is exact, and this is where
249
+ * that pays off for a reader: the walk below is the edges the producer chose,
250
+ * in order, even where two of them join the same pair of things.
251
+ */
252
+ function renderPaths(diagram) {
253
+ const paths = diagram.paths ?? [];
254
+ if (paths.length === 0) return "";
255
+
256
+ const labelOf = new Map(diagram.nodes.map((node) => [node.id, node.label]));
257
+ const edgeById = new Map(diagram.edges.map((edge) => [edge.id, edge]));
258
+
259
+ const blocks = paths.map((path) => {
260
+ const steps = path.edges.map((id) => {
261
+ const edge = edgeById.get(id);
262
+ if (edge === undefined) return "";
263
+ return `<li>${esc(labelOf.get(edge.from) ?? edge.from)} ` +
264
+ `<span class="pf-relation">${esc(UI.relations[edge.relation])}</span> ` +
265
+ `${esc(labelOf.get(edge.to) ?? edge.to)}` +
266
+ (edge.label ? ` — ${esc(edge.label)}` : "") + "</li>";
267
+ }).filter(Boolean);
268
+
269
+ return section(domId("s", "p", path.id), path.label, [
270
+ path.note ? `<p class="pf-module-summary">${esc(path.note)}</p>` : "",
271
+ `<div class="pf-kicker">${esc(UI.walks)}</div>`,
272
+ `<ol class="pf-walk">${steps.join("\n")}</ol>`,
273
+ renderEvidence(path.evidence),
274
+ `<div class="pf-pick" data-pf-controls hidden>` +
275
+ `<button type="button" class="pf-tool" data-pf-act="path" ` +
276
+ `data-pf-path="${esc(path.id)}" ` +
277
+ `aria-label="${esc(`${UI.highlight} ${path.label}`)}">` +
278
+ `${esc(UI.highlight)}</button></div>`,
279
+ ].filter(Boolean).join("\n"), `data-pf-entry="path" data-pf-for="${esc(path.id)}"`);
280
+ });
281
+
282
+ return heading(UI.paths) + blocks.join("\n");
283
+ }
284
+
285
+ function renderViews(diagram) {
286
+ const views = diagram.views ?? [];
287
+ if (views.length === 0) return "";
288
+ const labelOf = new Map(diagram.nodes.map((node) => [node.id, node.label]));
289
+
290
+ const blocks = views.map((view) => section(domId("s", "v", view.id), view.label, [
291
+ view.note ? `<p class="pf-module-summary">${esc(view.note)}</p>` : "",
292
+ `<div class="pf-kicker">${esc(UI.focuses)}</div>`,
293
+ `<ul class="pf-legend">${view.focus.map((id) =>
294
+ `<li>${esc(labelOf.get(id) ?? id)}</li>`).join("")}</ul>`,
295
+ renderEvidence(view.evidence),
296
+ ].filter(Boolean).join("\n")));
297
+
298
+ return heading(UI.views) + blocks.join("\n");
299
+ }
300
+
301
+ function heading(text) {
302
+ return `<h2 class="pf-module-title">${esc(text)}</h2>`;
303
+ }
304
+
305
+ function section(id, title, inner, attributes = "") {
306
+ const out = [
307
+ `<section class="pf-section" id="${esc(id)}"` +
308
+ `${attributes ? ` ${attributes}` : ""}>`,
309
+ ];
310
+ if (title) out.push(`<h3 class="pf-section-title">${esc(title)}</h3>`);
311
+ out.push('<div class="pf-card">', inner, "</div>", "</section>");
312
+ return out.join("\n");
313
+ }
314
+
315
+ /**
316
+ * Citations, in the presentation the lesson renderer already established.
317
+ *
318
+ * Identical on purpose. A reader who has learnt to read evidence in a lesson
319
+ * has learnt to read it here, and the shape is the shared `citation` from the
320
+ * common contract rather than anything this kind invented.
321
+ */
322
+ function renderEvidence(evidence) {
323
+ if (!evidence || evidence.length === 0) return "";
324
+ return [
325
+ '<div class="pf-evidence">',
326
+ `<div class="pf-evidence-label">${esc(UI.evidence)}</div>`,
327
+ "<ul>",
328
+ ...evidence.map((citation) => {
329
+ const parts = [`<span class="pf-cite-path">${esc(citation.path)}</span>`];
330
+ if (citation.lines) {
331
+ parts.push(`<span class="pf-cite-lines">lines ${citation.lines[0]}` +
332
+ `–${citation.lines[1]}</span>`);
333
+ }
334
+ if (citation.commit) {
335
+ parts.push(`<span class="pf-cite-commit">@ ${esc(citation.commit)}</span>`);
336
+ }
337
+ return `<li>${parts.join("")}</li>`;
338
+ }),
339
+ "</ul>",
340
+ "</div>",
341
+ ].join("\n");
342
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Text goes into HTML exactly one way: through here.
3
+ *
4
+ * The specification carries no HTML, by contract. That contract is only worth
5
+ * anything if the renderer enforces it, so every producer-supplied string is
6
+ * escaped on the way out and there is no raw-insertion helper to reach for.
7
+ * `<b>bold</b>` in a title renders as those nine characters, visibly, which is
8
+ * the correct behaviour: the producer owns content, the renderer owns markup,
9
+ * and a producer smuggling markup through content is the boundary failing.
10
+ */
11
+
12
+ const HTML = new Map([
13
+ ["&", "&amp;"],
14
+ ["<", "&lt;"],
15
+ [">", "&gt;"],
16
+ ['"', "&quot;"],
17
+ ["'", "&#39;"],
18
+ ]);
19
+
20
+ /** Escape for element content and for double-quoted attribute values alike. */
21
+ export function esc(value) {
22
+ return String(value).replace(/[&<>"']/g, (character) => HTML.get(character));
23
+ }
24
+
25
+ /**
26
+ * A DOM id derived from specification identifiers alone.
27
+ *
28
+ * Every id the renderer emits is built from ids the producer supplied, joined
29
+ * with a separator the identifier pattern forbids. No counter, no hash, no
30
+ * insertion order: the same specification yields the same ids on every machine.
31
+ */
32
+ export function domId(...parts) {
33
+ return parts.filter((part) => part !== undefined && part !== "").join("--");
34
+ }