@ybotok/loam 0.0.0 → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/COMPARISON.md +63 -0
- package/LICENSE +21 -0
- package/MIGRATING-from-OpenSpec.md +202 -0
- package/README.md +246 -9
- package/SCHEMA.md +425 -0
- package/SECURITY.md +9 -0
- package/dist/cli.js +142 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.js +240 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/archive.js +1313 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/delta.js +388 -0
- package/dist/commands/delta.js.map +1 -0
- package/dist/commands/dependencies.js +83 -0
- package/dist/commands/dependencies.js.map +1 -0
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +309 -0
- package/dist/commands/gherkin.js.map +1 -0
- package/dist/commands/init.js +250 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +291 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/migrate-openspec.js +950 -0
- package/dist/commands/migrate-openspec.js.map +1 -0
- package/dist/commands/new.js +413 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/rebase.js +356 -0
- package/dist/commands/rebase.js.map +1 -0
- package/dist/commands/show.js +320 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/status.js +254 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/unarchive.js +317 -0
- package/dist/commands/unarchive.js.map +1 -0
- package/dist/commands/validate.js +1591 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/verify.js +775 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/vouch.js +343 -0
- package/dist/commands/vouch.js.map +1 -0
- package/dist/core/agent.js +2065 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents-stamp.js +68 -0
- package/dist/core/agents-stamp.js.map +1 -0
- package/dist/core/arch.js +130 -0
- package/dist/core/arch.js.map +1 -0
- package/dist/core/brief.js +733 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/coherence.js +577 -0
- package/dist/core/coherence.js.map +1 -0
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +180 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delta.js +476 -0
- package/dist/core/delta.js.map +1 -0
- package/dist/core/dependencies.js +356 -0
- package/dist/core/dependencies.js.map +1 -0
- package/dist/core/docs.js +144 -0
- package/dist/core/docs.js.map +1 -0
- package/dist/core/doctor.js +559 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +225 -0
- package/dist/core/fleet-context.js.map +1 -0
- package/dist/core/frontmatter.js +199 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/gherkin.js +463 -0
- package/dist/core/gherkin.js.map +1 -0
- package/dist/core/health.js +67 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/ids.js +122 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/issue.js +9 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/likec4.js +479 -0
- package/dist/core/likec4.js.map +1 -0
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +552 -0
- package/dist/core/openapi-merge.js.map +1 -0
- package/dist/core/openapi.js +255 -0
- package/dist/core/openapi.js.map +1 -0
- package/dist/core/openspec-inventory.js +1440 -0
- package/dist/core/openspec-inventory.js.map +1 -0
- package/dist/core/path-safety.js +168 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/provenance.js +720 -0
- package/dist/core/provenance.js.map +1 -0
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +436 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/report.js +47 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/results.js +199 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/spec.js +481 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/staging.js +890 -0
- package/dist/core/staging.js.map +1 -0
- package/dist/core/status.js +1104 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +807 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/core/version.js +24 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +63 -4
|
@@ -0,0 +1,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
|