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/npm.md CHANGED
@@ -185,7 +185,7 @@ export default defineConfig({
185
185
 
186
186
  `npm.release.policy` replaces `certifyBreaking`. The default requires a major increment; `semverPolicy({ beforeV1: "minor" })` permits pre-1.0 minor increments too. Missing evidence or a compatible version delta leaves a support drop unapproved. Invalid evidence and resolver/policy errors fail setup. Compatibility failures against still-declared peers remain failures even when the integration has a breaking release. The old `releaseIntent` and `certifyBreaking` hooks are removed and fail with migration guidance. Policies must be functions and receive `{ previousVersion, version, delta }`.
187
187
 
188
- `prepareCommand`, `installCommand`, and each suite's `command` are nonempty shell strings. Preparation runs in the representative directory; installation and suites run in its disposable `test/` directory. Breakage captures stdout and stderr for reporting. All nonzero preparation/installation results fail setup; suite exit 1 denotes incompatibility and other nonzero exits denote setup failure. Functions are reserved for the release-delta resolver and policy.
188
+ `prepareCommand`, `installCommand`, and each suite's `command` are nonempty shell strings. Preparation runs in the representative directory; installation and suites run in its disposable `test/` directory. Breakage captures stdout and stderr for reporting. All nonzero preparation/installation results fail setup; suite exit 1 denotes incompatibility and other nonzero exits denote setup failure. Release-delta resolvers and policies are functions; the optional matrix's archive preparation accepts a command or function.
189
189
 
190
190
  ## Removing support
191
191
 
@@ -211,26 +211,158 @@ Tests, candidate packages, and their runtime dependencies must resolve the same
211
211
 
212
212
  ## CI and caching
213
213
 
214
- Keep the existing public check as `test:breakage` and add the npm check to that release gate, or use `test:breakage:npm` for a separate required job. Preparation must build and pack every participating candidate. A developer-owned matrix can run one configured representative per job, plus an audit job:
214
+ Keep the existing public check as `test:breakage` and add the npm check to that release gate, or use `test:breakage:npm` for a separate required job. For a local package, `npm check` still performs the complete check without a matrix plan. For a repository with several packages or distributed runners, use the optional plan → execute → verify protocol below. CI owns scheduling and artifact transport; Breakage owns discovery, audits, candidate identity, and receipt verification.
215
+
216
+ ### Configure and plan a matrix
217
+
218
+ Keep each package's existing `npm` configuration, representative manifests/lock files, and contracts. Add this to the root `breakage.config.ts`:
219
+
220
+ ```ts
221
+ import { defineConfig } from "breakage"
222
+
223
+ export default defineConfig({
224
+ npmMatrix: {
225
+ configs: ["packages/*/breakage.config.ts"],
226
+ prepare: "pnpm exec turbo run pack:breakage",
227
+ label: ({ packageName, description, representative }) =>
228
+ `${packageName}: ${description ?? representative}`,
229
+ },
230
+ })
231
+ ```
232
+
233
+ `configs` is a root-relative glob or list of globs. Only matched configs with an `npm` section participate; a package is discovered once even when patterns overlap. Package names must be unique. Root matrix configuration does not implicitly supply release hooks to package configs: share `release` through a TypeScript module and `defineConfig` as usual. Each required peer needs a relationship suite, each declared era needs coverage, and every selected representative must have executable audited suites. Keep exploratory environments outside the matrix's configured representative globs.
234
+
235
+ `prepare` runs once after the coverage/support audits. Use your existing build/pack task to create the exact `artifact` paths in the package configs, including registered local peers. It must not alter tracked sources, manifests, lock files, contracts, or release evidence. Tool choice and dependency build order remain yours. Alternatively, provide an async function receiving `{ rootDirectory, archives }`; each deduplicated archive request contains `{ packageName, directory, artifact }`, with both paths relative to the root. Its return value is ignored, and throwing fails planning. `label` is optional and receives package name, representative path, optional manifest description, and audited peers; the default is `packageName: representative`.
236
+
237
+ Use a clean Git checkout and ignore generated archives, `.breakage/npm-plan/`, `.breakage/receipts/`, and each representative's `test/` directory. Commit the intended source/config changes before planning. All package inputs and configured archive paths must stay within the checkout. The root must be the Git checkout root; the default is the current directory. Planning requires baseline tags or pinned baseline files and the same Changesets/tool dependencies as the package checks. With `changesetsReleaseDelta()`, both pending changesets and versioned manifests supply the existing release evidence; no matrix-specific release script is needed.
238
+
239
+ ```sh
240
+ breakage npm plan --config breakage.config.ts --out .breakage/npm-plan
241
+ # stdout is one JSON object: {"include":[{"id":"...","label":"...",...}]}
242
+ # Copy an id from that output; execute every id before verifying.
243
+ breakage npm execute <id> --plan .breakage/npm-plan/plan.json --receipt .breakage/receipts/<id>.json
244
+ breakage npm verify --plan .breakage/npm-plan/plan.json --receipts '.breakage/receipts/*.json'
245
+ ```
246
+
247
+ `plan` writes `plan.json` and content-addressed archives into the bundle directory, defaulting to `.breakage/npm-plan`. `--root` changes the checkout root for all three commands; config, output, plan, and receipt paths resolve from it. Quoting the receipt glob lets Breakage collect all matching files, including duplicates from different runners. Empty discovery, uncovered eras, unapproved support changes, and preparation failures are errors. A failed re-plan removes the previous `plan.json` in that output directory. After cancellation, discard the incomplete bundle and inspect/remove its `.lock` directory before retrying.
248
+
249
+ The plan has `formatVersion: 1`, a fresh run identity, source commit, configuration and input fingerprints, release evidence, coverage/support audits, expected environments/suites/peer identities, and archive SHA-256 digests. Its `id` binds the complete payload. Execution consumes the bundled archives directly; workers do not repack or restore archives over workspace outputs. Each receipt has `formatVersion: 1`, `planId`, environment `id`, `exitCode`, and a report or setup error. Environment IDs remain stable for a config/representative path, while plan IDs change on every planning run.
250
+
251
+ Execution rechecks the source, configuration, representative discovery, contract/manifests/lockfile inputs, release evidence, and archive digests before and after running the shared npm test engine with exactly the planned suites. A representative can contain a peer outside its declared support range while covering another peer; matrix execution omits that unsupported relationship's suite, matching the audit. Full peer identity and configuration validation still apply. Explicit `npm test --representative` exploration continues to run every configured suite whose peer is present. A valid plan produces a failure receipt on ordinary setup or compatibility failure. An invalid/unreadable plan or killed process may produce no receipt; that cannot satisfy verification. Assertions retain exit 1, setup failures exit 2, and successful execution exits 0. Planning and aggregate verification return 0 on success and 2 on any failure.
252
+
253
+ ### Use the same protocol from TypeScript
254
+
255
+ ```ts
256
+ import { readFile } from "node:fs/promises"
257
+ import { npmPlan, npmMatrix, npmExecute, npmVerify } from "breakage"
258
+ import config from "./breakage.config.ts"
259
+
260
+ const location = { rootDirectory: process.cwd() }
261
+ if (!config.npmMatrix) throw new Error("Configure npmMatrix first.")
262
+ const plan = await npmPlan(config.npmMatrix, location)
263
+ const receipts = []
264
+ for (const { id } of npmMatrix(plan).include) {
265
+ receipts.push(await npmExecute(plan, id, location))
266
+ }
267
+ const result = await npmVerify(plan, receipts, location)
268
+ process.exitCode = result.exitCode
269
+
270
+ // Another runner can load the transported bundle without depending on its old path.
271
+ const transportedPlan: unknown = JSON.parse(
272
+ await readFile(".breakage/npm-plan/plan.json", "utf8"),
273
+ )
274
+ ```
275
+
276
+ The exported types are `NpmMatrixConfig`, `NpmPrepareContext`, `NpmArchiveRequest`, `NpmMatrixLabelContext`, `NpmMatrixLocation`, `NpmPlan`, `NpmReceipt`, and `NpmVerification`. `npmVerify` validates the checkout and bundle as well as receipts. `verifyNpmReceipts(plan, receipts)` provides pure structural/audit/receipt verification for custom orchestrators that independently validate source/config/archive identity. It does not inspect the filesystem. Receipt order and runner-local installed paths do not affect either verifier.
277
+
278
+ ### Distribute the matrix in CI
279
+
280
+ This GitHub Actions example assumes your existing `./.github/actions/setup` installs pinned Node/package-manager versions and frozen workspace dependencies. Keep the plan bundle and receipts specific to the current workflow run; fetch release history in every checkout. The verify job must require successful planning and execution jobs as well as complete receipts, so cancellation or skipped jobs cannot reuse old success. Keep the ordinary public check required separately.
215
281
 
216
282
  ```yaml
283
+ name: Peer compatibility
284
+ on: [pull_request]
285
+ permissions:
286
+ contents: read
217
287
  jobs:
218
- npm-audit:
288
+ plan:
289
+ runs-on: ubuntu-latest
290
+ outputs:
291
+ matrix: ${{ steps.plan.outputs.matrix }}
219
292
  steps:
220
- # Checkout, install tools/dependencies, and prepare release evidence first.
221
- - run: breakage npm check --coverage-only
222
- npm-contracts:
293
+ - uses: actions/checkout@v4
294
+ with:
295
+ fetch-depth: 0
296
+ - uses: ./.github/actions/setup
297
+ - id: plan
298
+ run: |
299
+ matrix=$(pnpm exec breakage npm plan)
300
+ echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
301
+ - uses: actions/upload-artifact@v4
302
+ with:
303
+ name: peer-plan
304
+ path: .breakage/npm-plan/
305
+ include-hidden-files: true
306
+ if-no-files-found: error
307
+ execute:
308
+ needs: plan
309
+ runs-on: ubuntu-latest
310
+ name: ${{ matrix.label }}
223
311
  strategy:
224
312
  fail-fast: false
225
- matrix:
226
- representative: [peer-environments/core-1, peer-environments/core-2]
313
+ matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
314
+ steps:
315
+ - uses: actions/checkout@v4
316
+ with:
317
+ fetch-depth: 0
318
+ - uses: ./.github/actions/setup
319
+ - uses: actions/download-artifact@v4
320
+ with:
321
+ name: peer-plan
322
+ path: .breakage/npm-plan/
323
+ - env:
324
+ ENVIRONMENT_ID: ${{ matrix.id }}
325
+ run: pnpm exec breakage npm execute "$ENVIRONMENT_ID" --plan .breakage/npm-plan/plan.json --receipt ".breakage/receipts/$ENVIRONMENT_ID.json"
326
+ - uses: actions/upload-artifact@v4
327
+ if: always()
328
+ with:
329
+ name: peer-receipt-${{ matrix.id }}
330
+ path: .breakage/receipts/
331
+ include-hidden-files: true
332
+ if-no-files-found: error
333
+ verify:
334
+ name: Peer compatibility
335
+ if: always()
336
+ needs: [plan, execute]
337
+ runs-on: ubuntu-latest
227
338
  steps:
228
- # Checkout, install tools/dependencies, and build/pack candidates first.
229
- - run: breakage npm test --representative "${{ matrix.representative }}"
339
+ - env:
340
+ PLAN_RESULT: ${{ needs.plan.result }}
341
+ EXECUTION_RESULT: ${{ needs.execute.result }}
342
+ run: test "$PLAN_RESULT" = success && test "$EXECUTION_RESULT" = success
343
+ - uses: actions/checkout@v4
344
+ with:
345
+ fetch-depth: 0
346
+ - uses: ./.github/actions/setup
347
+ - uses: actions/download-artifact@v4
348
+ with:
349
+ name: peer-plan
350
+ path: .breakage/npm-plan/
351
+ - uses: actions/download-artifact@v4
352
+ with:
353
+ pattern: peer-receipt-*
354
+ path: .breakage/receipts/
355
+ - run: pnpm exec breakage npm verify --plan .breakage/npm-plan/plan.json --receipts '.breakage/receipts/**/*.json'
230
356
  ```
231
357
 
232
- This is a job excerpt: add your runner, checkout, setup, and working directory. Require the audit, all matrix jobs, and the public check before release. Updating the authored representative list requires updating the required matrix. Breakage neither generates workflows nor aggregates cross-job receipts.
358
+ Keep downloaded receipt artifacts in separate subdirectories: merging them could overwrite duplicate filenames before the verifier sees them. Require the stable aggregate `Peer compatibility` job. Pin action references according to your repository policy. Other CI systems can transport the same JSON and archive bundle, schedule `npmMatrix(plan).include`, and run the same commands.
359
+
360
+ ### Remove repository-specific orchestration
361
+
362
+ For atom.io-style scripts, replace discovery/coverage commands with `npm plan`, archive collection and copying with its bundle, per-environment wrappers with `npm execute`, and custom receipt validation with `npm verify`. Delete the custom plan/receipt types, hashing, suite enumeration, and aggregate verifier tests once the repository uses the protocol. Keep package configs/shared release helpers, authored peer environments/contracts, the build/pack task, tool setup, and CI artifact transport. No workspace manager, task runner, Changesets versioning, or CI provider is invoked implicitly.
363
+
364
+ Verification rejects missing, duplicate, unexpected, malformed, stale, failed, or skipped receipts; wrong package/peer identities; audit reports substituted for execution; and missing, duplicate, unexecuted, or failing suites. It validates coverage/support audits as well as executions. The protocol checks consistency within trusted CI, not cryptographic attestation of an untrusted worker: a worker able to fabricate both plans and receipts can fabricate success. Commands, hooks, environment variables, and external services remain repository-owned inputs; use identical toolchains and deterministic hooks across jobs.
233
365
 
234
- Start uncached. A correct cache key includes candidate/peer archives, suite files, representative manifests and lock files, observed installation identities, configuration and imported helpers, baseline, release-plan inputs, and command/environment/toolchain inputs. Unknown dynamic input means the check stays uncached. There is no new npm prelude or mandatory execution plan.
366
+ Start uncached. A correct cache key includes candidate/peer archives, suite files, representative manifests and lock files, observed installation identities, configuration and imported helpers, baseline, release-plan inputs, and command/environment/toolchain inputs. Unknown dynamic input means the check stays uncached. Do not reuse execution receipts across planning runs. There is no new npm prelude, and the matrix protocol is optional.
235
367
 
236
368
  Programmatic consumers can import `npmCheck`, `npmTest`, `formatNpmReport`, and their exported types from `breakage`. Both check functions return a structured `NpmReport` with `exitCode`, coverage, support findings, and independent representative/suite results; `mode` distinguishes audit from execution.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakage",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Run yesterday’s tests against tomorrow’s release.",
5
5
  "homepage": "https://github.com/jeremybanka/breakage#readme",
6
6
  "bugs": "https://github.com/jeremybanka/breakage/issues",
package/src/breakage.ts CHANGED
@@ -42,6 +42,23 @@ export type { CommandResult } from "./task.ts"
42
42
  export type { BreakageBaseline, BreakagePreludeOptions } from "./baseline.ts"
43
43
  export { breakagePrelude } from "./baseline.ts"
44
44
  export { formatNpmReport, npmCheck, npmTest } from "./npm/check.ts"
45
+ export {
46
+ npmPlan,
47
+ npmExecute,
48
+ npmVerify,
49
+ npmMatrix,
50
+ verifyNpmReceipts,
51
+ } from "./npm/matrix.ts"
52
+ export type {
53
+ NpmMatrixConfig,
54
+ NpmMatrixLabelContext,
55
+ NpmPrepareContext,
56
+ NpmArchiveRequest,
57
+ NpmPlan,
58
+ NpmReceipt,
59
+ NpmVerification,
60
+ NpmMatrixLocation,
61
+ } from "./npm/matrix.ts"
45
62
  export type {
46
63
  NpmConfig,
47
64
  NpmPeerEvidence,
package/src/breakage.x.ts CHANGED
@@ -27,6 +27,12 @@ import {
27
27
  import { breakage, breakagePrelude } from "./breakage.ts"
28
28
  import { errorMessage } from "./task.ts"
29
29
  import { npmCheckManual, npmTestManual, runNpmCli } from "./npm/cli.ts"
30
+ import {
31
+ npmPlanManual,
32
+ npmExecuteManual,
33
+ npmVerifyManual,
34
+ runNpmMatrixCli,
35
+ } from "./npm/matrix-cli.ts"
30
36
 
31
37
  // Either command spelling can override the other spelling in a config file.
32
38
  const commandOverrides = new Set<`test` | `testCommand`>()
@@ -37,6 +43,7 @@ const BREAKAGE_MANUAL = options(
37
43
  {
38
44
  release: { configOnly: true },
39
45
  npm: { configOnly: true },
46
+ npmMatrix: { configOnly: true },
40
47
  out: { configOnly: true },
41
48
  tagPattern: {
42
49
  schema: configFields.tagPattern,
@@ -151,6 +158,9 @@ const parse = cli(
151
158
  npm: optional({
152
159
  help: null,
153
160
  check: null,
161
+ plan: null,
162
+ execute: required({ $id: null }),
163
+ verify: null,
154
164
  test: optional({ $peer: required({ $range: null }) }),
155
165
  }),
156
166
  prelude: optional({ $configPath: null }),
@@ -163,6 +173,9 @@ const parse = cli(
163
173
  npm: noOptions(`Show npm compatibility commands.`),
164
174
  "npm/help": noOptions(`Show npm compatibility commands.`),
165
175
  "npm/check": npmCheckManual,
176
+ "npm/plan": npmPlanManual,
177
+ "npm/execute/$id": npmExecuteManual,
178
+ "npm/verify": npmVerifyManual,
166
179
  "npm/test": npmTestManual,
167
180
  "npm/test/$peer/$range": npmTestManual,
168
181
  prelude: PRELUDE_MANUAL,
@@ -206,6 +219,19 @@ if (completion !== undefined) {
206
219
  case `npm/check`:
207
220
  process.exitCode = await runNpmCli(`check`, inputs.opts)
208
221
  break
222
+ case `npm/plan`:
223
+ process.exitCode = await runNpmMatrixCli(`plan`, inputs.opts)
224
+ break
225
+ case `npm/execute/$id`:
226
+ process.exitCode = await runNpmMatrixCli(
227
+ `execute`,
228
+ inputs.opts,
229
+ inputs.params.id,
230
+ )
231
+ break
232
+ case `npm/verify`:
233
+ process.exitCode = await runNpmMatrixCli(`verify`, inputs.opts)
234
+ break
209
235
  case `npm/test`:
210
236
  process.exitCode = await runNpmCli(`test`, inputs.opts)
211
237
  break
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import { type } from "arktype"
5
5
  import { cli, options, type ConfigInput } from "comline"
6
6
 
7
7
  import { npmConfigSchema } from "./npm/config.ts"
8
+ import { npmMatrixConfigSchema } from "./npm/matrix-model.ts"
8
9
  import {
9
10
  releaseSchema,
10
11
  validateRelease,
@@ -34,6 +35,7 @@ export const configSchema = type({
34
35
  "out?": configFields.out,
35
36
  "release?": releaseSchema,
36
37
  "npm?": npmConfigSchema,
38
+ "npmMatrix?": npmMatrixConfigSchema,
37
39
  })
38
40
 
39
41
  /** The complete config, with no fields exposed to argv by this loader. */
@@ -48,6 +50,7 @@ export const configOptions = options(`Breakage configuration`, configSchema, {
48
50
  out: { configOnly: true },
49
51
  release: { configOnly: true },
50
52
  npm: { configOnly: true },
53
+ npmMatrix: { configOnly: true },
51
54
  })
52
55
  export type BreakageConfig = ConfigInput<typeof configOptions>
53
56
 
package/src/npm/check.ts CHANGED
@@ -133,6 +133,7 @@ async function runRepresentative(
133
133
  directory: string,
134
134
  coverageOnly: boolean,
135
135
  selection?: NpmTestSelection,
136
+ plannedSuites?: readonly string[],
136
137
  ): Promise<NpmRepresentativeResult> {
137
138
  const label = path.relative(base, directory).split(path.sep).join(`/`)
138
139
  const result: NpmRepresentativeResult = {
@@ -196,6 +197,7 @@ async function runRepresentative(
196
197
  const peers = Object.keys(config.suites).filter((peer) => {
197
198
  const evidence = result.peers[peer]
198
199
  if (!evidence) return false
200
+ if (plannedSuites !== undefined) return plannedSuites.includes(peer)
199
201
  if (selection && `peer` in selection) return peer === selection.peer
200
202
  if (selection && `representative` in selection) return true
201
203
  return matches(evidence.version, manifest.peerDependencies![peer]!)
@@ -257,6 +259,7 @@ async function run(
257
259
  baseDirectory: string,
258
260
  coverageOnly: boolean,
259
261
  selection?: NpmTestSelection,
262
+ plannedSuites?: readonly string[],
260
263
  ): Promise<NpmReport> {
261
264
  const report: NpmReport = {
262
265
  package: { name: `(unresolved)`, version: `(unresolved)` },
@@ -330,6 +333,7 @@ async function run(
330
333
  directory,
331
334
  coverageOnly,
332
335
  selection,
336
+ plannedSuites,
333
337
  ),
334
338
  ),
335
339
  ),
@@ -412,6 +416,21 @@ export async function npmTest(
412
416
  )
413
417
  }
414
418
 
419
+ /** Internal matrix entrypoint: keep the full config for validation and peer identity checks. */
420
+ export async function npmTestPlanned(
421
+ config: NpmConfig,
422
+ selection: { representative: string; suites: readonly string[] },
423
+ options: { baseDirectory?: string } = {},
424
+ ): Promise<NpmReport> {
425
+ return await run(
426
+ config,
427
+ options.baseDirectory ?? process.cwd(),
428
+ false,
429
+ { representative: selection.representative },
430
+ selection.suites,
431
+ )
432
+ }
433
+
415
434
  export function formatNpmReport(report: NpmReport): string {
416
435
  const lines = [
417
436
  `${report.package.name}@${report.package.version} — npm ${report.mode}`,
package/src/npm/cli.ts CHANGED
@@ -16,6 +16,7 @@ type CommonOptions = {
16
16
  json?: boolean | undefined
17
17
  release?: import("../release.ts").ReleaseConfig
18
18
  npm?: import("./types.ts").NpmConfig
19
+ npmMatrix?: import("./matrix-model.ts").NpmMatrixConfig
19
20
  }
20
21
  type CheckOptions = CommonOptions & { coverageOnly?: boolean | undefined }
21
22
  type TestOptions = CommonOptions & { representative?: string | undefined }
@@ -23,6 +24,7 @@ type TestOptions = CommonOptions & { representative?: string | undefined }
23
24
  const common = {
24
25
  release: configOptions.optionConfigs.release,
25
26
  npm: configOptions.optionConfigs.npm,
27
+ npmMatrix: configOptions.optionConfigs.npmMatrix,
26
28
  config: {
27
29
  schema: type(`string`),
28
30
  required: false,
@@ -47,6 +49,7 @@ export const npmCheckManual: OptionsGroup<CheckOptions> = options(
47
49
  "coverageOnly?": `boolean`,
48
50
  "release?": configOptions.optionsSchema.get(`release`),
49
51
  "npm?": configOptions.optionsSchema.get(`npm`),
52
+ "npmMatrix?": configOptions.optionsSchema.get(`npmMatrix`),
50
53
  }),
51
54
  {
52
55
  ...common,
@@ -69,6 +72,7 @@ export const npmTestManual: OptionsGroup<TestOptions> = options(
69
72
  "representative?": `string`,
70
73
  "release?": configOptions.optionsSchema.get(`release`),
71
74
  "npm?": configOptions.optionsSchema.get(`npm`),
75
+ "npmMatrix?": configOptions.optionsSchema.get(`npmMatrix`),
72
76
  }),
73
77
  {
74
78
  ...common,
@@ -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
+ }