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.
- package/README.md +164 -46
- package/dist/breakage-DIY49fzP.js +1349 -0
- package/dist/breakage-DIY49fzP.js.map +1 -0
- package/dist/breakage.d.ts +179 -11
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +3 -1
- package/dist/breakage.x.js +169 -67
- package/dist/breakage.x.js.map +1 -1
- package/dist/changesets.d.ts +8 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +27 -0
- package/dist/changesets.js.map +1 -0
- package/dist/release-BsdIDT4g.js +69 -0
- package/dist/release-BsdIDT4g.js.map +1 -0
- package/dist/release-D47BqRuB.d.ts +40 -0
- package/dist/release-D47BqRuB.d.ts.map +1 -0
- package/npm.md +236 -0
- package/package.json +51 -11
- package/src/breakage.ts +136 -70
- package/src/breakage.x.ts +160 -112
- package/src/changesets.ts +51 -0
- package/src/config.ts +128 -0
- package/src/npm/check.ts +449 -0
- package/src/npm/cli.ts +130 -0
- package/src/npm/config.ts +118 -0
- package/src/npm/environment.ts +661 -0
- package/src/npm/manifest.ts +134 -0
- package/src/npm/release.ts +173 -0
- package/src/npm/types.ts +64 -0
- package/src/release.ts +153 -0
- package/src/task.ts +54 -0
- package/dist/breakage-Dc4IldeW.js +0 -435
- package/dist/breakage-Dc4IldeW.js.map +0 -1
- package/dist/breakage.$configPath.schema.json +0 -33
- package/dist/breakage.help.schema.json +0 -6
- package/dist/breakage.main.schema.json +0 -33
- package/dist/breakage.prelude.$configPath.schema.json +0 -31
- package/dist/breakage.prelude.schema.json +0 -31
- package/dist/breakage.schema.schema.json +0 -10
package/src/npm/check.ts
ADDED
|
@@ -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
|
+
}
|