@ybotok/loam 0.0.0 → 0.1.0-beta.2
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/CHANGELOG.md +138 -0
- package/COMPARISON.md +63 -0
- package/LICENSE +21 -0
- package/MIGRATING-from-OpenSpec.md +202 -0
- package/README.md +246 -9
- package/SCHEMA.md +425 -0
- package/SECURITY.md +9 -0
- package/dist/cli.js +142 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.js +240 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/archive.js +1313 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/delta.js +388 -0
- package/dist/commands/delta.js.map +1 -0
- package/dist/commands/dependencies.js +83 -0
- package/dist/commands/dependencies.js.map +1 -0
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +309 -0
- package/dist/commands/gherkin.js.map +1 -0
- package/dist/commands/init.js +250 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +291 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/migrate-openspec.js +950 -0
- package/dist/commands/migrate-openspec.js.map +1 -0
- package/dist/commands/new.js +413 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/rebase.js +356 -0
- package/dist/commands/rebase.js.map +1 -0
- package/dist/commands/show.js +320 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/status.js +254 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/unarchive.js +317 -0
- package/dist/commands/unarchive.js.map +1 -0
- package/dist/commands/validate.js +1591 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/verify.js +775 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/vouch.js +343 -0
- package/dist/commands/vouch.js.map +1 -0
- package/dist/core/agent.js +2065 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents-stamp.js +68 -0
- package/dist/core/agents-stamp.js.map +1 -0
- package/dist/core/arch.js +130 -0
- package/dist/core/arch.js.map +1 -0
- package/dist/core/brief.js +733 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/coherence.js +577 -0
- package/dist/core/coherence.js.map +1 -0
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +180 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delta.js +476 -0
- package/dist/core/delta.js.map +1 -0
- package/dist/core/dependencies.js +356 -0
- package/dist/core/dependencies.js.map +1 -0
- package/dist/core/docs.js +144 -0
- package/dist/core/docs.js.map +1 -0
- package/dist/core/doctor.js +559 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +225 -0
- package/dist/core/fleet-context.js.map +1 -0
- package/dist/core/frontmatter.js +199 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/gherkin.js +463 -0
- package/dist/core/gherkin.js.map +1 -0
- package/dist/core/health.js +67 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/ids.js +122 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/issue.js +9 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/likec4.js +479 -0
- package/dist/core/likec4.js.map +1 -0
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +552 -0
- package/dist/core/openapi-merge.js.map +1 -0
- package/dist/core/openapi.js +255 -0
- package/dist/core/openapi.js.map +1 -0
- package/dist/core/openspec-inventory.js +1440 -0
- package/dist/core/openspec-inventory.js.map +1 -0
- package/dist/core/path-safety.js +168 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/provenance.js +720 -0
- package/dist/core/provenance.js.map +1 -0
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +436 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/report.js +47 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/results.js +199 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/spec.js +481 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/staging.js +890 -0
- package/dist/core/staging.js.map +1 -0
- package/dist/core/status.js +1104 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +807 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/core/version.js +24 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +63 -4
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The done-check: did the code that was built match the feature that was
|
|
3
|
+
* designed?
|
|
4
|
+
*
|
|
5
|
+
* The original plan was to extract C4 from the built code and diff it against
|
|
6
|
+
* the delta. There is no such extractor and there will not be one — nothing
|
|
7
|
+
* deterministic reads a service and says what its architecture MEANS, and two
|
|
8
|
+
* generated models of the same code disagree in wording every run, so the diff
|
|
9
|
+
* would flap and be switched off inside a week.
|
|
10
|
+
*
|
|
11
|
+
* What survives of the idea is the part that was always deterministic: the
|
|
12
|
+
* QUESTIONS. A feature's own artifacts say exactly what it promised — this
|
|
13
|
+
* service will exist, it will expose this operation, that service will call it,
|
|
14
|
+
* this scenario will have a test. loam derives that list mechanically from the
|
|
15
|
+
* same files `validate` already reads, an agent answers each claim with
|
|
16
|
+
* evidence, and `--record` writes the answers down beside the feature.
|
|
17
|
+
*
|
|
18
|
+
* A scenario claim is answered mechanically wherever it can be: `--results`
|
|
19
|
+
* reads a cucumber JSON report and matches it by the digest tag `loam gherkin`
|
|
20
|
+
* stamped, and only a green run confirms one (results.ts). Where it cannot —
|
|
21
|
+
* a legacy service whose suite is months away, which is the fleet loam exists
|
|
22
|
+
* for — `--record` may still take an agent's word for it, and the record marks
|
|
23
|
+
* that answer ATTESTED rather than run: `verified` is reserved for a checklist
|
|
24
|
+
* whose scenarios a run answered, and `verify.scenario-attested` says so on
|
|
25
|
+
* every surface that reads the record.
|
|
26
|
+
*
|
|
27
|
+
* Two properties make the record worth keeping. The claim ids are a function of
|
|
28
|
+
* the claim and of nothing else — so two runs are diffable, reordering the delta
|
|
29
|
+
* renames nothing, and rewording a scenario DOES rename its claim, because an
|
|
30
|
+
* answer about text nobody wrote must not carry over. And an answer set that
|
|
31
|
+
* does not correspond to the current checklist is refused rather than merged:
|
|
32
|
+
* an unchecked claim must never be able to masquerade as checked.
|
|
33
|
+
*
|
|
34
|
+
* loam does not judge the answers. It cannot: it never read the code. It only
|
|
35
|
+
* guarantees that every question was asked and that every answer is on the
|
|
36
|
+
* record next to the question it answers.
|
|
37
|
+
*/
|
|
38
|
+
import { createHash } from "node:crypto";
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import { parse, stringify } from "yaml";
|
|
43
|
+
import { elementService, loadFile, serviceOf } from "./likec4.js";
|
|
44
|
+
import { operationIds, operations } from "./openapi.js";
|
|
45
|
+
import { isRecord } from "./records.js";
|
|
46
|
+
import { featurePaths, featureSpecPaths, featureSpecServices, servicePaths } from "./repo.js";
|
|
47
|
+
import { parseRequirements } from "./spec.js";
|
|
48
|
+
/**
|
|
49
|
+
* What a claim is about. The order is the order the checklist comes back in,
|
|
50
|
+
* and it reads as the story of the feature: the service exists, it exposes its
|
|
51
|
+
* operations, the calls into it are wired, the behaviour is tested.
|
|
52
|
+
*/
|
|
53
|
+
export const CLAIM_KINDS = ["service.exists", "api.exposes", "c4.calls", "scenario.tested"];
|
|
54
|
+
/**
|
|
55
|
+
* How much of the sha256 goes into an id, and into a digest — the checklist's,
|
|
56
|
+
* and a scenario claim's runner-matching one, which is deliberately the same
|
|
57
|
+
* 16 hex `loam gherkin` stamps (its GHERKIN_DIGEST_LENGTH): the tag in a
|
|
58
|
+
* cucumber report and the digest on a claim must be the same string.
|
|
59
|
+
*/
|
|
60
|
+
const ID_LENGTH = 8;
|
|
61
|
+
const DIGEST_LENGTH = 16;
|
|
62
|
+
/** C4 kinds that model people. A person is never a service. (Mirrors validate.ts.) */
|
|
63
|
+
const ACTOR_KINDS = new Set(["person", "actor", "user"]);
|
|
64
|
+
/**
|
|
65
|
+
* Derive the checklist from a feature's own artifacts. No code is read here and
|
|
66
|
+
* none should be: the whole design rests on the questions being computed from
|
|
67
|
+
* documents that do not change between runs.
|
|
68
|
+
*/
|
|
69
|
+
export async function featureChecklist(docsDir, featureDir, featureId) {
|
|
70
|
+
const seen = new Map();
|
|
71
|
+
const claim = (kind, subject, parts, text) => ({
|
|
72
|
+
id: claimId(featureId, kind, parts, seen),
|
|
73
|
+
kind,
|
|
74
|
+
subject,
|
|
75
|
+
claim: text,
|
|
76
|
+
});
|
|
77
|
+
const exists = [];
|
|
78
|
+
const exposes = [];
|
|
79
|
+
const calls = [];
|
|
80
|
+
const scenarios = [];
|
|
81
|
+
// Architecture — what the delta promised the fleet would look like.
|
|
82
|
+
const deltaPath = featurePaths(featureDir).delta;
|
|
83
|
+
if (existsSync(deltaPath)) {
|
|
84
|
+
const res = await loadFile(deltaPath);
|
|
85
|
+
// A delta nobody can parse promises nothing checkable. `validate` already
|
|
86
|
+
// reports it; inventing claims out of a broken document would only add noise.
|
|
87
|
+
if (res.errors.length === 0) {
|
|
88
|
+
const elements = res.elements;
|
|
89
|
+
for (const e of elements) {
|
|
90
|
+
if (!e.tags.includes(featureId))
|
|
91
|
+
continue;
|
|
92
|
+
// A dotted id is a container INSIDE a service, and an actor is nobody's
|
|
93
|
+
// repository. Neither is a service that has to exist.
|
|
94
|
+
if (e.id.includes(".") || ACTOR_KINDS.has(e.kind.toLowerCase()))
|
|
95
|
+
continue;
|
|
96
|
+
const svc = elementService(e);
|
|
97
|
+
exists.push(claim("service.exists", svc, [svc], `service '${svc}' exists`));
|
|
98
|
+
}
|
|
99
|
+
for (const r of res.relationships) {
|
|
100
|
+
// Untagged edges are context for the diagram; an edge with no operation
|
|
101
|
+
// names nothing specific enough to look for in the code.
|
|
102
|
+
if (!r.tags.includes(featureId) || r.op === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
const from = serviceOf(elements, r.source);
|
|
105
|
+
const to = serviceOf(elements, r.target);
|
|
106
|
+
calls.push(claim("c4.calls", from, [from, to, r.op], `${from} calls '${r.op}' on ${to}`));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const svc of await featureSpecServices(featureDir)) {
|
|
111
|
+
const paths = featureSpecPaths(featureDir, svc);
|
|
112
|
+
// Contract — only what is NEW. A delta's openapi.yaml is a whole document,
|
|
113
|
+
// not a patch, so authors restate the living API inside it; asking whether a
|
|
114
|
+
// service still exposes what it already exposed is noise.
|
|
115
|
+
const featOps = (await operations(paths.openapi))
|
|
116
|
+
.filter((operation) => !operation.remove)
|
|
117
|
+
.map((operation) => operation.id);
|
|
118
|
+
if (featOps.length > 0) {
|
|
119
|
+
const living = new Set(await operationIds(servicePaths(docsDir, svc).openapi));
|
|
120
|
+
for (const op of featOps) {
|
|
121
|
+
if (living.has(op))
|
|
122
|
+
continue;
|
|
123
|
+
exposes.push(claim("api.exposes", svc, [svc, op], `${svc} exposes operationId '${op}'`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Behaviour — the scenarios of the requirements this feature CHANGES. A BASE
|
|
127
|
+
// requirement is the living state quoted inside the delta, and a REMOVED one
|
|
128
|
+
// is being retired: neither is work anybody has to have done.
|
|
129
|
+
if (existsSync(paths.spec)) {
|
|
130
|
+
for (const r of parseRequirements(await readFile(paths.spec, "utf8"))) {
|
|
131
|
+
if (r.kind === "BASE" || r.kind === "REMOVED")
|
|
132
|
+
continue;
|
|
133
|
+
for (const s of r.scenarios) {
|
|
134
|
+
const body = scenarioBodyHash(svc, s.lines);
|
|
135
|
+
scenarios.push({
|
|
136
|
+
...claim("scenario.tested", svc, [svc, r.id ?? r.name, s.name, body.slice(0, ID_LENGTH)], `scenario '${s.name}' of requirement '${r.name}' (${svc}) is covered by a test`),
|
|
137
|
+
digest: body.slice(0, DIGEST_LENGTH),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// The architecture spec axis, same walk: an arch scenario is a promise like
|
|
143
|
+
// any other — the outbox test nobody was going to write IS the point of the
|
|
144
|
+
// axis. The source file rides in the id tuple AND in the body hash itself
|
|
145
|
+
// (two axes are two namespaces: identically-worded scenarios must stay two
|
|
146
|
+
// questions, and the axis-salted digest is what keeps a business-only test
|
|
147
|
+
// run from answering the arch claim) and in the claim text, so the
|
|
148
|
+
// answering agent knows it is being asked for an integration/ops test, not
|
|
149
|
+
// an acceptance test.
|
|
150
|
+
if (existsSync(paths.archSpec)) {
|
|
151
|
+
for (const r of parseRequirements(await readFile(paths.archSpec, "utf8"))) {
|
|
152
|
+
if (r.kind === "BASE" || r.kind === "REMOVED")
|
|
153
|
+
continue;
|
|
154
|
+
for (const s of r.scenarios) {
|
|
155
|
+
const body = scenarioBodyHash(svc, s.lines, "arch");
|
|
156
|
+
scenarios.push({
|
|
157
|
+
...claim("scenario.tested", svc, [svc, "arch.spec.md", r.id ?? r.name, s.name, body.slice(0, ID_LENGTH)], `scenario '${s.name}' of arch requirement '${r.name}' (${svc}, arch.spec.md) is covered by a test`),
|
|
158
|
+
digest: body.slice(0, DIGEST_LENGTH),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const claims = [...exists, ...exposes, ...calls, ...scenarios];
|
|
165
|
+
return { feature: featureId, claims, digest: checklistDigest(claims) };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A claim's identity: a hash of what it says, and nothing about how it was
|
|
169
|
+
* produced.
|
|
170
|
+
*
|
|
171
|
+
* The feature id is part of it so an answers file for one feature can never
|
|
172
|
+
* validate against another. Two claims that really are identical (the same
|
|
173
|
+
* scenario name twice under one requirement) are distinguished by occurrence, in
|
|
174
|
+
* document order — they are still two questions, and answering one must not
|
|
175
|
+
* answer the other.
|
|
176
|
+
*
|
|
177
|
+
* The hash is short because the claim text sits next to it everywhere it is
|
|
178
|
+
* shown; it identifies a question, it does not authenticate one.
|
|
179
|
+
*/
|
|
180
|
+
function claimId(featureId, kind, parts, seen) {
|
|
181
|
+
// NUL-joined so no claim's own text can spell another claim's tuple by
|
|
182
|
+
// containing the separator: ['a b','c'] and ['a','b c'] stay two questions.
|
|
183
|
+
const tuple = [featureId, kind, ...parts].join("\u0000");
|
|
184
|
+
const n = (seen.get(tuple) ?? 0) + 1;
|
|
185
|
+
seen.set(tuple, n);
|
|
186
|
+
const canonical = n === 1 ? tuple : `${tuple}\u0000#${n}`;
|
|
187
|
+
return `${kind}-${createHash("sha256").update(canonical).digest("hex").slice(0, ID_LENGTH)}`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The full sha256 of a scenario's body — its lines joined and edge-trimmed,
|
|
191
|
+
* exactly as `serializeRequirements` frames them. The BODY, not the title,
|
|
192
|
+
* because rewriting the Given/When/Then under an unchanged heading is new text
|
|
193
|
+
* nobody answered for, and the promise — rewording a scenario renames its
|
|
194
|
+
* claim — has to hold for the words that actually specify the behaviour.
|
|
195
|
+
*
|
|
196
|
+
* ONE recipe with three consumers, deliberately in one place:
|
|
197
|
+
* `scenario.tested` claim ids fold in its first {@link ID_LENGTH} hex
|
|
198
|
+
* characters, a claim's `digest` and the `@loam-digest-<16hex>` tag `loam
|
|
199
|
+
* gherkin` stamps both take its first {@link DIGEST_LENGTH} — so the claim,
|
|
200
|
+
* the stamp and the report `--results` reads can never disagree about what a
|
|
201
|
+
* scenario says.
|
|
202
|
+
*
|
|
203
|
+
* The identity is (SERVICE, AXIS, body), and both salts are there for the same
|
|
204
|
+
* reason: a digest that spans two namespaces lets one green run answer a
|
|
205
|
+
* question nobody ran a test for.
|
|
206
|
+
*
|
|
207
|
+
* - The AXIS salt keeps `spec.md` and `arch.spec.md` apart. Without it a green
|
|
208
|
+
* BUSINESS run answered the arch claim too — an integration test nobody wrote
|
|
209
|
+
* read as run.
|
|
210
|
+
* - The SERVICE salt keeps two repositories apart. "the service returns 404 for
|
|
211
|
+
* an unknown id" is worded the same way in nine services of a real fleet;
|
|
212
|
+
* unsalted, all nine shared one digest, `loam gherkin` stamped one tag into
|
|
213
|
+
* nine repositories, and one repository's green run confirmed the other
|
|
214
|
+
* eight's claims — across suites that never ran each other's tests.
|
|
215
|
+
*
|
|
216
|
+
* The service salt is what makes that case CORRECT rather than merely refused:
|
|
217
|
+
* `contestedDigests` could only decline to answer a shared digest, which left a
|
|
218
|
+
* fleet with ordinary repeated wording holding claims nothing could ever
|
|
219
|
+
* answer.
|
|
220
|
+
*
|
|
221
|
+
* Both salts renamed every claim id and every stamped tag on the day they
|
|
222
|
+
* landed, and both readings are legible rather than silent: an existing
|
|
223
|
+
* `verification.yaml` answers a checklist digest that is no longer derived and
|
|
224
|
+
* reports STALE, and an existing generated `.feature` carries digests the
|
|
225
|
+
* living spec no longer computes and reports `gherkin.stale` beside the
|
|
226
|
+
* `gherkin.missing` for the new ones. One `loam gherkin` and one re-record
|
|
227
|
+
* clear both.
|
|
228
|
+
*/
|
|
229
|
+
export function scenarioBodyHash(service, lines, axis = "business") {
|
|
230
|
+
// NUL-separated like claimId's tuples, and for its reason: no body can spell
|
|
231
|
+
// the salt in front of it, so the namespaces stay disjoint. Spelled as an
|
|
232
|
+
// escape and never as a raw NUL in the literal — a raw one makes this file
|
|
233
|
+
// read as `data` to `file(1)` and invisible to `grep`.
|
|
234
|
+
const body = lines.join("\n").trim();
|
|
235
|
+
const onAxis = axis === "arch" ? `arch.spec.md\u0000${body}` : body;
|
|
236
|
+
return createHash("sha256").update(`${service}\u0000${onAxis}`).digest("hex");
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* A digest of the claim id SET — sorted, so reordering the artifacts does not
|
|
240
|
+
* make a record look stale. It changes when a claim is added, removed or
|
|
241
|
+
* reworded, which is exactly when an answer set stops describing the feature.
|
|
242
|
+
*/
|
|
243
|
+
function checklistDigest(claims) {
|
|
244
|
+
const ids = claims.map((c) => c.id).sort();
|
|
245
|
+
return createHash("sha256").update(ids.join("\n")).digest("hex").slice(0, DIGEST_LENGTH);
|
|
246
|
+
}
|
|
247
|
+
/* ------------------------------------------------------------------ */
|
|
248
|
+
/* The answers */
|
|
249
|
+
/* ------------------------------------------------------------------ */
|
|
250
|
+
/**
|
|
251
|
+
* Two verdicts, deliberately. Anything an agent cannot show evidence for is
|
|
252
|
+
* unconfirmed — a third value for "not applicable" would absorb every claim
|
|
253
|
+
* nobody wanted to answer.
|
|
254
|
+
*/
|
|
255
|
+
export const VERDICTS = ["confirmed", "unconfirmed"];
|
|
256
|
+
/**
|
|
257
|
+
* Who answered a claim: the `runner` (a cucumber report's digest-tagged
|
|
258
|
+
* scenarios, matched mechanically by `--results`) or an `agent` (somebody's
|
|
259
|
+
* word about the code, taken back by `--record`). On the record so a reviewer
|
|
260
|
+
* can tell a green run from an assertion.
|
|
261
|
+
*/
|
|
262
|
+
export const ANSWERED_BY = ["runner", "agent"];
|
|
263
|
+
/**
|
|
264
|
+
* Accept an answer set only if it answers THIS checklist: every claim once, no
|
|
265
|
+
* claim twice, nothing extra, and no "confirmed" that shows nothing.
|
|
266
|
+
*
|
|
267
|
+
* The refusals are the point of the command. If a stale or partial answer file
|
|
268
|
+
* could be recorded, `verification.yaml` would say a claim was checked when
|
|
269
|
+
* nobody ever asked it — and a record that can lie is worse than no record,
|
|
270
|
+
* because it looks like evidence.
|
|
271
|
+
*
|
|
272
|
+
* `runnerOwned` is the composition rule under `--results`: ids the test runner
|
|
273
|
+
* answers, which an answers file must therefore not touch. An entry naming one
|
|
274
|
+
* refuses with its own diagnosis — the id IS on the feature's checklist, so
|
|
275
|
+
* calling it unknown would send the caller hunting a staleness that is not
|
|
276
|
+
* there.
|
|
277
|
+
*
|
|
278
|
+
* `context` is what turns the last line from a circle into an instruction. In a
|
|
279
|
+
* fleet the commonest way to "miss" a claim is that it is not yours: the
|
|
280
|
+
* checklist spans ten repositories and this one can only answer its own. Told
|
|
281
|
+
* only to "answer the claims it lists", an agent in the payment-service repo
|
|
282
|
+
* re-runs verify, sees the same checkout-web claims it cannot speak for, and
|
|
283
|
+
* either loops or invents evidence. So when every unanswered claim belongs to
|
|
284
|
+
* another service, say THAT, and name the command that records them.
|
|
285
|
+
*/
|
|
286
|
+
export function checkAnswers(claims, raw, runnerOwned, context) {
|
|
287
|
+
const refuse = (code, message) => ({ ok: false, code, message });
|
|
288
|
+
const list = Array.isArray(raw)
|
|
289
|
+
? raw
|
|
290
|
+
: isRecord(raw) && Array.isArray(raw["answers"])
|
|
291
|
+
? raw["answers"]
|
|
292
|
+
: null;
|
|
293
|
+
if (list === null) {
|
|
294
|
+
return refuse("answers-unreadable", "Expected a JSON array of answers, or an object with an `answers` array: " +
|
|
295
|
+
`[{ "id": "...", "verdict": "confirmed", "evidence": ["file:line"] }]`);
|
|
296
|
+
}
|
|
297
|
+
const answers = [];
|
|
298
|
+
for (const [i, entry] of list.entries()) {
|
|
299
|
+
const where = `answer ${i + 1}`;
|
|
300
|
+
if (!isRecord(entry))
|
|
301
|
+
return refuse("answers-unreadable", `${where} is not an object`);
|
|
302
|
+
const id = entry["id"];
|
|
303
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
304
|
+
return refuse("answers-unreadable", `${where} has no claim id`);
|
|
305
|
+
}
|
|
306
|
+
const verdict = entry["verdict"];
|
|
307
|
+
if (typeof verdict !== "string" || !VERDICTS.includes(verdict)) {
|
|
308
|
+
return refuse("answers-unreadable", `${where} ('${id}') has verdict ${JSON.stringify(verdict)} — expected one of ${VERDICTS.join(", ")}`);
|
|
309
|
+
}
|
|
310
|
+
const note = typeof entry["note"] === "string" ? entry["note"].trim() : "";
|
|
311
|
+
answers.push({
|
|
312
|
+
id,
|
|
313
|
+
verdict: verdict,
|
|
314
|
+
evidence: stringList(entry["evidence"]),
|
|
315
|
+
...(note.length > 0 ? { note } : {}),
|
|
316
|
+
answered_by: "agent",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const byId = new Map();
|
|
320
|
+
const twice = [];
|
|
321
|
+
for (const a of answers) {
|
|
322
|
+
if (byId.has(a.id))
|
|
323
|
+
twice.push(a.id);
|
|
324
|
+
else
|
|
325
|
+
byId.set(a.id, a);
|
|
326
|
+
}
|
|
327
|
+
const known = new Set(claims.map((c) => c.id));
|
|
328
|
+
const strayIds = [...new Set(answers.map((a) => a.id).filter((id) => !known.has(id)))];
|
|
329
|
+
const runnerHit = strayIds.filter((id) => runnerOwned?.has(id) === true);
|
|
330
|
+
const unknown = strayIds.filter((id) => runnerOwned?.has(id) !== true);
|
|
331
|
+
const missingClaims = claims.filter((c) => !byId.has(c.id));
|
|
332
|
+
const missing = missingClaims.map((c) => c.id);
|
|
333
|
+
if (runnerHit.length + unknown.length + missing.length + twice.length > 0) {
|
|
334
|
+
const parts = [
|
|
335
|
+
runnerHit.length > 0
|
|
336
|
+
? `${runnerHit.length} answer(s) name scenario claim(s) the test runner owns under --results: ${runnerHit.join(", ")} — the report answers those; take them out of the answers file`
|
|
337
|
+
: "",
|
|
338
|
+
unknown.length > 0 ? `${unknown.length} answer(s) name a claim that is not on the checklist: ${unknown.join(", ")}` : "",
|
|
339
|
+
missing.length > 0 ? `${missing.length} claim(s) have no answer: ${missing.join(", ")}` : "",
|
|
340
|
+
twice.length > 0 ? `${twice.length} claim(s) answered more than once: ${[...new Set(twice)].join(", ")}` : "",
|
|
341
|
+
].filter((s) => s.length > 0);
|
|
342
|
+
return refuse("answers-mismatch", `These answers do not match the checklist — ${parts.join("; ")}. ` +
|
|
343
|
+
missingAdvice(missingClaims, claims.filter((c) => byId.has(c.id)), context));
|
|
344
|
+
}
|
|
345
|
+
const unevidenced = claims
|
|
346
|
+
.map((c) => byId.get(c.id))
|
|
347
|
+
.filter((a) => a.verdict === "confirmed" && a.evidence.length === 0)
|
|
348
|
+
.map((a) => a.id);
|
|
349
|
+
if (unevidenced.length > 0) {
|
|
350
|
+
return refuse("answers-unevidenced", `${unevidenced.length} claim(s) are confirmed with no evidence: ${unevidenced.join(", ")}. A confirmation with nothing behind it is an assertion — give a file:line, or answer 'unconfirmed'.`);
|
|
351
|
+
}
|
|
352
|
+
// Checklist order, not the order they were written in: the record reads in the
|
|
353
|
+
// order the questions were asked, whatever the agent did.
|
|
354
|
+
return { ok: true, answers: claims.map((c) => byId.get(c.id)) };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The last sentence of an `answers-mismatch`. "Re-run and answer the claims it
|
|
358
|
+
* lists" is sound advice only when the caller CAN answer them. In a fleet the
|
|
359
|
+
* checklist is federated — a feature's claims are filed under the service whose
|
|
360
|
+
* code answers them — so the honest diagnosis for a repo that answered
|
|
361
|
+
* everything it owns is "the rest is not yours", together with the form that
|
|
362
|
+
* records the rest. Anything else sends the caller round the same loop.
|
|
363
|
+
*/
|
|
364
|
+
function missingAdvice(missing, answered, context) {
|
|
365
|
+
const generic = "Re-run `loam verify` and answer the claims it lists.";
|
|
366
|
+
if (context === undefined || missing.length === 0 || answered.length === 0)
|
|
367
|
+
return generic;
|
|
368
|
+
// The signature of "I answered mine and the rest is not mine": the services
|
|
369
|
+
// that own the unanswered claims and the services that own the answered ones
|
|
370
|
+
// do not overlap. A single-service checklist with one claim forgotten fails
|
|
371
|
+
// this test and keeps the generic advice, which is the right advice there —
|
|
372
|
+
// that claim really is the caller's to answer.
|
|
373
|
+
const owners = [...new Set(missing.map((c) => c.subject))].sort();
|
|
374
|
+
const covered = new Set(answered.map((c) => c.subject));
|
|
375
|
+
if (owners.some((s) => covered.has(s)))
|
|
376
|
+
return generic;
|
|
377
|
+
const form = `\`loam verify ${context.feature} --record answers.json --service <svc>\``;
|
|
378
|
+
const mine = context.service;
|
|
379
|
+
return mine === undefined
|
|
380
|
+
? `Those claims are owned by ${owners.join(", ")} — each service attests its own from its own repository (${form}); ` +
|
|
381
|
+
"this all-at-once form has to answer the whole checklist in one file."
|
|
382
|
+
: `Every unanswered claim belongs to a different service (${owners.join(", ")}) and this repository can only attest '${mine}' — ` +
|
|
383
|
+
`run ${form} in each of those repositories instead.`;
|
|
384
|
+
}
|
|
385
|
+
function stringList(v) {
|
|
386
|
+
if (typeof v === "string")
|
|
387
|
+
return v.trim().length > 0 ? [v.trim()] : [];
|
|
388
|
+
if (!Array.isArray(v))
|
|
389
|
+
return [];
|
|
390
|
+
return v.map((x) => String(x).trim()).filter((s) => s.length > 0);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The confirmed `scenario.tested` claims that no test run answered.
|
|
394
|
+
*
|
|
395
|
+
* A scenario claim's whole premise is that a run answers it — its digest IS the
|
|
396
|
+
* tag `loam gherkin` stamps, and `--results` matches the two mechanically. An
|
|
397
|
+
* agent may still answer one, because a legacy service has no runnable suite
|
|
398
|
+
* for months and that fleet is who loam is for; but the answer is somebody's
|
|
399
|
+
* word about a test, and it must not read as the run. A record written before
|
|
400
|
+
* `--results` existed carries no `answered_by` at all: unknown counts as agent
|
|
401
|
+
* here, because the alternative is to credit a run nobody can point at.
|
|
402
|
+
*/
|
|
403
|
+
export function attestedClaims(claims) {
|
|
404
|
+
return claims
|
|
405
|
+
.filter((c) => c.kind === "scenario.tested" && c.verdict === "confirmed" && c.answered_by !== "runner")
|
|
406
|
+
.map((c) => c.id);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* `unanswered` is the count the answers themselves cannot show: a federated
|
|
410
|
+
* record holds only what somebody answered, and the claims still owed to other
|
|
411
|
+
* repositories exist on the checklist, not in `claims[]`. Read views that carry
|
|
412
|
+
* an `unanswered` status inside the list pass 0 and are counted in place.
|
|
413
|
+
*/
|
|
414
|
+
export function tallyAnswers(claims, unanswered = 0) {
|
|
415
|
+
const of = (verdict) => claims.filter((c) => c.verdict === verdict).length;
|
|
416
|
+
return {
|
|
417
|
+
claims: claims.length + unanswered,
|
|
418
|
+
confirmed: of("confirmed"),
|
|
419
|
+
unconfirmed: of("unconfirmed"),
|
|
420
|
+
unanswered: of("unanswered") + unanswered,
|
|
421
|
+
attested: attestedClaims(claims).length,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The record's own answers, recounted. Never `v.summary`: the summary is what
|
|
426
|
+
* whoever wrote the file said the answers add up to, and every reader that
|
|
427
|
+
* believed it reported a record full of unconfirmed claims as fully confirmed.
|
|
428
|
+
* `readVerificationState` refuses a record whose summary contradicts this, so a
|
|
429
|
+
* caller holding a Verification may use either — and should use this one.
|
|
430
|
+
*/
|
|
431
|
+
export function tallyRecord(v) {
|
|
432
|
+
return tallyAnswers(v.claims, v.summary.unanswered ?? 0);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Three states, because two were a lie. `verified` is the strong claim — every
|
|
436
|
+
* question answered, every answer confirmed, and every scenario answered by a
|
|
437
|
+
* digest-matched green run. `attested` is the same completeness resting, for at
|
|
438
|
+
* least one scenario, on an agent's word. `unverified` is everything else:
|
|
439
|
+
* nothing recorded, a record gone stale, a claim unanswered or unconfirmed, or
|
|
440
|
+
* a checklist that asks nothing at all.
|
|
441
|
+
*/
|
|
442
|
+
export const VERIFICATION_VERDICTS = ["verified", "attested", "unverified"];
|
|
443
|
+
export function verificationVerdict(t, stale = false) {
|
|
444
|
+
if (stale || t.claims === 0 || t.confirmed !== t.claims)
|
|
445
|
+
return "unverified";
|
|
446
|
+
return t.attested > 0 ? "attested" : "verified";
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The one notice `verify`, `list` and `status` all show: this record's scenario
|
|
450
|
+
* claims were confirmed without a run.
|
|
451
|
+
*
|
|
452
|
+
* It is a warning and it gates nothing — verify has never gated, and a service
|
|
453
|
+
* with no suite yet has to be able to ship. What it must never do is read the
|
|
454
|
+
* same as a green run.
|
|
455
|
+
*/
|
|
456
|
+
export function attestedNotice(claims, feature) {
|
|
457
|
+
const ids = attestedClaims(claims);
|
|
458
|
+
if (ids.length === 0)
|
|
459
|
+
return null;
|
|
460
|
+
return {
|
|
461
|
+
code: "verify.scenario-attested",
|
|
462
|
+
severity: "warn",
|
|
463
|
+
message: `${ids.length} scenario claim(s) are confirmed on an agent's word, not on a test run: ${ids.join(", ")}. ` +
|
|
464
|
+
`Answer them mechanically once the suite runs: \`loam verify ${feature} --results <cucumber.json>\`.`,
|
|
465
|
+
claims: ids,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Where the record lives: inside the feature, so `archive` carries it into
|
|
470
|
+
* `features/archive/` with everything else and a reviewer finds it next to the
|
|
471
|
+
* delta it is about. YAML rather than frontmatter or JSON — it is data, it has
|
|
472
|
+
* to survive without loam, and one claim per block is a diff a person can read.
|
|
473
|
+
*/
|
|
474
|
+
export function verificationPath(featureDir) {
|
|
475
|
+
return join(featureDir, "verification.yaml");
|
|
476
|
+
}
|
|
477
|
+
export function buildVerification(checklist, answers, today, report) {
|
|
478
|
+
const byId = new Map(answers.map((a) => [a.id, a]));
|
|
479
|
+
const claims = checklist.claims.map((c) => {
|
|
480
|
+
// Callers hand in an answer per claim — `runnerAnswers` is total over the
|
|
481
|
+
// checklist, and both other callers map the checklist to build theirs. The
|
|
482
|
+
// branch is here because the alternative to a diagnosis is a `TypeError` on
|
|
483
|
+
// `a.verdict`, which cli.ts reports as `internal` with a stack about a
|
|
484
|
+
// property of `undefined` and no word about which claim went missing.
|
|
485
|
+
const a = byId.get(c.id);
|
|
486
|
+
if (a === undefined)
|
|
487
|
+
throw new Error(`buildVerification: no answer for claim ${c.id}`);
|
|
488
|
+
return {
|
|
489
|
+
id: c.id,
|
|
490
|
+
kind: c.kind,
|
|
491
|
+
claim: c.claim,
|
|
492
|
+
verdict: a.verdict,
|
|
493
|
+
answered_by: a.answered_by,
|
|
494
|
+
evidence: a.evidence,
|
|
495
|
+
...(a.note === undefined ? {} : { note: a.note }),
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
const confirmed = claims.filter((c) => c.verdict === "confirmed").length;
|
|
499
|
+
return {
|
|
500
|
+
feature: checklist.feature,
|
|
501
|
+
recorded: today,
|
|
502
|
+
checklist: checklist.digest,
|
|
503
|
+
summary: { claims: claims.length, confirmed, unconfirmed: claims.length - confirmed },
|
|
504
|
+
claims,
|
|
505
|
+
...(report === undefined ? {} : { report }),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Replace one service's contribution while retaining current contributions
|
|
510
|
+
* from other repositories. Claim ids removed from the live checklist are
|
|
511
|
+
* pruned from both the answers and their attestations, so a changed feature can
|
|
512
|
+
* never keep a green answer to a question it no longer asks.
|
|
513
|
+
*/
|
|
514
|
+
export function buildFederatedVerification(checklist, service, answers, previous, recorded, commit, report) {
|
|
515
|
+
const currentById = new Map(checklist.claims.map((claim) => [claim.id, claim]));
|
|
516
|
+
const localIds = new Set(checklist.claims.filter((claim) => claim.subject === service).map((claim) => claim.id));
|
|
517
|
+
// An old all-at-once record remains readable, but its answers have no commit
|
|
518
|
+
// attestation and therefore cannot silently become somebody else's
|
|
519
|
+
// federated contribution. The first schema-2 write starts a clean federation.
|
|
520
|
+
const previouslyAttested = new Set((previous?.attestations ?? []).flatMap((a) => a.claims));
|
|
521
|
+
const retained = (previous?.schema === 2 ? previous.claims : []).filter((claim) => {
|
|
522
|
+
const current = currentById.get(claim.id);
|
|
523
|
+
return current !== undefined && current.subject !== service && previouslyAttested.has(claim.id);
|
|
524
|
+
});
|
|
525
|
+
const local = answers.map((answer) => {
|
|
526
|
+
// Same contract as `buildVerification`: an answer is built from the live
|
|
527
|
+
// checklist, so it always names a current claim. Off-checklist answers do
|
|
528
|
+
// NOT belong here — `discarded` means "an answer the PREVIOUS record held",
|
|
529
|
+
// and routing an incoming stray into it would change what the printed
|
|
530
|
+
// notice tells an operator. A caller that reaches this gets told which
|
|
531
|
+
// answer it was rather than a `TypeError` on `claim.kind`.
|
|
532
|
+
const claim = currentById.get(answer.id);
|
|
533
|
+
if (claim === undefined) {
|
|
534
|
+
throw new Error(`buildFederatedVerification: answer ${answer.id} names no current claim`);
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
id: claim.id,
|
|
538
|
+
kind: claim.kind,
|
|
539
|
+
subject: claim.subject,
|
|
540
|
+
claim: claim.claim,
|
|
541
|
+
verdict: answer.verdict,
|
|
542
|
+
answered_by: answer.answered_by,
|
|
543
|
+
evidence: answer.evidence,
|
|
544
|
+
...(answer.note === undefined ? {} : { note: answer.note }),
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
const byId = new Map([...retained, ...local].map((claim) => [claim.id, claim]));
|
|
548
|
+
const claims = checklist.claims.flatMap((claim) => {
|
|
549
|
+
const answer = byId.get(claim.id);
|
|
550
|
+
if (answer === undefined)
|
|
551
|
+
return [];
|
|
552
|
+
// Normalize retained legacy entries to the current question text and add
|
|
553
|
+
// the subject schema 2 needs for future independent replacement.
|
|
554
|
+
return [{ ...answer, kind: claim.kind, subject: claim.subject, claim: claim.claim }];
|
|
555
|
+
});
|
|
556
|
+
const retainedAttestations = (previous?.attestations ?? []).flatMap((attestation) => {
|
|
557
|
+
if (attestation.service === service)
|
|
558
|
+
return [];
|
|
559
|
+
const ids = attestation.claims.filter((id) => currentById.get(id)?.subject === attestation.service && byId.has(id));
|
|
560
|
+
return ids.length === 0 ? [] : [{ ...attestation, claims: ids }];
|
|
561
|
+
});
|
|
562
|
+
const attestations = [
|
|
563
|
+
...retainedAttestations,
|
|
564
|
+
{
|
|
565
|
+
service,
|
|
566
|
+
commit,
|
|
567
|
+
recorded,
|
|
568
|
+
claims: checklist.claims.filter((c) => localIds.has(c.id)).map((c) => c.id),
|
|
569
|
+
// Filed with the attestation, not the record: the report is one
|
|
570
|
+
// repository's run, and it is pruned or retained with that repository's
|
|
571
|
+
// answers rather than outliving them.
|
|
572
|
+
...(report === undefined ? {} : { report }),
|
|
573
|
+
},
|
|
574
|
+
].sort((a, b) => a.service.localeCompare(b.service));
|
|
575
|
+
const confirmed = claims.filter((claim) => claim.verdict === "confirmed").length;
|
|
576
|
+
const unconfirmed = claims.length - confirmed;
|
|
577
|
+
const unanswered = checklist.claims.length - claims.length;
|
|
578
|
+
// Everything the old record answered that the new one does not. The caller
|
|
579
|
+
// prints it; nothing here decides it is acceptable.
|
|
580
|
+
const discarded = (previous?.claims ?? [])
|
|
581
|
+
.filter((claim) => !byId.has(claim.id))
|
|
582
|
+
.map((claim) => ({
|
|
583
|
+
id: claim.id,
|
|
584
|
+
claim: claim.claim,
|
|
585
|
+
...(claim.subject === undefined ? {} : { subject: claim.subject }),
|
|
586
|
+
reason: currentById.has(claim.id) ? "unattested" : "off-checklist",
|
|
587
|
+
}));
|
|
588
|
+
return {
|
|
589
|
+
verification: {
|
|
590
|
+
schema: 2,
|
|
591
|
+
feature: checklist.feature,
|
|
592
|
+
recorded,
|
|
593
|
+
checklist: checklist.digest,
|
|
594
|
+
summary: { claims: checklist.claims.length, confirmed, unconfirmed, unanswered },
|
|
595
|
+
claims,
|
|
596
|
+
attestations,
|
|
597
|
+
},
|
|
598
|
+
discarded,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The record as a file. The header explains what the reader is looking at,
|
|
603
|
+
* because the whole point is that this is legible to someone who has never run
|
|
604
|
+
* loam — including the part loam cannot vouch for.
|
|
605
|
+
*/
|
|
606
|
+
export function renderVerification(v) {
|
|
607
|
+
const header = [
|
|
608
|
+
`# Verification record for ${v.feature} — written by \`loam verify ${v.feature}\` (--results / --record).`,
|
|
609
|
+
"#",
|
|
610
|
+
"# Every claim below was derived mechanically from this feature's own artifacts:",
|
|
611
|
+
"# delta.likec4, specs/<svc>/spec.md, specs/<svc>/arch.spec.md and specs/<svc>/",
|
|
612
|
+
"# openapi.yaml. Each verdict names who answered it: `answered_by: runner` means a",
|
|
613
|
+
"# cucumber JSON report's digest-tagged scenarios answered it mechanically;",
|
|
614
|
+
"# `answered_by: agent` means somebody's word about the code, which loam did not",
|
|
615
|
+
"# check. Nothing gates on either.",
|
|
616
|
+
"#",
|
|
617
|
+
"# A `scenario.tested` claim confirmed by an agent is ATTESTED, not run: loam",
|
|
618
|
+
"# reports it as `verify.scenario-attested` and the feature does not count as",
|
|
619
|
+
"# verified until a report answers it. `report:` records the file a --results run",
|
|
620
|
+
"# read — its sha256 and mtime say WHICH file, not that it came from that commit;",
|
|
621
|
+
"# no digest can say that.",
|
|
622
|
+
"#",
|
|
623
|
+
"# `checklist` is a digest of the claim ids. If `loam verify` stops reporting the same",
|
|
624
|
+
"# one, the feature changed after this was recorded and these answers are stale.",
|
|
625
|
+
...(v.schema === 2
|
|
626
|
+
? [
|
|
627
|
+
"#",
|
|
628
|
+
"# Schema 2 is federated: each service entry under `attestations` binds its claim ids",
|
|
629
|
+
"# and file:line evidence to that repository's git commit. Missing claims are honestly",
|
|
630
|
+
"# unanswered; another service run may add them without rewriting existing attestations.",
|
|
631
|
+
]
|
|
632
|
+
: []),
|
|
633
|
+
"",
|
|
634
|
+
].join("\n");
|
|
635
|
+
// lineWidth 0: never fold a claim onto a second line — these are grepped and diffed.
|
|
636
|
+
return header + stringify(v, { lineWidth: 0 });
|
|
637
|
+
}
|
|
638
|
+
export async function readVerificationState(featureDir) {
|
|
639
|
+
const path = verificationPath(featureDir);
|
|
640
|
+
if (!existsSync(path))
|
|
641
|
+
return { state: "absent" };
|
|
642
|
+
let text;
|
|
643
|
+
try {
|
|
644
|
+
text = await readFile(path, "utf8");
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
return { state: "unreadable", reason: err instanceof Error ? err.message : String(err) };
|
|
648
|
+
}
|
|
649
|
+
let doc;
|
|
650
|
+
try {
|
|
651
|
+
doc = parse(text);
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
return { state: "unreadable", reason: yamlReason(err) };
|
|
655
|
+
}
|
|
656
|
+
const verification = asVerification(doc);
|
|
657
|
+
if (verification === null) {
|
|
658
|
+
return {
|
|
659
|
+
state: "unreadable",
|
|
660
|
+
reason: "it parses as YAML but does not have a verification record's shape (feature, recorded, checklist, summary and claims)",
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const miscount = summaryDisagreement(verification);
|
|
664
|
+
if (miscount === null)
|
|
665
|
+
return { state: "ok", verification };
|
|
666
|
+
return {
|
|
667
|
+
state: "unreadable",
|
|
668
|
+
// Spelled twice on purpose: the field is what a caller branches on, the
|
|
669
|
+
// prose is what a terminal prints and what somebody greps for.
|
|
670
|
+
code: "verify.record-miscounted",
|
|
671
|
+
reason: `verify.record-miscounted: ${miscount}`,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Does the record's `summary` add up from its own `claims[]`?
|
|
676
|
+
*
|
|
677
|
+
* The summary is what wrote the file; the claims are what it answered. Nothing
|
|
678
|
+
* ever compared them, so every reader that took the shortcut — verify's frozen
|
|
679
|
+
* verdict for a shipped feature, list's column, status's block — reported a
|
|
680
|
+
* record full of unconfirmed claims as fully confirmed. Believing either side
|
|
681
|
+
* over the other would be a guess about which half was tampered with, so a
|
|
682
|
+
* record that contradicts itself is refused like any other file loam cannot
|
|
683
|
+
* use. The code is in the reason: it travels wherever the reason is printed.
|
|
684
|
+
*/
|
|
685
|
+
function summaryDisagreement(v) {
|
|
686
|
+
const t = tallyAnswers(v.claims);
|
|
687
|
+
const unanswered = v.summary.unanswered ?? 0;
|
|
688
|
+
const says = [];
|
|
689
|
+
if (v.summary.confirmed !== t.confirmed) {
|
|
690
|
+
says.push(`says ${v.summary.confirmed} confirmed where claims[] holds ${t.confirmed}`);
|
|
691
|
+
}
|
|
692
|
+
if (v.summary.unconfirmed !== t.unconfirmed) {
|
|
693
|
+
says.push(`says ${v.summary.unconfirmed} unconfirmed where claims[] holds ${t.unconfirmed}`);
|
|
694
|
+
}
|
|
695
|
+
if (v.summary.claims !== v.claims.length + unanswered) {
|
|
696
|
+
says.push(`says ${v.summary.claims} claim(s)${unanswered === 0 ? "" : ` including ${unanswered} unanswered`} where claims[] holds ${v.claims.length}`);
|
|
697
|
+
}
|
|
698
|
+
return says.length === 0
|
|
699
|
+
? null
|
|
700
|
+
: `its summary ${says.join(", ")} — the record contradicts itself, so neither half can be believed`;
|
|
701
|
+
}
|
|
702
|
+
/** A YAML failure with its line, when the parser located one: that line is the fix. */
|
|
703
|
+
function yamlReason(err) {
|
|
704
|
+
const message = err instanceof Error ? err.message.split("\n")[0].trim() : String(err);
|
|
705
|
+
const pos = err?.linePos;
|
|
706
|
+
const line = Array.isArray(pos) ? pos[0]?.line : undefined;
|
|
707
|
+
return line === undefined ? `YAML error: ${message}` : `YAML error at line ${line}: ${message}`;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The record beside a feature, or null when there is none loam can use. Kept
|
|
711
|
+
* for readers that have nothing to say about the difference (`loam list`'s
|
|
712
|
+
* verification column shows one glyph either way); anything that WRITES must
|
|
713
|
+
* use {@link readVerificationState} instead, or it overwrites what it could not
|
|
714
|
+
* read.
|
|
715
|
+
*
|
|
716
|
+
* A record whose summary contradicts its own claims is one of the nulls now, so
|
|
717
|
+
* no reader can print its counts as fact. Reading it as ABSENT is the safe half
|
|
718
|
+
* of the truth and not the whole of it: a reader that can say why should take
|
|
719
|
+
* {@link readVerificationState} and its `code`.
|
|
720
|
+
*/
|
|
721
|
+
export async function readVerification(featureDir) {
|
|
722
|
+
const read = await readVerificationState(featureDir);
|
|
723
|
+
return read.state === "ok" ? read.verification : null;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* The shape check `readVerification` stands behind: everything its readers
|
|
727
|
+
* dereference (verify's report/frozen views, list's verification column) must
|
|
728
|
+
* be present and typed, or the cast would manufacture a Verification that
|
|
729
|
+
* crashes them. Deliberately no tighter than the readers need: extra keys pass
|
|
730
|
+
* (a human may annotate), `answered_by` stays optional (absent in records that
|
|
731
|
+
* predate `--results`) and is any string, like `kind` — the readers only ever
|
|
732
|
+
* compare them, and a record written by a newer loam must not read as absent.
|
|
733
|
+
*/
|
|
734
|
+
function asVerification(doc) {
|
|
735
|
+
if (!isRecord(doc))
|
|
736
|
+
return null;
|
|
737
|
+
const summary = doc["summary"];
|
|
738
|
+
if (typeof doc["feature"] !== "string" ||
|
|
739
|
+
typeof doc["recorded"] !== "string" ||
|
|
740
|
+
typeof doc["checklist"] !== "string" ||
|
|
741
|
+
!isRecord(summary) ||
|
|
742
|
+
!isCount(summary["claims"]) ||
|
|
743
|
+
!isCount(summary["confirmed"]) ||
|
|
744
|
+
!isCount(summary["unconfirmed"]) ||
|
|
745
|
+
(summary["unanswered"] !== undefined && !isCount(summary["unanswered"])) ||
|
|
746
|
+
!Array.isArray(doc["claims"])) {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
for (const c of doc["claims"]) {
|
|
750
|
+
if (!isRecord(c))
|
|
751
|
+
return null;
|
|
752
|
+
if (typeof c["id"] !== "string" || typeof c["kind"] !== "string" || typeof c["claim"] !== "string")
|
|
753
|
+
return null;
|
|
754
|
+
if (c["subject"] !== undefined && typeof c["subject"] !== "string")
|
|
755
|
+
return null;
|
|
756
|
+
if (typeof c["verdict"] !== "string" || !VERDICTS.includes(c["verdict"]))
|
|
757
|
+
return null;
|
|
758
|
+
// The frozen view iterates evidence per claim — a scalar here would crash it.
|
|
759
|
+
if (!Array.isArray(c["evidence"]) || c["evidence"].some((e) => typeof e !== "string"))
|
|
760
|
+
return null;
|
|
761
|
+
if (c["note"] !== undefined && typeof c["note"] !== "string")
|
|
762
|
+
return null;
|
|
763
|
+
if (c["answered_by"] !== undefined && typeof c["answered_by"] !== "string")
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
if (doc["schema"] !== undefined && doc["schema"] !== 2)
|
|
767
|
+
return null;
|
|
768
|
+
if (!isConsumedReport(doc["report"]))
|
|
769
|
+
return null;
|
|
770
|
+
if (doc["attestations"] !== undefined) {
|
|
771
|
+
if (!Array.isArray(doc["attestations"]))
|
|
772
|
+
return null;
|
|
773
|
+
for (const a of doc["attestations"]) {
|
|
774
|
+
if (!isRecord(a))
|
|
775
|
+
return null;
|
|
776
|
+
if (typeof a["service"] !== "string" || typeof a["commit"] !== "string" || typeof a["recorded"] !== "string")
|
|
777
|
+
return null;
|
|
778
|
+
if (!/^[0-9a-f]{40,64}$/i.test(a["commit"]))
|
|
779
|
+
return null;
|
|
780
|
+
if (!Array.isArray(a["claims"]) || a["claims"].some((id) => typeof id !== "string"))
|
|
781
|
+
return null;
|
|
782
|
+
if (!isConsumedReport(a["report"]))
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return doc;
|
|
787
|
+
}
|
|
788
|
+
/** Absent is fine — most records answer nothing from a report. Present must be whole: it is printed. */
|
|
789
|
+
function isConsumedReport(v) {
|
|
790
|
+
if (v === undefined)
|
|
791
|
+
return true;
|
|
792
|
+
return (isRecord(v) &&
|
|
793
|
+
typeof v["path"] === "string" &&
|
|
794
|
+
typeof v["digest"] === "string" &&
|
|
795
|
+
typeof v["mtime"] === "string" &&
|
|
796
|
+
isCount(v["scenarios"]));
|
|
797
|
+
}
|
|
798
|
+
/** A summary count: a finite number. YAML hands back strings for anything quoted. */
|
|
799
|
+
function isCount(v) {
|
|
800
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
801
|
+
}
|
|
802
|
+
export async function writeVerification(featureDir, v) {
|
|
803
|
+
const path = verificationPath(featureDir);
|
|
804
|
+
await writeFile(path, renderVerification(v), "utf8");
|
|
805
|
+
return path;
|
|
806
|
+
}
|
|
807
|
+
//# sourceMappingURL=verify.js.map
|