breakage 0.8.5 → 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.
Files changed (45) hide show
  1. package/README.md +163 -47
  2. package/dist/{breakage-B7dJsgcu.js → breakage-DJiIiLzL.js} +958 -221
  3. package/dist/breakage-DJiIiLzL.js.map +1 -0
  4. package/dist/breakage.d.ts +239 -71
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +216 -79
  8. package/dist/breakage.x.js.map +1 -1
  9. package/dist/changesets.d.ts +8 -0
  10. package/dist/changesets.d.ts.map +1 -0
  11. package/dist/changesets.js +27 -0
  12. package/dist/changesets.js.map +1 -0
  13. package/dist/release-BsdIDT4g.js +69 -0
  14. package/dist/release-BsdIDT4g.js.map +1 -0
  15. package/dist/release-D47BqRuB.d.ts +40 -0
  16. package/dist/release-D47BqRuB.d.ts.map +1 -0
  17. package/npm.md +198 -44
  18. package/package.json +45 -6
  19. package/src/breakage.ts +142 -73
  20. package/src/breakage.x.ts +174 -127
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +131 -0
  23. package/src/npm/check.ts +70 -50
  24. package/src/npm/cli.ts +30 -22
  25. package/src/npm/config.ts +38 -6
  26. package/src/npm/environment.ts +10 -10
  27. package/src/npm/matrix-cli.ts +170 -0
  28. package/src/npm/matrix-model.ts +395 -0
  29. package/src/npm/matrix.ts +657 -0
  30. package/src/npm/release.ts +9 -25
  31. package/src/npm/types.ts +5 -45
  32. package/src/release.ts +153 -0
  33. package/src/{npm/command.ts → task.ts} +2 -0
  34. package/dist/breakage-B7dJsgcu.js.map +0 -1
  35. package/dist/breakage.$configPath.schema.json +0 -33
  36. package/dist/breakage.help.schema.json +0 -6
  37. package/dist/breakage.main.schema.json +0 -33
  38. package/dist/breakage.npm.check.schema.json +0 -16
  39. package/dist/breakage.npm.help.schema.json +0 -6
  40. package/dist/breakage.npm.schema.json +0 -6
  41. package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
  42. package/dist/breakage.npm.test.schema.json +0 -16
  43. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  44. package/dist/breakage.prelude.schema.json +0 -31
  45. package/dist/breakage.schema.schema.json +0 -10
@@ -0,0 +1,170 @@
1
+ import { glob, readFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { type } from "arktype"
4
+ import { options } from "comline"
5
+ import { configOptions, defaultConfig, loadConfig } from "../config.ts"
6
+ import { errorMessage } from "../task.ts"
7
+ import { formatNpmReport } from "./check.ts"
8
+ import {
9
+ npmPlan,
10
+ npmExecute,
11
+ npmVerify,
12
+ npmMatrix,
13
+ writeMatrixJson,
14
+ } from "./matrix.ts"
15
+
16
+ const hiddenSchema = {
17
+ "release?": configOptions.optionsSchema.get(`release`),
18
+ "npm?": configOptions.optionsSchema.get(`npm`),
19
+ "npmMatrix?": configOptions.optionsSchema.get(`npmMatrix`),
20
+ }
21
+ const hidden = {
22
+ release: { configOnly: true },
23
+ npm: { configOnly: true },
24
+ npmMatrix: { configOnly: true },
25
+ } as const
26
+ const root = {
27
+ schema: type(`string`),
28
+ required: false,
29
+ description: `Source Git checkout root (defaults to the current directory).`,
30
+ example: `--root .`,
31
+ completion: { fileSystem: `directories` as const },
32
+ }
33
+ const plan = {
34
+ schema: type(`string`),
35
+ required: true,
36
+ description: `Prepared plan.json; archives are read from its bundle directory.`,
37
+ example: `--plan .breakage/npm-plan/plan.json`,
38
+ completion: { fileSystem: `files` as const },
39
+ }
40
+
41
+ export const npmPlanManual = options(
42
+ `Audit the peer matrix and prepare a portable archive bundle; print matrix JSON.`,
43
+ type({
44
+ ...hiddenSchema,
45
+ "config?": `string`,
46
+ "root?": `string`,
47
+ "out?": `string`,
48
+ }),
49
+ {
50
+ ...hidden,
51
+ root,
52
+ config: {
53
+ schema: type(`string`),
54
+ required: false,
55
+ description: `TypeScript configuration containing npmMatrix.`,
56
+ example: `--config breakage.config.ts`,
57
+ completion: { fileSystem: `files` },
58
+ },
59
+ out: {
60
+ schema: type(`string`),
61
+ required: false,
62
+ description: `Bundle directory (defaults to .breakage/npm-plan).`,
63
+ example: `--out .breakage/peer-bundle`,
64
+ completion: { fileSystem: `directories` },
65
+ },
66
+ },
67
+ )
68
+ export const npmExecuteManual = options(
69
+ `Execute one planned representative and write its receipt.`,
70
+ type({
71
+ ...hiddenSchema,
72
+ plan: `string`,
73
+ receipt: `string`,
74
+ "root?": `string`,
75
+ }),
76
+ {
77
+ ...hidden,
78
+ root,
79
+ plan,
80
+ receipt: {
81
+ schema: type(`string`),
82
+ required: true,
83
+ description: `Output receipt filename.`,
84
+ example: `--receipt .breakage/receipts/environment.json`,
85
+ completion: { fileSystem: `files` },
86
+ },
87
+ },
88
+ )
89
+ export const npmVerifyManual = options(
90
+ `Verify the complete planned matrix against collected execution receipts.`,
91
+ type({
92
+ ...hiddenSchema,
93
+ plan: `string`,
94
+ receipts: `string`,
95
+ "root?": `string`,
96
+ }),
97
+ {
98
+ ...hidden,
99
+ root,
100
+ plan,
101
+ receipts: {
102
+ schema: type(`string`),
103
+ required: true,
104
+ description: `Glob selecting all collected JSON receipts, relative to root.`,
105
+ example: `--receipts '.breakage/receipts/**/*.json'`,
106
+ completion: { fileSystem: `files` },
107
+ },
108
+ },
109
+ )
110
+
111
+ export async function runNpmMatrixCli(
112
+ action: `plan` | `execute` | `verify`,
113
+ opts: {
114
+ root?: string
115
+ config?: string
116
+ out?: string
117
+ plan?: string
118
+ receipt?: string
119
+ receipts?: string
120
+ },
121
+ id?: string,
122
+ ): Promise<number> {
123
+ try {
124
+ const rootDirectory = path.resolve(opts.root ?? process.cwd())
125
+ if (action === `plan`) {
126
+ const config = await loadConfig(
127
+ path.resolve(
128
+ rootDirectory,
129
+ opts.config ?? defaultConfig(rootDirectory),
130
+ ),
131
+ )
132
+ if (!config.npmMatrix)
133
+ throw new Error(`Configure npmMatrix.configs and npmMatrix.prepare.`)
134
+ const prepared = await npmPlan(config.npmMatrix, {
135
+ rootDirectory,
136
+ ...(opts.out ? { bundleDirectory: opts.out } : {}),
137
+ })
138
+ process.stdout.write(`${JSON.stringify(npmMatrix(prepared))}\n`)
139
+ return 0
140
+ }
141
+ const filename = path.resolve(rootDirectory, opts.plan!)
142
+ const value: unknown = JSON.parse(await readFile(filename, `utf8`))
143
+ const location = { rootDirectory, bundleDirectory: path.dirname(filename) }
144
+ if (action === `execute`) {
145
+ const receipt = await npmExecute(value, id!, location)
146
+ await writeMatrixJson(path.resolve(rootDirectory, opts.receipt!), receipt)
147
+ if (receipt.report) process.stdout.write(formatNpmReport(receipt.report))
148
+ if (receipt.error) process.stderr.write(`${receipt.error}\n`)
149
+ return receipt.exitCode
150
+ }
151
+ const receipts: unknown[] = []
152
+ for await (const filename of glob(opts.receipts!, { cwd: rootDirectory }))
153
+ receipts.push(
154
+ JSON.parse(
155
+ await readFile(path.resolve(rootDirectory, filename), `utf8`),
156
+ ),
157
+ )
158
+ const result = await npmVerify(value, receipts, location)
159
+ if (result.errors.length)
160
+ process.stderr.write(`${result.errors.join(`\n`)}\n`)
161
+ else
162
+ process.stdout.write(
163
+ `Verified passing receipts for all ${result.environments} planned environments.\n`,
164
+ )
165
+ return result.exitCode
166
+ } catch (error) {
167
+ process.stderr.write(`npm matrix setup error: ${errorMessage(error)}\n`)
168
+ return 2
169
+ }
170
+ }
@@ -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
+ }