@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.
- package/gate-attestation.mjs +23 -0
- package/package.json +3 -1
- package/src/analysis/analyze.mjs +6 -0
- package/src/analysis/csharp.mjs +18 -0
- package/src/analysis/go.mjs +18 -0
- package/src/analysis/java.mjs +15 -0
- package/src/analysis/kotlin.mjs +15 -0
- package/src/analysis/python.mjs +25 -3
- package/src/analysis/rust.mjs +18 -0
- package/src/analysis/source-util.mjs +11 -5
- package/src/canonical.mjs +43 -25
- package/src/commands/README.md +63 -12
- package/src/commands/change-intent.mjs +25 -1
- package/src/commands/change.mjs +31 -12
- package/src/commands/coverage-verdict.mjs +11 -4
- package/src/commands/delta-snapshot.mjs +13 -5
- package/src/commands/discover.mjs +59 -38
- package/src/commands/graph.mjs +29 -20
- package/src/commands/history.mjs +12 -11
- package/src/governance/evolution-event.mjs +36 -2
- package/src/intent/intent-manifest.json +5 -5
- package/src/lsp/diagnose.mjs +12 -3
- package/src/report/discover-text.mjs +31 -9
- package/src/report/graph-text.mjs +25 -5
- package/src/verify-gate-attestation.mjs +323 -0
|
@@ -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
|
+
}
|