breakage 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{breakage-DIY49fzP.js → breakage-DJiIiLzL.js} +632 -9
- package/dist/breakage-DJiIiLzL.js.map +1 -0
- package/dist/breakage.d.ts +198 -86
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +2 -2
- package/dist/breakage.x.js +142 -3
- package/dist/breakage.x.js.map +1 -1
- package/npm.md +144 -12
- package/package.json +1 -1
- package/src/breakage.ts +17 -0
- package/src/breakage.x.ts +26 -0
- package/src/config.ts +3 -0
- package/src/npm/check.ts +19 -0
- package/src/npm/cli.ts +4 -0
- package/src/npm/matrix-cli.ts +170 -0
- package/src/npm/matrix-model.ts +395 -0
- package/src/npm/matrix.ts +657 -0
- package/dist/breakage-DIY49fzP.js.map +0 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// cspell:words premajor preminor prepatch
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { type } from "arktype"
|
|
4
|
+
import type { NpmPeerEvidence, NpmReport } from "./types.ts"
|
|
5
|
+
|
|
6
|
+
export type NpmArchiveRequest = {
|
|
7
|
+
packageName: string
|
|
8
|
+
directory: string
|
|
9
|
+
artifact: string
|
|
10
|
+
}
|
|
11
|
+
export type NpmPrepareContext = {
|
|
12
|
+
rootDirectory: string
|
|
13
|
+
archives: NpmArchiveRequest[]
|
|
14
|
+
}
|
|
15
|
+
export type NpmMatrixLabelContext = {
|
|
16
|
+
packageName: string
|
|
17
|
+
representative: string
|
|
18
|
+
description?: string
|
|
19
|
+
peers: Record<string, NpmPeerEvidence>
|
|
20
|
+
}
|
|
21
|
+
export const npmMatrixConfigSchema = type({
|
|
22
|
+
"+": `reject`,
|
|
23
|
+
configs: `string | string[]`,
|
|
24
|
+
prepare: type(`string`).or(
|
|
25
|
+
type(`Function`).as<(context: NpmPrepareContext) => void | Promise<void>>(),
|
|
26
|
+
),
|
|
27
|
+
"label?": type(`Function`).as<(context: NpmMatrixLabelContext) => string>(),
|
|
28
|
+
})
|
|
29
|
+
export type NpmMatrixConfig = typeof npmMatrixConfigSchema.infer
|
|
30
|
+
|
|
31
|
+
const transition = type({
|
|
32
|
+
previousVersion: `string`,
|
|
33
|
+
version: `string`,
|
|
34
|
+
delta: `'major' | 'premajor' | 'minor' | 'preminor' | 'patch' | 'prepatch' | 'prerelease' | 'release' | 'none'`,
|
|
35
|
+
})
|
|
36
|
+
const identity = type({
|
|
37
|
+
version: `string`,
|
|
38
|
+
source: `'installed' | 'candidate'`,
|
|
39
|
+
})
|
|
40
|
+
const reportSchema = type({
|
|
41
|
+
package: { name: `string`, version: `string` },
|
|
42
|
+
mode: `'check' | 'test' | 'coverage-only'`,
|
|
43
|
+
exitCode: `0 | 1 | 2`,
|
|
44
|
+
errors: `string[]`,
|
|
45
|
+
coverage: type({
|
|
46
|
+
peer: `string`,
|
|
47
|
+
era: `string`,
|
|
48
|
+
representatives: `string[]`,
|
|
49
|
+
}).array(),
|
|
50
|
+
support: {
|
|
51
|
+
status: `'unchanged' | 'breaking-release' | 'end-of-support' | 'first-release' | 'not-checked' | 'setup-error'`,
|
|
52
|
+
changes: type({
|
|
53
|
+
peer: `string`,
|
|
54
|
+
reason: `'narrowed-range' | 'required-peer'`,
|
|
55
|
+
previous: `string | null`,
|
|
56
|
+
current: `string | null`,
|
|
57
|
+
}).array(),
|
|
58
|
+
"previousVersion?": `string`,
|
|
59
|
+
"release?": transition.and({ breaking: `boolean` }),
|
|
60
|
+
"error?": `string`,
|
|
61
|
+
},
|
|
62
|
+
representatives: type({
|
|
63
|
+
path: `string`,
|
|
64
|
+
status: `'passed' | 'audited' | 'skipped' | 'compatibility-failure' | 'setup-error'`,
|
|
65
|
+
peers: {
|
|
66
|
+
"[string]": identity.and({ path: `string`, "archiveVersion?": `string` }),
|
|
67
|
+
},
|
|
68
|
+
suites: type({
|
|
69
|
+
peer: `string`,
|
|
70
|
+
status: `'passed' | 'compatibility-failure' | 'setup-error' | 'not-run'`,
|
|
71
|
+
"output?": `string`,
|
|
72
|
+
}).array(),
|
|
73
|
+
warnings: `string[]`,
|
|
74
|
+
"error?": `string`,
|
|
75
|
+
}).array(),
|
|
76
|
+
}).as<NpmReport>()
|
|
77
|
+
|
|
78
|
+
export const npmPlanSchema = type({
|
|
79
|
+
"+": `reject`,
|
|
80
|
+
formatVersion: `1`,
|
|
81
|
+
id: `string`,
|
|
82
|
+
runId: `string`,
|
|
83
|
+
commit: `string`,
|
|
84
|
+
configs: `string[]`,
|
|
85
|
+
packages: type({
|
|
86
|
+
config: `string`,
|
|
87
|
+
directory: `string`,
|
|
88
|
+
name: `string`,
|
|
89
|
+
configDigest: `string`,
|
|
90
|
+
inputDigest: `string`,
|
|
91
|
+
audit: reportSchema,
|
|
92
|
+
releases: type({
|
|
93
|
+
context: {
|
|
94
|
+
packageName: `string`,
|
|
95
|
+
packageDirectory: `string`,
|
|
96
|
+
currentVersion: `string`,
|
|
97
|
+
"baseline?": {
|
|
98
|
+
version: `string`,
|
|
99
|
+
"ref?": `string`,
|
|
100
|
+
"commit?": `string`,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
"transition?": transition,
|
|
104
|
+
}).array(),
|
|
105
|
+
}).array(),
|
|
106
|
+
environments: type({
|
|
107
|
+
id: `string`,
|
|
108
|
+
label: `string`,
|
|
109
|
+
packageName: `string`,
|
|
110
|
+
representative: `string`,
|
|
111
|
+
suites: `string[]`,
|
|
112
|
+
peers: { "[string]": identity },
|
|
113
|
+
}).array(),
|
|
114
|
+
archives: type({ path: `string`, file: `string`, sha256: `string` }).array(),
|
|
115
|
+
})
|
|
116
|
+
export type NpmPlan = typeof npmPlanSchema.infer
|
|
117
|
+
export const npmReceiptSchema = type({
|
|
118
|
+
"+": `reject`,
|
|
119
|
+
formatVersion: `1`,
|
|
120
|
+
planId: `string`,
|
|
121
|
+
id: `string`,
|
|
122
|
+
exitCode: `0 | 1 | 2`,
|
|
123
|
+
"report?": reportSchema,
|
|
124
|
+
"error?": `string`,
|
|
125
|
+
})
|
|
126
|
+
export type NpmReceipt = typeof npmReceiptSchema.infer
|
|
127
|
+
export type NpmVerification = {
|
|
128
|
+
exitCode: 0 | 1 | 2
|
|
129
|
+
errors: string[]
|
|
130
|
+
environments: number
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Stable object ordering; arrays retain their semantic order. */
|
|
134
|
+
export function canonical(value: unknown): string {
|
|
135
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(`,`)}]`
|
|
136
|
+
if (value && typeof value === `object`)
|
|
137
|
+
return `{${Object.entries(value)
|
|
138
|
+
.filter(([, v]) => v !== undefined)
|
|
139
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
140
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
|
|
141
|
+
.join(`,`)}}`
|
|
142
|
+
return JSON.stringify(value) ?? `undefined`
|
|
143
|
+
}
|
|
144
|
+
export function digest(value: string | Uint8Array): string {
|
|
145
|
+
return createHash(`sha256`).update(value).digest(`hex`)
|
|
146
|
+
}
|
|
147
|
+
export function planDigest(plan: Omit<NpmPlan, `id`>): string {
|
|
148
|
+
return digest(canonical(plan))
|
|
149
|
+
}
|
|
150
|
+
export function peerIdentities(
|
|
151
|
+
peers: Record<string, NpmPeerEvidence>,
|
|
152
|
+
): Record<string, { version: string; source: `installed` | `candidate` }> {
|
|
153
|
+
return Object.fromEntries(
|
|
154
|
+
Object.entries(peers).map(([name, { version, source }]) => [
|
|
155
|
+
name,
|
|
156
|
+
{ version, source },
|
|
157
|
+
]),
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
export function requireMatrix(
|
|
161
|
+
condition: unknown,
|
|
162
|
+
message: string,
|
|
163
|
+
): asserts condition {
|
|
164
|
+
if (!condition) throw new Error(message)
|
|
165
|
+
}
|
|
166
|
+
function sameNames(
|
|
167
|
+
actual: string[],
|
|
168
|
+
expected: string[],
|
|
169
|
+
message: string,
|
|
170
|
+
): void {
|
|
171
|
+
const counts = (names: string[]): Map<string, number> => {
|
|
172
|
+
const result = new Map<string, number>()
|
|
173
|
+
for (const name of names) result.set(name, (result.get(name) ?? 0) + 1)
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
const actualCounts = counts(actual)
|
|
177
|
+
const expectedCounts = counts(expected)
|
|
178
|
+
const missing = [...expectedCounts]
|
|
179
|
+
.filter(([name, count]) => (actualCounts.get(name) ?? 0) < count)
|
|
180
|
+
.map(([name]) => name)
|
|
181
|
+
const extra = [...actualCounts]
|
|
182
|
+
.filter(([name, count]) => count > (expectedCounts.get(name) ?? 0))
|
|
183
|
+
.map(([name]) => name)
|
|
184
|
+
requireMatrix(
|
|
185
|
+
!missing.length && !extra.length,
|
|
186
|
+
`${message}${missing.length ? ` Missing: ${missing.join(`, `)}.` : ``}${extra.length ? ` Unexpected or duplicated: ${extra.join(`, `)}.` : ``}`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Validate the audit as well as the plan envelope; empty or partial plans never pass. */
|
|
191
|
+
export function validateNpmPlan(value: unknown): NpmPlan {
|
|
192
|
+
const plan = npmPlanSchema.assert(value)
|
|
193
|
+
const { id, ...payload } = plan
|
|
194
|
+
requireMatrix(
|
|
195
|
+
id === planDigest(payload),
|
|
196
|
+
`Plan digest does not match its contents.`,
|
|
197
|
+
)
|
|
198
|
+
requireMatrix(
|
|
199
|
+
plan.runId.length > 0 && /^[a-f0-9]{40,64}$/.test(plan.commit),
|
|
200
|
+
`Invalid plan source identity.`,
|
|
201
|
+
)
|
|
202
|
+
requireMatrix(
|
|
203
|
+
plan.configs.length &&
|
|
204
|
+
plan.packages.length &&
|
|
205
|
+
plan.environments.length &&
|
|
206
|
+
plan.archives.length,
|
|
207
|
+
`Plan must contain packages, environments, and archives.`,
|
|
208
|
+
)
|
|
209
|
+
for (const [name, values] of [
|
|
210
|
+
[`package`, plan.packages.map((p) => p.name)],
|
|
211
|
+
[`configuration`, plan.packages.map((p) => p.config)],
|
|
212
|
+
[`environment`, plan.environments.map((e) => e.id)],
|
|
213
|
+
[`archive`, plan.archives.map((a) => a.path)],
|
|
214
|
+
] as const)
|
|
215
|
+
requireMatrix(
|
|
216
|
+
new Set(values).size === values.length,
|
|
217
|
+
`Duplicate ${name} identities in plan.`,
|
|
218
|
+
)
|
|
219
|
+
for (const archive of plan.archives)
|
|
220
|
+
requireMatrix(
|
|
221
|
+
/^[a-f0-9]{64}$/.test(archive.sha256) &&
|
|
222
|
+
archive.file === `archives/${archive.sha256}.tgz`,
|
|
223
|
+
`Invalid archive digest or bundle path: ${archive.path}.`,
|
|
224
|
+
)
|
|
225
|
+
sameNames(
|
|
226
|
+
[...new Set(plan.environments.map((e) => e.packageName))],
|
|
227
|
+
plan.packages.map((p) => p.name),
|
|
228
|
+
`Plan environments do not match package audits.`,
|
|
229
|
+
)
|
|
230
|
+
for (const pkg of plan.packages) {
|
|
231
|
+
const audit = pkg.audit
|
|
232
|
+
requireMatrix(
|
|
233
|
+
audit.package.name === pkg.name &&
|
|
234
|
+
audit.mode === `coverage-only` &&
|
|
235
|
+
audit.exitCode === 0 &&
|
|
236
|
+
audit.errors.length === 0 &&
|
|
237
|
+
!audit.support.error,
|
|
238
|
+
`Coverage audit failed for ${pkg.name}.`,
|
|
239
|
+
)
|
|
240
|
+
requireMatrix(
|
|
241
|
+
[`unchanged`, `breaking-release`, `first-release`].includes(
|
|
242
|
+
audit.support.status,
|
|
243
|
+
),
|
|
244
|
+
`Support audit did not pass for ${pkg.name}.`,
|
|
245
|
+
)
|
|
246
|
+
requireMatrix(
|
|
247
|
+
audit.coverage.length &&
|
|
248
|
+
audit.coverage.every((c) => c.representatives.length),
|
|
249
|
+
`Uncovered peer era for ${pkg.name}.`,
|
|
250
|
+
)
|
|
251
|
+
const expected = plan.environments.filter((e) => e.packageName === pkg.name)
|
|
252
|
+
requireMatrix(
|
|
253
|
+
new Set(expected.map((e) => e.representative)).size === expected.length,
|
|
254
|
+
`Duplicate representative paths for ${pkg.name}.`,
|
|
255
|
+
)
|
|
256
|
+
sameNames(
|
|
257
|
+
audit.representatives.map((r) => r.path),
|
|
258
|
+
expected.map((e) => e.representative),
|
|
259
|
+
`Missing or duplicate audited environments for ${pkg.name}.`,
|
|
260
|
+
)
|
|
261
|
+
for (const env of expected) {
|
|
262
|
+
const result = audit.representatives.find(
|
|
263
|
+
(r) => r.path === env.representative,
|
|
264
|
+
)!
|
|
265
|
+
requireMatrix(
|
|
266
|
+
result.status === `audited` && !result.error && env.suites.length,
|
|
267
|
+
`Environment was not audited: ${pkg.name} / ${env.representative}.`,
|
|
268
|
+
)
|
|
269
|
+
sameNames(
|
|
270
|
+
result.suites.map((s) => s.peer),
|
|
271
|
+
env.suites,
|
|
272
|
+
`Audited suites differ: ${env.id}.`,
|
|
273
|
+
)
|
|
274
|
+
requireMatrix(
|
|
275
|
+
new Set(env.suites).size === env.suites.length &&
|
|
276
|
+
result.suites.every((s) => s.status === `not-run`),
|
|
277
|
+
`Invalid audited suites: ${env.id}.`,
|
|
278
|
+
)
|
|
279
|
+
requireMatrix(
|
|
280
|
+
canonical(peerIdentities(result.peers)) === canonical(env.peers),
|
|
281
|
+
`Audited peer identities differ: ${env.id}.`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
for (const coverage of audit.coverage)
|
|
285
|
+
for (const name of coverage.representatives)
|
|
286
|
+
requireMatrix(
|
|
287
|
+
expected.some(
|
|
288
|
+
(e) =>
|
|
289
|
+
e.representative === name && e.suites.includes(coverage.peer),
|
|
290
|
+
),
|
|
291
|
+
`Coverage references an unplanned suite: ${pkg.name} / ${name} / ${coverage.peer}.`,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
return plan
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Pure receipt verification; runner-local installation paths are diagnostic only. */
|
|
298
|
+
export function verifyNpmReceipts(
|
|
299
|
+
value: unknown,
|
|
300
|
+
receipts: readonly unknown[],
|
|
301
|
+
): NpmVerification {
|
|
302
|
+
const errors: string[] = []
|
|
303
|
+
let environments = 0
|
|
304
|
+
try {
|
|
305
|
+
const plan = validateNpmPlan(value)
|
|
306
|
+
environments = plan.environments.length
|
|
307
|
+
const parsed = receipts.map((r, i) => {
|
|
308
|
+
try {
|
|
309
|
+
return npmReceiptSchema.assert(r)
|
|
310
|
+
} catch (cause) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Malformed receipt ${i + 1}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
313
|
+
{ cause },
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
const environmentName = (id: string): string => {
|
|
318
|
+
const env = plan.environments.find((e) => e.id === id)
|
|
319
|
+
return env ? `${env.packageName} / ${env.representative} (${id})` : id
|
|
320
|
+
}
|
|
321
|
+
sameNames(
|
|
322
|
+
parsed.map((r) => environmentName(r.id)),
|
|
323
|
+
plan.environments.map((e) => environmentName(e.id)),
|
|
324
|
+
`Missing, duplicate, or unexpected environment receipts.`,
|
|
325
|
+
)
|
|
326
|
+
for (const env of plan.environments) {
|
|
327
|
+
try {
|
|
328
|
+
const receipt = parsed.find((r) => r.id === env.id)!
|
|
329
|
+
requireMatrix(
|
|
330
|
+
receipt.planId === plan.id,
|
|
331
|
+
`Receipt belongs to a different plan.`,
|
|
332
|
+
)
|
|
333
|
+
requireMatrix(
|
|
334
|
+
receipt.exitCode === 0 && !receipt.error && receipt.report,
|
|
335
|
+
receipt.error ?? `Environment execution failed.`,
|
|
336
|
+
)
|
|
337
|
+
const report = receipt.report
|
|
338
|
+
const pkg = plan.packages.find((p) => p.name === env.packageName)!
|
|
339
|
+
requireMatrix(
|
|
340
|
+
canonical(report.package) === canonical(pkg.audit.package),
|
|
341
|
+
`Wrong package identity.`,
|
|
342
|
+
)
|
|
343
|
+
requireMatrix(
|
|
344
|
+
report.mode === `test` &&
|
|
345
|
+
report.exitCode === 0 &&
|
|
346
|
+
report.errors.length === 0 &&
|
|
347
|
+
report.support.status === `not-checked` &&
|
|
348
|
+
!report.support.error,
|
|
349
|
+
`Receipt does not contain a successful execution report.`,
|
|
350
|
+
)
|
|
351
|
+
sameNames(
|
|
352
|
+
report.representatives.map((r) => r.path),
|
|
353
|
+
pkg.audit.representatives.map((r) => r.path),
|
|
354
|
+
`Missing or duplicate representatives in execution report.`,
|
|
355
|
+
)
|
|
356
|
+
for (const result of report.representatives) {
|
|
357
|
+
if (result.path !== env.representative) {
|
|
358
|
+
requireMatrix(
|
|
359
|
+
result.status === `skipped` &&
|
|
360
|
+
result.suites.length === 0 &&
|
|
361
|
+
!result.error,
|
|
362
|
+
`Unexpected execution: ${result.path}.`,
|
|
363
|
+
)
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
requireMatrix(
|
|
367
|
+
result.status === `passed` && !result.error,
|
|
368
|
+
`Environment did not execute successfully.`,
|
|
369
|
+
)
|
|
370
|
+
sameNames(
|
|
371
|
+
result.suites.map((s) => s.peer),
|
|
372
|
+
env.suites,
|
|
373
|
+
`Missing, duplicate, or unexpected relationship suites.`,
|
|
374
|
+
)
|
|
375
|
+
for (const suite of result.suites)
|
|
376
|
+
requireMatrix(
|
|
377
|
+
suite.status === `passed`,
|
|
378
|
+
`Suite did not pass: ${suite.peer}.`,
|
|
379
|
+
)
|
|
380
|
+
requireMatrix(
|
|
381
|
+
canonical(peerIdentities(result.peers)) === canonical(env.peers),
|
|
382
|
+
`Installed peer identities differ from the audit.`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
errors.push(
|
|
387
|
+
`${env.packageName} / ${env.representative}: ${error instanceof Error ? error.message : String(error)}`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch (error) {
|
|
392
|
+
errors.push(error instanceof Error ? error.message : String(error))
|
|
393
|
+
}
|
|
394
|
+
return { exitCode: errors.length ? 2 : 0, errors, environments }
|
|
395
|
+
}
|