breakage 0.8.4 → 0.9.0

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 (39) hide show
  1. package/README.md +164 -46
  2. package/dist/breakage-DIY49fzP.js +1349 -0
  3. package/dist/breakage-DIY49fzP.js.map +1 -0
  4. package/dist/breakage.d.ts +179 -11
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +169 -67
  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 +236 -0
  18. package/package.json +51 -11
  19. package/src/breakage.ts +136 -70
  20. package/src/breakage.x.ts +160 -112
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +128 -0
  23. package/src/npm/check.ts +449 -0
  24. package/src/npm/cli.ts +130 -0
  25. package/src/npm/config.ts +118 -0
  26. package/src/npm/environment.ts +661 -0
  27. package/src/npm/manifest.ts +134 -0
  28. package/src/npm/release.ts +173 -0
  29. package/src/npm/types.ts +64 -0
  30. package/src/release.ts +153 -0
  31. package/src/task.ts +54 -0
  32. package/dist/breakage-Dc4IldeW.js +0 -435
  33. package/dist/breakage-Dc4IldeW.js.map +0 -1
  34. package/dist/breakage.$configPath.schema.json +0 -33
  35. package/dist/breakage.help.schema.json +0 -6
  36. package/dist/breakage.main.schema.json +0 -33
  37. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  38. package/dist/breakage.prelude.schema.json +0 -31
  39. package/dist/breakage.schema.schema.json +0 -10
package/src/breakage.x.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import * as path from "node:path"
3
+ import path from "node:path"
4
4
  import { inspect } from "node:util"
5
5
 
6
- import { type } from "arktype"
7
- import type { OptionsGroup } from "comline"
8
6
  import {
9
7
  cli,
10
8
  completionResponse,
@@ -15,25 +13,33 @@ import {
15
13
  optional,
16
14
  options,
17
15
  parseBooleanOption,
16
+ required,
18
17
  } from "comline"
19
18
  import logger from "takua"
20
19
 
21
- import type { BreakageOptions } from "./breakage.ts"
20
+ import {
21
+ defaultConfig,
22
+ configPath,
23
+ resolveConfig,
24
+ configSchema,
25
+ configFields,
26
+ } from "./config.ts"
22
27
  import { breakage, breakagePrelude } from "./breakage.ts"
28
+ import { errorMessage } from "./task.ts"
29
+ import { npmCheckManual, npmTestManual, runNpmCli } from "./npm/cli.ts"
30
+
31
+ // Either command spelling can override the other spelling in a config file.
32
+ const commandOverrides = new Set<`test` | `testCommand`>()
23
33
 
24
34
  const BREAKAGE_MANUAL = options(
25
35
  `Check for breaking changes in a package.`,
26
- type({
27
- "tagPattern?": `string`,
28
- "baselineFile?": `string`,
29
- testPattern: `string`,
30
- testCommand: `string`,
31
- certifyCommand: `string`,
32
- "baseDirname?": `string`,
33
- "verbose?": `boolean`,
34
- }),
36
+ configSchema,
35
37
  {
38
+ release: { configOnly: true },
39
+ npm: { configOnly: true },
40
+ out: { configOnly: true },
36
41
  tagPattern: {
42
+ schema: configFields.tagPattern,
37
43
  aliases: [`tag-pattern`],
38
44
  completion: { repeatable: false },
39
45
  flag: `g`,
@@ -42,6 +48,7 @@ const BREAKAGE_MANUAL = options(
42
48
  example: `--tagPattern="my-library"`,
43
49
  },
44
50
  baselineFile: {
51
+ schema: configFields.baselineFile,
45
52
  aliases: [`baseline-file`],
46
53
  completion: { repeatable: false, fileSystem: `files` },
47
54
  required: false,
@@ -49,30 +56,41 @@ const BREAKAGE_MANUAL = options(
49
56
  example: `--baseline-file=.breakage/baseline.json`,
50
57
  },
51
58
  testPattern: {
59
+ schema: configFields.testPattern,
52
60
  aliases: [`test-pattern`, `pattern`],
53
61
  completion: { repeatable: false },
54
62
  flag: `p`,
55
- required: true,
63
+ required: false,
56
64
  description: `The pattern to match test files that test the public API of the library.`,
57
65
  example: `--pattern="*__public.test.ts"`,
58
66
  },
59
67
  testCommand: {
68
+ parse: (value) => {
69
+ commandOverrides.add(`testCommand`)
70
+ return value
71
+ },
72
+ schema: configFields.testCommand,
60
73
  aliases: [`test-command`],
61
74
  completion: { repeatable: false },
62
75
  flag: `t`,
63
- required: true,
76
+ required: false,
64
77
  description: `Complete bash command that runs the tests for the library's public API.`,
65
78
  example: `--testCommand="npm run test"`,
66
79
  },
67
- certifyCommand: {
68
- aliases: [`certify-command`],
80
+ test: {
81
+ parse: (value) => {
82
+ commandOverrides.add(`test`)
83
+ return value
84
+ },
85
+ schema: configFields.test,
69
86
  completion: { repeatable: false },
70
- flag: `c`,
71
- required: true,
72
- description: `Complete bash command that determines whether a major version bump for your package is indicated in the workspace. Exit code 0 indicates that a major version bump is indicated, and exit code 1 indicates that no major version bump is indicated.`,
73
- example: `--certifyCommand=\tsx scripts/certify-major-version-bump.node`,
87
+ required: false,
88
+ description: `Shell command running the restored public contracts.`,
89
+ example: `--test="npm run test:public"`,
74
90
  },
75
91
  baseDirname: {
92
+ parse: (value) => path.resolve(value),
93
+ schema: configFields.baseDirname,
76
94
  aliases: [`base-dir`, `base-dirname`],
77
95
  completion: { repeatable: false, fileSystem: `directories` },
78
96
  flag: `d`,
@@ -81,6 +99,7 @@ const BREAKAGE_MANUAL = options(
81
99
  example: `--baseDirname=./packages/my-package`,
82
100
  },
83
101
  verbose: {
102
+ schema: configFields.verbose,
84
103
  completion: { repeatable: false },
85
104
  flag: `v`,
86
105
  required: false,
@@ -89,20 +108,11 @@ const BREAKAGE_MANUAL = options(
89
108
  parse: parseBooleanOption,
90
109
  },
91
110
  },
92
- ) satisfies OptionsGroup<BreakageOptions>
111
+ )
93
112
 
94
113
  const PRELUDE_MANUAL = options(
95
114
  `Resolve and fetch the release baseline for a cacheable compatibility check.`,
96
- type({
97
- "tagPattern?": `string`,
98
- "baseDirname?": `string`,
99
- "out?": `string`,
100
- "testPattern?": `string`,
101
- "testCommand?": `string`,
102
- "certifyCommand?": `string`,
103
- "baselineFile?": `string`,
104
- "verbose?": `boolean`,
105
- }),
115
+ configSchema,
106
116
  {
107
117
  ...BREAKAGE_MANUAL.optionConfigs,
108
118
  testPattern: {
@@ -113,11 +123,8 @@ const PRELUDE_MANUAL = options(
113
123
  ...BREAKAGE_MANUAL.optionConfigs.testCommand,
114
124
  required: false,
115
125
  },
116
- certifyCommand: {
117
- ...BREAKAGE_MANUAL.optionConfigs.certifyCommand,
118
- required: false,
119
- },
120
126
  out: {
127
+ schema: configFields.out,
121
128
  completion: { repeatable: false, fileSystem: `files` },
122
129
  required: false,
123
130
  description: `Gitignored snapshot path relative to the base directory (default: .breakage/baseline.json).`,
@@ -126,27 +133,26 @@ const PRELUDE_MANUAL = options(
126
133
  },
127
134
  )
128
135
 
129
- const SCHEMA_MANUAL = options(
130
- `Create a copy of the schema for configuring breakage.`,
131
- type({ "outdir?": `string` }),
132
- {
133
- outdir: {
134
- aliases: [`out-dir`],
135
- completion: { repeatable: false, fileSystem: `directories` },
136
- flag: `o`,
137
- required: false,
138
- description: `Directory to write the schema to.`,
139
- example: `--outdir=./dist`,
140
- },
141
- },
142
- )
143
-
136
+ let loadedConfigPath: string | undefined
144
137
  const parse = cli(
145
138
  {
146
139
  cliName: `breakage`,
140
+ discoverConfigPath: (segments: readonly string[]) => {
141
+ if (segments[0] === `help` || segments[0] === `npm`) return undefined
142
+ const explicit = segments[0] === `prelude` ? segments[1] : segments[0]
143
+ loadedConfigPath = configPath(
144
+ explicit ?? defaultConfig(process.cwd()),
145
+ explicit !== undefined,
146
+ )
147
+ return loadedConfigPath
148
+ },
147
149
  routes: optional({
148
150
  help: null,
149
- schema: null,
151
+ npm: optional({
152
+ help: null,
153
+ check: null,
154
+ test: optional({ $peer: required({ $range: null }) }),
155
+ }),
150
156
  prelude: optional({ $configPath: null }),
151
157
  $configPath: null,
152
158
  }),
@@ -154,7 +160,11 @@ const parse = cli(
154
160
  "": BREAKAGE_MANUAL,
155
161
  $configPath: BREAKAGE_MANUAL,
156
162
  help: noOptions(`Show usage.`),
157
- schema: SCHEMA_MANUAL,
163
+ npm: noOptions(`Show npm compatibility commands.`),
164
+ "npm/help": noOptions(`Show npm compatibility commands.`),
165
+ "npm/check": npmCheckManual,
166
+ "npm/test": npmTestManual,
167
+ "npm/test/$peer/$range": npmTestManual,
158
168
  prelude: PRELUDE_MANUAL,
159
169
  "prelude/$configPath": PRELUDE_MANUAL,
160
170
  },
@@ -162,15 +172,6 @@ const parse = cli(
162
172
  $configPath: { fileSystem: `files` },
163
173
  "prelude/$configPath": { fileSystem: `files` },
164
174
  },
165
- discoverConfigPath: (args: readonly string[]) => {
166
- if (args[0] === `help` || args[0] === `schema`) {
167
- return
168
- }
169
- const configPath =
170
- (args[0] === `prelude` ? args[1] : args[0]) ??
171
- path.join(process.cwd(), `breakage.config.json`)
172
- return configPath
173
- },
174
175
  },
175
176
  {
176
177
  info: (message, ...data) => logger.info(`breakage`, message, data),
@@ -182,63 +183,110 @@ const completion = await completionResponse(parse.definition, process.argv)
182
183
  if (completion !== undefined) {
183
184
  process.stdout.write(completion)
184
185
  } else {
185
- const { inputs, warnings, writeJsonSchema } = parse(process.argv)
186
- logWarnings(warnings)
186
+ try {
187
+ const { inputs, warnings } = await parse.async(process.argv)
188
+ logWarnings(warnings)
189
+ if (
190
+ process.argv
191
+ .slice(2)
192
+ .some((arg) =>
193
+ /^(?:--certifyCommand|--certify-command|-c)(?:=|$)/.test(arg),
194
+ )
195
+ )
196
+ throw new Error(
197
+ `certifyCommand was removed. Configure release.resolveDelta and release.policy instead.`,
198
+ )
187
199
 
188
- switch (inputs.case) {
189
- case `help`:
190
- console.log(help(parse.definition))
191
- break
192
- case `schema`:
193
- {
194
- const { outdir } = inputs.opts
195
- writeJsonSchema(outdir ?? `.`)
196
- process.stdout.write(`📝 Wrote json.schema.`)
200
+ switch (inputs.case) {
201
+ case `npm`:
202
+ case `npm/help`:
203
+ case `help`:
204
+ console.log(help(parse.definition))
205
+ break
206
+ case `npm/check`:
207
+ process.exitCode = await runNpmCli(`check`, inputs.opts)
208
+ break
209
+ case `npm/test`:
210
+ process.exitCode = await runNpmCli(`test`, inputs.opts)
211
+ break
212
+ case `npm/test/$peer/$range`:
213
+ process.exitCode = await runNpmCli(`test`, inputs.opts, inputs.params)
214
+ break
215
+ case `prelude`:
216
+ case `prelude/$configPath`: {
217
+ const config = resolveConfig(inputs.opts, loadedConfigPath)
218
+ const baseline = await breakagePrelude({
219
+ ...config,
220
+ out: config.out ?? `.breakage/baseline.json`,
221
+ })
222
+ process.stdout.write(`Pinned ${baseline.ref} at ${baseline.commit}.\n`)
223
+ break
197
224
  }
198
- break
199
- case `prelude`:
200
- case `prelude/$configPath`: {
201
- const baseline = await breakagePrelude({
202
- ...inputs.opts,
203
- out: inputs.opts.out ?? `.breakage/baseline.json`,
204
- })
205
- process.stdout.write(`Pinned ${baseline.ref} at ${baseline.commit}.\n`)
206
- break
207
- }
208
- case ``:
209
- case `$configPath`: {
210
- const { returnValue } = await encapsulate(() => breakage(inputs.opts), {
211
- console: true,
212
- stdout: true,
213
- })
214
- if (`testResult` in returnValue && returnValue.breakingChangesFound) {
215
- process.stdout.write(returnValue.testResult)
216
- }
217
- if (`breakingChangesFound` in returnValue) {
218
- if (returnValue.breakingChangesFound) {
219
- if (returnValue.breakingChangesCertified) {
220
- process.stdout.write(
221
- `👷 Breaking changes were found and certified.`,
222
- )
223
- process.exit(0)
225
+ case ``:
226
+ case `$configPath`: {
227
+ const merged = resolveConfig(inputs.opts, loadedConfigPath)
228
+ if (
229
+ commandOverrides.has(`testCommand`) &&
230
+ !commandOverrides.has(`test`)
231
+ )
232
+ delete merged.test
233
+ if (
234
+ commandOverrides.has(`test`) &&
235
+ !commandOverrides.has(`testCommand`)
236
+ )
237
+ delete merged.testCommand
238
+ if (!merged.testPattern)
239
+ throw new Error(`required arguments were not provided: testPattern.`)
240
+ const { returnValue } = await encapsulate(
241
+ () =>
242
+ breakage({
243
+ ...merged,
244
+ testPattern: merged.testPattern!,
245
+ }),
246
+ {
247
+ console: true,
248
+ stdout: true,
249
+ },
250
+ )
251
+ if (`release` in returnValue && returnValue.release) {
252
+ const { previousVersion, version, delta, breaking } =
253
+ returnValue.release
254
+ process.stdout.write(
255
+ `Release: ${previousVersion} → ${version} (${delta}; ${breaking ? `breaking` : `compatible`}).\n`,
256
+ )
257
+ }
258
+ if (`testResult` in returnValue && returnValue.breakingChangesFound) {
259
+ process.stdout.write(returnValue.testResult)
260
+ }
261
+ if (`breakingChangesFound` in returnValue) {
262
+ if (returnValue.breakingChangesFound) {
263
+ if (returnValue.release?.breaking) {
264
+ process.stdout.write(
265
+ `👷 Breaking changes are covered by the breaking release.`,
266
+ )
267
+ process.exit(0)
268
+ } else {
269
+ process.stdout.write(
270
+ `❌ Breaking changes require a breaking release.`,
271
+ )
272
+ process.exit(1)
273
+ }
224
274
  } else {
225
- process.stdout.write(
226
- `❌ Breaking changes were found, but not certified.`,
227
- )
228
- process.exit(1)
275
+ process.stdout.write(`✅ No breaking changes were found.`)
276
+ process.exit(0)
229
277
  }
230
278
  } else {
231
- process.stdout.write(`✅ No breaking changes were found.`)
232
- process.exit(0)
279
+ process.stdout.write(
280
+ `💥 Breakage failed to determine breaking changes.` +
281
+ `\n` +
282
+ inspect(returnValue),
283
+ )
284
+ process.exit(2)
233
285
  }
234
- } else {
235
- process.stdout.write(
236
- `💥 Breakage failed to determine breaking changes.` +
237
- `\n` +
238
- inspect(returnValue),
239
- )
240
- process.exit(2)
241
286
  }
242
287
  }
288
+ } catch (error) {
289
+ process.stderr.write(`Breakage setup error: ${errorMessage(error)}\n`)
290
+ process.exitCode = 2
243
291
  }
244
292
  }
@@ -0,0 +1,51 @@
1
+ import { assembleReleasePlan } from "@changesets/assemble-release-plan"
2
+ import { readConfig } from "@changesets/config"
3
+ import { readPreState } from "@changesets/pre"
4
+ import { readChangesets } from "@changesets/read"
5
+ import { getPackages } from "@manypkg/get-packages"
6
+
7
+ import {
8
+ versionTransition,
9
+ type ReleaseDeltaContext,
10
+ type VersionTransition,
11
+ } from "./release.ts"
12
+
13
+ /** Read the actual Changesets plan, including dependent bumps and prerelease state. */
14
+ export async function readChangesetsReleasePlan(
15
+ directory: string = process.cwd(),
16
+ ): Promise<ReadonlyMap<string, string>> {
17
+ const packages = await getPackages(directory)
18
+ const root = packages.rootDir
19
+ const { config, errors } = await readConfig(root, packages)
20
+ if (errors) throw new Error(errors.join(`\n`))
21
+ const [changesets, preState] = await Promise.all([
22
+ readChangesets(root),
23
+ readPreState(root),
24
+ ])
25
+ const plan = assembleReleasePlan(changesets, packages, config, preState)
26
+ return new Map(
27
+ plan.releases.flatMap(({ name, newVersion }) =>
28
+ newVersion === undefined ? [] : [[name, newVersion] as const],
29
+ ),
30
+ )
31
+ }
32
+
33
+ /** Compare the selected baseline (or current manifest) with the planned or versioned release. */
34
+ export function changesetsReleaseDelta({
35
+ directory,
36
+ }: { directory?: string } = {}): (
37
+ context: ReleaseDeltaContext,
38
+ ) => Promise<VersionTransition> {
39
+ return async ({
40
+ packageName,
41
+ packageDirectory,
42
+ currentVersion,
43
+ baseline,
44
+ }) => {
45
+ const version =
46
+ (await readChangesetsReleasePlan(directory ?? packageDirectory)).get(
47
+ packageName,
48
+ ) ?? currentVersion
49
+ return versionTransition(baseline?.version ?? currentVersion, version)
50
+ }
51
+ }
package/src/config.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { type } from "arktype"
5
+ import { cli, options, type ConfigInput } from "comline"
6
+
7
+ import { npmConfigSchema } from "./npm/config.ts"
8
+ import {
9
+ releaseSchema,
10
+ validateRelease,
11
+ rejectCertificationOptions,
12
+ } from "./release.ts"
13
+
14
+ // Reuse these field schemas for CLI metadata even though hooks cannot become JSON Schema.
15
+ export const configFields = {
16
+ tagPattern: type(`string`),
17
+ baselineFile: type(`string`),
18
+ testPattern: type(`string`),
19
+ testCommand: type(`string`),
20
+ test: type(`string`),
21
+ baseDirname: type(`string`),
22
+ verbose: type(`boolean`),
23
+ out: type(`string`),
24
+ }
25
+ export const configSchema = type({
26
+ "+": `reject`,
27
+ "tagPattern?": configFields.tagPattern.or(`undefined`),
28
+ "baselineFile?": configFields.baselineFile.or(`undefined`),
29
+ "testPattern?": configFields.testPattern,
30
+ "testCommand?": configFields.testCommand,
31
+ "test?": configFields.test,
32
+ "baseDirname?": configFields.baseDirname,
33
+ "verbose?": configFields.verbose.or(`undefined`),
34
+ "out?": configFields.out,
35
+ "release?": releaseSchema,
36
+ "npm?": npmConfigSchema,
37
+ })
38
+
39
+ /** The complete config, with no fields exposed to argv by this loader. */
40
+ export const configOptions = options(`Breakage configuration`, configSchema, {
41
+ tagPattern: { configOnly: true },
42
+ baselineFile: { configOnly: true },
43
+ testPattern: { configOnly: true },
44
+ testCommand: { configOnly: true },
45
+ test: { configOnly: true },
46
+ baseDirname: { configOnly: true },
47
+ verbose: { configOnly: true },
48
+ out: { configOnly: true },
49
+ release: { configOnly: true },
50
+ npm: { configOnly: true },
51
+ })
52
+ export type BreakageConfig = ConfigInput<typeof configOptions>
53
+
54
+ /** Share the root release model with npm checks unless they supply their own. */
55
+ export function defineConfig(config: BreakageConfig): BreakageConfig {
56
+ if (config && typeof config === `object`) {
57
+ rejectCertificationOptions(config)
58
+ if (config.npm) rejectCertificationOptions(config.npm)
59
+ if (config.release !== undefined) validateRelease(config.release)
60
+ if (config.npm?.release !== undefined) validateRelease(config.npm.release)
61
+ }
62
+ configSchema.assert(config)
63
+ return {
64
+ ...config,
65
+ ...(config.npm
66
+ ? {
67
+ npm: {
68
+ ...config.npm,
69
+ ...(config.release && config.npm.release === undefined
70
+ ? { release: config.release }
71
+ : {}),
72
+ },
73
+ }
74
+ : {}),
75
+ }
76
+ }
77
+
78
+ export function defaultConfig(directory: string): string {
79
+ const filename = path.join(directory, `breakage.config.ts`)
80
+ if (
81
+ !existsSync(filename) &&
82
+ existsSync(path.join(directory, `breakage.config.json`))
83
+ )
84
+ throw new Error(
85
+ `JSON configuration was removed. Migrate breakage.config.json to breakage.config.ts.`,
86
+ )
87
+ return filename
88
+ }
89
+
90
+ /** Comline ignores absent discovered files; explicit paths must exist. */
91
+ export function configPath(filename: string, required = true): string {
92
+ const absolute = path.resolve(filename)
93
+ if (path.extname(absolute) !== `.ts`)
94
+ throw new Error(
95
+ `Configuration must be a .ts module with a default export; JSON configuration was removed.`,
96
+ )
97
+ if (required && !existsSync(absolute))
98
+ throw new Error(`Configuration file does not exist: ${absolute}`)
99
+ return absolute
100
+ }
101
+
102
+ /** Apply application semantics after Comline validates and merges configuration. */
103
+ export function resolveConfig(
104
+ config: BreakageConfig,
105
+ filename?: string,
106
+ ): BreakageConfig {
107
+ return {
108
+ ...defineConfig(config),
109
+ baseDirname: path.resolve(
110
+ filename ? path.dirname(filename) : process.cwd(),
111
+ config.baseDirname ?? `.`,
112
+ ),
113
+ }
114
+ }
115
+
116
+ /** Use Comline's module loading and full-schema validation for programmatic and npm consumers. */
117
+ export async function loadConfig(filename: string): Promise<BreakageConfig> {
118
+ const absolute = configPath(filename)
119
+ const parse = cli({
120
+ cliName: `breakage`,
121
+ routeOptions: { "": configOptions },
122
+ discoverConfigPath: () => absolute,
123
+ })
124
+ return resolveConfig(
125
+ (await parse.async([process.execPath, `breakage`])).inputs.opts,
126
+ absolute,
127
+ )
128
+ }