create-pathfinder 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -0
- package/package.json +1 -1
- package/skills/learn-codebase/SKILL.md +188 -17
- package/skills/learn-feature/SKILL.md +136 -15
- package/skills/map-system/SKILL.md +293 -0
- package/skills/render-artifact/SKILL.md +187 -0
- package/skills/render-artifact/engine/bin/render.mjs +225 -0
- package/skills/render-artifact/engine/deliver.mjs +197 -0
- package/skills/render-artifact/engine/doctor.mjs +96 -0
- package/skills/render-artifact/engine/examples/diagram.json +223 -0
- package/skills/render-artifact/engine/examples/lesson.json +242 -0
- package/skills/render-artifact/engine/references/determinism.md +71 -0
- package/skills/render-artifact/engine/references/specification.md +149 -0
- package/skills/render-artifact/engine/references/validation.md +268 -0
- package/skills/render-artifact/engine/render/behavior.mjs +128 -0
- package/skills/render-artifact/engine/render/diagram.mjs +342 -0
- package/skills/render-artifact/engine/render/escape.mjs +34 -0
- package/skills/render-artifact/engine/render/graph/behavior.mjs +394 -0
- package/skills/render-artifact/engine/render/graph/draw.mjs +204 -0
- package/skills/render-artifact/engine/render/graph/interaction.mjs +174 -0
- package/skills/render-artifact/engine/render/graph/layout.mjs +698 -0
- package/skills/render-artifact/engine/render/graph/style.mjs +200 -0
- package/skills/render-artifact/engine/render/graph/width.mjs +204 -0
- package/skills/render-artifact/engine/render/index.mjs +50 -0
- package/skills/render-artifact/engine/render/lesson.mjs +294 -0
- package/skills/render-artifact/engine/render/shell.mjs +275 -0
- package/skills/render-artifact/engine/render/theme.mjs +592 -0
- package/skills/render-artifact/engine/schemas/common.schema.json +101 -0
- package/skills/render-artifact/engine/schemas/diagram.schema.json +176 -0
- package/skills/render-artifact/engine/schemas/lesson.schema.json +210 -0
- package/skills/render-artifact/engine/validate/composition.mjs +395 -0
- package/skills/render-artifact/engine/validate/diagnostics.mjs +83 -0
- package/skills/render-artifact/engine/validate/diagram-parts.mjs +68 -0
- package/skills/render-artifact/engine/validate/evidence.mjs +302 -0
- package/skills/render-artifact/engine/validate/index.mjs +132 -0
- package/skills/render-artifact/engine/validate/jsonschema.mjs +312 -0
- package/skills/render-artifact/engine/validate/structural.mjs +241 -0
- package/skills/render-artifact/engine/verification.mjs +76 -0
- package/skills/render-artifact/engine/version.mjs +24 -0
|
@@ -0,0 +1,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
|
+
}
|