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,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
+ }
@@ -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
+ }