@ecoma-io/archkeep 0.22.0 → 0.22.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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * The external blocking-gate attestation — the machine-readable form of
3
+ * `../../../docs/doctrine/roadmap.md`'s second 1.0 condition: a workspace OUTSIDE
4
+ * this repository running `archkeep check` as a blocking gate.
5
+ *
6
+ * An attestation is a small JSON file an external consumer publishes. It
7
+ * carries no verdict authority and proves nothing by existing: it is a claim,
8
+ * structured tightly enough that a reviewer can check every named fact, and
9
+ * this module is what checks the structure so the reviewer never has to.
10
+ * `scripts/check-readiness.mjs` ingests validated attestations through
11
+ * `--attestations`; `scripts/verify-gate-attestation.mjs` is the CLI face of
12
+ * this module; the `./gate-attestation` package subpath is the installed face
13
+ * a consumer reaches from its own tree, so an attestation can be validated
14
+ * against the version that consumer actually installed rather than a clone of
15
+ * this repository.
16
+ *
17
+ * ## What the validator decides, and what it refuses to decide
18
+ *
19
+ * It validates shape and internal consistency only. Three of those decisions
20
+ * carry the whole design, each argued at its field below:
21
+ *
22
+ * - **A green-only run proves nothing** — the `proof` block demands both
23
+ * directions, because "we run Archkeep" without a demonstrated failure and
24
+ * recovery is indistinguishable from a command that exits 0 on everything.
25
+ * - **The red direction must be exit 1** — archkeep's own documented contract
26
+ * (`docs/reference/exit-codes.md`): findings are 1, a run that could not
27
+ * complete is 3, and a gate failing on 3 is a broken install, not a boundary
28
+ * verdict. Demanding exactly 1 keeps "it crashed" from counting as "it
29
+ * blocked".
30
+ * - **Unknown fields refuse** — an attestation proves exactly the fields
31
+ * below; a schema that silently accepts extra keys grows spellings nobody
32
+ * validates.
33
+ *
34
+ * What NO validator here can decide: whether the named commit exists, whether
35
+ * CI actually ran, whether the run URLs are real. Those facts live outside any
36
+ * file's reach, which is why readiness stays a report and the acceptance is a
37
+ * human's — the module's job ends at making the claim precise enough to check.
38
+ *
39
+ * Security posture: this module executes nothing from the attestation, spawns
40
+ * no child process, and treats every byte as untrusted data (`SECURITY.md`).
41
+ * A malformed file is a refusal naming every problem found, never a partial
42
+ * pass — an attestation with one bad field and nine good ones proves nothing,
43
+ * so reporting the ten good ones beside it would be the silent direction.
44
+ */
45
+ import { readFileSync } from "node:fs";
46
+
47
+ /** Bumped when a field's meaning changes, never reused. */
48
+ export const GATE_ATTESTATION_SCHEMA_VERSION = 1;
49
+
50
+ /** The only package whose adoption this condition speaks about. */
51
+ export const ATTESTED_PACKAGE = "@ecoma-io/archkeep";
52
+
53
+ /**
54
+ * One external consumer's blocking-gate claim.
55
+ *
56
+ * @typedef {object} GateAttestation
57
+ * @property {1} schemaVersion This format's version; a different number is a
58
+ * different format and is refused rather than guessed at.
59
+ * @property {string} repository The consumer, `owner/name`.
60
+ * @property {string} commit The full 40-hex SHA of the consumer commit the
61
+ * gate ran at — the binding that makes the evidence stale-detectable.
62
+ * @property {{name: string, version: string}} tool Exactly
63
+ * `@ecoma-io/archkeep`, and the semver the gate ran.
64
+ * @property {{command: string, blocking: true}} gate The command their CI
65
+ * runs, and the claim that it blocks — `false` here is a report, not a gate.
66
+ * @property {{violationExitCode: 1, recoveryExitCode: 0}} proof Both
67
+ * directions demonstrated: a controlled violation failed the build with the
68
+ * findings exit code, and removing it restored green.
69
+ */
70
+
71
+ /**
72
+ * Whether the attested `gate.command` names an invocation that verifiably
73
+ * reaches the boundary verdict through one of this package's documented entry
74
+ * points.
75
+ *
76
+ * Two spellings count, and nothing else:
77
+ *
78
+ * 1. **The command names the check subcommand.** `/\bcheck\b/` matches
79
+ * `archkeep check`, `npx archkeep check`, `pnpm archkeep check`, and any
80
+ * npm-script alias whose own name carries `check` as a word
81
+ * (`archkeep:check`, `check-boundaries`) — every one of these is the check
82
+ * invocation on its face.
83
+ * 2. **The command is a package-manager script alias whose name is this
84
+ * tool's own.** A consumer whose CI step is `pnpm arch` (an npm script
85
+ * defined as `archkeep check`) writes exactly what its CI runs. The alias
86
+ * name must be the whole word `arch` or begin `archkeep` — the spellings
87
+ * this package's own name produces — and must arrive through the
88
+ * package-manager script form (`pnpm`, `npm run`, `yarn`). `npm test`,
89
+ * `pnpm build` and `pnpm archive` are refused: a rule that accepted every
90
+ * script name would be a gate that verifies nothing.
91
+ *
92
+ * What no string test can decide: whether the alias actually resolves to
93
+ * `archkeep check`. The attestation's `proof` block — exit 1 on a controlled
94
+ * violation, exit 0 after recovery — is what demonstrates the gate works; the
95
+ * command test exists to keep the field from naming something that could not
96
+ * be that gate at all.
97
+ *
98
+ * @param {string} command The attested `gate.command`.
99
+ * @returns {boolean}
100
+ */
101
+ export function reachesCheckVerdict(command) {
102
+ return (
103
+ /\bcheck\b/u.test(command) ||
104
+ /^(?:pnpm\s|npm\s+run\s|yarn\s+)(?:arch\b|archkeep)/u.test(command)
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Validates one parsed attestation, returning the normalized record readiness
110
+ * ingests. Throws naming EVERY problem, because an attestation is accepted
111
+ * whole or not at all.
112
+ *
113
+ * @param {unknown} record The parsed JSON document.
114
+ * @returns {{repository: string, commit: string, version: string, command: string}}
115
+ * @throws {Error} On the first round-trip where anything is wrong — with
116
+ * every wrong thing named.
117
+ */
118
+ export function validateGateAttestation(record) {
119
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
120
+ throw new Error(
121
+ "archkeep: a gate attestation must be a single JSON object — got " +
122
+ `${record === null ? "null" : Array.isArray(record) ? "an array" : typeof record}`,
123
+ );
124
+ }
125
+
126
+ /** @type {string[]} */
127
+ const problems = [];
128
+ const r = /** @type {Record<string, unknown>} */ (record);
129
+
130
+ if (r.schemaVersion !== GATE_ATTESTATION_SCHEMA_VERSION) {
131
+ problems.push(
132
+ `"schemaVersion" must be ${GATE_ATTESTATION_SCHEMA_VERSION}; got ` +
133
+ `${JSON.stringify(r.schemaVersion) ?? "undefined"} — a different number is a ` +
134
+ `different format, and reading one as the other would invent meanings`,
135
+ );
136
+ }
137
+
138
+ if (
139
+ typeof r.repository !== "string" ||
140
+ !/^[A-Za-z0-9][A-Za-z0-9.-]*\/[A-Za-z0-9._-]+$/u.test(r.repository)
141
+ ) {
142
+ problems.push(
143
+ `"repository" must be 'owner/name'; got ${JSON.stringify(r.repository) ?? "undefined"} — ` +
144
+ `the condition is about a workspace OUTSIDE this repository, and a name this ` +
145
+ `shape cannot hold cannot name one`,
146
+ );
147
+ }
148
+
149
+ if (typeof r.commit !== "string" || !/^[0-9a-f]{40}$/u.test(r.commit)) {
150
+ problems.push(
151
+ `"commit" must be the full 40-hex SHA the gate ran at; got ` +
152
+ `${JSON.stringify(r.commit) ?? "undefined"} — a short or symbolic ref could name ` +
153
+ `a different commit tomorrow, which is how stale evidence goes unnoticed`,
154
+ );
155
+ }
156
+
157
+ const tool = r.tool;
158
+ if (tool === null || typeof tool !== "object" || Array.isArray(tool)) {
159
+ problems.push(`"tool" must be an object; got ${tool === null ? "null" : typeof tool}`);
160
+ } else {
161
+ const t = /** @type {Record<string, unknown>} */ (tool);
162
+ if (t.name !== ATTESTED_PACKAGE) {
163
+ problems.push(
164
+ `"tool.name" must be '${ATTESTED_PACKAGE}'; got ${JSON.stringify(t.name) ?? "undefined"} — ` +
165
+ `this condition speaks about this package and no other`,
166
+ );
167
+ }
168
+ if (typeof t.version !== "string" || !/^\d+\.\d+\.\d+$/u.test(t.version)) {
169
+ problems.push(
170
+ `"tool.version" must be a bare semver (major.minor.patch); got ` +
171
+ `${JSON.stringify(t.version) ?? "undefined"} — ranges and tags would make the ` +
172
+ `claim unverifiable against a registry document`,
173
+ );
174
+ }
175
+ }
176
+
177
+ const gate = r.gate;
178
+ if (gate === null || typeof gate !== "object" || Array.isArray(gate)) {
179
+ problems.push(`"gate" must be an object; got ${gate === null ? "null" : typeof gate}`);
180
+ } else {
181
+ const g = /** @type {Record<string, unknown>} */ (gate);
182
+ if (g.blocking !== true) {
183
+ problems.push(
184
+ `"gate.blocking" must be true; got ${JSON.stringify(g.blocking) ?? "undefined"} — ` +
185
+ `a non-blocking run is a report about architecture, not a gate a build answers to`,
186
+ );
187
+ }
188
+ if (
189
+ typeof g.command !== "string" ||
190
+ !reachesCheckVerdict(g.command) ||
191
+ g.command.trim() === ""
192
+ ) {
193
+ problems.push(
194
+ `"gate.command" must name the check invocation their CI runs; got ` +
195
+ `${JSON.stringify(g.command) ?? "undefined"} — the command must reach the boundary ` +
196
+ `verdict: either it names the check subcommand, or it is a package-manager script ` +
197
+ `alias whose name is this tool's own (pnpm arch, npm run arch, pnpm archkeep…)`,
198
+ );
199
+ }
200
+ }
201
+
202
+ const proof = r.proof;
203
+ if (proof === null || typeof proof !== "object" || Array.isArray(proof)) {
204
+ problems.push(`"proof" must be an object; got ${proof === null ? "null" : typeof proof}`);
205
+ } else {
206
+ const p = /** @type {Record<string, unknown>} */ (proof);
207
+ if (p.violationExitCode !== 1) {
208
+ problems.push(
209
+ `"proof.violationExitCode" must be 1 — archkeep's documented findings exit; got ` +
210
+ `${JSON.stringify(p.violationExitCode) ?? "undefined"}. 0 would prove the gate ` +
211
+ `never blocks, and 3 proves it could not look, not that it judged`,
212
+ );
213
+ }
214
+ if (p.recoveryExitCode !== 0) {
215
+ problems.push(
216
+ `"proof.recoveryExitCode" must be 0 — removing the violation restored green; got ` +
217
+ `${JSON.stringify(p.recoveryExitCode) ?? "undefined"}. Without the recovery half, ` +
218
+ `a permanently red pipeline would satisfy this condition too`,
219
+ );
220
+ }
221
+ }
222
+
223
+ const known = new Set(["schemaVersion", "repository", "commit", "tool", "gate", "proof"]);
224
+ const unknownKeys = Object.keys(r).filter((key) => !known.has(key));
225
+ if (unknownKeys.length > 0) {
226
+ problems.push(
227
+ `unknown field(s) ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} — an ` +
228
+ `attestation proves exactly the fields this format defines; extra keys would be ` +
229
+ `claims nobody validates`,
230
+ );
231
+ }
232
+
233
+ if (problems.length > 0) {
234
+ throw new Error(`archkeep: the gate attestation is not valid:\n - ${problems.join("\n - ")}`);
235
+ }
236
+
237
+ const t = /** @type {{name: string, version: string}} */ (r.tool);
238
+ const g = /** @type {{command: string}} */ (r.gate);
239
+ return {
240
+ repository: /** @type {string} */ (r.repository),
241
+ commit: /** @type {string} */ (r.commit),
242
+ version: t.version,
243
+ command: g.command,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Reduces validated attestations to what `scripts/check-readiness.mjs`'s
249
+ * `evaluate` reads as `externalAdopters`. When a registry document was
250
+ * supplied, the attested version must be one it published — an attestation
251
+ * about a version nobody can install describes a gate that cannot exist yet.
252
+ *
253
+ * The same adopter arriving twice (a recovery chain's red and green halves,
254
+ * or a re-run over the same file) collapses to one entry: readiness prints
255
+ * one row per repository, not one per attestation file.
256
+ *
257
+ * @param {{repository: string, commit: string, version: string, command: string}[]} attestations
258
+ * Already-validated records, in file order.
259
+ * @param {string[] | null} publishedVersions From `versionsFromRegistry`, or
260
+ * `null` when no registry document was supplied.
261
+ * @returns {string[]} Distinct `owner/name@version` entries, first-seen order,
262
+ * ready for `adoptionRow`.
263
+ * @throws {Error} When a registry document was supplied and an attested
264
+ * version is absent from it.
265
+ */
266
+ export function verifiedAdopters(attestations, publishedVersions) {
267
+ if (publishedVersions !== null) {
268
+ for (const attestation of attestations) {
269
+ if (!publishedVersions.includes(attestation.version)) {
270
+ throw new Error(
271
+ `archkeep: ${attestation.repository}'s attestation names ${ATTESTED_PACKAGE} ` +
272
+ `${attestation.version}, which the supplied registry document has never ` +
273
+ `published — a gate built from a version nobody can install proves nothing ` +
274
+ `about the package consumers get`,
275
+ );
276
+ }
277
+ }
278
+ }
279
+ /** @type {string[]} */
280
+ const entries = [];
281
+ const seen = new Set();
282
+ for (const { repository, version } of attestations) {
283
+ const entry = `${repository}@${version}`;
284
+ if (!seen.has(entry)) {
285
+ seen.add(entry);
286
+ entries.push(entry);
287
+ }
288
+ }
289
+ return entries;
290
+ }
291
+
292
+ /**
293
+ * Reads and validates one attestation file, naming the path in every error.
294
+ *
295
+ * @param {string} path
296
+ * @returns {{repository: string, commit: string, version: string, command: string}}
297
+ * @throws {Error}
298
+ */
299
+ export function readGateAttestation(path) {
300
+ let text;
301
+ try {
302
+ text = readFileSync(path, "utf8");
303
+ } catch (cause) {
304
+ throw new Error(
305
+ `archkeep: cannot read the gate attestation at '${path}': ${cause?.message ?? cause}`,
306
+ { cause },
307
+ );
308
+ }
309
+ let parsed;
310
+ try {
311
+ parsed = JSON.parse(text);
312
+ } catch (cause) {
313
+ throw new Error(
314
+ `archkeep: the gate attestation at '${path}' is not valid JSON: ${cause?.message ?? cause}`,
315
+ { cause },
316
+ );
317
+ }
318
+ try {
319
+ return validateGateAttestation(parsed);
320
+ } catch (cause) {
321
+ throw new Error(`archkeep: ${path}\n${cause?.message ?? cause}`, { cause });
322
+ }
323
+ }