breakage 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +163 -47
  2. package/dist/{breakage-B7dJsgcu.js → breakage-DJiIiLzL.js} +958 -221
  3. package/dist/breakage-DJiIiLzL.js.map +1 -0
  4. package/dist/breakage.d.ts +239 -71
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +216 -79
  8. package/dist/breakage.x.js.map +1 -1
  9. package/dist/changesets.d.ts +8 -0
  10. package/dist/changesets.d.ts.map +1 -0
  11. package/dist/changesets.js +27 -0
  12. package/dist/changesets.js.map +1 -0
  13. package/dist/release-BsdIDT4g.js +69 -0
  14. package/dist/release-BsdIDT4g.js.map +1 -0
  15. package/dist/release-D47BqRuB.d.ts +40 -0
  16. package/dist/release-D47BqRuB.d.ts.map +1 -0
  17. package/npm.md +198 -44
  18. package/package.json +45 -6
  19. package/src/breakage.ts +142 -73
  20. package/src/breakage.x.ts +174 -127
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +131 -0
  23. package/src/npm/check.ts +70 -50
  24. package/src/npm/cli.ts +30 -22
  25. package/src/npm/config.ts +38 -6
  26. package/src/npm/environment.ts +10 -10
  27. package/src/npm/matrix-cli.ts +170 -0
  28. package/src/npm/matrix-model.ts +395 -0
  29. package/src/npm/matrix.ts +657 -0
  30. package/src/npm/release.ts +9 -25
  31. package/src/npm/types.ts +5 -45
  32. package/src/release.ts +153 -0
  33. package/src/{npm/command.ts → task.ts} +2 -0
  34. package/dist/breakage-B7dJsgcu.js.map +0 -1
  35. package/dist/breakage.$configPath.schema.json +0 -33
  36. package/dist/breakage.help.schema.json +0 -6
  37. package/dist/breakage.main.schema.json +0 -33
  38. package/dist/breakage.npm.check.schema.json +0 -16
  39. package/dist/breakage.npm.help.schema.json +0 -6
  40. package/dist/breakage.npm.schema.json +0 -6
  41. package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
  42. package/dist/breakage.npm.test.schema.json +0 -16
  43. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  44. package/dist/breakage.prelude.schema.json +0 -31
  45. package/dist/breakage.schema.schema.json +0 -10
@@ -0,0 +1,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,131 @@
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 { npmMatrixConfigSchema } from "./npm/matrix-model.ts"
9
+ import {
10
+ releaseSchema,
11
+ validateRelease,
12
+ rejectCertificationOptions,
13
+ } from "./release.ts"
14
+
15
+ // Reuse these field schemas for CLI metadata even though hooks cannot become JSON Schema.
16
+ export const configFields = {
17
+ tagPattern: type(`string`),
18
+ baselineFile: type(`string`),
19
+ testPattern: type(`string`),
20
+ testCommand: type(`string`),
21
+ test: type(`string`),
22
+ baseDirname: type(`string`),
23
+ verbose: type(`boolean`),
24
+ out: type(`string`),
25
+ }
26
+ export const configSchema = type({
27
+ "+": `reject`,
28
+ "tagPattern?": configFields.tagPattern.or(`undefined`),
29
+ "baselineFile?": configFields.baselineFile.or(`undefined`),
30
+ "testPattern?": configFields.testPattern,
31
+ "testCommand?": configFields.testCommand,
32
+ "test?": configFields.test,
33
+ "baseDirname?": configFields.baseDirname,
34
+ "verbose?": configFields.verbose.or(`undefined`),
35
+ "out?": configFields.out,
36
+ "release?": releaseSchema,
37
+ "npm?": npmConfigSchema,
38
+ "npmMatrix?": npmMatrixConfigSchema,
39
+ })
40
+
41
+ /** The complete config, with no fields exposed to argv by this loader. */
42
+ export const configOptions = options(`Breakage configuration`, configSchema, {
43
+ tagPattern: { configOnly: true },
44
+ baselineFile: { configOnly: true },
45
+ testPattern: { configOnly: true },
46
+ testCommand: { configOnly: true },
47
+ test: { configOnly: true },
48
+ baseDirname: { configOnly: true },
49
+ verbose: { configOnly: true },
50
+ out: { configOnly: true },
51
+ release: { configOnly: true },
52
+ npm: { configOnly: true },
53
+ npmMatrix: { configOnly: true },
54
+ })
55
+ export type BreakageConfig = ConfigInput<typeof configOptions>
56
+
57
+ /** Share the root release model with npm checks unless they supply their own. */
58
+ export function defineConfig(config: BreakageConfig): BreakageConfig {
59
+ if (config && typeof config === `object`) {
60
+ rejectCertificationOptions(config)
61
+ if (config.npm) rejectCertificationOptions(config.npm)
62
+ if (config.release !== undefined) validateRelease(config.release)
63
+ if (config.npm?.release !== undefined) validateRelease(config.npm.release)
64
+ }
65
+ configSchema.assert(config)
66
+ return {
67
+ ...config,
68
+ ...(config.npm
69
+ ? {
70
+ npm: {
71
+ ...config.npm,
72
+ ...(config.release && config.npm.release === undefined
73
+ ? { release: config.release }
74
+ : {}),
75
+ },
76
+ }
77
+ : {}),
78
+ }
79
+ }
80
+
81
+ export function defaultConfig(directory: string): string {
82
+ const filename = path.join(directory, `breakage.config.ts`)
83
+ if (
84
+ !existsSync(filename) &&
85
+ existsSync(path.join(directory, `breakage.config.json`))
86
+ )
87
+ throw new Error(
88
+ `JSON configuration was removed. Migrate breakage.config.json to breakage.config.ts.`,
89
+ )
90
+ return filename
91
+ }
92
+
93
+ /** Comline ignores absent discovered files; explicit paths must exist. */
94
+ export function configPath(filename: string, required = true): string {
95
+ const absolute = path.resolve(filename)
96
+ if (path.extname(absolute) !== `.ts`)
97
+ throw new Error(
98
+ `Configuration must be a .ts module with a default export; JSON configuration was removed.`,
99
+ )
100
+ if (required && !existsSync(absolute))
101
+ throw new Error(`Configuration file does not exist: ${absolute}`)
102
+ return absolute
103
+ }
104
+
105
+ /** Apply application semantics after Comline validates and merges configuration. */
106
+ export function resolveConfig(
107
+ config: BreakageConfig,
108
+ filename?: string,
109
+ ): BreakageConfig {
110
+ return {
111
+ ...defineConfig(config),
112
+ baseDirname: path.resolve(
113
+ filename ? path.dirname(filename) : process.cwd(),
114
+ config.baseDirname ?? `.`,
115
+ ),
116
+ }
117
+ }
118
+
119
+ /** Use Comline's module loading and full-schema validation for programmatic and npm consumers. */
120
+ export async function loadConfig(filename: string): Promise<BreakageConfig> {
121
+ const absolute = configPath(filename)
122
+ const parse = cli({
123
+ cliName: `breakage`,
124
+ routeOptions: { "": configOptions },
125
+ discoverConfigPath: () => absolute,
126
+ })
127
+ return resolveConfig(
128
+ (await parse.async([process.execPath, `breakage`])).inputs.opts,
129
+ absolute,
130
+ )
131
+ }
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) {
@@ -138,6 +133,7 @@ async function runRepresentative(
138
133
  directory: string,
139
134
  coverageOnly: boolean,
140
135
  selection?: NpmTestSelection,
136
+ plannedSuites?: readonly string[],
141
137
  ): Promise<NpmRepresentativeResult> {
142
138
  const label = path.relative(base, directory).split(path.sep).join(`/`)
143
139
  const result: NpmRepresentativeResult = {
@@ -201,6 +197,7 @@ async function runRepresentative(
201
197
  const peers = Object.keys(config.suites).filter((peer) => {
202
198
  const evidence = result.peers[peer]
203
199
  if (!evidence) return false
200
+ if (plannedSuites !== undefined) return plannedSuites.includes(peer)
204
201
  if (selection && `peer` in selection) return peer === selection.peer
205
202
  if (selection && `representative` in selection) return true
206
203
  return matches(evidence.version, manifest.peerDependencies![peer]!)
@@ -262,6 +259,7 @@ async function run(
262
259
  baseDirectory: string,
263
260
  coverageOnly: boolean,
264
261
  selection?: NpmTestSelection,
262
+ plannedSuites?: readonly string[],
265
263
  ): Promise<NpmReport> {
266
264
  const report: NpmReport = {
267
265
  package: { name: `(unresolved)`, version: `(unresolved)` },
@@ -297,24 +295,30 @@ async function run(
297
295
  )
298
296
  eras(selection.range)
299
297
  }
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
298
+ // One piece of release evidence per package and baseline per invocation, including failures.
299
+ const releases = new Map<string, Promise<VersionTransition | undefined>>()
300
+ const resolveDelta = (
301
+ context: ReleaseDeltaContext,
302
+ ): Promise<VersionTransition | undefined> => {
303
+ const key = JSON.stringify([
304
+ context.packageDirectory,
305
+ context.currentVersion,
306
+ context.baseline,
307
+ ])
308
+ let pending = releases.get(key)
309
+ if (!pending) {
310
+ pending = resolveReleaseDelta(config.release!, context)
311
+ releases.set(key, pending)
312
+ }
313
+ return pending
314
+ }
303
315
  const effective: NpmConfig = {
304
316
  ...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
- },
317
+ ...(config.release
318
+ ? { release: { ...config.release, resolveDelta } }
319
+ : {}),
317
320
  }
321
+
318
322
  const directories = await discover(config, base)
319
323
  const [support, representatives] = await Promise.all([
320
324
  selection
@@ -329,6 +333,7 @@ async function run(
329
333
  directory,
330
334
  coverageOnly,
331
335
  selection,
336
+ plannedSuites,
332
337
  ),
333
338
  ),
334
339
  ),
@@ -411,6 +416,21 @@ export async function npmTest(
411
416
  )
412
417
  }
413
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
+
414
434
  export function formatNpmReport(report: NpmReport): string {
415
435
  const lines = [
416
436
  `${report.package.name}@${report.package.version} — npm ${report.mode}`,
package/src/npm/cli.ts CHANGED
@@ -1,30 +1,39 @@
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
+ npmMatrix?: import("./matrix-model.ts").NpmMatrixConfig
20
+ }
17
21
  type CheckOptions = CommonOptions & { coverageOnly?: boolean | undefined }
18
22
  type TestOptions = CommonOptions & { representative?: string | undefined }
19
23
 
20
24
  const common = {
25
+ release: configOptions.optionConfigs.release,
26
+ npm: configOptions.optionConfigs.npm,
27
+ npmMatrix: configOptions.optionConfigs.npmMatrix,
21
28
  config: {
29
+ schema: type(`string`),
22
30
  required: false,
23
- description: `Configuration file (defaults to breakage.config.ts, then .json).`,
31
+ description: `Configuration file (defaults to breakage.config.ts).`,
24
32
  example: `--config ./breakage.config.ts`,
25
33
  completion: { fileSystem: `files` as const, repeatable: false },
26
34
  },
27
35
  json: {
36
+ schema: type(`boolean`),
28
37
  required: false,
29
38
  description: `Emit the structured result.`,
30
39
  example: `--json`,
@@ -34,10 +43,18 @@ const common = {
34
43
 
35
44
  export const npmCheckManual: OptionsGroup<CheckOptions> = options(
36
45
  `Check npm peer compatibility and changes in declared support.`,
37
- type({ "config?": `string`, "json?": `boolean`, "coverageOnly?": `boolean` }),
46
+ type({
47
+ "config?": `string`,
48
+ "json?": `boolean`,
49
+ "coverageOnly?": `boolean`,
50
+ "release?": configOptions.optionsSchema.get(`release`),
51
+ "npm?": configOptions.optionsSchema.get(`npm`),
52
+ "npmMatrix?": configOptions.optionsSchema.get(`npmMatrix`),
53
+ }),
38
54
  {
39
55
  ...common,
40
56
  coverageOnly: {
57
+ schema: type(`boolean`),
41
58
  aliases: [`coverage-only`],
42
59
  required: false,
43
60
  description: `Audit coverage and support changes without executing assertions.`,
@@ -53,10 +70,14 @@ export const npmTestManual: OptionsGroup<TestOptions> = options(
53
70
  "config?": `string`,
54
71
  "json?": `boolean`,
55
72
  "representative?": `string`,
73
+ "release?": configOptions.optionsSchema.get(`release`),
74
+ "npm?": configOptions.optionsSchema.get(`npm`),
75
+ "npmMatrix?": configOptions.optionsSchema.get(`npmMatrix`),
56
76
  }),
57
77
  {
58
78
  ...common,
59
79
  representative: {
80
+ schema: type(`string`),
60
81
  required: false,
61
82
  description: `Select an exact configured representative path relative to the package.`,
62
83
  example: `--representative peer-environments/react-19`,
@@ -65,15 +86,6 @@ export const npmTestManual: OptionsGroup<TestOptions> = options(
65
86
  },
66
87
  )
67
88
 
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
89
  export async function runNpmCli(
78
90
  action: `check` | `test`,
79
91
  opts: CheckOptions & TestOptions,
@@ -81,11 +93,7 @@ export async function runNpmCli(
81
93
  ): Promise<number> {
82
94
  try {
83
95
  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
96
+ const config = (await loadConfig(filename)).npm
89
97
  validateConfig(config)
90
98
  let selection: NpmTestSelection | undefined
91
99
  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
  }