@ybotok/loam 0.0.0 → 0.1.0-beta.1

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