breakage 0.8.3 → 0.8.5

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