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.
- package/README.md +8 -6
- package/dist/breakage-B7dJsgcu.js +1235 -0
- package/dist/breakage-B7dJsgcu.js.map +1 -0
- package/dist/breakage.d.ts +113 -1
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +1 -1
- package/dist/breakage.npm.check.schema.json +16 -0
- package/dist/breakage.npm.help.schema.json +6 -0
- package/dist/breakage.npm.schema.json +6 -0
- package/dist/breakage.npm.test.$peer.$range.schema.json +16 -0
- package/dist/breakage.npm.test.schema.json +16 -0
- package/dist/breakage.x.js +109 -5
- package/dist/breakage.x.js.map +1 -1
- package/npm.md +214 -0
- package/package.json +9 -7
- package/src/breakage.ts +14 -0
- package/src/breakage.x.ts +30 -3
- package/src/npm/check.ts +448 -0
- package/src/npm/cli.ts +126 -0
- package/src/npm/command.ts +52 -0
- package/src/npm/config.ts +86 -0
- package/src/npm/environment.ts +661 -0
- package/src/npm/manifest.ts +134 -0
- package/src/npm/release.ts +189 -0
- package/src/npm/types.ts +104 -0
- package/dist/breakage-Dc4IldeW.js +0 -435
- package/dist/breakage-Dc4IldeW.js.map +0 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
import semver from "semver"
|
|
4
|
+
|
|
5
|
+
export type Manifest = {
|
|
6
|
+
name: string
|
|
7
|
+
version: string
|
|
8
|
+
type?: string
|
|
9
|
+
dependencies?: Record<string, string>
|
|
10
|
+
optionalDependencies?: Record<string, string>
|
|
11
|
+
devDependencies?: Record<string, string>
|
|
12
|
+
peerDependencies?: Record<string, string>
|
|
13
|
+
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
|
14
|
+
breakage?: { candidates?: string[] }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function record(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return typeof value === `object` && value !== null && !Array.isArray(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function packageName(value: string): boolean {
|
|
22
|
+
return /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseManifest(value: unknown, label: string): Manifest {
|
|
26
|
+
if (
|
|
27
|
+
!record(value) ||
|
|
28
|
+
typeof value.name !== `string` ||
|
|
29
|
+
!packageName(value.name) ||
|
|
30
|
+
typeof value.version !== `string` ||
|
|
31
|
+
!semver.valid(value.version)
|
|
32
|
+
) {
|
|
33
|
+
throw new Error(`Invalid package identity in ${label}.`)
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
value.peerDependenciesMeta !== undefined &&
|
|
37
|
+
(!record(value.peerDependenciesMeta) ||
|
|
38
|
+
Object.values(value.peerDependenciesMeta).some(
|
|
39
|
+
(entry) =>
|
|
40
|
+
!record(entry) ||
|
|
41
|
+
(entry.optional !== undefined && typeof entry.optional !== `boolean`),
|
|
42
|
+
))
|
|
43
|
+
) {
|
|
44
|
+
throw new Error(`Invalid peerDependenciesMeta in ${label}.`)
|
|
45
|
+
}
|
|
46
|
+
for (const field of [
|
|
47
|
+
`dependencies`,
|
|
48
|
+
`optionalDependencies`,
|
|
49
|
+
`devDependencies`,
|
|
50
|
+
`peerDependencies`,
|
|
51
|
+
] as const) {
|
|
52
|
+
const entries = value[field]
|
|
53
|
+
if (
|
|
54
|
+
entries !== undefined &&
|
|
55
|
+
(!record(entries) ||
|
|
56
|
+
Object.entries(entries).some(
|
|
57
|
+
([name, spec]) => !packageName(name) || typeof spec !== `string`,
|
|
58
|
+
))
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(`Invalid ${field} in ${label}.`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
value.breakage !== undefined &&
|
|
65
|
+
(!record(value.breakage) ||
|
|
66
|
+
(value.breakage.candidates !== undefined &&
|
|
67
|
+
(!Array.isArray(value.breakage.candidates) ||
|
|
68
|
+
value.breakage.candidates.some(
|
|
69
|
+
(name: unknown) => typeof name !== `string` || !packageName(name),
|
|
70
|
+
))))
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(`Invalid candidate peers in ${label}.`)
|
|
73
|
+
}
|
|
74
|
+
return value as Manifest
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readManifest(filename: string): Promise<Manifest> {
|
|
78
|
+
return parseManifest(JSON.parse(await readFile(filename, `utf8`)), filename)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Current support is authored explicitly; only historical shorthand is resolved. */
|
|
82
|
+
export async function normalizeWorkspacePeers(
|
|
83
|
+
manifest: Manifest,
|
|
84
|
+
versionAtBaseline?: (peer: string) => Promise<string>,
|
|
85
|
+
): Promise<Manifest> {
|
|
86
|
+
if (!manifest.peerDependencies) return manifest
|
|
87
|
+
const entries = await Promise.all(
|
|
88
|
+
Object.entries(manifest.peerDependencies).map(async ([peer, range]) => {
|
|
89
|
+
if (!range.startsWith(`workspace:`)) return [peer, range] as const
|
|
90
|
+
const requested = range.slice(`workspace:`.length)
|
|
91
|
+
if ([``, `*`, `^`, `~`].includes(requested)) {
|
|
92
|
+
if (!versionAtBaseline)
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Moving workspace peer range ${peer}: ${range} in ${manifest.name}. Declare explicit support eras, for example ^1.0.0 || ^2.0.0, so local version changes cannot change the support promise.`,
|
|
95
|
+
)
|
|
96
|
+
const version = await versionAtBaseline(peer)
|
|
97
|
+
if (!semver.valid(version))
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Invalid historical workspace version for ${peer}: ${version}.`,
|
|
100
|
+
)
|
|
101
|
+
return [
|
|
102
|
+
peer,
|
|
103
|
+
`${requested === `^` || requested === `~` ? requested : ``}${version}`,
|
|
104
|
+
] as const
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
eras(requested)
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Unsupported workspace peer range for ${peer}: ${range}.`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
return [peer, requested] as const
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
return { ...manifest, peerDependencies: Object.fromEntries(entries) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function eras(range: string): string[] {
|
|
120
|
+
const parts = range.split(`||`).map((part) => part.trim())
|
|
121
|
+
if (parts.some((part) => !part || semver.validRange(part) === null)) {
|
|
122
|
+
throw new Error(`Invalid peer range: ${range}.`)
|
|
123
|
+
}
|
|
124
|
+
return parts
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Previews only represent an explicitly enrolled exact preview. */
|
|
128
|
+
export function matches(version: string, range: string): boolean {
|
|
129
|
+
return eras(range).some((era) =>
|
|
130
|
+
semver.prerelease(version)
|
|
131
|
+
? semver.valid(era.replace(/^=\s*/, ``)) === semver.valid(version)
|
|
132
|
+
: semver.satisfies(version, era),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
|
|
3
|
+
import semver from "semver"
|
|
4
|
+
import { simpleGit } from "simple-git"
|
|
5
|
+
|
|
6
|
+
import { readBaseline } from "../baseline.ts"
|
|
7
|
+
import { withDirectoryLock } from "../directory-lock.ts"
|
|
8
|
+
import { latestReleaseTag } from "../release-tag.ts"
|
|
9
|
+
import { validatePeerRanges } from "./config.ts"
|
|
10
|
+
import type { Manifest } from "./manifest.ts"
|
|
11
|
+
import { normalizeWorkspacePeers, parseManifest, record } from "./manifest.ts"
|
|
12
|
+
import type {
|
|
13
|
+
NpmConfig,
|
|
14
|
+
ReleaseContext,
|
|
15
|
+
ReleaseIntent,
|
|
16
|
+
SupportChange,
|
|
17
|
+
} from "./types.ts"
|
|
18
|
+
|
|
19
|
+
export async function intent(
|
|
20
|
+
config: NpmConfig,
|
|
21
|
+
context: ReleaseContext,
|
|
22
|
+
): Promise<ReleaseIntent> {
|
|
23
|
+
if (!config.releaseIntent) return { status: `none` }
|
|
24
|
+
const result: unknown = await config.releaseIntent(context)
|
|
25
|
+
if (record(result) && result.status === `none`) return { status: `none` }
|
|
26
|
+
if (
|
|
27
|
+
!record(result) ||
|
|
28
|
+
result.status !== `release` ||
|
|
29
|
+
typeof result.version !== `string` ||
|
|
30
|
+
!semver.valid(result.version) ||
|
|
31
|
+
semver.lt(result.version, context.currentVersion)
|
|
32
|
+
) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid release intent for ${context.packageName}; expected none or a current/newer exact version.`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
return { status: `release`, version: result.version }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compare the union, including adjacent alternatives. semver.subset compares each
|
|
42
|
+
* conjunction to a single conjunction and cannot prove coverage by their union.
|
|
43
|
+
* Membership can change only at comparator boundaries or prerelease tuple gates.
|
|
44
|
+
*/
|
|
45
|
+
export function includesRange(current: string, previous: string): boolean {
|
|
46
|
+
const ranges = [new semver.Range(previous), new semver.Range(current)]
|
|
47
|
+
const samples = new Set<string>([`0.0.0`, `0.0.0-0`])
|
|
48
|
+
for (const range of ranges) {
|
|
49
|
+
for (const conjunction of range.set) {
|
|
50
|
+
for (const comparator of conjunction) {
|
|
51
|
+
const version = comparator.semver
|
|
52
|
+
if (typeof version === `symbol`) continue
|
|
53
|
+
const base = `${version.major}.${version.minor}.${version.patch}`
|
|
54
|
+
samples.add(version.version)
|
|
55
|
+
samples.add(base)
|
|
56
|
+
samples.add(`${base}-0`)
|
|
57
|
+
// The immediate successor in a prerelease sequence appends numeric zero.
|
|
58
|
+
if (version.prerelease.length) samples.add(`${version.version}.0`)
|
|
59
|
+
// Stable versions are discrete; the next patch starts another tuple gate.
|
|
60
|
+
const next = semver.inc(base, `patch`)
|
|
61
|
+
if (next) {
|
|
62
|
+
samples.add(next)
|
|
63
|
+
samples.add(`${next}-0`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...samples].every(
|
|
69
|
+
(version) =>
|
|
70
|
+
!semver.satisfies(version, previous) ||
|
|
71
|
+
semver.satisfies(version, current),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function supportChanges(
|
|
76
|
+
previous: Manifest,
|
|
77
|
+
current: Manifest,
|
|
78
|
+
): SupportChange[] {
|
|
79
|
+
const before = previous.peerDependencies ?? {}
|
|
80
|
+
const after = current.peerDependencies ?? {}
|
|
81
|
+
validatePeerRanges(before)
|
|
82
|
+
validatePeerRanges(after)
|
|
83
|
+
return Object.entries(after).flatMap(([peer, range]) => {
|
|
84
|
+
const old = before[peer]
|
|
85
|
+
const wasOptional = previous.peerDependenciesMeta?.[peer]?.optional === true
|
|
86
|
+
const isOptional = current.peerDependenciesMeta?.[peer]?.optional === true
|
|
87
|
+
// Absence scenarios are not generated, but making an optional peer required
|
|
88
|
+
// still changes the installation contract for existing consumers.
|
|
89
|
+
return (old === undefined && isOptional) ||
|
|
90
|
+
(old !== undefined &&
|
|
91
|
+
includesRange(range, old) &&
|
|
92
|
+
!(wasOptional && !isOptional))
|
|
93
|
+
? []
|
|
94
|
+
: [
|
|
95
|
+
{
|
|
96
|
+
peer,
|
|
97
|
+
reason:
|
|
98
|
+
old === undefined || (wasOptional && !isOptional)
|
|
99
|
+
? (`required-peer` as const)
|
|
100
|
+
: (`narrowed-range` as const),
|
|
101
|
+
previous: old ?? null,
|
|
102
|
+
current: range,
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function previousManifest(
|
|
109
|
+
config: NpmConfig,
|
|
110
|
+
directory: string,
|
|
111
|
+
): Promise<Manifest | undefined> {
|
|
112
|
+
if (`firstRelease` in config.baseline) return undefined
|
|
113
|
+
const git = simpleGit(directory)
|
|
114
|
+
let commit: string
|
|
115
|
+
if (config.baseline.baselineFile) {
|
|
116
|
+
commit = (
|
|
117
|
+
await readBaseline(
|
|
118
|
+
git,
|
|
119
|
+
path.resolve(directory, config.baseline.baselineFile),
|
|
120
|
+
config.baseline,
|
|
121
|
+
)
|
|
122
|
+
).commit
|
|
123
|
+
} else {
|
|
124
|
+
const tags = await git.listRemote([`--tags`, `origin`])
|
|
125
|
+
const ref = latestReleaseTag(tags, config.baseline.tagPattern)
|
|
126
|
+
if (!ref)
|
|
127
|
+
throw new Error(
|
|
128
|
+
`No npm release baseline found; configure a tag pattern or explicitly declare firstRelease.`,
|
|
129
|
+
)
|
|
130
|
+
const refs = new Map(
|
|
131
|
+
tags
|
|
132
|
+
.trim()
|
|
133
|
+
.split(`\n`)
|
|
134
|
+
.map((line) => {
|
|
135
|
+
const [sha, name] = line.split(`\t`)
|
|
136
|
+
return [name, sha]
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
const resolved = refs.get(`${ref}^{}`) ?? refs.get(ref)
|
|
140
|
+
if (!resolved || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(resolved))
|
|
141
|
+
throw new Error(`Invalid release commit for ${ref}.`)
|
|
142
|
+
commit = resolved
|
|
143
|
+
const common = await git.revparse([
|
|
144
|
+
`--path-format=absolute`,
|
|
145
|
+
`--git-common-dir`,
|
|
146
|
+
])
|
|
147
|
+
await withDirectoryLock(path.join(common, `breakage-fetch.lock`), () =>
|
|
148
|
+
git.fetch([`origin`, commit, `--no-tags`, `--no-write-fetch-head`]),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const prefix = await git.revparse([`--show-prefix`])
|
|
152
|
+
const manifest = parseManifest(
|
|
153
|
+
JSON.parse(await git.show([`${commit}:${prefix}package.json`])),
|
|
154
|
+
`release ${commit}`,
|
|
155
|
+
)
|
|
156
|
+
return await normalizeWorkspacePeers(manifest, async (peer) => {
|
|
157
|
+
const local = config.localPeers?.[peer]
|
|
158
|
+
if (!local)
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Historical workspace peer ${peer} needs a localPeers directory to resolve release ${commit}.`,
|
|
161
|
+
)
|
|
162
|
+
const root = await git.revparse([`--show-toplevel`])
|
|
163
|
+
const relative = path.relative(
|
|
164
|
+
root,
|
|
165
|
+
path.resolve(directory, local.directory),
|
|
166
|
+
)
|
|
167
|
+
if (
|
|
168
|
+
relative === `..` ||
|
|
169
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
170
|
+
path.isAbsolute(relative)
|
|
171
|
+
)
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Historical workspace peer ${peer} escapes the release repository.`,
|
|
174
|
+
)
|
|
175
|
+
const filename = path
|
|
176
|
+
.join(relative, `package.json`)
|
|
177
|
+
.split(path.sep)
|
|
178
|
+
.join(`/`)
|
|
179
|
+
const historical = parseManifest(
|
|
180
|
+
JSON.parse(await git.show([`${commit}:${filename}`])),
|
|
181
|
+
`release ${commit}:${filename}`,
|
|
182
|
+
)
|
|
183
|
+
if (historical.name !== peer)
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Historical workspace peer identity mismatch for ${peer}.`,
|
|
186
|
+
)
|
|
187
|
+
return historical.version
|
|
188
|
+
})
|
|
189
|
+
}
|
package/src/npm/types.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type ReleaseIntent =
|
|
2
|
+
| { status: `none` }
|
|
3
|
+
| { status: `release`; version: string }
|
|
4
|
+
|
|
5
|
+
export type ReleaseContext = {
|
|
6
|
+
packageName: string
|
|
7
|
+
packageDirectory: string
|
|
8
|
+
currentVersion: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SupportChange = {
|
|
12
|
+
peer: string
|
|
13
|
+
reason: `narrowed-range` | `required-peer`
|
|
14
|
+
previous: string | null
|
|
15
|
+
current: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ReleaseEvidence = {
|
|
19
|
+
version: string
|
|
20
|
+
delta: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type NpmSuite = {
|
|
24
|
+
/** Package-relative globs; preserve relative paths inside the stage. */
|
|
25
|
+
files: string[]
|
|
26
|
+
/** Run once; 1 means assertion failure, other nonzero codes mean setup failure. */
|
|
27
|
+
command: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type NpmConfig = {
|
|
31
|
+
packageDirectory?: string
|
|
32
|
+
artifact: string
|
|
33
|
+
representatives: string | string[]
|
|
34
|
+
lockfile: string
|
|
35
|
+
prepareCommand: string
|
|
36
|
+
installCommand?: string
|
|
37
|
+
suites: Record<string, NpmSuite>
|
|
38
|
+
/** Currently declared local peers must support and exercise each planned release. */
|
|
39
|
+
localPeers?: Record<string, { directory: string; artifact: string }>
|
|
40
|
+
baseline:
|
|
41
|
+
| { firstRelease: true }
|
|
42
|
+
| { tagPattern?: string; baselineFile?: string }
|
|
43
|
+
releaseIntent?: (
|
|
44
|
+
context: ReleaseContext,
|
|
45
|
+
) => ReleaseIntent | Promise<ReleaseIntent>
|
|
46
|
+
certifyBreaking?: (context: {
|
|
47
|
+
previousVersion: string
|
|
48
|
+
release: ReleaseEvidence
|
|
49
|
+
changes: SupportChange[]
|
|
50
|
+
}) => boolean | Promise<boolean>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type NpmTestSelection =
|
|
54
|
+
| { peer: string; range: string }
|
|
55
|
+
| { representative: string }
|
|
56
|
+
|
|
57
|
+
export type NpmPeerEvidence = {
|
|
58
|
+
version: string
|
|
59
|
+
path: string
|
|
60
|
+
source: `installed` | `candidate`
|
|
61
|
+
archiveVersion?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type NpmSuiteResult = {
|
|
65
|
+
peer: string
|
|
66
|
+
status: `passed` | `compatibility-failure` | `setup-error` | `not-run`
|
|
67
|
+
output?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type NpmRepresentativeResult = {
|
|
71
|
+
path: string
|
|
72
|
+
status:
|
|
73
|
+
| `passed`
|
|
74
|
+
| `audited`
|
|
75
|
+
| `skipped`
|
|
76
|
+
| `compatibility-failure`
|
|
77
|
+
| `setup-error`
|
|
78
|
+
peers: Record<string, NpmPeerEvidence>
|
|
79
|
+
suites: NpmSuiteResult[]
|
|
80
|
+
warnings: string[]
|
|
81
|
+
error?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type NpmReport = {
|
|
85
|
+
package: { name: string; version: string }
|
|
86
|
+
mode: `check` | `coverage-only` | `test`
|
|
87
|
+
exitCode: 0 | 1 | 2
|
|
88
|
+
representatives: NpmRepresentativeResult[]
|
|
89
|
+
coverage: { peer: string; era: string; representatives: string[] }[]
|
|
90
|
+
support: {
|
|
91
|
+
status:
|
|
92
|
+
| `unchanged`
|
|
93
|
+
| `certified`
|
|
94
|
+
| `end-of-support`
|
|
95
|
+
| `first-release`
|
|
96
|
+
| `not-checked`
|
|
97
|
+
| `setup-error`
|
|
98
|
+
changes: SupportChange[]
|
|
99
|
+
previousVersion?: string
|
|
100
|
+
release?: ReleaseEvidence
|
|
101
|
+
error?: string
|
|
102
|
+
}
|
|
103
|
+
errors: string[]
|
|
104
|
+
}
|