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
@@ -0,0 +1,449 @@
1
+ import path from "node:path"
2
+
3
+ import {
4
+ resolveReleaseDelta,
5
+ versionDelta,
6
+ type ReleaseDeltaContext,
7
+ type VersionTransition,
8
+ } from "../release.ts"
9
+ import { command, errorMessage } from "../task.ts"
10
+ import { validateConfig, validatePeerRanges } from "./config.ts"
11
+ import {
12
+ checkCandidatePeers,
13
+ discover,
14
+ maySelectEnvironment,
15
+ prepareEnvironment,
16
+ stageCandidate,
17
+ verifyResolution,
18
+ withStage,
19
+ } from "./environment.ts"
20
+ import type { Manifest } from "./manifest.ts"
21
+ import {
22
+ eras,
23
+ matches,
24
+ normalizeWorkspacePeers,
25
+ readManifest,
26
+ } from "./manifest.ts"
27
+ import { releaseVersion, previousManifest, supportChanges } from "./release.ts"
28
+ import type {
29
+ NpmConfig,
30
+ NpmReport,
31
+ NpmRepresentativeResult,
32
+ NpmTestSelection,
33
+ } from "./types.ts"
34
+
35
+ async function checkSupport(
36
+ config: NpmConfig,
37
+ base: string,
38
+ manifest: Manifest,
39
+ ): Promise<NpmReport[`support`]> {
40
+ try {
41
+ const previous = await previousManifest(config, base)
42
+ if (!previous) return { status: `first-release`, changes: [] }
43
+ if (previous.name !== manifest.name)
44
+ throw new Error(
45
+ `Release baseline belongs to ${previous.name}, not ${manifest.name}.`,
46
+ )
47
+ const changes = supportChanges(previous, manifest)
48
+ if (!changes.length)
49
+ return { status: `unchanged`, previousVersion: previous.version, changes }
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)
59
+ return {
60
+ status: `end-of-support`,
61
+ previousVersion: previous.version,
62
+ changes,
63
+ }
64
+ const release = versionDelta(
65
+ evidence.previousVersion,
66
+ evidence.version,
67
+ config.release?.policy,
68
+ )
69
+
70
+ return {
71
+ status: release.breaking ? `breaking-release` : `end-of-support`,
72
+ previousVersion: previous.version,
73
+ changes,
74
+ release,
75
+ }
76
+ } catch (error) {
77
+ return { status: `setup-error`, changes: [], error: errorMessage(error) }
78
+ }
79
+ }
80
+
81
+ async function checkLocalReleases(
82
+ config: NpmConfig,
83
+ base: string,
84
+ manifest: Manifest,
85
+ representatives: NpmRepresentativeResult[],
86
+ ): Promise<string[]> {
87
+ const findings = await Promise.all(
88
+ Object.entries(config.localPeers ?? {}).map(async ([peer, local]) => {
89
+ const range = manifest.peerDependencies?.[peer]
90
+ // A registration may be retained solely to resolve an older baseline.
91
+ if (range === undefined) return []
92
+ try {
93
+ const directory = path.resolve(base, local.directory)
94
+ const current = await readManifest(path.join(directory, `package.json`))
95
+ if (current.name !== peer)
96
+ throw new Error(`Local peer identity mismatch for ${peer}.`)
97
+ const release = await releaseVersion(config, {
98
+ packageName: peer,
99
+ packageDirectory: directory,
100
+ currentVersion: current.version,
101
+ })
102
+ if (release === undefined) return []
103
+ if (!matches(release, range))
104
+ return [
105
+ `Declared support for ${peer}: ${range} does not include its planned local release ${release}. Update the peer range to include the intended version.`,
106
+ ]
107
+ if (
108
+ !representatives.some(
109
+ (result) =>
110
+ result.peers[peer]?.source === `candidate` &&
111
+ result.peers[peer]?.version === release &&
112
+ result.suites.some((suite) => suite.peer === peer),
113
+ )
114
+ )
115
+ return [
116
+ `No coordinated representative covers planned local peer ${peer}@${release}. Enroll ${peer} with breakage.candidates and configure its relationship suite.`,
117
+ ]
118
+ return []
119
+ } catch (error) {
120
+ return [
121
+ `Cannot inspect planned local release for ${peer}: ${errorMessage(error)}`,
122
+ ]
123
+ }
124
+ }),
125
+ )
126
+ return findings.flat()
127
+ }
128
+
129
+ async function runRepresentative(
130
+ config: NpmConfig,
131
+ base: string,
132
+ manifest: Manifest,
133
+ directory: string,
134
+ coverageOnly: boolean,
135
+ selection?: NpmTestSelection,
136
+ ): Promise<NpmRepresentativeResult> {
137
+ const label = path.relative(base, directory).split(path.sep).join(`/`)
138
+ const result: NpmRepresentativeResult = {
139
+ path: label,
140
+ status: `skipped`,
141
+ peers: {},
142
+ suites: [],
143
+ warnings: [],
144
+ }
145
+ if (
146
+ selection &&
147
+ `representative` in selection &&
148
+ path.resolve(base, selection.representative) !== directory
149
+ )
150
+ return result
151
+ try {
152
+ if (
153
+ selection &&
154
+ `peer` in selection &&
155
+ !(await maySelectEnvironment(
156
+ config,
157
+ base,
158
+ directory,
159
+ manifest,
160
+ selection,
161
+ ))
162
+ )
163
+ return result
164
+ return await withStage(directory, async (stage) => {
165
+ const environment = await prepareEnvironment(
166
+ config,
167
+ base,
168
+ directory,
169
+ stage,
170
+ manifest,
171
+ )
172
+ result.peers = environment.peers
173
+ for (const peer of Object.keys(manifest.peerDependencies ?? {})) {
174
+ if (
175
+ !result.peers[peer] &&
176
+ !manifest.peerDependenciesMeta?.[peer]?.optional
177
+ )
178
+ throw new Error(
179
+ `Complete representative is missing required peer ${peer}.`,
180
+ )
181
+ }
182
+ for (const [peer, evidence] of Object.entries(result.peers)) {
183
+ const range = manifest.peerDependencies?.[peer]
184
+ if (range !== undefined && !matches(evidence.version, range))
185
+ result.warnings.push(
186
+ `${peer}@${evidence.version} is outside declared support.`,
187
+ )
188
+ }
189
+ if (
190
+ selection &&
191
+ `peer` in selection &&
192
+ (!result.peers[selection.peer] ||
193
+ !matches(result.peers[selection.peer]!.version, selection.range))
194
+ )
195
+ return result
196
+ const peers = Object.keys(config.suites).filter((peer) => {
197
+ const evidence = result.peers[peer]
198
+ if (!evidence) return false
199
+ if (selection && `peer` in selection) return peer === selection.peer
200
+ if (selection && `representative` in selection) return true
201
+ return matches(evidence.version, manifest.peerDependencies![peer]!)
202
+ })
203
+ result.suites = peers.map((peer) => ({ peer, status: `not-run` }))
204
+ if (!peers.length) return result
205
+ result.warnings.push(...checkCandidatePeers(environment, !!selection))
206
+ if (coverageOnly) {
207
+ result.status = `audited`
208
+ return result
209
+ }
210
+ await stageCandidate(
211
+ config,
212
+ base,
213
+ manifest,
214
+ environment,
215
+ peers.map((peer) => config.suites[peer]!),
216
+ )
217
+ for (const suite of result.suites) {
218
+ try {
219
+ await verifyResolution(manifest, environment)
220
+ const execution = await command(
221
+ config.suites[suite.peer]!.command,
222
+ stage,
223
+ )
224
+ await verifyResolution(manifest, environment)
225
+ suite.status =
226
+ execution.code === 0
227
+ ? `passed`
228
+ : execution.code === 1
229
+ ? `compatibility-failure`
230
+ : `setup-error`
231
+ if (execution.output) suite.output = execution.output
232
+ } catch (error) {
233
+ suite.status = `setup-error`
234
+ suite.output = errorMessage(error)
235
+ }
236
+ }
237
+ result.status = result.suites.some(
238
+ (suite) => suite.status === `setup-error`,
239
+ )
240
+ ? `setup-error`
241
+ : result.suites.some(
242
+ (suite) => suite.status === `compatibility-failure`,
243
+ )
244
+ ? `compatibility-failure`
245
+ : `passed`
246
+ return result
247
+ })
248
+ } catch (error) {
249
+ result.status = `setup-error`
250
+ result.error = errorMessage(error)
251
+ return result
252
+ }
253
+ }
254
+
255
+ async function run(
256
+ config: NpmConfig,
257
+ baseDirectory: string,
258
+ coverageOnly: boolean,
259
+ selection?: NpmTestSelection,
260
+ ): Promise<NpmReport> {
261
+ const report: NpmReport = {
262
+ package: { name: `(unresolved)`, version: `(unresolved)` },
263
+ mode: selection ? `test` : coverageOnly ? `coverage-only` : `check`,
264
+ exitCode: 0,
265
+ representatives: [],
266
+ coverage: [],
267
+ support: { status: `not-checked`, changes: [] },
268
+ errors: [],
269
+ }
270
+ try {
271
+ validateConfig(config)
272
+ const base = path.resolve(baseDirectory, config.packageDirectory ?? `.`)
273
+ const manifest = await normalizeWorkspacePeers(
274
+ await readManifest(path.join(base, `package.json`)),
275
+ )
276
+ report.package = { name: manifest.name, version: manifest.version }
277
+ validatePeerRanges(manifest.peerDependencies ?? {})
278
+ for (const peer of Object.keys(config.suites)) {
279
+ if (!manifest.peerDependencies?.[peer])
280
+ throw new Error(`Suite ${peer} has no peerDependencies declaration.`)
281
+ }
282
+ for (const peer of Object.keys(config.localPeers ?? {})) {
283
+ if (manifest.peerDependencies?.[peer] && !config.suites[peer])
284
+ throw new Error(
285
+ `Declared local peer ${peer} needs a relationship suite.`,
286
+ )
287
+ }
288
+ if (selection && `peer` in selection) {
289
+ if (!config.suites[selection.peer])
290
+ throw new Error(
291
+ `No relationship suite configured for ${selection.peer}.`,
292
+ )
293
+ eras(selection.range)
294
+ }
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
+ }
312
+ const effective: NpmConfig = {
313
+ ...config,
314
+ ...(config.release
315
+ ? { release: { ...config.release, resolveDelta } }
316
+ : {}),
317
+ }
318
+
319
+ const directories = await discover(config, base)
320
+ const [support, representatives] = await Promise.all([
321
+ selection
322
+ ? Promise.resolve(report.support)
323
+ : checkSupport(effective, base, manifest),
324
+ Promise.all(
325
+ directories.map((directory) =>
326
+ runRepresentative(
327
+ effective,
328
+ base,
329
+ manifest,
330
+ directory,
331
+ coverageOnly,
332
+ selection,
333
+ ),
334
+ ),
335
+ ),
336
+ ])
337
+ report.support = support
338
+ report.representatives = representatives
339
+ if (!selection) {
340
+ report.errors.push(
341
+ ...(await checkLocalReleases(
342
+ effective,
343
+ base,
344
+ manifest,
345
+ representatives,
346
+ )),
347
+ )
348
+ for (const peer of Object.keys(config.suites)) {
349
+ for (const era of eras(manifest.peerDependencies![peer]!)) {
350
+ const matching = representatives
351
+ .filter(
352
+ (representative) =>
353
+ representative.peers[peer] &&
354
+ matches(representative.peers[peer]!.version, era),
355
+ )
356
+ .map((representative) => representative.path)
357
+ report.coverage.push({ peer, era, representatives: matching })
358
+ if (!matching.length)
359
+ report.errors.push(`No representative covers ${peer}: ${era}.`)
360
+ }
361
+ }
362
+ } else if (
363
+ !representatives.some(
364
+ (representative) => representative.suites.length > 0,
365
+ )
366
+ ) {
367
+ report.errors.push(
368
+ `No representative matched the requested test selection.`,
369
+ )
370
+ }
371
+ if (
372
+ support.status === `setup-error` ||
373
+ report.errors.length ||
374
+ representatives.some((result) => result.status === `setup-error`)
375
+ )
376
+ report.exitCode = 2
377
+ else if (
378
+ support.status === `end-of-support` ||
379
+ representatives.some(
380
+ (result) => result.status === `compatibility-failure`,
381
+ )
382
+ )
383
+ report.exitCode = 1
384
+ } catch (error) {
385
+ report.errors.push(errorMessage(error))
386
+ report.exitCode = 2
387
+ }
388
+ return report
389
+ }
390
+
391
+ export async function npmCheck(
392
+ config: NpmConfig,
393
+ options: { baseDirectory?: string; coverageOnly?: boolean } = {},
394
+ ): Promise<NpmReport> {
395
+ return await run(
396
+ config,
397
+ options.baseDirectory ?? process.cwd(),
398
+ options.coverageOnly ?? false,
399
+ )
400
+ }
401
+
402
+ export async function npmTest(
403
+ config: NpmConfig,
404
+ selection: NpmTestSelection,
405
+ options: { baseDirectory?: string } = {},
406
+ ): Promise<NpmReport> {
407
+ return await run(
408
+ config,
409
+ options.baseDirectory ?? process.cwd(),
410
+ false,
411
+ selection,
412
+ )
413
+ }
414
+
415
+ export function formatNpmReport(report: NpmReport): string {
416
+ const lines = [
417
+ `${report.package.name}@${report.package.version} — npm ${report.mode}`,
418
+ ]
419
+ for (const result of report.representatives) {
420
+ lines.push(`${result.path}: ${result.status}`)
421
+ for (const [peer, evidence] of Object.entries(result.peers)) {
422
+ lines.push(
423
+ ` ${peer}@${evidence.version}${evidence.source === `candidate` ? ` (conditional on publication)` : ` (installed)`}`,
424
+ )
425
+ }
426
+ for (const suite of result.suites) {
427
+ lines.push(` ${suite.peer}: ${suite.status}`)
428
+ if (suite.status !== `passed` && suite.output) lines.push(suite.output)
429
+ }
430
+ for (const warning of result.warnings) lines.push(` Warning: ${warning}`)
431
+ if (result.error) lines.push(` ${result.error}`)
432
+ }
433
+ lines.push(`Support: ${report.support.status}`)
434
+ for (const change of report.support.changes)
435
+ lines.push(
436
+ ` ${change.peer}: ${change.previous ?? `(unrestricted)`} → ${change.current ?? `(unrestricted)`} (${change.reason})`,
437
+ )
438
+ if (report.support.release)
439
+ lines.push(
440
+ ` Intended release: ${report.support.release.version} (${report.support.release.delta})`,
441
+ )
442
+ if (report.support.error) lines.push(report.support.error)
443
+ lines.push(...report.errors)
444
+ if (report.mode === `coverage-only`)
445
+ lines.push(
446
+ `Coverage audit only; compatibility assertions were not executed.`,
447
+ )
448
+ return `${lines.join(`\n`)}\n`
449
+ }
package/src/npm/cli.ts ADDED
@@ -0,0 +1,130 @@
1
+ import path from "node:path"
2
+
3
+ import { type } from "arktype"
4
+ import type { OptionsGroup } from "comline"
5
+ import { options, parseBooleanOption } from "comline"
6
+
7
+ import { configOptions, defaultConfig, loadConfig } from "../config.ts"
8
+
9
+ import { formatNpmReport, npmCheck, npmTest } from "./check.ts"
10
+ import { errorMessage } from "../task.ts"
11
+ import { validateConfig } from "./config.ts"
12
+ import type { NpmTestSelection } from "./types.ts"
13
+
14
+ type CommonOptions = {
15
+ config?: string | undefined
16
+ json?: boolean | undefined
17
+ release?: import("../release.ts").ReleaseConfig
18
+ npm?: import("./types.ts").NpmConfig
19
+ }
20
+ type CheckOptions = CommonOptions & { coverageOnly?: boolean | undefined }
21
+ type TestOptions = CommonOptions & { representative?: string | undefined }
22
+
23
+ const common = {
24
+ release: configOptions.optionConfigs.release,
25
+ npm: configOptions.optionConfigs.npm,
26
+ config: {
27
+ schema: type(`string`),
28
+ required: false,
29
+ description: `Configuration file (defaults to breakage.config.ts).`,
30
+ example: `--config ./breakage.config.ts`,
31
+ completion: { fileSystem: `files` as const, repeatable: false },
32
+ },
33
+ json: {
34
+ schema: type(`boolean`),
35
+ required: false,
36
+ description: `Emit the structured result.`,
37
+ example: `--json`,
38
+ parse: parseBooleanOption,
39
+ },
40
+ }
41
+
42
+ export const npmCheckManual: OptionsGroup<CheckOptions> = options(
43
+ `Check npm peer compatibility and changes in declared support.`,
44
+ type({
45
+ "config?": `string`,
46
+ "json?": `boolean`,
47
+ "coverageOnly?": `boolean`,
48
+ "release?": configOptions.optionsSchema.get(`release`),
49
+ "npm?": configOptions.optionsSchema.get(`npm`),
50
+ }),
51
+ {
52
+ ...common,
53
+ coverageOnly: {
54
+ schema: type(`boolean`),
55
+ aliases: [`coverage-only`],
56
+ required: false,
57
+ description: `Audit coverage and support changes without executing assertions.`,
58
+ example: `--coverage-only`,
59
+ parse: parseBooleanOption,
60
+ },
61
+ },
62
+ )
63
+
64
+ export const npmTestManual: OptionsGroup<TestOptions> = options(
65
+ `Run current contracts in matching complete peer environments.`,
66
+ type({
67
+ "config?": `string`,
68
+ "json?": `boolean`,
69
+ "representative?": `string`,
70
+ "release?": configOptions.optionsSchema.get(`release`),
71
+ "npm?": configOptions.optionsSchema.get(`npm`),
72
+ }),
73
+ {
74
+ ...common,
75
+ representative: {
76
+ schema: type(`string`),
77
+ required: false,
78
+ description: `Select an exact configured representative path relative to the package.`,
79
+ example: `--representative peer-environments/react-19`,
80
+ completion: { fileSystem: `directories`, repeatable: false },
81
+ },
82
+ },
83
+ )
84
+
85
+ export async function runNpmCli(
86
+ action: `check` | `test`,
87
+ opts: CheckOptions & TestOptions,
88
+ peerSelection?: { peer: string; range: string },
89
+ ): Promise<number> {
90
+ try {
91
+ const filename = path.resolve(opts.config ?? defaultConfig(process.cwd()))
92
+ const config = (await loadConfig(filename)).npm
93
+ validateConfig(config)
94
+ let selection: NpmTestSelection | undefined
95
+ if (action === `test`) {
96
+ if (peerSelection && opts.representative)
97
+ throw new Error(`Choose a peer range or a representative, not both.`)
98
+ selection =
99
+ peerSelection ??
100
+ (opts.representative
101
+ ? { representative: opts.representative }
102
+ : undefined)
103
+ if (!selection)
104
+ throw new Error(
105
+ `Use npm test <peer> <range> or npm test --representative <path>.`,
106
+ )
107
+ }
108
+ const options = {
109
+ baseDirectory: path.dirname(filename),
110
+ coverageOnly: opts.coverageOnly ?? false,
111
+ }
112
+ const report = selection
113
+ ? await npmTest(config, selection, options)
114
+ : await npmCheck(config, options)
115
+ process.stdout.write(
116
+ opts.json
117
+ ? `${JSON.stringify(report, null, 2)}\n`
118
+ : formatNpmReport(report),
119
+ )
120
+ return report.exitCode
121
+ } catch (error) {
122
+ const message = errorMessage(error)
123
+ process.stdout.write(
124
+ opts.json
125
+ ? `${JSON.stringify({ exitCode: 2, errors: [message] })}\n`
126
+ : `npm setup error: ${message}\n`,
127
+ )
128
+ return 2
129
+ }
130
+ }
@@ -0,0 +1,118 @@
1
+ import path from "node:path"
2
+ import { type } from "arktype"
3
+
4
+ import { releaseSchema, rejectCertificationOptions } from "../release.ts"
5
+
6
+ import { eras, packageName, record } from "./manifest.ts"
7
+ import type { NpmConfig } from "./types.ts"
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
+
34
+ function nonempty(value: unknown): value is string {
35
+ return typeof value === `string` && value.trim().length > 0
36
+ }
37
+
38
+ export function validateConfig(value: unknown): asserts value is NpmConfig {
39
+ if (!record(value)) throw new Error(`Configuration must have an npm object.`)
40
+ rejectCertificationOptions(value)
41
+ npmConfigSchema.assert(value)
42
+ for (const key of [`artifact`, `lockfile`] as const) {
43
+ if (!nonempty(value[key]))
44
+ throw new Error(`npm.${key} must be a nonempty string.`)
45
+ }
46
+ if (
47
+ path.isAbsolute(value.lockfile as string) ||
48
+ (value.lockfile as string).split(/[\\/]/).includes(`..`)
49
+ ) {
50
+ throw new Error(`npm.lockfile must stay inside each representative.`)
51
+ }
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) {
59
+ if (value[key] !== undefined && !nonempty(value[key]))
60
+ throw new Error(`Invalid npm.${key}.`)
61
+ }
62
+ const globs =
63
+ typeof value.representatives === `string`
64
+ ? [value.representatives]
65
+ : value.representatives
66
+ if (
67
+ !Array.isArray(globs) ||
68
+ !globs.length ||
69
+ globs.some((glob: unknown) => !nonempty(glob))
70
+ ) {
71
+ throw new Error(`npm.representatives must contain directory globs.`)
72
+ }
73
+ if (!record(value.suites) || !Object.keys(value.suites).length)
74
+ throw new Error(`Configure at least one npm peer suite.`)
75
+ for (const [peer, suite] of Object.entries(value.suites)) {
76
+ if (
77
+ !packageName(peer) ||
78
+ !record(suite) ||
79
+ !nonempty(suite.command) ||
80
+ !Array.isArray(suite.files) ||
81
+ !suite.files.length ||
82
+ suite.files.some((file: unknown) => !nonempty(file))
83
+ ) {
84
+ throw new Error(`Invalid npm suite for ${peer}.`)
85
+ }
86
+ }
87
+ if (
88
+ !record(value.baseline) ||
89
+ (`firstRelease` in value.baseline
90
+ ? value.baseline.firstRelease !== true ||
91
+ Object.keys(value.baseline).length !== 1
92
+ : Object.entries(value.baseline).some(
93
+ ([key, item]) =>
94
+ ![`tagPattern`, `baselineFile`].includes(key) || !nonempty(item),
95
+ ))
96
+ ) {
97
+ throw new Error(
98
+ `Configure npm.baseline with tagPattern/baselineFile or { firstRelease: true }.`,
99
+ )
100
+ }
101
+ if (value.localPeers !== undefined) {
102
+ if (!record(value.localPeers)) throw new Error(`Invalid npm.localPeers.`)
103
+ for (const [peer, local] of Object.entries(value.localPeers)) {
104
+ if (
105
+ !packageName(peer) ||
106
+ !record(local) ||
107
+ !nonempty(local.directory) ||
108
+ !nonempty(local.artifact)
109
+ ) {
110
+ throw new Error(`Invalid local peer ${peer}.`)
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ export function validatePeerRanges(peers: Record<string, string>): void {
117
+ for (const range of Object.values(peers)) eras(range)
118
+ }