breakage 0.0.0-stage → 0.0.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.
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as path from "node:path"
4
+ import { inspect } from "node:util"
5
+
6
+ import { type } from "arktype"
7
+ import type { OptionsGroup } from "comline"
8
+ import {
9
+ cli,
10
+ completionResponse,
11
+ encapsulate,
12
+ help,
13
+ logWarnings,
14
+ noOptions,
15
+ optional,
16
+ options,
17
+ parseBooleanOption,
18
+ } from "comline"
19
+ import logger from "takua"
20
+
21
+ import type { BreakageOptions } from "./breakage.ts"
22
+ import { breakage } from "./breakage.ts"
23
+
24
+ const BREAKAGE_MANUAL = options(
25
+ `Check for breaking changes in a package.`,
26
+ type({
27
+ "tagPattern?": `string`,
28
+ testPattern: `string`,
29
+ testCommand: `string`,
30
+ certifyCommand: `string`,
31
+ "baseDirname?": `string`,
32
+ "verbose?": `boolean`,
33
+ }),
34
+ {
35
+ tagPattern: {
36
+ aliases: [`tag-pattern`],
37
+ completion: { repeatable: false },
38
+ flag: `g`,
39
+ required: false,
40
+ description: `RegExp which, if found matched to a git tag, will be considered a release tag for your library.`,
41
+ example: `--tagPattern="my-library"`,
42
+ },
43
+ testPattern: {
44
+ aliases: [`test-pattern`, `pattern`],
45
+ completion: { repeatable: false },
46
+ flag: `p`,
47
+ required: true,
48
+ description: `The pattern to match test files that test the public API of the library.`,
49
+ example: `--pattern="*__public.test.ts"`,
50
+ },
51
+ testCommand: {
52
+ aliases: [`test-command`],
53
+ completion: { repeatable: false },
54
+ flag: `t`,
55
+ required: true,
56
+ description: `Complete bash command that runs the tests for the library's public API.`,
57
+ example: `--testCommand="npm run test"`,
58
+ },
59
+ certifyCommand: {
60
+ aliases: [`certify-command`],
61
+ completion: { repeatable: false },
62
+ flag: `c`,
63
+ required: true,
64
+ description: `Complete bash command that determines whether a major version bump for your package is indicated in the workspace. Exit code 0 indicates that a major version bump is indicated, and exit code 1 indicates that no major version bump is indicated.`,
65
+ example: `--certifyCommand=\tsx scripts/certify-major-version-bump.node`,
66
+ },
67
+ baseDirname: {
68
+ aliases: [`base-dir`, `base-dirname`],
69
+ completion: { repeatable: false, fileSystem: `directories` },
70
+ flag: `d`,
71
+ required: false,
72
+ description: `The base directory to run the command from.`,
73
+ example: `--baseDirname=./packages/my-package`,
74
+ },
75
+ verbose: {
76
+ completion: { repeatable: false },
77
+ flag: `v`,
78
+ required: false,
79
+ description: `Prints out more information about the process.`,
80
+ example: `--verbose`,
81
+ parse: parseBooleanOption,
82
+ },
83
+ },
84
+ ) satisfies OptionsGroup<BreakageOptions>
85
+
86
+ const SCHEMA_MANUAL = options(
87
+ `Create a copy of the schema for configuring breakage.`,
88
+ type({ "outdir?": `string` }),
89
+ {
90
+ outdir: {
91
+ aliases: [`out-dir`],
92
+ completion: { repeatable: false, fileSystem: `directories` },
93
+ flag: `o`,
94
+ required: false,
95
+ description: `Directory to write the schema to.`,
96
+ example: `--outdir=./dist`,
97
+ },
98
+ },
99
+ )
100
+
101
+ const parse = cli(
102
+ {
103
+ cliName: `breakage`,
104
+ routes: optional({ help: null, schema: null, $configPath: null }),
105
+ routeOptions: {
106
+ "": BREAKAGE_MANUAL,
107
+ $configPath: BREAKAGE_MANUAL,
108
+ help: noOptions(`Show usage.`),
109
+ schema: SCHEMA_MANUAL,
110
+ },
111
+ positionalCompletions: { $configPath: { fileSystem: `files` } },
112
+ discoverConfigPath: (args: readonly string[]) => {
113
+ if (args[0] === `help` || args[0] === `schema`) {
114
+ return
115
+ }
116
+ const configPath =
117
+ args[0] ?? path.join(process.cwd(), `breakage.config.json`)
118
+ return configPath
119
+ },
120
+ },
121
+ {
122
+ info: (message, ...data) => logger.info(`breakage`, message, data),
123
+ error: (message, ...data) => logger.error(`breakage`, message, data),
124
+ },
125
+ )
126
+
127
+ const completion = await completionResponse(parse.definition, process.argv)
128
+ if (completion !== undefined) {
129
+ process.stdout.write(completion)
130
+ } else {
131
+ const { inputs, warnings, writeJsonSchema } = parse(process.argv)
132
+ logWarnings(warnings)
133
+
134
+ switch (inputs.case) {
135
+ case `help`:
136
+ console.log(help(parse.definition))
137
+ break
138
+ case `schema`:
139
+ {
140
+ const { outdir } = inputs.opts
141
+ writeJsonSchema(outdir ?? `.`)
142
+ process.stdout.write(`📝 Wrote json.schema.`)
143
+ }
144
+ break
145
+ case ``:
146
+ case `$configPath`: {
147
+ const { returnValue } = await encapsulate(() => breakage(inputs.opts), {
148
+ console: true,
149
+ stdout: true,
150
+ })
151
+ if (`testResult` in returnValue && returnValue.breakingChangesFound) {
152
+ process.stdout.write(returnValue.testResult)
153
+ }
154
+ if (`breakingChangesFound` in returnValue) {
155
+ if (returnValue.breakingChangesFound) {
156
+ if (returnValue.breakingChangesCertified) {
157
+ process.stdout.write(
158
+ `👷 Breaking changes were found and certified.`,
159
+ )
160
+ process.exit(0)
161
+ } else {
162
+ process.stdout.write(
163
+ `❌ Breaking changes were found, but not certified.`,
164
+ )
165
+ process.exit(1)
166
+ }
167
+ } else {
168
+ process.stdout.write(`✅ No breaking changes were found.`)
169
+ process.exit(0)
170
+ }
171
+ } else {
172
+ process.stdout.write(
173
+ `💥 Breakage failed to determine breaking changes.` +
174
+ `\n` +
175
+ inspect(returnValue),
176
+ )
177
+ process.exit(2)
178
+ }
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,29 @@
1
+ import { mkdir, rmdir } from "node:fs/promises"
2
+ import { setTimeout } from "node:timers/promises"
3
+
4
+ export async function withDirectoryLock<T>(
5
+ lock: string,
6
+ action: () => Promise<T>,
7
+ waitForCleanup = false,
8
+ ): Promise<T> {
9
+ const deadline = Date.now() + 60_000
10
+ for (;;) {
11
+ try {
12
+ await mkdir(lock)
13
+ break
14
+ } catch (thrown) {
15
+ if ((thrown as NodeJS.ErrnoException).code !== `EEXIST`) throw thrown
16
+ if (!waitForCleanup && Date.now() >= deadline) {
17
+ throw new Error(
18
+ `Timed out waiting for ${lock}. Check for an interrupted operation before removing this lock.`,
19
+ )
20
+ }
21
+ await setTimeout(20)
22
+ }
23
+ }
24
+ try {
25
+ return await action()
26
+ } finally {
27
+ await rmdir(lock)
28
+ }
29
+ }
@@ -0,0 +1,62 @@
1
+ type Release = { ref: string; core: string[]; prerelease: string[] }
2
+
3
+ function compareInteger(a: string, b: string): number {
4
+ return a.length - b.length || (a > b ? 1 : a < b ? -1 : 0)
5
+ }
6
+
7
+ function compareRelease(a: Release, b: Release): number {
8
+ for (let index = 0; index < 3; index++) {
9
+ const difference = compareInteger(a.core[index]!, b.core[index]!)
10
+ if (difference) return difference
11
+ }
12
+ if (!a.prerelease.length || !b.prerelease.length) {
13
+ return Number(!a.prerelease.length) - Number(!b.prerelease.length)
14
+ }
15
+ for (
16
+ let index = 0;
17
+ index < Math.min(a.prerelease.length, b.prerelease.length);
18
+ index++
19
+ ) {
20
+ const left = a.prerelease[index]!
21
+ const right = b.prerelease[index]!
22
+ const leftNumeric = /^\d+$/.test(left)
23
+ const rightNumeric = /^\d+$/.test(right)
24
+ const difference =
25
+ leftNumeric && rightNumeric
26
+ ? compareInteger(left, right)
27
+ : leftNumeric !== rightNumeric
28
+ ? Number(rightNumeric) - Number(leftNumeric)
29
+ : left > right
30
+ ? 1
31
+ : left < right
32
+ ? -1
33
+ : 0
34
+ if (difference) return difference
35
+ }
36
+ return a.prerelease.length - b.prerelease.length
37
+ }
38
+
39
+ export function latestReleaseTag(
40
+ remoteTags: string,
41
+ tagPattern?: string,
42
+ ): string | undefined {
43
+ const pattern = tagPattern ? new RegExp(tagPattern) : undefined
44
+ let latest: Release | undefined
45
+ for (const line of remoteTags.split(`\n`)) {
46
+ if (pattern && !pattern.test(line)) continue
47
+ const ref = line.split(`\t`)[1]?.replace(/\^\{\}$/, ``)
48
+ if (!ref?.startsWith(`refs/tags/`)) continue
49
+ const name = ref.slice(`refs/tags/`.length)
50
+ const version = name.slice(name.lastIndexOf(`@`) + 1)
51
+ const match =
52
+ /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
53
+ version,
54
+ )
55
+ if (!match) continue
56
+ const prerelease = match[4]?.split(`.`) ?? []
57
+ if (prerelease.some((identifier) => /^0\d+$/.test(identifier))) continue
58
+ const release: Release = { ref, core: match.slice(1, 4), prerelease }
59
+ if (!latest || compareRelease(release, latest) > 0) latest = release
60
+ }
61
+ return latest?.ref
62
+ }
@@ -0,0 +1,252 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ realpath,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises"
13
+ import path from "node:path"
14
+
15
+ import type { SimpleGit } from "simple-git"
16
+ import { simpleGit } from "simple-git"
17
+
18
+ import { withDirectoryLock } from "./directory-lock.ts"
19
+
20
+ type ActiveCheck = {
21
+ pid: number
22
+ paths: string[]
23
+ head: string
24
+ recoveryRequired?: boolean
25
+ }
26
+
27
+ function isActiveCheck(value: unknown): value is ActiveCheck {
28
+ if (typeof value !== `object` || value === null) return false
29
+ const record = value as Partial<ActiveCheck>
30
+ return (
31
+ typeof record.pid === `number` &&
32
+ Number.isSafeInteger(record.pid) &&
33
+ record.pid > 0 &&
34
+ typeof record.head === `string` &&
35
+ /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(record.head) &&
36
+ (record.recoveryRequired === undefined ||
37
+ typeof record.recoveryRequired === `boolean`) &&
38
+ Array.isArray(record.paths) &&
39
+ record.paths.length > 0 &&
40
+ record.paths.every(
41
+ (file) =>
42
+ typeof file === `string` &&
43
+ !path.isAbsolute(file) &&
44
+ !path.win32.isAbsolute(file) &&
45
+ file
46
+ .split(`/`)
47
+ .every((part) => part !== `` && part !== `.` && part !== `..`),
48
+ )
49
+ )
50
+ }
51
+
52
+ async function readRecoveryRecord(recordPath: string): Promise<ActiveCheck> {
53
+ try {
54
+ const record: unknown = JSON.parse(await readFile(recordPath, `utf8`))
55
+ if (!isActiveCheck(record))
56
+ throw new TypeError(`Invalid recovery record fields`)
57
+ return record
58
+ } catch (cause) {
59
+ throw new Error(
60
+ `A breakage run needs recovery. The record ${recordPath} is unreadable or invalid. Verify no check is running and recover its test files before removing this record.`,
61
+ { cause },
62
+ )
63
+ }
64
+ }
65
+
66
+ async function writeRecoveryRecord(
67
+ recordPath: string,
68
+ record: ActiveCheck,
69
+ ): Promise<void> {
70
+ const temporaryPath = `${recordPath}.${randomUUID()}.tmp`
71
+ try {
72
+ await writeFile(temporaryPath, JSON.stringify(record), { flag: `wx` })
73
+ await rename(temporaryPath, recordPath)
74
+ } finally {
75
+ await rm(temporaryPath, { force: true })
76
+ }
77
+ }
78
+
79
+ type TestFileState = {
80
+ isClean: () => Promise<boolean>
81
+ replaceTests: (
82
+ release: string,
83
+ files: string[],
84
+ ) => Promise<() => Promise<void>>
85
+ }
86
+
87
+ function assertContained(root: string, filename: string): void {
88
+ const relative = path.relative(root, filename)
89
+ if (
90
+ relative === `..` ||
91
+ relative.startsWith(`..${path.sep}`) ||
92
+ path.isAbsolute(relative)
93
+ ) {
94
+ throw new Error(`Cannot replace tests outside the worktree: ${filename}.`)
95
+ }
96
+ }
97
+
98
+ async function validateParents(
99
+ root: string,
100
+ filenames: string[],
101
+ ): Promise<void> {
102
+ const checked = new Set<string>()
103
+ for (const filename of filenames) {
104
+ assertContained(root, filename)
105
+ for (let parent = path.dirname(filename); ; parent = path.dirname(parent)) {
106
+ if (checked.has(parent)) break
107
+ const stats = await lstat(parent).catch(
108
+ (thrown: NodeJS.ErrnoException) => {
109
+ if (thrown.code !== `ENOENT`) throw thrown
110
+ },
111
+ )
112
+ if (stats && !stats.isDirectory())
113
+ throw new Error(`Cannot replace tests through ${parent}.`)
114
+ checked.add(parent)
115
+ if (parent === root) break
116
+ }
117
+ }
118
+ }
119
+
120
+ // This lock covers Git setup and restoration only. Commands run after it is released.
121
+ export async function withTestFileState<T>(
122
+ git: SimpleGit,
123
+ baseDirname: string,
124
+ action: (state: TestFileState) => Promise<T>,
125
+ ): Promise<T> {
126
+ const root = await realpath(await git.revparse([`--show-toplevel`]))
127
+ baseDirname = await realpath(baseDirname)
128
+ assertContained(root, baseDirname)
129
+ const stateDirectory = path.join(
130
+ await git.revparse([`--absolute-git-dir`]),
131
+ `breakage`,
132
+ )
133
+ await mkdir(stateDirectory, { recursive: true })
134
+ const lock = path.join(stateDirectory, `lock`)
135
+ return withDirectoryLock(lock, async () => {
136
+ const activeChecks: ActiveCheck[] = []
137
+ for (const entry of await readdir(stateDirectory)) {
138
+ if (!entry.endsWith(`.json`)) continue
139
+ const recordPath = path.join(stateDirectory, entry)
140
+ const record = await readRecoveryRecord(recordPath)
141
+ let interrupted = record.recoveryRequired
142
+ try {
143
+ process.kill(record.pid, 0)
144
+ } catch (thrown) {
145
+ if ((thrown as NodeJS.ErrnoException).code !== `EPERM`)
146
+ interrupted = true
147
+ }
148
+ if (interrupted)
149
+ throw new Error(
150
+ `A breakage run needs recovery. Restore the test paths recorded in ${recordPath} before removing that record.`,
151
+ )
152
+ activeChecks.push(record)
153
+ }
154
+ const activePaths = activeChecks.flatMap((record) => record.paths)
155
+ return action({
156
+ isClean: async () =>
157
+ !(await simpleGit(root).raw([
158
+ `--no-optional-locks`,
159
+ `status`,
160
+ `--porcelain`,
161
+ `--untracked-files=all`,
162
+ `--`,
163
+ `.`,
164
+ ...activePaths.map((file) => `:(top,literal,exclude)${file}`),
165
+ ])),
166
+ replaceTests: async (release, files) => {
167
+ const paths = files.map((file) =>
168
+ path
169
+ .relative(root, path.resolve(baseDirname, file))
170
+ .split(path.sep)
171
+ .join(`/`),
172
+ )
173
+ for (const file of paths) {
174
+ if (
175
+ activePaths.some(
176
+ (active) =>
177
+ file === active ||
178
+ file.startsWith(`${active}/`) ||
179
+ active.startsWith(`${file}/`),
180
+ )
181
+ ) {
182
+ throw new Error(
183
+ `Another breakage run is using ${file}. Concurrent checks must use disjoint test paths.`,
184
+ )
185
+ }
186
+ }
187
+ const head = await git.revparse([`HEAD`])
188
+ const currentFiles = new Set(
189
+ (await git.raw([`ls-tree`, `-r`, `--name-only`, `-z`, head]))
190
+ .split(`\0`)
191
+ .filter(Boolean),
192
+ )
193
+ const existing = files.filter((file) => currentFiles.has(file))
194
+ const absent = files.filter((file) => !currentFiles.has(file))
195
+ const modes = new Map<string, number>()
196
+ const filenames = files.map((file) => path.resolve(baseDirname, file))
197
+ await validateParents(root, filenames)
198
+ for (const file of files) {
199
+ const filename = path.resolve(baseDirname, file)
200
+
201
+ const stats = await lstat(filename).catch(
202
+ (thrown: NodeJS.ErrnoException) => {
203
+ if (thrown.code !== `ENOENT`) throw thrown
204
+ },
205
+ )
206
+ if (!currentFiles.has(file) && stats)
207
+ throw new Error(
208
+ `Cannot overwrite the existing untracked path ${filename}.`,
209
+ )
210
+ if (stats?.isFile()) modes.set(filename, stats.mode)
211
+ }
212
+ const recordPath = path.join(stateDirectory, `${randomUUID()}.json`)
213
+ const record: ActiveCheck = { pid: process.pid, paths, head }
214
+ await writeRecoveryRecord(recordPath, record)
215
+ const restore = async () => {
216
+ await writeRecoveryRecord(recordPath, {
217
+ ...record,
218
+ recoveryRequired: true,
219
+ })
220
+ await validateParents(root, filenames)
221
+ if (existing.length)
222
+ await git.raw([
223
+ `--literal-pathspecs`,
224
+ `restore`,
225
+ `--source=${head}`,
226
+ `--worktree`,
227
+ `--`,
228
+ ...existing,
229
+ ])
230
+ for (const file of absent)
231
+ await rm(path.resolve(baseDirname, file), { force: true })
232
+ for (const [filename, mode] of modes) await chmod(filename, mode)
233
+ await rm(recordPath)
234
+ }
235
+ try {
236
+ await git.raw([
237
+ `--literal-pathspecs`,
238
+ `restore`,
239
+ `--source=${release}`,
240
+ `--worktree`,
241
+ `--`,
242
+ ...files,
243
+ ])
244
+ } catch (thrown) {
245
+ await restore()
246
+ throw thrown
247
+ }
248
+ return () => withDirectoryLock(lock, restore, true)
249
+ },
250
+ })
251
+ })
252
+ }