@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,759 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { loadConfig } from "../core/config.js";
6
+ import { emitJson, fail, repoPath, reportNoConfig } from "../core/json.js";
7
+ import { resolvePortableFileInside } from "../core/path-safety.js";
8
+ import { featuresDir, resolveFeature } from "../core/repo.js";
9
+ import { contestedDigests, readCucumberReport, runnerAnswers } from "../core/results.js";
10
+ import { attestedNotice, buildVerification, buildFederatedVerification, checkAnswers, featureChecklist, readVerificationState, tallyAnswers, tallyRecord, verificationPath, verificationVerdict, writeVerification, } from "../core/verify.js";
11
+ export function registerVerify(program) {
12
+ program
13
+ .command("verify")
14
+ .argument("<featureId>", "feature id, e.g. FEAT-101")
15
+ .description("Check a shipped feature against its own promises: derive the claims, record the answers")
16
+ .option("--record <file>", "record a JSON answer set against the current checklist")
17
+ .option("--results <file>", "answer the scenario.tested claims mechanically from a cucumber JSON test report")
18
+ .option("--service <id>", "record only this service's claims, bound to the current repository commit")
19
+ .option("--json", "emit the machine contract instead of the human view")
20
+ .action(async (featureId, opts) => {
21
+ const json = opts.json === true;
22
+ const config = await loadConfig();
23
+ if (!config) {
24
+ reportNoConfig(json);
25
+ return;
26
+ }
27
+ const { docsDir } = config;
28
+ const recording = opts.record !== undefined || opts.results !== undefined;
29
+ // WRITING is bound to the repository; READING is not. An attestation
30
+ // pins claims to this repo's git HEAD and to file:line evidence inside
31
+ // it, so `--record --service X` may only run where loam.json says this
32
+ // repo IS X — vouch's and gherkin's refusal, for vouch's reason: from
33
+ // anywhere else, that repository is somebody else's. Reading takes
34
+ // `--service` as a pure lens (which claims are checkout-web's, and what
35
+ // has it said?) and needs no binding at all, because it writes nothing.
36
+ if (recording && opts.service !== undefined) {
37
+ if (config.service === undefined) {
38
+ return fail(json, "repository-unavailable", `Cannot attest for '${opts.service}' from here: this is not a service repo — loam.json declares no \`service\`. ` +
39
+ `A federated attestation binds the answers to this repository's git HEAD and to evidence inside it, so the repository has to say which service it is. ` +
40
+ `Add "service": "${opts.service}" to loam.json, or record it from that service's own repo.`);
41
+ }
42
+ if (config.service !== opts.service) {
43
+ return fail(json, "service-mismatch", `This repository is configured as service '${config.service}', so it cannot attest claims for '${opts.service}'.`);
44
+ }
45
+ }
46
+ // Archived features resolve too: a shipped feature's verification is worth
47
+ // reading back, and it travelled into the archive with everything else.
48
+ const feature = await resolveFeature(docsDir, featureId, "include");
49
+ if (!feature) {
50
+ return fail(json, "unknown-target", `No feature '${featureId}' under ${featuresDir(docsDir)}.`);
51
+ }
52
+ // Before anything else: a verification.yaml that exists but will not read
53
+ // is a refusal, not an absence. It holds somebody's answers and — in a
54
+ // fleet — other repositories' attestations; treating it as "not verified"
55
+ // let the next --record silently overwrite all of it, and made the read
56
+ // view say "no record" about a file sitting right there.
57
+ const existing = await readVerificationState(feature.dir);
58
+ if (existing.state === "unreadable") {
59
+ return fail(json, "record-unreadable", `${repoPath(docsDir, verificationPath(feature.dir))} exists but cannot be read as a verification record: ${existing.reason}. ` +
60
+ (existing.code === "verify.record-miscounted"
61
+ ? "Record the answers again rather than editing the counts: a summary made to agree by hand still says nothing about what was checked."
62
+ : "It is plain YAML — repair it by hand, or delete it and record again. loam will not overwrite a record it could not read."));
63
+ }
64
+ const recorded = existing.state === "ok" ? existing.verification : null;
65
+ // But an archived feature never gets a re-derived checklist — see the
66
+ // header. Its record is frozen history, and --record / --results refuse
67
+ // alike: the code is `invalid-option` rather than `answers-mismatch`
68
+ // because the answers were never compared to anything — there is no
69
+ // current checklist to answer, so the wrong thing here is the option,
70
+ // not the answer set.
71
+ if (feature.archived) {
72
+ if (recording) {
73
+ return fail(json, "invalid-option", `${feature.id} is archived — its verification is history now. \`loam unarchive ${feature.id}\` first if the answers really need to change.`);
74
+ }
75
+ reportFrozen(docsDir, feature.dir, feature.id, recorded, json);
76
+ return;
77
+ }
78
+ const checklist = await featureChecklist(docsDir, feature.dir, feature.id);
79
+ if (recording) {
80
+ // Standing in a service repo, `--service` is what the repo already
81
+ // says: omitting it must not fall back to the legacy all-at-once form,
82
+ // which claims the whole fleet's checklist on this one repo's word.
83
+ //
84
+ // The repo root, not the cwd — `vouch`'s and `gherkin`'s reason. An
85
+ // attestation names a commit and rests on evidence resolved inside
86
+ // "this repository", and `config.root` is the directory loam.json was
87
+ // actually found in. Off `process.cwd()`, running from a subdirectory
88
+ // silently changed all three: evidence resolved against the
89
+ // subdirectory, `--results` had to live in it, and `git rev-parse` ran
90
+ // there — a submodule or nested checkout under it would have named
91
+ // somebody else's commit on this repository's attestation.
92
+ await record(docsDir, feature.dir, checklist, opts.service ?? config.service, config.root ?? process.cwd(), recorded, opts, json);
93
+ return;
94
+ }
95
+ report(docsDir, feature.dir, checklist, recorded, json, {
96
+ lens: opts.service,
97
+ bound: config.service,
98
+ });
99
+ });
100
+ }
101
+ /* ------------------------------------------------------------------ */
102
+ /* Reading */
103
+ /* ------------------------------------------------------------------ */
104
+ function statuses(checklist, claims, recorded) {
105
+ const subjects = new Map(checklist.claims.map((claim) => [claim.id, claim.subject]));
106
+ const attested = recorded?.schema !== 2
107
+ ? null
108
+ : new Set((recorded.attestations ?? []).flatMap((attestation) => attestation.claims.filter((id) => subjects.get(id) === attestation.service)));
109
+ const byId = new Map((recorded?.claims ?? [])
110
+ .filter((claim) => attested === null || attested.has(claim.id))
111
+ .map((claim) => [claim.id, claim]));
112
+ return claims.map((c) => {
113
+ const answer = byId.get(c.id);
114
+ return {
115
+ id: c.id,
116
+ kind: c.kind,
117
+ subject: c.subject,
118
+ claim: c.claim,
119
+ verdict: answer?.verdict ?? "unanswered",
120
+ ...(answer?.answered_by === undefined ? {} : { answered_by: answer.answered_by }),
121
+ evidence: answer?.evidence ?? [],
122
+ ...(answer?.note === undefined ? {} : { note: answer.note }),
123
+ };
124
+ });
125
+ }
126
+ /**
127
+ * The read view.
128
+ *
129
+ * `lens` is `--service`: the same checklist, narrowed to the claims that
130
+ * service's code answers. It exists because in a ten-repo fleet the whole
131
+ * checklist is never one repository's business, and an agent that cannot ask
132
+ * "what do I still owe on FEAT-1?" answers the question by guessing. Narrowing
133
+ * narrows the verdict too — `verified` then means "every claim owned by this
134
+ * service is confirmed", which is why the payload names the service it applied.
135
+ *
136
+ * `bound` is loam.json's `service` — what this repository says it is. It only
137
+ * shapes the footer, and it is the whole of how federation is discoverable:
138
+ * the recording form printed here is the one that will actually work from here.
139
+ */
140
+ function report(docsDir, featureDir, checklist, recorded, json, scope = {}) {
141
+ const lens = scope.lens;
142
+ const owners = [...new Set(checklist.claims.map((c) => c.subject))].sort();
143
+ const scoped = lens === undefined ? checklist.claims : checklist.claims.filter((c) => c.subject === lens);
144
+ const claims = statuses(checklist, scoped, recorded);
145
+ const count = (v) => claims.filter((c) => c.verdict === v).length;
146
+ const summary = {
147
+ claims: claims.length,
148
+ confirmed: count("confirmed"),
149
+ unconfirmed: count("unconfirmed"),
150
+ unanswered: count("unanswered"),
151
+ };
152
+ // A record that answers a different question set is not an answer to this one,
153
+ // however complete it looks — so staleness disqualifies it as a whole.
154
+ const stale = recorded !== null && recorded.checklist !== checklist.digest;
155
+ const tally = tallyAnswers(claims);
156
+ const verdict = verificationVerdict(tally, stale);
157
+ // `verified` keeps its name and its type, and loses its old meaning: a
158
+ // scenario claim on an agent's word is answered, not run, so it no longer
159
+ // reads the same as a green suite. `verdict` carries the distinction.
160
+ const verified = verdict === "verified";
161
+ // Contest is derivable from the checklist alone, so the read view says it
162
+ // too: an agent looking at what it owes should learn that a second service
163
+ // answers the same claim BEFORE it runs a suite whose report cannot be
164
+ // attributed. Always from the whole checklist, never the lens — a shared
165
+ // digest is a fact about the feature, and a narrowed view is the one place it
166
+ // would otherwise be invisible.
167
+ const notices = [...noticesFor(claims, checklist.feature), ...contestedNotices(checklist.claims)];
168
+ if (json) {
169
+ emitJson({
170
+ feature: checklist.feature,
171
+ path: repoPath(docsDir, featureDir),
172
+ digest: checklist.digest,
173
+ // Named only when narrowed, so a consumer can never mistake a service's
174
+ // verdict for the feature's.
175
+ ...(lens === undefined ? {} : { service: lens, checklistClaims: checklist.claims.length }),
176
+ verified,
177
+ verdict,
178
+ attested: tally.attested,
179
+ summary,
180
+ services: owners,
181
+ recorded: recorded === null
182
+ ? null
183
+ : {
184
+ path: repoPath(docsDir, verificationPath(featureDir)),
185
+ recorded: recorded.recorded,
186
+ checklist: recorded.checklist,
187
+ stale,
188
+ ...(recorded.attestations === undefined ? {} : { attestations: recorded.attestations }),
189
+ ...(recorded.report === undefined ? {} : { report: recorded.report }),
190
+ },
191
+ claims,
192
+ ...(notices.length === 0 ? {} : { notices }),
193
+ });
194
+ return;
195
+ }
196
+ const scopeNote = lens === undefined ? "" : ` owned by ${lens} (of ${plural(checklist.claims.length, "claim")} on the checklist)`;
197
+ console.log(`${checklist.feature} — ${plural(claims.length, "claim")}${scopeNote} derived from ${repoPath(docsDir, featureDir)}\n`);
198
+ if (claims.length === 0) {
199
+ console.log(lens === undefined
200
+ ? " Nothing to check: this feature's delta, specs and openapi promise nothing yet."
201
+ : ` No claim on this checklist is owned by '${lens}'.${owners.length > 0 ? ` The services that own claims here are ${owners.join(", ")}.` : ""}`);
202
+ return;
203
+ }
204
+ for (const c of claims) {
205
+ console.log(` ${MARK[c.verdict]} ${c.id} [${c.subject}] ${c.claim}${answeredMark(c)}`);
206
+ for (const e of c.evidence)
207
+ console.log(` ${e}`);
208
+ if (c.note !== undefined)
209
+ console.log(` note: ${c.note}`);
210
+ }
211
+ console.log("");
212
+ if (recorded === null) {
213
+ console.log(` Not verified — no ${repoPath(docsDir, verificationPath(featureDir))}.`);
214
+ }
215
+ else {
216
+ console.log(` Recorded ${recorded.recorded} — ${summary.confirmed} confirmed, ${summary.unconfirmed} unconfirmed, ${summary.unanswered} unanswered.`);
217
+ if (stale) {
218
+ console.log(" STALE: the feature changed after this was recorded. Answer the claims above again.");
219
+ }
220
+ for (const attestation of recorded.attestations ?? []) {
221
+ console.log(` Attested by ${attestation.service} at ${attestation.commit.slice(0, 12)} (${attestation.recorded}, ${plural(attestation.claims.length, "claim")}).`);
222
+ if (attestation.report !== undefined)
223
+ console.log(` from ${reportLine(attestation.report)}`);
224
+ }
225
+ if (recorded.report !== undefined)
226
+ console.log(` Answered from ${reportLine(recorded.report)}.`);
227
+ }
228
+ for (const notice of notices)
229
+ console.log(` ⚠ ${notice.code}: ${notice.message}`);
230
+ // An attested record has answered every question — telling it to "answer each
231
+ // claim" would send an agent round a loop it has already run. The notice
232
+ // above names the one thing left, which is a test run.
233
+ if (verdict === "unverified") {
234
+ console.log("\n Answer each claim, then record the answers:\n");
235
+ console.log(' [{ "id": "<claim id>", "verdict": "confirmed", "evidence": ["src/x.ts:42"] }]');
236
+ console.log(`\n loam verify ${checklist.feature} --record answers.json${recordSuffix(scope, owners)}`);
237
+ for (const line of federationNote(scope, owners))
238
+ console.log(line);
239
+ console.log("\n A claim you cannot show evidence for is `unconfirmed` — say why in `note`.");
240
+ }
241
+ }
242
+ /**
243
+ * The `--service` the printed form should carry. It comes from loam.json, never
244
+ * from the checklist: the only attestation this repository can make is its own,
245
+ * and a hint naming a service that lives somewhere else is a hint that fails.
246
+ */
247
+ function recordSuffix(scope, owners) {
248
+ if (scope.bound !== undefined)
249
+ return ` --service ${scope.bound}`;
250
+ return owners.length > 1 || scope.lens !== undefined ? " --service <svc>" : "";
251
+ }
252
+ /** Why the form above has a `--service` on it — the one place federation is taught. */
253
+ function federationNote(scope, owners) {
254
+ if (scope.bound !== undefined) {
255
+ const others = owners.filter((s) => s !== scope.bound);
256
+ return [
257
+ "",
258
+ ` This repository is ${scope.bound}, so it attests only ${scope.bound}'s claims, bound to its git HEAD.`,
259
+ ...(others.length === 0
260
+ ? []
261
+ : [` The claims owned by ${others.join(", ")} are recorded the same way from those repositories.`]),
262
+ ];
263
+ }
264
+ if (owners.length > 1 || scope.lens !== undefined) {
265
+ return [
266
+ "",
267
+ ` This checklist spans ${plural(owners.length, "service")} (${owners.join(", ")}) — each attests its own claims`,
268
+ " from its own repository, where loam.json names that service.",
269
+ ];
270
+ }
271
+ return [];
272
+ }
273
+ /**
274
+ * The frozen view: an archived feature's record, verbatim. No checklist is
275
+ * derived and no staleness is judged — there is nothing current to judge
276
+ * against, and pretending otherwise is how a true record reads as a lie.
277
+ * `frozen` is the marker a consumer branches on.
278
+ *
279
+ * The verdict is recounted from `claims[]`, never taken from `summary`: this is
280
+ * the post-ship verdict on a feature nobody can re-derive a checklist for, so a
281
+ * record whose counts had drifted from its own answers reported a shipped
282
+ * feature as fully confirmed. (A record that contradicts itself never reaches
283
+ * here — `readVerificationState` refuses it — so the recount and the summary
284
+ * agree; the recount is what says WHY.) No record at all is reported as exactly
285
+ * that, with `summary: null`, not zero claims, which would falsely say the
286
+ * feature promised nothing.
287
+ */
288
+ function reportFrozen(docsDir, featureDir, featureId, v, json) {
289
+ const tally = v === null ? null : tallyRecord(v);
290
+ const verdict = tally === null ? "unverified" : verificationVerdict(tally);
291
+ const notices = v === null ? [] : noticesFor(v.claims, featureId);
292
+ if (json) {
293
+ emitJson({
294
+ feature: featureId,
295
+ path: repoPath(docsDir, featureDir),
296
+ frozen: true,
297
+ verified: verdict === "verified",
298
+ verdict,
299
+ attested: tally?.attested ?? 0,
300
+ summary: v === null ? null : v.summary,
301
+ recorded: v === null
302
+ ? null
303
+ : {
304
+ path: repoPath(docsDir, verificationPath(featureDir)),
305
+ recorded: v.recorded,
306
+ checklist: v.checklist,
307
+ ...(v.attestations === undefined ? {} : { attestations: v.attestations }),
308
+ ...(v.report === undefined ? {} : { report: v.report }),
309
+ },
310
+ claims: v === null ? [] : v.claims,
311
+ ...(notices.length === 0 ? {} : { notices }),
312
+ });
313
+ return;
314
+ }
315
+ if (v === null) {
316
+ console.log(`${featureId} is archived and has no verification record — nothing was recorded before it shipped.`);
317
+ return;
318
+ }
319
+ console.log(`${featureId} — verification recorded ${v.recorded}, frozen at archive (${repoPath(docsDir, featureDir)})\n`);
320
+ for (const c of v.claims) {
321
+ // `subject` is absent in records written before schema 2 — an old record
322
+ // must read as itself, not gain a service it never named.
323
+ const subject = c.subject === undefined ? "" : ` [${c.subject}]`;
324
+ console.log(` ${MARK[c.verdict]} ${c.id}${subject} ${c.claim}${answeredMark(c)}`);
325
+ for (const e of c.evidence)
326
+ console.log(` ${e}`);
327
+ if (c.note !== undefined)
328
+ console.log(` note: ${c.note}`);
329
+ }
330
+ const counts = tallyRecord(v);
331
+ console.log(`\n Recorded ${v.recorded} — ${counts.confirmed} confirmed, ${counts.unconfirmed} unconfirmed.`);
332
+ console.log(" This checklist is frozen at record time: the feature is archived and its claims are not re-derived.");
333
+ for (const attestation of v.attestations ?? []) {
334
+ console.log(` Attested by ${attestation.service} at ${attestation.commit.slice(0, 12)} (${attestation.recorded}).`);
335
+ if (attestation.report !== undefined)
336
+ console.log(` from ${reportLine(attestation.report)}`);
337
+ }
338
+ if (v.report !== undefined)
339
+ console.log(` Answered from ${reportLine(v.report)}.`);
340
+ for (const notice of notices)
341
+ console.log(` ⚠ ${notice.code}: ${notice.message}`);
342
+ }
343
+ const MARK = { confirmed: "✓", unconfirmed: "✗", unanswered: "?" };
344
+ /**
345
+ * Who answered, where that changes what the verdict means. `[runner]` is a
346
+ * digest-matched green run; `[attested]` is a scenario claim confirmed on
347
+ * somebody's word — the same ✓, a weaker fact, and it must be visible on the
348
+ * line itself and not only in the summary. Everything else is unmarked: an
349
+ * agent's answer about a service existing or an operation being exposed is
350
+ * exactly what the checklist asks for.
351
+ */
352
+ function answeredMark(c) {
353
+ if (c.answered_by === "runner")
354
+ return " [runner]";
355
+ return c.kind === "scenario.tested" && c.verdict === "confirmed" ? " [attested]" : "";
356
+ }
357
+ /** Everything a verify surface has to say about a set of answers beyond the counts. */
358
+ function noticesFor(claims, feature) {
359
+ const attested = attestedNotice(claims, feature);
360
+ return attested === null ? [] : [attested];
361
+ }
362
+ /**
363
+ * The claims one report could not attribute: a scenario digest — and therefore
364
+ * an `@loam-digest-…` tag — that more than one service claims.
365
+ *
366
+ * This is now a GUARD rather than a diagnosis of ordinary authoring. Until the
367
+ * digest was salted by service, two services wording a scenario identically
368
+ * really did share one tag, and this is what stopped one repository's green run
369
+ * from confirming the other's claim. `scenarioBodyHash` folds the owning
370
+ * service into the hash, so a claim's digest is computed from the same service
371
+ * the claim is filed under and two subjects cannot share one short of a
372
+ * truncated-sha256 collision. It stays because the invariant is worth asserting
373
+ * where it is relied on: if this ever fires, a digest is answering for two
374
+ * services and no report can say which, so nothing here may confirm.
375
+ *
376
+ * Derived from claims rather than from a report, so the read view raises it too
377
+ * — the collision exists the moment the checklist does, and learning about it
378
+ * from a `--results` run that already happened is learning too late.
379
+ */
380
+ function contestedNotices(claims) {
381
+ const contested = contestedDigests(claims);
382
+ if (contested.size === 0)
383
+ return [];
384
+ const shared = [...contested].map(([digest, services]) => `${digest} (${services.join(", ")})`);
385
+ return [
386
+ {
387
+ code: "verify.digest-contested",
388
+ severity: "warn",
389
+ message: `${plural(contested.size, "scenario digest")} claimed by more than one service: ${shared.join("; ")}. ` +
390
+ "The digest is salted by service, so this is a hash collision, not shared wording — but one report still " +
391
+ "cannot say whose suite ran them, so a shared --results run leaves them unconfirmed. " +
392
+ "Record each service's claims from its own repository with --service.",
393
+ claims: claims.filter((c) => c.digest !== undefined && contested.has(c.digest)).map((c) => c.id),
394
+ },
395
+ ];
396
+ }
397
+ /** The report a `--results` run consumed, as one line a reviewer can check by hand. */
398
+ function reportLine(r) {
399
+ return `${r.path} (sha256 ${r.digest.slice(0, 12)}…, ${plural(r.scenarios, "tagged scenario")}, written ${r.mtime})`;
400
+ }
401
+ /* ------------------------------------------------------------------ */
402
+ /* Recording */
403
+ /* ------------------------------------------------------------------ */
404
+ /**
405
+ * `repoDir` is the repository an attestation is ABOUT: the commit it names, the
406
+ * tree its evidence must resolve inside, and where a `--results` report has to
407
+ * live. It is threaded rather than read here because it comes from
408
+ * `config.root`, and a `process.cwd()` in this function was the whole bug —
409
+ * from a subdirectory, "this repo" quietly became that subdirectory.
410
+ */
411
+ async function record(docsDir, featureDir, checklist, service, repoDir, previous, opts, json) {
412
+ // The legacy all-at-once form answers the WHOLE checklist on one repository's
413
+ // word and writes a schema-1 record — no attestations, no commits. Run over a
414
+ // federated record it does not merge and it does not migrate: it erases every
415
+ // other service's commit-bound attestation, and the erasure is invisible
416
+ // afterwards because what replaces it is a well-formed, plausible record. So
417
+ // it refuses, and names the repositories whose word is on the file.
418
+ const attested = previous?.schema === 2 ? (previous.attestations ?? []) : [];
419
+ if (service === undefined && attested.length > 0) {
420
+ const services = [...new Set(attested.map((a) => a.service))].sort();
421
+ return fail(json, "record-federated", `${repoPath(docsDir, verificationPath(featureDir))} is a federated record: ${plural(services.length, "service")} (${services.join(", ")}) have attested claims against their own git commits. ` +
422
+ "Recording without --service would replace all of it with one unattested answer set. " +
423
+ `Record this service's claims from its own repo instead: \`loam verify ${checklist.feature} --record answers.json --service <svc>\`.`);
424
+ }
425
+ const scopedClaims = service === undefined ? checklist.claims : checklist.claims.filter((claim) => claim.subject === service);
426
+ if (service !== undefined && scopedClaims.length === 0) {
427
+ return fail(json, "unknown-service", `The current ${checklist.feature} checklist has no claims owned by service '${service}'.`);
428
+ }
429
+ // The runner's half: with --results, every scenario.tested claim is the
430
+ // report's to answer — matched by digest, confirmed only by a green run.
431
+ const runnerClaims = opts.results === undefined ? [] : scopedClaims.filter((c) => c.kind === "scenario.tested");
432
+ const agentClaims = opts.results === undefined ? scopedClaims : scopedClaims.filter((c) => c.kind !== "scenario.tested");
433
+ let fromRunner = [];
434
+ let consumed;
435
+ if (opts.results !== undefined) {
436
+ // A federated record attests for THIS repository, so its report has to be a
437
+ // file inside it — see readResults.
438
+ const read = await readResults(opts.results, service === undefined ? undefined : repoDir);
439
+ if (!read.ok)
440
+ return fail(json, read.code, read.message);
441
+ consumed = read.report;
442
+ fromRunner = runnerAnswers(runnerClaims, read.scenarios, consumed.path);
443
+ }
444
+ // The agent's half — exactly what the runner does not own. --results alone
445
+ // is legal only when the runner owns the whole checklist: anything left over
446
+ // refuses with the ids, the same discipline as a claim with no answer.
447
+ let raw = [];
448
+ if (opts.record !== undefined) {
449
+ try {
450
+ // The one path that stays cwd-relative, and deliberately: the answer set
451
+ // is a file the caller just wrote and hands to the command, not part of
452
+ // the repository being attested. Nothing on the record points at it.
453
+ raw = JSON.parse(await readFile(resolve(process.cwd(), opts.record), "utf8"));
454
+ }
455
+ catch (err) {
456
+ return fail(json, "answers-unreadable", `Cannot read ${opts.record}: ${err instanceof Error ? err.message : String(err)}`);
457
+ }
458
+ }
459
+ else if (agentClaims.length > 0 && opts.results !== undefined) {
460
+ return fail(json, "answers-mismatch", `--results answers only the scenario.tested claims; ${agentClaims.length} claim(s) have no answer: ` +
461
+ `${agentClaims.map((c) => c.id).join(", ")}. Record them with --record <answers.json> alongside --results.`);
462
+ }
463
+ const checked = checkAnswers(agentClaims, raw, opts.results === undefined ? undefined : new Set(runnerClaims.map((c) => c.id)), { feature: checklist.feature, ...(service === undefined ? {} : { service }) });
464
+ if (!checked.ok)
465
+ return fail(json, checked.code, checked.message);
466
+ let serviceCommit;
467
+ if (service !== undefined) {
468
+ const commit = await repositoryCommit(repoDir);
469
+ if (!commit.ok)
470
+ return fail(json, "repository-unavailable", commit.message);
471
+ serviceCommit = commit.commit;
472
+ // Both halves, one validator. The runner's evidence used to skip it
473
+ // entirely: `--results` minted confirmations whose evidence strings nothing
474
+ // ever checked, in the one mode that promises every answer is bound to this
475
+ // repository at this commit.
476
+ const evidenceFailure = await validateServiceEvidence([...fromRunner, ...checked.answers], repoDir, serviceCommit, consumed);
477
+ if (evidenceFailure !== null)
478
+ return fail(json, "answers-unevidenced", evidenceFailure);
479
+ }
480
+ const recorded = today(new Date());
481
+ let verification;
482
+ let discarded = [];
483
+ if (service === undefined) {
484
+ verification = buildVerification(checklist, [...fromRunner, ...checked.answers], recorded, consumed);
485
+ }
486
+ else {
487
+ const built = buildFederatedVerification(checklist, service, [...fromRunner, ...checked.answers], previous, recorded, serviceCommit, consumed);
488
+ verification = built.verification;
489
+ discarded = built.discarded;
490
+ }
491
+ const path = await writeVerification(featureDir, verification);
492
+ const unconfirmed = verification.claims.filter((c) => c.verdict === "unconfirmed");
493
+ // The same judgment read mode makes (see report): the record just written IS
494
+ // the current checklist's answer, so `stale` is false by construction and the
495
+ // remaining question is whether every claim was confirmed — and, now, by
496
+ // whom. Counted from the claims, not from the summary this same call wrote.
497
+ // Without these fields an agent that recorded all-confirmed answers would
498
+ // have to re-run verify just to learn the state it created.
499
+ const tally = tallyRecord(verification);
500
+ const verdict = verificationVerdict(tally);
501
+ const verified = verdict === "verified";
502
+ const notices = [...noticesFor(verification.claims, verification.feature), ...contestedNotices(runnerClaims)];
503
+ if (json) {
504
+ emitJson({
505
+ feature: verification.feature,
506
+ path: repoPath(docsDir, path),
507
+ digest: verification.checklist,
508
+ verified,
509
+ verdict,
510
+ attested: tally.attested,
511
+ recorded: verification.recorded,
512
+ summary: verification.summary,
513
+ ...(verification.attestations === undefined ? {} : { attestations: verification.attestations }),
514
+ ...(consumed === undefined ? {} : { report: consumed }),
515
+ unconfirmed: unconfirmed.map((c) => ({ id: c.id, claim: c.claim, ...(c.note === undefined ? {} : { note: c.note }) })),
516
+ ...(discarded.length === 0 ? {} : { discarded }),
517
+ ...(notices.length === 0 ? {} : { notices }),
518
+ });
519
+ return;
520
+ }
521
+ console.log(`${verification.feature} verification recorded — ${repoPath(docsDir, path)}\n`);
522
+ console.log(` ${tally.confirmed} of ${plural(tally.claims, "claim")} confirmed with evidence.`);
523
+ if (consumed !== undefined) {
524
+ console.log(` ${plural(fromRunner.length, "scenario claim")} answered by the test runner (${opts.results})` +
525
+ `${opts.record === undefined ? "" : `, ${checked.answers.length} by ${opts.record}`}.`);
526
+ console.log(` Report read: ${reportLine(consumed)}.`);
527
+ }
528
+ if (service !== undefined) {
529
+ const attestation = verification.attestations?.find((item) => item.service === service);
530
+ if (attestation !== undefined) {
531
+ console.log(` ${service} attested at git commit ${attestation.commit}.`);
532
+ }
533
+ }
534
+ for (const c of unconfirmed) {
535
+ console.log(` ✗ ${c.claim}${c.note === undefined ? "" : ` — ${c.note}`}`);
536
+ }
537
+ // What the previous record answered and this one does not. A federated write
538
+ // is a partial write, so the first one over an all-at-once record drops every
539
+ // answer it cannot attribute to a commit — correct, but silence here reads as
540
+ // loam having lost the answers, and nobody goes looking for what to re-record.
541
+ if (discarded.length > 0) {
542
+ const off = discarded.filter((d) => d.reason === "off-checklist").length;
543
+ console.log(`\n ${plural(discarded.length, "earlier answer")} from ${repoPath(docsDir, verificationPath(featureDir))} ${discarded.length === 1 ? "is" : "are"} not carried into this record:`);
544
+ for (const d of discarded) {
545
+ console.log(` - ${d.id}${d.subject === undefined ? "" : ` [${d.subject}]`} ${d.claim}` +
546
+ (d.reason === "off-checklist"
547
+ ? " (the feature changed; nothing asks this any more)"
548
+ : " (no commit attestation binds it — its service must record it again)"));
549
+ }
550
+ if (discarded.length > off) {
551
+ console.log(` Each owning service records its own with \`loam verify ${verification.feature} --record answers.json --service <svc>\` in its repo.`);
552
+ }
553
+ }
554
+ console.log("");
555
+ for (const notice of notices)
556
+ console.log(` ⚠ ${notice.code}: ${notice.message}`);
557
+ console.log(tally.unanswered > 0
558
+ ? ` Partial federation — ${plural(tally.unanswered, "claim")} remain unanswered for their owning service repositories.`
559
+ : verdict === "attested"
560
+ ? " Recorded as attested, not verified: the record travels into features/archive/ saying so."
561
+ : unconfirmed.length === 0
562
+ ? " The record travels with the feature into features/archive/."
563
+ : " Recorded as it stands. Nothing gates on this — it is what a reviewer reads later, so leave it true.");
564
+ }
565
+ /**
566
+ * The report as an artifact, not merely as a parse.
567
+ *
568
+ * `repoDir` is set in federated mode, and there the report must be a file
569
+ * INSIDE the repository being attested, resolved by the same rules as evidence
570
+ * (`resolvePortableFileInside`): an attestation says "at this commit, in this
571
+ * repository", and a report living somewhere else answers for a run nobody
572
+ * standing here can find. The legacy all-at-once form binds to no repository at
573
+ * all, so it takes the path as spelled — its looser contract, unchanged.
574
+ *
575
+ * Either way the bytes are digested and the file's mtime read, because loam
576
+ * cannot prove a JSON file came from executing this commit and should stop
577
+ * implying otherwise. It can say precisely which file it consumed, and that
578
+ * goes on the record.
579
+ */
580
+ async function readResults(spelled, repoDir) {
581
+ let path;
582
+ if (repoDir === undefined) {
583
+ path = resolve(process.cwd(), spelled);
584
+ }
585
+ else {
586
+ try {
587
+ path = resolvePortableFileInside(repoDir, spelled, "test report");
588
+ }
589
+ catch (err) {
590
+ return {
591
+ ok: false,
592
+ code: "answers-unreadable",
593
+ message: `Cannot answer from ${spelled}: ${err instanceof Error ? err.message : String(err)}. ` +
594
+ "A federated attestation rests on a report inside the repository it attests — give the path relative to the repo root.",
595
+ };
596
+ }
597
+ }
598
+ let bytes;
599
+ let mtime;
600
+ try {
601
+ bytes = await readFile(path);
602
+ mtime = (await stat(path)).mtime;
603
+ }
604
+ catch (err) {
605
+ return {
606
+ ok: false,
607
+ code: "answers-unreadable",
608
+ message: `Cannot read ${spelled} as a cucumber JSON report: ${err instanceof Error ? err.message : String(err)}`,
609
+ };
610
+ }
611
+ let doc;
612
+ try {
613
+ doc = JSON.parse(bytes.toString("utf8"));
614
+ }
615
+ catch (err) {
616
+ return {
617
+ ok: false,
618
+ code: "answers-unreadable",
619
+ message: `Cannot read ${spelled} as a cucumber JSON report: ${err instanceof Error ? err.message : String(err)}`,
620
+ };
621
+ }
622
+ const parsed = readCucumberReport(doc, spelled);
623
+ if (!parsed.ok)
624
+ return { ok: false, code: "answers-unreadable", message: parsed.message };
625
+ return {
626
+ ok: true,
627
+ scenarios: parsed.scenarios,
628
+ report: {
629
+ path: spelled,
630
+ digest: createHash("sha256").update(bytes).digest("hex"),
631
+ mtime: mtime.toISOString(),
632
+ scenarios: parsed.scenarios.length,
633
+ },
634
+ };
635
+ }
636
+ /**
637
+ * In federated mode, a confirmation is accepted only when its evidence holds up
638
+ * in this repository: an agent's `file:line` resolves to a real line that is
639
+ * unchanged at the attested commit, and the runner's evidence names the report
640
+ * loam just read. Legacy global mode deliberately keeps its original, looser
641
+ * evidence contract for backward compatibility.
642
+ */
643
+ async function validateServiceEvidence(answers, repoDir, commit, report) {
644
+ if (report !== undefined) {
645
+ // Most reports are build output and untracked, and `git diff` is quiet on
646
+ // those — nothing here invents a rule for them. One the repository DOES
647
+ // carry has to match the commit being attested, like any other file the
648
+ // evidence rests on.
649
+ const clean = await git(repoDir, ["diff", "--quiet", commit, "--", report.path]);
650
+ if (clean.code === 1) {
651
+ return `The test report '${report.path}' is committed to this repository and differs from ${commit.slice(0, 12)} — commit it or attest the commit it belongs to.`;
652
+ }
653
+ }
654
+ for (const answer of answers) {
655
+ if (answer.verdict !== "confirmed")
656
+ continue;
657
+ if (answer.answered_by === "runner") {
658
+ // The runner's evidence is a scenario inside the report, not a file:line
659
+ // in the source — there is nothing to resolve. What must hold is that it
660
+ // names THAT report: an answer pointing anywhere else is not this run's.
661
+ const stray = answer.evidence.filter((e) => report === undefined || !e.startsWith(`${report.path}: `));
662
+ if (stray.length > 0) {
663
+ return `Claim ${answer.id} has runner evidence ${stray.map((e) => `'${e}'`).join(", ")} that does not name the report loam read.`;
664
+ }
665
+ continue;
666
+ }
667
+ for (const evidence of answer.evidence) {
668
+ const match = /^(.+):([1-9]\d*)$/.exec(evidence);
669
+ if (match === null) {
670
+ return `Claim ${answer.id} has evidence '${evidence}' — service evidence must be a canonical relative file:line.`;
671
+ }
672
+ const relativePath = match[1];
673
+ const line = Number(match[2]);
674
+ let absolutePath;
675
+ try {
676
+ absolutePath = resolvePortableFileInside(repoDir, relativePath, `evidence for ${answer.id}`);
677
+ }
678
+ catch (err) {
679
+ return `Claim ${answer.id} has unsafe evidence '${evidence}': ${err instanceof Error ? err.message : String(err)}`;
680
+ }
681
+ try {
682
+ const info = await stat(absolutePath);
683
+ if (!info.isFile()) {
684
+ return `Claim ${answer.id} has evidence '${evidence}', but '${relativePath}' is not a regular file.`;
685
+ }
686
+ const source = await readFile(absolutePath, "utf8");
687
+ const lines = source.split(/\r\n|\n|\r/).length;
688
+ if (line > lines) {
689
+ return `Claim ${answer.id} has evidence '${evidence}', but '${relativePath}' has only ${lines} line(s).`;
690
+ }
691
+ const committed = await committedFile(repoDir, commit, relativePath);
692
+ if (!committed.ok) {
693
+ return `Claim ${answer.id} has evidence '${evidence}' that is not bound to ${commit.slice(0, 12)}: ${committed.message}`;
694
+ }
695
+ const committedLines = committed.source.split(/\r\n|\n|\r/).length;
696
+ if (line > committedLines) {
697
+ return `Claim ${answer.id} has evidence '${evidence}', but '${relativePath}' has only ${committedLines} line(s) at ${commit.slice(0, 12)}.`;
698
+ }
699
+ }
700
+ catch (err) {
701
+ return `Claim ${answer.id} has unreadable evidence '${evidence}': ${err instanceof Error ? err.message : String(err)}`;
702
+ }
703
+ }
704
+ }
705
+ return null;
706
+ }
707
+ /** Require the evidence blob to exist at HEAD and have no uncommitted edits. */
708
+ async function committedFile(repoDir, commit, path) {
709
+ const clean = await git(repoDir, ["diff", "--quiet", commit, "--", path]);
710
+ if (clean.code !== 0) {
711
+ return {
712
+ ok: false,
713
+ message: clean.code === 1 ? `'${path}' has uncommitted changes` : clean.stderr || "git diff failed",
714
+ };
715
+ }
716
+ const blob = await git(repoDir, ["show", `${commit}:${path}`]);
717
+ if (blob.code !== 0) {
718
+ return { ok: false, message: blob.stderr || `'${path}' is not tracked by that commit` };
719
+ }
720
+ return { ok: true, source: blob.stdout };
721
+ }
722
+ function git(repoDir, args) {
723
+ return new Promise((done) => {
724
+ execFile("git", ["-C", repoDir, ...args], { encoding: "utf8" }, (error, stdout, stderr) => {
725
+ done({
726
+ code: error === null ? 0 : (error.code ?? 1),
727
+ stdout,
728
+ stderr: stderr.trim(),
729
+ });
730
+ });
731
+ });
732
+ }
733
+ /** Resolve HEAD without a shell, so repository paths remain data, never code. */
734
+ async function repositoryCommit(repoDir) {
735
+ const result = await git(repoDir, ["rev-parse", "--verify", "HEAD^{commit}"]);
736
+ if (result.code !== 0) {
737
+ return {
738
+ ok: false,
739
+ message: `Federated verification requires a git repository with a committed HEAD: ${result.stderr || "git rev-parse failed"}`,
740
+ };
741
+ }
742
+ const commit = result.stdout.trim();
743
+ if (!/^[0-9a-f]{40,64}$/i.test(commit)) {
744
+ return { ok: false, message: `Git returned an invalid HEAD commit '${commit}'.` };
745
+ }
746
+ return { ok: true, commit };
747
+ }
748
+ /**
749
+ * The local calendar day, like `vouch`: a record is somebody saying "today I
750
+ * looked", and `toISOString` files an evening in the Americas under tomorrow.
751
+ */
752
+ function today(now) {
753
+ const pad = (n) => String(n).padStart(2, "0");
754
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
755
+ }
756
+ function plural(n, noun) {
757
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
758
+ }
759
+ //# sourceMappingURL=verify.js.map