breakage 0.8.5 → 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 (42) hide show
  1. package/README.md +163 -47
  2. package/dist/{breakage-B7dJsgcu.js → breakage-DIY49fzP.js} +331 -217
  3. package/dist/breakage-DIY49fzP.js.map +1 -0
  4. package/dist/breakage.d.ts +111 -55
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +78 -80
  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 +55 -33
  18. package/package.json +45 -6
  19. package/src/breakage.ts +125 -73
  20. package/src/breakage.x.ts +148 -127
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +128 -0
  23. package/src/npm/check.ts +51 -50
  24. package/src/npm/cli.ts +26 -22
  25. package/src/npm/config.ts +38 -6
  26. package/src/npm/environment.ts +10 -10
  27. package/src/npm/release.ts +9 -25
  28. package/src/npm/types.ts +5 -45
  29. package/src/release.ts +153 -0
  30. package/src/{npm/command.ts → task.ts} +2 -0
  31. package/dist/breakage-B7dJsgcu.js.map +0 -1
  32. package/dist/breakage.$configPath.schema.json +0 -33
  33. package/dist/breakage.help.schema.json +0 -6
  34. package/dist/breakage.main.schema.json +0 -33
  35. package/dist/breakage.npm.check.schema.json +0 -16
  36. package/dist/breakage.npm.help.schema.json +0 -6
  37. package/dist/breakage.npm.schema.json +0 -6
  38. package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
  39. package/dist/breakage.npm.test.schema.json +0 -16
  40. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  41. package/dist/breakage.prelude.schema.json +0 -31
  42. package/dist/breakage.schema.schema.json +0 -10
package/src/breakage.x.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import path from "node:path"
3
4
  import { inspect } from "node:util"
4
5
 
5
- import { type } from "arktype"
6
- import type { OptionsGroup } from "comline"
7
6
  import {
8
7
  cli,
9
8
  completionResponse,
@@ -18,28 +17,29 @@ import {
18
17
  } from "comline"
19
18
  import logger from "takua"
20
19
 
21
- import type { BreakageOptions } from "./breakage.ts"
22
- import { breakage, breakagePrelude } from "./breakage.ts"
23
20
  import {
24
21
  defaultConfig,
25
- npmCheckManual,
26
- npmTestManual,
27
- runNpmCli,
28
- } from "./npm/cli.ts"
22
+ configPath,
23
+ resolveConfig,
24
+ configSchema,
25
+ configFields,
26
+ } from "./config.ts"
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`>()
29
33
 
30
34
  const BREAKAGE_MANUAL = options(
31
35
  `Check for breaking changes in a package.`,
32
- type({
33
- "tagPattern?": `string`,
34
- "baselineFile?": `string`,
35
- testPattern: `string`,
36
- testCommand: `string`,
37
- certifyCommand: `string`,
38
- "baseDirname?": `string`,
39
- "verbose?": `boolean`,
40
- }),
36
+ configSchema,
41
37
  {
38
+ release: { configOnly: true },
39
+ npm: { configOnly: true },
40
+ out: { configOnly: true },
42
41
  tagPattern: {
42
+ schema: configFields.tagPattern,
43
43
  aliases: [`tag-pattern`],
44
44
  completion: { repeatable: false },
45
45
  flag: `g`,
@@ -48,6 +48,7 @@ const BREAKAGE_MANUAL = options(
48
48
  example: `--tagPattern="my-library"`,
49
49
  },
50
50
  baselineFile: {
51
+ schema: configFields.baselineFile,
51
52
  aliases: [`baseline-file`],
52
53
  completion: { repeatable: false, fileSystem: `files` },
53
54
  required: false,
@@ -55,30 +56,41 @@ const BREAKAGE_MANUAL = options(
55
56
  example: `--baseline-file=.breakage/baseline.json`,
56
57
  },
57
58
  testPattern: {
59
+ schema: configFields.testPattern,
58
60
  aliases: [`test-pattern`, `pattern`],
59
61
  completion: { repeatable: false },
60
62
  flag: `p`,
61
- required: true,
63
+ required: false,
62
64
  description: `The pattern to match test files that test the public API of the library.`,
63
65
  example: `--pattern="*__public.test.ts"`,
64
66
  },
65
67
  testCommand: {
68
+ parse: (value) => {
69
+ commandOverrides.add(`testCommand`)
70
+ return value
71
+ },
72
+ schema: configFields.testCommand,
66
73
  aliases: [`test-command`],
67
74
  completion: { repeatable: false },
68
75
  flag: `t`,
69
- required: true,
76
+ required: false,
70
77
  description: `Complete bash command that runs the tests for the library's public API.`,
71
78
  example: `--testCommand="npm run test"`,
72
79
  },
73
- certifyCommand: {
74
- aliases: [`certify-command`],
80
+ test: {
81
+ parse: (value) => {
82
+ commandOverrides.add(`test`)
83
+ return value
84
+ },
85
+ schema: configFields.test,
75
86
  completion: { repeatable: false },
76
- flag: `c`,
77
- required: true,
78
- 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.`,
79
- 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"`,
80
90
  },
81
91
  baseDirname: {
92
+ parse: (value) => path.resolve(value),
93
+ schema: configFields.baseDirname,
82
94
  aliases: [`base-dir`, `base-dirname`],
83
95
  completion: { repeatable: false, fileSystem: `directories` },
84
96
  flag: `d`,
@@ -87,6 +99,7 @@ const BREAKAGE_MANUAL = options(
87
99
  example: `--baseDirname=./packages/my-package`,
88
100
  },
89
101
  verbose: {
102
+ schema: configFields.verbose,
90
103
  completion: { repeatable: false },
91
104
  flag: `v`,
92
105
  required: false,
@@ -95,20 +108,11 @@ const BREAKAGE_MANUAL = options(
95
108
  parse: parseBooleanOption,
96
109
  },
97
110
  },
98
- ) satisfies OptionsGroup<BreakageOptions>
111
+ )
99
112
 
100
113
  const PRELUDE_MANUAL = options(
101
114
  `Resolve and fetch the release baseline for a cacheable compatibility check.`,
102
- type({
103
- "tagPattern?": `string`,
104
- "baseDirname?": `string`,
105
- "out?": `string`,
106
- "testPattern?": `string`,
107
- "testCommand?": `string`,
108
- "certifyCommand?": `string`,
109
- "baselineFile?": `string`,
110
- "verbose?": `boolean`,
111
- }),
115
+ configSchema,
112
116
  {
113
117
  ...BREAKAGE_MANUAL.optionConfigs,
114
118
  testPattern: {
@@ -119,11 +123,8 @@ const PRELUDE_MANUAL = options(
119
123
  ...BREAKAGE_MANUAL.optionConfigs.testCommand,
120
124
  required: false,
121
125
  },
122
- certifyCommand: {
123
- ...BREAKAGE_MANUAL.optionConfigs.certifyCommand,
124
- required: false,
125
- },
126
126
  out: {
127
+ schema: configFields.out,
127
128
  completion: { repeatable: false, fileSystem: `files` },
128
129
  required: false,
129
130
  description: `Gitignored snapshot path relative to the base directory (default: .breakage/baseline.json).`,
@@ -132,27 +133,21 @@ const PRELUDE_MANUAL = options(
132
133
  },
133
134
  )
134
135
 
135
- const SCHEMA_MANUAL = options(
136
- `Create a copy of the schema for configuring breakage.`,
137
- type({ "outdir?": `string` }),
138
- {
139
- outdir: {
140
- aliases: [`out-dir`],
141
- completion: { repeatable: false, fileSystem: `directories` },
142
- flag: `o`,
143
- required: false,
144
- description: `Directory to write the schema to.`,
145
- example: `--outdir=./dist`,
146
- },
147
- },
148
- )
149
-
136
+ let loadedConfigPath: string | undefined
150
137
  const parse = cli(
151
138
  {
152
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
+ },
153
149
  routes: optional({
154
150
  help: null,
155
- schema: null,
156
151
  npm: optional({
157
152
  help: null,
158
153
  check: null,
@@ -165,7 +160,6 @@ const parse = cli(
165
160
  "": BREAKAGE_MANUAL,
166
161
  $configPath: BREAKAGE_MANUAL,
167
162
  help: noOptions(`Show usage.`),
168
- schema: SCHEMA_MANUAL,
169
163
  npm: noOptions(`Show npm compatibility commands.`),
170
164
  "npm/help": noOptions(`Show npm compatibility commands.`),
171
165
  "npm/check": npmCheckManual,
@@ -178,15 +172,6 @@ const parse = cli(
178
172
  $configPath: { fileSystem: `files` },
179
173
  "prelude/$configPath": { fileSystem: `files` },
180
174
  },
181
- discoverConfigPath: (args: readonly string[]) => {
182
- if (args[0] === `help` || args[0] === `schema` || args[0] === `npm`) {
183
- return
184
- }
185
- const configPath =
186
- (args[0] === `prelude` ? args[1] : args[0]) ??
187
- defaultConfig(process.cwd())
188
- return configPath
189
- },
190
175
  },
191
176
  {
192
177
  info: (message, ...data) => logger.info(`breakage`, message, data),
@@ -198,74 +183,110 @@ const completion = await completionResponse(parse.definition, process.argv)
198
183
  if (completion !== undefined) {
199
184
  process.stdout.write(completion)
200
185
  } else {
201
- const { inputs, warnings, writeJsonSchema } = parse(process.argv)
202
- 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
+ )
203
199
 
204
- switch (inputs.case) {
205
- case `npm`:
206
- case `npm/help`:
207
- case `help`:
208
- console.log(help(parse.definition))
209
- break
210
- case `npm/check`:
211
- process.exitCode = await runNpmCli(`check`, inputs.opts)
212
- break
213
- case `npm/test`:
214
- process.exitCode = await runNpmCli(`test`, inputs.opts)
215
- break
216
- case `npm/test/$peer/$range`:
217
- process.exitCode = await runNpmCli(`test`, inputs.opts, inputs.params)
218
- break
219
- case `schema`:
220
- {
221
- const { outdir } = inputs.opts
222
- writeJsonSchema(outdir ?? `.`)
223
- process.stdout.write(`📝 Wrote json.schema.`)
224
- }
225
- break
226
- case `prelude`:
227
- case `prelude/$configPath`: {
228
- const baseline = await breakagePrelude({
229
- ...inputs.opts,
230
- out: inputs.opts.out ?? `.breakage/baseline.json`,
231
- })
232
- process.stdout.write(`Pinned ${baseline.ref} at ${baseline.commit}.\n`)
233
- break
234
- }
235
- case ``:
236
- case `$configPath`: {
237
- const { returnValue } = await encapsulate(() => breakage(inputs.opts), {
238
- console: true,
239
- stdout: true,
240
- })
241
- if (`testResult` in returnValue && returnValue.breakingChangesFound) {
242
- process.stdout.write(returnValue.testResult)
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
243
224
  }
244
- if (`breakingChangesFound` in returnValue) {
245
- if (returnValue.breakingChangesFound) {
246
- if (returnValue.breakingChangesCertified) {
247
- process.stdout.write(
248
- `👷 Breaking changes were found and certified.`,
249
- )
250
- 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
+ }
251
274
  } else {
252
- process.stdout.write(
253
- `❌ Breaking changes were found, but not certified.`,
254
- )
255
- process.exit(1)
275
+ process.stdout.write(`✅ No breaking changes were found.`)
276
+ process.exit(0)
256
277
  }
257
278
  } else {
258
- process.stdout.write(`✅ No breaking changes were found.`)
259
- process.exit(0)
279
+ process.stdout.write(
280
+ `💥 Breakage failed to determine breaking changes.` +
281
+ `\n` +
282
+ inspect(returnValue),
283
+ )
284
+ process.exit(2)
260
285
  }
261
- } else {
262
- process.stdout.write(
263
- `💥 Breakage failed to determine breaking changes.` +
264
- `\n` +
265
- inspect(returnValue),
266
- )
267
- process.exit(2)
268
286
  }
269
287
  }
288
+ } catch (error) {
289
+ process.stderr.write(`Breakage setup error: ${errorMessage(error)}\n`)
290
+ process.exitCode = 2
270
291
  }
271
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
+ }