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,302 @@
1
+ /**
2
+ * Layer 3 — evidence. Every citation resolves at the declared commit.
3
+ *
4
+ * Against the commit, never the working tree. A lesson that cited the tree
5
+ * would be checked against whatever the author happened to have open, and would
6
+ * go stale silently the moment anyone edited a file. Against a commit it either
7
+ * resolves or it does not, forever.
8
+ *
9
+ * Local Git only. There is no fetch, no clone, and no host lookup: a missing
10
+ * commit is reported, not retrieved. Reaching the network to make a validation
11
+ * pass would turn "the evidence was checked" into "the evidence was checked
12
+ * somewhere, against something".
13
+ *
14
+ * Three resolution failures, kept apart on purpose, because they mean three
15
+ * different things to the person reading them:
16
+ *
17
+ * source_commit_unavailable the declared commit cannot be resolved here
18
+ * evidence_path_absent the commit resolves; the file is not in it
19
+ * evidence_range_invalid the file is there; the cited lines are not
20
+ *
21
+ * And, for a `derived` diagram, three requirement failures — a fact asserted
22
+ * with nothing behind it:
23
+ *
24
+ * node_without_evidence a component nothing in the repository shows
25
+ * edge_without_evidence a relationship nothing in the repository shows
26
+ * claim_without_evidence prose on a group, path or view, uncited
27
+ *
28
+ * None of them is a warning and none of them downgrades to a skip. An engine
29
+ * that shrugged at an unresolvable commit would deliver an artifact claiming
30
+ * its evidence was verified when nothing had been.
31
+ *
32
+ * **What this layer establishes, exactly.** That the cited file exists at the
33
+ * declared commit, that the cited line range exists in it, and that the
34
+ * material cited is there to be read. Not that the assertion resting on it is
35
+ * true. Repository documentation — a README, an ADR, a runbook — is first-class
36
+ * citation material and is checked the same way, which is not a statement that
37
+ * documentation carries the same authority as the code it describes. Pathfinder
38
+ * verifies provenance, not truth, and no diagnostic here may suggest otherwise.
39
+ */
40
+
41
+ import { spawnSync } from "node:child_process";
42
+
43
+ import { diagramEvidenceSites } from "./diagram-parts.mjs";
44
+ import { diagnostic } from "./diagnostics.mjs";
45
+
46
+ /**
47
+ * @typedef {object} EvidenceResult
48
+ * @property {import("./diagnostics.mjs").Diagnostic[]} diagnostics
49
+ * @property {number} resolved citations that resolved completely. The
50
+ * verification sentence is gated on this being non-zero, so a
51
+ * specification with nothing to check cannot earn one by having
52
+ * nothing go wrong.
53
+ */
54
+
55
+ /**
56
+ * @param {object} spec a specification that passed structure and composition
57
+ * @param {string} repoDir the directory whose Git history evidence resolves in
58
+ * @returns {EvidenceResult}
59
+ */
60
+ export function validateEvidence(spec, repoDir) {
61
+ const out = [];
62
+ const citations = collectCitations(spec, out);
63
+
64
+ const git = probeGit(repoDir);
65
+ if (!git.available) {
66
+ out.push(diagnostic("evidence", "source_commit_unavailable", "source.commit",
67
+ `${git.reason} Evidence resolves against \`${spec.source.commit}\` in ` +
68
+ `\`${spec.source.repo}\` using local Git only; nothing is fetched.`,
69
+ spec.source.repo));
70
+ return { diagnostics: out, resolved: 0 };
71
+ }
72
+
73
+ /** Resolved object names, memoised per abbreviated commit. */
74
+ const resolved = new Map();
75
+ const resolve = (commit, path, subject) => {
76
+ if (resolved.has(commit)) return resolved.get(commit);
77
+ const full = revParse(repoDir, commit);
78
+ if (full === null) {
79
+ out.push(diagnostic("evidence", "source_commit_unavailable", path,
80
+ `commit \`${commit}\` is not present in the repository at ` +
81
+ `\`${repoDir}\`. The specimen declares \`${spec.source.repo}\`; this ` +
82
+ `checkout is a different repository, or the commit has not been ` +
83
+ `fetched. Evidence is not verified, so validation fails rather than ` +
84
+ `claiming it was.`, subject));
85
+ }
86
+ resolved.set(commit, full);
87
+ return full;
88
+ };
89
+
90
+ /** Blob line counts, memoised per `commit:path`. */
91
+ const lineCounts = new Map();
92
+
93
+ let resolvedCount = 0;
94
+
95
+ for (const citation of citations) {
96
+ const commit = citation.commit ?? spec.source.commit;
97
+ const full = resolve(commit, citation.commit ? citation.path : "source.commit",
98
+ citation.subject);
99
+ if (full === null) continue;
100
+
101
+ const key = `${full}:${citation.path}`;
102
+ if (!lineCounts.has(key)) lineCounts.set(key, readBlobLineCount(repoDir, full, citation.path));
103
+ const count = lineCounts.get(key);
104
+
105
+ if (count === null) {
106
+ out.push(diagnostic("evidence", "evidence_path_absent", citation.path,
107
+ `${citation.subject} cites \`${citation.path}\`, which does not exist ` +
108
+ `at commit \`${commit}\` — checked against that commit, not the ` +
109
+ `working tree.`, citation.subject));
110
+ continue;
111
+ }
112
+
113
+ if (citation.lines) {
114
+ const [start, end] = citation.lines;
115
+ if (end < start) {
116
+ out.push(diagnostic("evidence", "evidence_range_invalid", citation.path,
117
+ `${citation.subject} cites lines ${start}-${end} of ` +
118
+ `\`${citation.path}\`, which ends before it begins`, citation.subject));
119
+ continue;
120
+ }
121
+ if (end > count) {
122
+ out.push(diagnostic("evidence", "evidence_range_invalid", citation.path,
123
+ `${citation.subject} cites lines ${start}-${end} of ` +
124
+ `\`${citation.path}\`, which has ${count} line(s) at commit ` +
125
+ `\`${commit}\``, citation.subject));
126
+ continue;
127
+ }
128
+ }
129
+
130
+ resolvedCount += 1;
131
+ }
132
+
133
+ return { diagnostics: out, resolved: resolvedCount };
134
+ }
135
+
136
+ /**
137
+ * Walk the specification for citations, in document order, and fail every
138
+ * concept that carries none.
139
+ *
140
+ * The emptiness rule lives here rather than in the schema so it is reported as
141
+ * the evidence claim it is. A `minItems` in the schema would fail the same
142
+ * specification one layer earlier and say "must have at least 1 item", which
143
+ * tells a producer about arrays when the thing they got wrong was a claim.
144
+ */
145
+ function collectCitations(spec, out) {
146
+ const citations = [];
147
+
148
+ const take = (evidence, subject, path) => {
149
+ for (let i = 0; i < (evidence ?? []).length; i += 1) {
150
+ citations.push({ ...evidence[i], subject, path: evidence[i].path, at: `${path}[${i}]` });
151
+ }
152
+ };
153
+
154
+ if (spec.kind === "diagram") {
155
+ collectDiagramCitations(spec, take, out);
156
+ return citations;
157
+ }
158
+
159
+ spec.lesson.modules.forEach((module, m) => {
160
+ module.sections.forEach((section, s) => {
161
+ const at = `lesson.modules[${m}].sections[${s}]`;
162
+
163
+ if (section.type === "concept" && (section.evidence ?? []).length === 0) {
164
+ out.push(diagnostic("evidence", "concept_without_evidence", `${at}.evidence`,
165
+ `concept "${section.title}" asserts something about the source and ` +
166
+ `cites nothing. A concept without evidence is an error, not a ` +
167
+ `warning: an uncited claim is the renderer asserting domain content, ` +
168
+ `which it must never do.`, section.title));
169
+ }
170
+
171
+ const label = section.title ?? `${section.type} section`;
172
+ take(section.evidence, `${section.type} "${label}"`, `${at}.evidence`);
173
+
174
+ if (section.type === "flow") {
175
+ section.steps.forEach((step, i) => {
176
+ take(step.evidence, `flow step "${step.title}"`, `${at}.steps[${i}].evidence`);
177
+ });
178
+ }
179
+ if (section.type === "quiz") {
180
+ section.questions.forEach((question, i) => {
181
+ take(question.evidence, `question "${question.prompt}"`,
182
+ `${at}.questions[${i}].evidence`);
183
+ });
184
+ }
185
+ });
186
+ });
187
+
188
+ return citations;
189
+ }
190
+
191
+ /**
192
+ * A diagram's citations, in document order — and, for a `derived` diagram, the
193
+ * requirement that its facts have some.
194
+ *
195
+ * `derived` means the diagram maps what the repository asserts about itself at
196
+ * the declared commit. So there is no uncited derived fact: a node says a
197
+ * component exists, an edge says two things relate, and each is a claim a
198
+ * reader must be able to go and check. A component that appears nowhere in
199
+ * source, configuration, infrastructure or repository documentation is not
200
+ * eligible for a `derived` diagram at all — a diagram that needs it is
201
+ * `proposed`.
202
+ *
203
+ * A group, path or view is the one place the rule is narrower, and deliberately:
204
+ * its label is a name, and a name asserts nothing that its already-cited members
205
+ * do not. Its `summary` or `note` is prose making a further claim, and that is
206
+ * what needs backing. A label-only boundary is free.
207
+ *
208
+ * `proposed` requires none of this. Its citations are optional and every one
209
+ * present is still resolved, which is the whole difference between a design
210
+ * document that cites its influences and one that claims to describe what runs.
211
+ */
212
+ function collectDiagramCitations(spec, take, out) {
213
+ const derived = spec.provenance === "derived";
214
+
215
+ for (const site of diagramEvidenceSites(spec.diagram)) {
216
+ if (derived && site.evidence.length === 0) {
217
+ if (site.role === "node") {
218
+ out.push(diagnostic("evidence", "node_without_evidence", site.path,
219
+ `${site.subject} is a component this diagram says the repository ` +
220
+ `has, and cites nothing for it. A derived diagram maps what the ` +
221
+ `repository asserts about itself, so every node names where a reader ` +
222
+ `can check it — the entry point that accepts an actor, the client ` +
223
+ `for an external system, the manifest or entry module for a ` +
224
+ `subsystem. If nothing in the repository shows it, the diagram is ` +
225
+ `\`proposed\`.`, site.subject));
226
+ } else if (site.role === "edge") {
227
+ out.push(diagnostic("evidence", "edge_without_evidence", site.path,
228
+ `${site.subject} asserts a relationship and cites nothing for it. ` +
229
+ `That two things relate is exactly the kind of claim a reader comes ` +
230
+ `to a diagram to check, so a derived diagram says where the ` +
231
+ `relationship is visible.`, site.subject));
232
+ } else if (site.claim) {
233
+ out.push(diagnostic("evidence", "claim_without_evidence", site.path,
234
+ `${site.subject} carries prose that asserts something, and cites ` +
235
+ `nothing for it. Its label would have needed no citation — a name is ` +
236
+ `not a claim, and its members are cited already — but a \`summary\` ` +
237
+ `or \`note\` says something further. Cite it, or delete it and let ` +
238
+ `the label stand alone.`, site.subject));
239
+ }
240
+ }
241
+
242
+ take(site.evidence, site.subject, site.path);
243
+ }
244
+ }
245
+
246
+ /** Is there a Git we can ask, and is `repoDir` inside a repository? */
247
+ function probeGit(repoDir) {
248
+ const version = git(repoDir, ["--version"]);
249
+ if (version === null) {
250
+ return { available: false, reason: "Git is not available on this machine." };
251
+ }
252
+ const top = git(repoDir, ["rev-parse", "--show-toplevel"]);
253
+ if (top === null) {
254
+ return {
255
+ available: false,
256
+ reason: `\`${repoDir}\` is not inside a Git repository, so no commit can be resolved.`,
257
+ };
258
+ }
259
+ return { available: true, root: top.trim() };
260
+ }
261
+
262
+ /** The full object name for a commit-ish, or null if it does not resolve. */
263
+ function revParse(repoDir, commit) {
264
+ const out = git(repoDir, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
265
+ return out === null ? null : out.trim();
266
+ }
267
+
268
+ /**
269
+ * Line count of a blob at a commit, or null when the path is absent or is not
270
+ * a file. A trailing newline does not add a line, so the count matches what an
271
+ * editor shows.
272
+ */
273
+ function readBlobLineCount(repoDir, commit, path) {
274
+ const type = git(repoDir, ["cat-file", "-t", `${commit}:${path}`]);
275
+ if (type === null || type.trim() !== "blob") return null;
276
+
277
+ const content = git(repoDir, ["cat-file", "blob", `${commit}:${path}`]);
278
+ if (content === null) return null;
279
+ if (content === "") return 0;
280
+ return content.endsWith("\n")
281
+ ? content.split("\n").length - 1
282
+ : content.split("\n").length;
283
+ }
284
+
285
+ /**
286
+ * Run Git and return stdout, or null on any failure.
287
+ *
288
+ * `spawnSync` with an argument array, never a shell string: a citation path is
289
+ * producer-supplied text, and the one thing it must never become is part of a
290
+ * command line.
291
+ */
292
+ function git(cwd, args) {
293
+ const result = spawnSync("git", args, {
294
+ cwd,
295
+ encoding: "utf8",
296
+ maxBuffer: 64 * 1024 * 1024,
297
+ // No pager, no config-driven surprises in output we parse.
298
+ env: { ...process.env, GIT_PAGER: "cat", GIT_OPTIONAL_LOCKS: "0" },
299
+ });
300
+ if (result.error || result.status !== 0) return null;
301
+ return result.stdout;
302
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The validation layers, run in order and reported apart.
3
+ *
4
+ * Layers stay separate all the way to the caller. Collapsing them into one
5
+ * boolean is the failure this design exists to prevent: "valid" is not a claim,
6
+ * and each of these layers supports a different one.
7
+ */
8
+
9
+ import { validateStructure } from "./structural.mjs";
10
+ import { validateComposition } from "./composition.mjs";
11
+ import { validateEvidence } from "./evidence.mjs";
12
+ import { byLayer, LAYER_CLAIMS } from "./diagnostics.mjs";
13
+ import { markPassed } from "../verification.mjs";
14
+
15
+ export { LAYERS, LAYER_CLAIMS, byLayer, diagnostic } from "./diagnostics.mjs";
16
+ export { KINDS } from "./structural.mjs";
17
+
18
+ // Deliberately not re-exported here: `attest` and `isAttestation`.
19
+ // `verification.mjs` is the one module they are imported from. A second
20
+ // door onto the surface that gates an artifact's truth claim buys nothing
21
+ // and gives a future caller somewhere else to look.
22
+
23
+ /**
24
+ * @typedef {object} LayerNotRun
25
+ * @property {string} layer
26
+ * @property {string} reason why it does not apply, in the reader's terms
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} ValidationResult
31
+ * @property {boolean} ok
32
+ * @property {import("./diagnostics.mjs").Diagnostic[]} diagnostics
33
+ * @property {string[]} ran layers that actually executed
34
+ * @property {string[]} skipped layers that did not, because an earlier one failed
35
+ * @property {LayerNotRun[]} notRun layers that did not apply, each with its reason
36
+ * @property {number} resolvedCitations citations that resolved at the declared commit
37
+ */
38
+
39
+ /**
40
+ * Why the evidence layer does not run for a specification with no source.
41
+ *
42
+ * It is reported, never inferred and never quietly passed. A layer that had
43
+ * nothing to check is not a layer that checked something: reporting it as
44
+ * `ok evidence` would put the strongest word in the report against the weakest
45
+ * claim in it, and a reader skimming four green lines would conclude the
46
+ * citations had been verified when there were none.
47
+ *
48
+ * There is nothing missing here to fix. A diagram describing an intended system
49
+ * legitimately has no repository behind it, and the honest report of that is
50
+ * this sentence rather than either a pass or a failure.
51
+ */
52
+ const EVIDENCE_NOT_RUN =
53
+ "this specification declares no source, so there is no commit to resolve " +
54
+ "against and no citation to resolve — citations are refused outright for " +
55
+ "such a specification. Nothing was checked here, which is not the same as " +
56
+ "nothing being wrong.";
57
+
58
+ /**
59
+ * @param {unknown} spec
60
+ * @param {{ repoDir: string }} options where evidence resolves
61
+ * @returns {ValidationResult}
62
+ */
63
+ export function validateSpecification(spec, { repoDir }) {
64
+ const structural = validateStructure(spec);
65
+ if (structural.length > 0) {
66
+ return {
67
+ ok: false,
68
+ diagnostics: structural,
69
+ ran: ["structural"],
70
+ skipped: ["composition", "evidence"],
71
+ notRun: [],
72
+ resolvedCitations: 0,
73
+ };
74
+ }
75
+
76
+ // A specification with no source has no evidence layer to run. The structural
77
+ // layer has already refused any citation in it, so this is not a check being
78
+ // waived — there is provably nothing for it to check.
79
+ const sourceless = !Object.prototype.hasOwnProperty.call(spec, "source");
80
+
81
+ // Composition and evidence are independent of one another: a duplicate
82
+ // identifier does not make a citation unresolvable, and reporting both in one
83
+ // pass saves a producer a second round trip.
84
+ const composition = validateComposition(spec);
85
+ const evidence = sourceless
86
+ ? { diagnostics: [], resolved: 0 }
87
+ : validateEvidence(spec, repoDir);
88
+
89
+ const rest = [...composition, ...evidence.diagnostics];
90
+ const result = {
91
+ ok: rest.length === 0,
92
+ diagnostics: rest,
93
+ ran: sourceless
94
+ ? ["structural", "composition"]
95
+ : ["structural", "composition", "evidence"],
96
+ skipped: [],
97
+ notRun: sourceless ? [{ layer: "evidence", reason: EVIDENCE_NOT_RUN }] : [],
98
+ resolvedCitations: evidence.resolved,
99
+ };
100
+
101
+ // Brand the pass. This is the only place a result becomes something an
102
+ // attestation can be minted from, so a verification claim in an artifact
103
+ // traces back to this line and to no other.
104
+ return result.ok ? markPassed(result) : result;
105
+ }
106
+
107
+ /** Human-readable layer report. Ordering comes from `LAYERS`, never from a hash. */
108
+ export function formatReport(result) {
109
+ const lines = [];
110
+ for (const group of byLayer(result.diagnostics)) {
111
+ if (group.layer === "delivery") continue;
112
+ if (result.skipped.includes(group.layer)) {
113
+ lines.push(` ~ ${group.layer}: not run — an earlier layer failed`);
114
+ continue;
115
+ }
116
+ const notRun = (result.notRun ?? []).find((entry) => entry.layer === group.layer);
117
+ if (notRun) {
118
+ lines.push(` ~ ${group.layer}: not run — ${notRun.reason}`);
119
+ continue;
120
+ }
121
+ if (group.diagnostics.length === 0) {
122
+ lines.push(` ok ${group.layer}: ${LAYER_CLAIMS[group.layer]}`);
123
+ continue;
124
+ }
125
+ lines.push(` FAIL ${group.layer}: ${group.diagnostics.length} problem(s)`);
126
+ for (const d of group.diagnostics) {
127
+ lines.push(` [${d.code}] ${d.path}`);
128
+ lines.push(` ${d.message}`);
129
+ }
130
+ }
131
+ return lines.join("\n");
132
+ }