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/npm/check.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import path from "node:path"
2
2
 
3
- import semver from "semver"
4
-
5
- import { command, errorMessage } from "./command.ts"
3
+ import {
4
+ resolveReleaseDelta,
5
+ versionDelta,
6
+ type ReleaseDeltaContext,
7
+ type VersionTransition,
8
+ } from "../release.ts"
9
+ import { command, errorMessage } from "../task.ts"
6
10
  import { validateConfig, validatePeerRanges } from "./config.ts"
7
11
  import {
8
12
  checkCandidatePeers,
@@ -20,13 +24,12 @@ import {
20
24
  normalizeWorkspacePeers,
21
25
  readManifest,
22
26
  } from "./manifest.ts"
23
- import { intent, previousManifest, supportChanges } from "./release.ts"
27
+ import { releaseVersion, previousManifest, supportChanges } from "./release.ts"
24
28
  import type {
25
29
  NpmConfig,
26
30
  NpmReport,
27
31
  NpmRepresentativeResult,
28
32
  NpmTestSelection,
29
- ReleaseIntent,
30
33
  } from "./types.ts"
31
34
 
32
35
  async function checkSupport(
@@ -44,36 +47,28 @@ async function checkSupport(
44
47
  const changes = supportChanges(previous, manifest)
45
48
  if (!changes.length)
46
49
  return { status: `unchanged`, previousVersion: previous.version, changes }
47
- const evidence = await intent(config, {
48
- packageName: manifest.name,
49
- packageDirectory: base,
50
- currentVersion: manifest.version,
51
- })
52
- if (evidence.status === `none`)
50
+ const evidence = config.release
51
+ ? await resolveReleaseDelta(config.release, {
52
+ packageName: manifest.name,
53
+ packageDirectory: base,
54
+ currentVersion: manifest.version,
55
+ baseline: { version: previous.version },
56
+ })
57
+ : undefined
58
+ if (evidence === undefined)
53
59
  return {
54
60
  status: `end-of-support`,
55
61
  previousVersion: previous.version,
56
62
  changes,
57
63
  }
58
- if (!semver.gt(evidence.version, previous.version))
59
- throw new Error(
60
- `The intended release must be newer than baseline ${previous.version}.`,
61
- )
62
- const release = {
63
- version: evidence.version,
64
- delta: semver.diff(previous.version, evidence.version) ?? `none`,
65
- }
66
- const certified = config.certifyBreaking
67
- ? await config.certifyBreaking({
68
- previousVersion: previous.version,
69
- release,
70
- changes,
71
- })
72
- : false
73
- if (typeof certified !== `boolean`)
74
- throw new Error(`certifyBreaking must return a boolean.`)
64
+ const release = versionDelta(
65
+ evidence.previousVersion,
66
+ evidence.version,
67
+ config.release?.policy,
68
+ )
69
+
75
70
  return {
76
- status: certified ? `certified` : `end-of-support`,
71
+ status: release.breaking ? `breaking-release` : `end-of-support`,
77
72
  previousVersion: previous.version,
78
73
  changes,
79
74
  release,
@@ -99,26 +94,26 @@ async function checkLocalReleases(
99
94
  const current = await readManifest(path.join(directory, `package.json`))
100
95
  if (current.name !== peer)
101
96
  throw new Error(`Local peer identity mismatch for ${peer}.`)
102
- const release = await intent(config, {
97
+ const release = await releaseVersion(config, {
103
98
  packageName: peer,
104
99
  packageDirectory: directory,
105
100
  currentVersion: current.version,
106
101
  })
107
- if (release.status === `none`) return []
108
- if (!matches(release.version, range))
102
+ if (release === undefined) return []
103
+ if (!matches(release, range))
109
104
  return [
110
- `Declared support for ${peer}: ${range} does not include its planned local release ${release.version}. Update the peer range to include the intended version.`,
105
+ `Declared support for ${peer}: ${range} does not include its planned local release ${release}. Update the peer range to include the intended version.`,
111
106
  ]
112
107
  if (
113
108
  !representatives.some(
114
109
  (result) =>
115
110
  result.peers[peer]?.source === `candidate` &&
116
- result.peers[peer]?.version === release.version &&
111
+ result.peers[peer]?.version === release &&
117
112
  result.suites.some((suite) => suite.peer === peer),
118
113
  )
119
114
  )
120
115
  return [
121
- `No coordinated representative covers planned local peer ${peer}@${release.version}. Enroll ${peer} with breakage.candidates and configure its relationship suite.`,
116
+ `No coordinated representative covers planned local peer ${peer}@${release}. Enroll ${peer} with breakage.candidates and configure its relationship suite.`,
122
117
  ]
123
118
  return []
124
119
  } catch (error) {
@@ -297,24 +292,30 @@ async function run(
297
292
  )
298
293
  eras(selection.range)
299
294
  }
300
- // One piece of release evidence per package per invocation, including failures.
301
- const releases = new Map<string, Promise<ReleaseIntent>>()
302
- const configuredHook = config.releaseIntent
295
+ // One piece of release evidence per package and baseline per invocation, including failures.
296
+ const releases = new Map<string, Promise<VersionTransition | undefined>>()
297
+ const resolveDelta = (
298
+ context: ReleaseDeltaContext,
299
+ ): Promise<VersionTransition | undefined> => {
300
+ const key = JSON.stringify([
301
+ context.packageDirectory,
302
+ context.currentVersion,
303
+ context.baseline,
304
+ ])
305
+ let pending = releases.get(key)
306
+ if (!pending) {
307
+ pending = resolveReleaseDelta(config.release!, context)
308
+ releases.set(key, pending)
309
+ }
310
+ return pending
311
+ }
303
312
  const effective: NpmConfig = {
304
313
  ...config,
305
- releaseIntent: (context) => {
306
- let pending = releases.get(context.packageDirectory)
307
- if (!pending) {
308
- pending = Promise.resolve().then(() =>
309
- configuredHook
310
- ? configuredHook(context)
311
- : ({ status: `none` } as const),
312
- )
313
- releases.set(context.packageDirectory, pending)
314
- }
315
- return pending
316
- },
314
+ ...(config.release
315
+ ? { release: { ...config.release, resolveDelta } }
316
+ : {}),
317
317
  }
318
+
318
319
  const directories = await discover(config, base)
319
320
  const [support, representatives] = await Promise.all([
320
321
  selection
package/src/npm/cli.ts CHANGED
@@ -1,30 +1,37 @@
1
- import { existsSync } from "node:fs"
2
- import { readFile } from "node:fs/promises"
3
1
  import path from "node:path"
4
- import { pathToFileURL } from "node:url"
5
2
 
6
3
  import { type } from "arktype"
7
4
  import type { OptionsGroup } from "comline"
8
5
  import { options, parseBooleanOption } from "comline"
9
6
 
7
+ import { configOptions, defaultConfig, loadConfig } from "../config.ts"
8
+
10
9
  import { formatNpmReport, npmCheck, npmTest } from "./check.ts"
11
- import { errorMessage } from "./command.ts"
10
+ import { errorMessage } from "../task.ts"
12
11
  import { validateConfig } from "./config.ts"
13
- import { record } from "./manifest.ts"
14
12
  import type { NpmTestSelection } from "./types.ts"
15
13
 
16
- type CommonOptions = { config?: string | undefined; json?: boolean | undefined }
14
+ type CommonOptions = {
15
+ config?: string | undefined
16
+ json?: boolean | undefined
17
+ release?: import("../release.ts").ReleaseConfig
18
+ npm?: import("./types.ts").NpmConfig
19
+ }
17
20
  type CheckOptions = CommonOptions & { coverageOnly?: boolean | undefined }
18
21
  type TestOptions = CommonOptions & { representative?: string | undefined }
19
22
 
20
23
  const common = {
24
+ release: configOptions.optionConfigs.release,
25
+ npm: configOptions.optionConfigs.npm,
21
26
  config: {
27
+ schema: type(`string`),
22
28
  required: false,
23
- description: `Configuration file (defaults to breakage.config.ts, then .json).`,
29
+ description: `Configuration file (defaults to breakage.config.ts).`,
24
30
  example: `--config ./breakage.config.ts`,
25
31
  completion: { fileSystem: `files` as const, repeatable: false },
26
32
  },
27
33
  json: {
34
+ schema: type(`boolean`),
28
35
  required: false,
29
36
  description: `Emit the structured result.`,
30
37
  example: `--json`,
@@ -34,10 +41,17 @@ const common = {
34
41
 
35
42
  export const npmCheckManual: OptionsGroup<CheckOptions> = options(
36
43
  `Check npm peer compatibility and changes in declared support.`,
37
- type({ "config?": `string`, "json?": `boolean`, "coverageOnly?": `boolean` }),
44
+ type({
45
+ "config?": `string`,
46
+ "json?": `boolean`,
47
+ "coverageOnly?": `boolean`,
48
+ "release?": configOptions.optionsSchema.get(`release`),
49
+ "npm?": configOptions.optionsSchema.get(`npm`),
50
+ }),
38
51
  {
39
52
  ...common,
40
53
  coverageOnly: {
54
+ schema: type(`boolean`),
41
55
  aliases: [`coverage-only`],
42
56
  required: false,
43
57
  description: `Audit coverage and support changes without executing assertions.`,
@@ -53,10 +67,13 @@ export const npmTestManual: OptionsGroup<TestOptions> = options(
53
67
  "config?": `string`,
54
68
  "json?": `boolean`,
55
69
  "representative?": `string`,
70
+ "release?": configOptions.optionsSchema.get(`release`),
71
+ "npm?": configOptions.optionsSchema.get(`npm`),
56
72
  }),
57
73
  {
58
74
  ...common,
59
75
  representative: {
76
+ schema: type(`string`),
60
77
  required: false,
61
78
  description: `Select an exact configured representative path relative to the package.`,
62
79
  example: `--representative peer-environments/react-19`,
@@ -65,15 +82,6 @@ export const npmTestManual: OptionsGroup<TestOptions> = options(
65
82
  },
66
83
  )
67
84
 
68
- export function defaultConfig(directory: string): string {
69
- return path.join(
70
- directory,
71
- existsSync(path.join(directory, `breakage.config.ts`))
72
- ? `breakage.config.ts`
73
- : `breakage.config.json`,
74
- )
75
- }
76
-
77
85
  export async function runNpmCli(
78
86
  action: `check` | `test`,
79
87
  opts: CheckOptions & TestOptions,
@@ -81,11 +89,7 @@ export async function runNpmCli(
81
89
  ): Promise<number> {
82
90
  try {
83
91
  const filename = path.resolve(opts.config ?? defaultConfig(process.cwd()))
84
- const loaded: unknown = filename.endsWith(`.json`)
85
- ? JSON.parse(await readFile(filename, `utf8`))
86
- : ((await import(pathToFileURL(filename).href)) as { default: unknown })
87
- .default
88
- const config = record(loaded) ? loaded.npm : undefined
92
+ const config = (await loadConfig(filename)).npm
89
93
  validateConfig(config)
90
94
  let selection: NpmTestSelection | undefined
91
95
  if (action === `test`) {
package/src/npm/config.ts CHANGED
@@ -1,15 +1,45 @@
1
1
  import path from "node:path"
2
+ import { type } from "arktype"
3
+
4
+ import { releaseSchema, rejectCertificationOptions } from "../release.ts"
2
5
 
3
6
  import { eras, packageName, record } from "./manifest.ts"
4
7
  import type { NpmConfig } from "./types.ts"
5
8
 
9
+ export const npmSuiteSchema = type({
10
+ "+": `reject`,
11
+ files: `string[]`,
12
+ command: `string`,
13
+ })
14
+ export const npmConfigSchema = type({
15
+ "+": `reject`,
16
+ "release?": releaseSchema,
17
+ "packageDirectory?": `string`,
18
+ artifact: `string`,
19
+ representatives: `string | string[]`,
20
+ lockfile: `string`,
21
+ prepareCommand: `string`,
22
+ "installCommand?": `string`,
23
+ suites: { "[string]": npmSuiteSchema },
24
+ "localPeers?": {
25
+ "[string]": { "+": `reject`, directory: `string`, artifact: `string` },
26
+ },
27
+ baseline: type({ "+": `reject`, firstRelease: `true` }).or({
28
+ "+": `reject`,
29
+ "tagPattern?": `string`,
30
+ "baselineFile?": `string`,
31
+ }),
32
+ })
33
+
6
34
  function nonempty(value: unknown): value is string {
7
35
  return typeof value === `string` && value.trim().length > 0
8
36
  }
9
37
 
10
38
  export function validateConfig(value: unknown): asserts value is NpmConfig {
11
39
  if (!record(value)) throw new Error(`Configuration must have an npm object.`)
12
- for (const key of [`artifact`, `lockfile`, `prepareCommand`] as const) {
40
+ rejectCertificationOptions(value)
41
+ npmConfigSchema.assert(value)
42
+ for (const key of [`artifact`, `lockfile`] as const) {
13
43
  if (!nonempty(value[key]))
14
44
  throw new Error(`npm.${key} must be a nonempty string.`)
15
45
  }
@@ -19,7 +49,13 @@ export function validateConfig(value: unknown): asserts value is NpmConfig {
19
49
  ) {
20
50
  throw new Error(`npm.lockfile must stay inside each representative.`)
21
51
  }
22
- for (const key of [`packageDirectory`, `installCommand`] as const) {
52
+ if (!nonempty(value.prepareCommand))
53
+ throw new Error(
54
+ `npm.prepareCommand must be a nonempty shell command string.`,
55
+ )
56
+ if (value.installCommand !== undefined && !nonempty(value.installCommand))
57
+ throw new Error(`Invalid npm.installCommand.`)
58
+ for (const key of [`packageDirectory`] as const) {
23
59
  if (value[key] !== undefined && !nonempty(value[key]))
24
60
  throw new Error(`Invalid npm.${key}.`)
25
61
  }
@@ -75,10 +111,6 @@ export function validateConfig(value: unknown): asserts value is NpmConfig {
75
111
  }
76
112
  }
77
113
  }
78
- for (const hook of [`releaseIntent`, `certifyBreaking`] as const) {
79
- if (value[hook] !== undefined && typeof value[hook] !== `function`)
80
- throw new Error(`npm.${hook} must be a function.`)
81
- }
82
114
  }
83
115
 
84
116
  export function validatePeerRanges(peers: Record<string, string>): void {
@@ -16,11 +16,11 @@ import path from "node:path"
16
16
  import semver from "semver"
17
17
  import { simpleGit } from "simple-git"
18
18
 
19
- import { prepare } from "./command.ts"
19
+ import { prepare } from "../task.ts"
20
20
  import { validatePeerRanges } from "./config.ts"
21
21
  import type { Manifest } from "./manifest.ts"
22
22
  import { matches, normalizeWorkspacePeers, readManifest } from "./manifest.ts"
23
- import { intent } from "./release.ts"
23
+ import { releaseVersion } from "./release.ts"
24
24
  import type { NpmConfig, NpmPeerEvidence, NpmSuite } from "./types.ts"
25
25
 
26
26
  export type Environment = {
@@ -192,14 +192,14 @@ export async function maySelectEnvironment(
192
192
  )
193
193
  if (current.name !== peer)
194
194
  throw new Error(`Local peer identity mismatch for ${peer}.`)
195
- const release = await intent(config, {
195
+ const release = await releaseVersion(config, {
196
196
  packageName: peer,
197
197
  packageDirectory,
198
198
  currentVersion: current.version,
199
199
  })
200
- if (release.status === `none`)
201
- throw new Error(`Candidate ${peer} has no certified release intent.`)
202
- return matches(release.version, range)
200
+ if (release === undefined)
201
+ throw new Error(`Candidate ${peer} has no release version.`)
202
+ return matches(release, range)
203
203
  }
204
204
  const declared = declarations[peer]
205
205
  return declared !== undefined && matches(declared, range)
@@ -243,18 +243,18 @@ export async function prepareEnvironment(
243
243
  if (localManifest.name !== peer)
244
244
  throw new Error(`Local peer identity mismatch for ${peer}.`)
245
245
  validatePeerRanges(localManifest.peerDependencies ?? {})
246
- const evidence = await intent(config, {
246
+ const evidence = await releaseVersion(config, {
247
247
  packageName: peer,
248
248
  packageDirectory: localDirectory,
249
249
  currentVersion: localManifest.version,
250
250
  })
251
- if (evidence.status === `none`)
252
- throw new Error(`Candidate ${peer} has no certified release intent.`)
251
+ if (evidence === undefined)
252
+ throw new Error(`Candidate ${peer} has no release version.`)
253
253
  candidates[peer] = {
254
254
  directory: localDirectory,
255
255
  artifact: path.resolve(base, local.artifact),
256
256
  currentVersion: localManifest.version,
257
- version: evidence.version,
257
+ version: evidence,
258
258
  manifest: localManifest,
259
259
  }
260
260
  }
@@ -3,38 +3,22 @@ import path from "node:path"
3
3
  import semver from "semver"
4
4
  import { simpleGit } from "simple-git"
5
5
 
6
+ import { resolveReleaseDelta, type ReleaseDeltaContext } from "../release.ts"
6
7
  import { readBaseline } from "../baseline.ts"
7
8
  import { withDirectoryLock } from "../directory-lock.ts"
8
9
  import { latestReleaseTag } from "../release-tag.ts"
9
10
  import { validatePeerRanges } from "./config.ts"
10
11
  import type { Manifest } from "./manifest.ts"
11
- import { normalizeWorkspacePeers, parseManifest, record } from "./manifest.ts"
12
- import type {
13
- NpmConfig,
14
- ReleaseContext,
15
- ReleaseIntent,
16
- SupportChange,
17
- } from "./types.ts"
12
+ import { normalizeWorkspacePeers, parseManifest } from "./manifest.ts"
13
+ import type { NpmConfig, SupportChange } from "./types.ts"
18
14
 
19
- export async function intent(
15
+ export async function releaseVersion(
20
16
  config: NpmConfig,
21
- context: ReleaseContext,
22
- ): Promise<ReleaseIntent> {
23
- if (!config.releaseIntent) return { status: `none` }
24
- const result: unknown = await config.releaseIntent(context)
25
- if (record(result) && result.status === `none`) return { status: `none` }
26
- if (
27
- !record(result) ||
28
- result.status !== `release` ||
29
- typeof result.version !== `string` ||
30
- !semver.valid(result.version) ||
31
- semver.lt(result.version, context.currentVersion)
32
- ) {
33
- throw new Error(
34
- `Invalid release intent for ${context.packageName}; expected none or a current/newer exact version.`,
35
- )
36
- }
37
- return { status: `release`, version: result.version }
17
+ context: ReleaseDeltaContext,
18
+ ): Promise<string | undefined> {
19
+ return config.release
20
+ ? (await resolveReleaseDelta(config.release, context))?.version
21
+ : undefined
38
22
  }
39
23
 
40
24
  /**
package/src/npm/types.ts CHANGED
@@ -1,12 +1,4 @@
1
- export type ReleaseIntent =
2
- | { status: `none` }
3
- | { status: `release`; version: string }
4
-
5
- export type ReleaseContext = {
6
- packageName: string
7
- packageDirectory: string
8
- currentVersion: string
9
- }
1
+ import type { VersionDelta } from "../release.ts"
10
2
 
11
3
  export type SupportChange = {
12
4
  peer: string
@@ -15,40 +7,8 @@ export type SupportChange = {
15
7
  current: string | null
16
8
  }
17
9
 
18
- export type ReleaseEvidence = {
19
- version: string
20
- delta: string
21
- }
22
-
23
- export type NpmSuite = {
24
- /** Package-relative globs; preserve relative paths inside the stage. */
25
- files: string[]
26
- /** Run once; 1 means assertion failure, other nonzero codes mean setup failure. */
27
- command: string
28
- }
29
-
30
- export type NpmConfig = {
31
- packageDirectory?: string
32
- artifact: string
33
- representatives: string | string[]
34
- lockfile: string
35
- prepareCommand: string
36
- installCommand?: string
37
- suites: Record<string, NpmSuite>
38
- /** Currently declared local peers must support and exercise each planned release. */
39
- localPeers?: Record<string, { directory: string; artifact: string }>
40
- baseline:
41
- | { firstRelease: true }
42
- | { tagPattern?: string; baselineFile?: string }
43
- releaseIntent?: (
44
- context: ReleaseContext,
45
- ) => ReleaseIntent | Promise<ReleaseIntent>
46
- certifyBreaking?: (context: {
47
- previousVersion: string
48
- release: ReleaseEvidence
49
- changes: SupportChange[]
50
- }) => boolean | Promise<boolean>
51
- }
10
+ export type NpmSuite = typeof import("./config.ts").npmSuiteSchema.infer
11
+ export type NpmConfig = typeof import("./config.ts").npmConfigSchema.infer
52
12
 
53
13
  export type NpmTestSelection =
54
14
  | { peer: string; range: string }
@@ -90,14 +50,14 @@ export type NpmReport = {
90
50
  support: {
91
51
  status:
92
52
  | `unchanged`
93
- | `certified`
53
+ | `breaking-release`
94
54
  | `end-of-support`
95
55
  | `first-release`
96
56
  | `not-checked`
97
57
  | `setup-error`
98
58
  changes: SupportChange[]
99
59
  previousVersion?: string
100
- release?: ReleaseEvidence
60
+ release?: VersionDelta
101
61
  error?: string
102
62
  }
103
63
  errors: string[]
package/src/release.ts ADDED
@@ -0,0 +1,153 @@
1
+ // cspell:words premajor preminor prepatch
2
+ import semver from "semver"
3
+ import { type } from "arktype"
4
+
5
+ export type ReleaseDeltaContext = {
6
+ packageName: string
7
+ packageDirectory: string
8
+ currentVersion: string
9
+ /** The selected historical release, when checking compatibility. */
10
+ baseline?: { version: string; ref?: string; commit?: string }
11
+ }
12
+ export type VersionTransition = {
13
+ previousVersion: string
14
+ version: string
15
+ delta:
16
+ | `major`
17
+ | `premajor`
18
+ | `minor`
19
+ | `preminor`
20
+ | `patch`
21
+ | `prepatch`
22
+ | `prerelease`
23
+ | `release`
24
+ | `none`
25
+ }
26
+ export type ReleaseDeltaResolver = (
27
+ context: ReleaseDeltaContext,
28
+ ) => VersionTransition | undefined | Promise<VersionTransition | undefined>
29
+ export type VersionDelta = VersionTransition & { breaking: boolean }
30
+ export type SemverPolicyOptions = { beforeV1?: `major` | `minor` | `patch` }
31
+ export type ReleasePolicyFunction = (delta: VersionTransition) => boolean
32
+ export type ReleasePolicy = ReleasePolicyFunction
33
+ export const releaseSchema = type({
34
+ "+": `reject`,
35
+ "resolveDelta?": type(`Function`).as<ReleaseDeltaResolver>(),
36
+ "policy?": type(`Function`).as<ReleasePolicy>(),
37
+ })
38
+ export type ReleaseConfig = typeof releaseSchema.infer
39
+
40
+ /** Choose which version increments permit breaking changes. Prerelease-only increments do not. */
41
+ export function semverPolicy({
42
+ beforeV1 = `major`,
43
+ }: SemverPolicyOptions = {}): ReleasePolicyFunction {
44
+ if (![`major`, `minor`, `patch`].includes(beforeV1))
45
+ throw new Error(`Invalid beforeV1 policy.`)
46
+ return ({ previousVersion, version }) => {
47
+ const previous = new semver.SemVer(previousVersion)
48
+ const next = new semver.SemVer(version)
49
+ if (next.major > previous.major) return true
50
+ if (previous.major !== 0 || beforeV1 === `major`) return false
51
+ return (
52
+ next.minor > previous.minor ||
53
+ (beforeV1 === `patch` && next.patch > previous.patch)
54
+ )
55
+ }
56
+ }
57
+
58
+ /** Describe a version transition without deciding whether it permits breakage. */
59
+ export function versionTransition(
60
+ previousVersion: string,
61
+ version: string,
62
+ ): VersionTransition {
63
+ if (!semver.valid(previousVersion) || !semver.valid(version))
64
+ throw new Error(`Expected exact semantic versions.`)
65
+ return {
66
+ previousVersion,
67
+ version,
68
+ delta: semver.diff(previousVersion, version) ?? `none`,
69
+ }
70
+ }
71
+
72
+ /** Pure comparison; equal and older versions never permit breaking changes. */
73
+ export function versionDelta(
74
+ previousVersion: string,
75
+ version: string,
76
+ policy: ReleasePolicy = semverPolicy(),
77
+ ): VersionDelta {
78
+ const evidence = versionTransition(previousVersion, version)
79
+ const breaking = semver.gt(version, previousVersion)
80
+ ? policy(evidence)
81
+ : false
82
+ if (typeof breaking !== `boolean`)
83
+ throw new Error(`Release policy must return a boolean.`)
84
+ return { ...evidence, breaking }
85
+ }
86
+
87
+ export function validateRelease(
88
+ value: unknown,
89
+ ): asserts value is ReleaseConfig {
90
+ if (typeof value !== `object` || value === null || Array.isArray(value))
91
+ throw new Error(`release must be an object.`)
92
+ if (`version` in value)
93
+ throw new Error(
94
+ `release.version was removed. Use release.resolveDelta to return a version transition.`,
95
+ )
96
+ releaseSchema.assert(value)
97
+ }
98
+
99
+ /** Resolve and validate release evidence; policy is applied separately. */
100
+ export async function resolveReleaseDelta(
101
+ config: ReleaseConfig,
102
+ context: ReleaseDeltaContext,
103
+ ): Promise<VersionTransition | undefined> {
104
+ validateRelease(config)
105
+ const transition = config.resolveDelta
106
+ ? await config.resolveDelta(context)
107
+ : versionTransition(
108
+ context.baseline?.version ?? context.currentVersion,
109
+ context.currentVersion,
110
+ )
111
+ if (transition === undefined) return undefined
112
+ if (
113
+ typeof transition !== `object` ||
114
+ transition === null ||
115
+ Array.isArray(transition) ||
116
+ typeof transition.previousVersion !== `string` ||
117
+ !semver.valid(transition.previousVersion) ||
118
+ typeof transition.version !== `string` ||
119
+ !semver.valid(transition.version) ||
120
+ !semver.valid(context.currentVersion) ||
121
+ semver.lt(transition.version, context.currentVersion)
122
+ )
123
+ throw new Error(
124
+ `Invalid release transition for ${context.packageName}; expected exact versions and a current/newer target.`,
125
+ )
126
+ if (
127
+ context.baseline &&
128
+ transition.previousVersion !== context.baseline.version
129
+ )
130
+ throw new Error(
131
+ `Release transition for ${context.packageName} does not match the selected baseline.`,
132
+ )
133
+ const evidence = versionTransition(
134
+ transition.previousVersion,
135
+ transition.version,
136
+ )
137
+ if (transition.delta !== evidence.delta)
138
+ throw new Error(
139
+ `Release transition for ${context.packageName} has an inconsistent semver delta.`,
140
+ )
141
+ // Only evidence crosses this boundary; resolver-supplied policy decisions are ignored.
142
+ return evidence
143
+ }
144
+
145
+ /** Removed hooks must fail explicitly, including in untyped configurations. */
146
+ export function rejectCertificationOptions(value: object): void {
147
+ for (const key of [`certifyCommand`, `releaseIntent`, `certifyBreaking`]) {
148
+ if (key in value)
149
+ throw new Error(
150
+ `${key} was removed. Use release.resolveDelta and release.policy instead.`,
151
+ )
152
+ }
153
+ }
@@ -8,6 +8,8 @@ export async function command(
8
8
  commandLine: string,
9
9
  cwd: string,
10
10
  ): Promise<CommandResult> {
11
+ if (typeof commandLine !== `string` || !commandLine.trim())
12
+ throw new Error(`Commands must be nonempty shell strings.`)
11
13
  return await new Promise((resolve, reject) => {
12
14
  const child = spawn(commandLine, {
13
15
  cwd,