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