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.
Files changed (39) hide show
  1. package/README.md +164 -46
  2. package/dist/breakage-DIY49fzP.js +1349 -0
  3. package/dist/breakage-DIY49fzP.js.map +1 -0
  4. package/dist/breakage.d.ts +179 -11
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +169 -67
  8. package/dist/breakage.x.js.map +1 -1
  9. package/dist/changesets.d.ts +8 -0
  10. package/dist/changesets.d.ts.map +1 -0
  11. package/dist/changesets.js +27 -0
  12. package/dist/changesets.js.map +1 -0
  13. package/dist/release-BsdIDT4g.js +69 -0
  14. package/dist/release-BsdIDT4g.js.map +1 -0
  15. package/dist/release-D47BqRuB.d.ts +40 -0
  16. package/dist/release-D47BqRuB.d.ts.map +1 -0
  17. package/npm.md +236 -0
  18. package/package.json +51 -11
  19. package/src/breakage.ts +136 -70
  20. package/src/breakage.x.ts +160 -112
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +128 -0
  23. package/src/npm/check.ts +449 -0
  24. package/src/npm/cli.ts +130 -0
  25. package/src/npm/config.ts +118 -0
  26. package/src/npm/environment.ts +661 -0
  27. package/src/npm/manifest.ts +134 -0
  28. package/src/npm/release.ts +173 -0
  29. package/src/npm/types.ts +64 -0
  30. package/src/release.ts +153 -0
  31. package/src/task.ts +54 -0
  32. package/dist/breakage-Dc4IldeW.js +0 -435
  33. package/dist/breakage-Dc4IldeW.js.map +0 -1
  34. package/dist/breakage.$configPath.schema.json +0 -33
  35. package/dist/breakage.help.schema.json +0 -6
  36. package/dist/breakage.main.schema.json +0 -33
  37. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  38. package/dist/breakage.prelude.schema.json +0 -31
  39. package/dist/breakage.schema.schema.json +0 -10
@@ -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,173 @@
1
+ import path from "node:path"
2
+
3
+ import semver from "semver"
4
+ import { simpleGit } from "simple-git"
5
+
6
+ import { resolveReleaseDelta, type ReleaseDeltaContext } from "../release.ts"
7
+ import { readBaseline } from "../baseline.ts"
8
+ import { withDirectoryLock } from "../directory-lock.ts"
9
+ import { latestReleaseTag } from "../release-tag.ts"
10
+ import { validatePeerRanges } from "./config.ts"
11
+ import type { Manifest } from "./manifest.ts"
12
+ import { normalizeWorkspacePeers, parseManifest } from "./manifest.ts"
13
+ import type { NpmConfig, SupportChange } from "./types.ts"
14
+
15
+ export async function releaseVersion(
16
+ config: NpmConfig,
17
+ context: ReleaseDeltaContext,
18
+ ): Promise<string | undefined> {
19
+ return config.release
20
+ ? (await resolveReleaseDelta(config.release, context))?.version
21
+ : undefined
22
+ }
23
+
24
+ /**
25
+ * Compare the union, including adjacent alternatives. semver.subset compares each
26
+ * conjunction to a single conjunction and cannot prove coverage by their union.
27
+ * Membership can change only at comparator boundaries or prerelease tuple gates.
28
+ */
29
+ export function includesRange(current: string, previous: string): boolean {
30
+ const ranges = [new semver.Range(previous), new semver.Range(current)]
31
+ const samples = new Set<string>([`0.0.0`, `0.0.0-0`])
32
+ for (const range of ranges) {
33
+ for (const conjunction of range.set) {
34
+ for (const comparator of conjunction) {
35
+ const version = comparator.semver
36
+ if (typeof version === `symbol`) continue
37
+ const base = `${version.major}.${version.minor}.${version.patch}`
38
+ samples.add(version.version)
39
+ samples.add(base)
40
+ samples.add(`${base}-0`)
41
+ // The immediate successor in a prerelease sequence appends numeric zero.
42
+ if (version.prerelease.length) samples.add(`${version.version}.0`)
43
+ // Stable versions are discrete; the next patch starts another tuple gate.
44
+ const next = semver.inc(base, `patch`)
45
+ if (next) {
46
+ samples.add(next)
47
+ samples.add(`${next}-0`)
48
+ }
49
+ }
50
+ }
51
+ }
52
+ return [...samples].every(
53
+ (version) =>
54
+ !semver.satisfies(version, previous) ||
55
+ semver.satisfies(version, current),
56
+ )
57
+ }
58
+
59
+ export function supportChanges(
60
+ previous: Manifest,
61
+ current: Manifest,
62
+ ): SupportChange[] {
63
+ const before = previous.peerDependencies ?? {}
64
+ const after = current.peerDependencies ?? {}
65
+ validatePeerRanges(before)
66
+ validatePeerRanges(after)
67
+ return Object.entries(after).flatMap(([peer, range]) => {
68
+ const old = before[peer]
69
+ const wasOptional = previous.peerDependenciesMeta?.[peer]?.optional === true
70
+ const isOptional = current.peerDependenciesMeta?.[peer]?.optional === true
71
+ // Absence scenarios are not generated, but making an optional peer required
72
+ // still changes the installation contract for existing consumers.
73
+ return (old === undefined && isOptional) ||
74
+ (old !== undefined &&
75
+ includesRange(range, old) &&
76
+ !(wasOptional && !isOptional))
77
+ ? []
78
+ : [
79
+ {
80
+ peer,
81
+ reason:
82
+ old === undefined || (wasOptional && !isOptional)
83
+ ? (`required-peer` as const)
84
+ : (`narrowed-range` as const),
85
+ previous: old ?? null,
86
+ current: range,
87
+ },
88
+ ]
89
+ })
90
+ }
91
+
92
+ export async function previousManifest(
93
+ config: NpmConfig,
94
+ directory: string,
95
+ ): Promise<Manifest | undefined> {
96
+ if (`firstRelease` in config.baseline) return undefined
97
+ const git = simpleGit(directory)
98
+ let commit: string
99
+ if (config.baseline.baselineFile) {
100
+ commit = (
101
+ await readBaseline(
102
+ git,
103
+ path.resolve(directory, config.baseline.baselineFile),
104
+ config.baseline,
105
+ )
106
+ ).commit
107
+ } else {
108
+ const tags = await git.listRemote([`--tags`, `origin`])
109
+ const ref = latestReleaseTag(tags, config.baseline.tagPattern)
110
+ if (!ref)
111
+ throw new Error(
112
+ `No npm release baseline found; configure a tag pattern or explicitly declare firstRelease.`,
113
+ )
114
+ const refs = new Map(
115
+ tags
116
+ .trim()
117
+ .split(`\n`)
118
+ .map((line) => {
119
+ const [sha, name] = line.split(`\t`)
120
+ return [name, sha]
121
+ }),
122
+ )
123
+ const resolved = refs.get(`${ref}^{}`) ?? refs.get(ref)
124
+ if (!resolved || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(resolved))
125
+ throw new Error(`Invalid release commit for ${ref}.`)
126
+ commit = resolved
127
+ const common = await git.revparse([
128
+ `--path-format=absolute`,
129
+ `--git-common-dir`,
130
+ ])
131
+ await withDirectoryLock(path.join(common, `breakage-fetch.lock`), () =>
132
+ git.fetch([`origin`, commit, `--no-tags`, `--no-write-fetch-head`]),
133
+ )
134
+ }
135
+ const prefix = await git.revparse([`--show-prefix`])
136
+ const manifest = parseManifest(
137
+ JSON.parse(await git.show([`${commit}:${prefix}package.json`])),
138
+ `release ${commit}`,
139
+ )
140
+ return await normalizeWorkspacePeers(manifest, async (peer) => {
141
+ const local = config.localPeers?.[peer]
142
+ if (!local)
143
+ throw new Error(
144
+ `Historical workspace peer ${peer} needs a localPeers directory to resolve release ${commit}.`,
145
+ )
146
+ const root = await git.revparse([`--show-toplevel`])
147
+ const relative = path.relative(
148
+ root,
149
+ path.resolve(directory, local.directory),
150
+ )
151
+ if (
152
+ relative === `..` ||
153
+ relative.startsWith(`..${path.sep}`) ||
154
+ path.isAbsolute(relative)
155
+ )
156
+ throw new Error(
157
+ `Historical workspace peer ${peer} escapes the release repository.`,
158
+ )
159
+ const filename = path
160
+ .join(relative, `package.json`)
161
+ .split(path.sep)
162
+ .join(`/`)
163
+ const historical = parseManifest(
164
+ JSON.parse(await git.show([`${commit}:${filename}`])),
165
+ `release ${commit}:${filename}`,
166
+ )
167
+ if (historical.name !== peer)
168
+ throw new Error(
169
+ `Historical workspace peer identity mismatch for ${peer}.`,
170
+ )
171
+ return historical.version
172
+ })
173
+ }
@@ -0,0 +1,64 @@
1
+ import type { VersionDelta } from "../release.ts"
2
+
3
+ export type SupportChange = {
4
+ peer: string
5
+ reason: `narrowed-range` | `required-peer`
6
+ previous: string | null
7
+ current: string | null
8
+ }
9
+
10
+ export type NpmSuite = typeof import("./config.ts").npmSuiteSchema.infer
11
+ export type NpmConfig = typeof import("./config.ts").npmConfigSchema.infer
12
+
13
+ export type NpmTestSelection =
14
+ | { peer: string; range: string }
15
+ | { representative: string }
16
+
17
+ export type NpmPeerEvidence = {
18
+ version: string
19
+ path: string
20
+ source: `installed` | `candidate`
21
+ archiveVersion?: string
22
+ }
23
+
24
+ export type NpmSuiteResult = {
25
+ peer: string
26
+ status: `passed` | `compatibility-failure` | `setup-error` | `not-run`
27
+ output?: string
28
+ }
29
+
30
+ export type NpmRepresentativeResult = {
31
+ path: string
32
+ status:
33
+ | `passed`
34
+ | `audited`
35
+ | `skipped`
36
+ | `compatibility-failure`
37
+ | `setup-error`
38
+ peers: Record<string, NpmPeerEvidence>
39
+ suites: NpmSuiteResult[]
40
+ warnings: string[]
41
+ error?: string
42
+ }
43
+
44
+ export type NpmReport = {
45
+ package: { name: string; version: string }
46
+ mode: `check` | `coverage-only` | `test`
47
+ exitCode: 0 | 1 | 2
48
+ representatives: NpmRepresentativeResult[]
49
+ coverage: { peer: string; era: string; representatives: string[] }[]
50
+ support: {
51
+ status:
52
+ | `unchanged`
53
+ | `breaking-release`
54
+ | `end-of-support`
55
+ | `first-release`
56
+ | `not-checked`
57
+ | `setup-error`
58
+ changes: SupportChange[]
59
+ previousVersion?: string
60
+ release?: VersionDelta
61
+ error?: string
62
+ }
63
+ errors: string[]
64
+ }
package/src/release.ts ADDED
@@ -0,0 +1,153 @@
1
+ // cspell:words premajor preminor prepatch
2
+ import semver from "semver"
3
+ import { type } from "arktype"
4
+
5
+ export type ReleaseDeltaContext = {
6
+ packageName: string
7
+ packageDirectory: string
8
+ currentVersion: string
9
+ /** The selected historical release, when checking compatibility. */
10
+ baseline?: { version: string; ref?: string; commit?: string }
11
+ }
12
+ export type VersionTransition = {
13
+ previousVersion: string
14
+ version: string
15
+ delta:
16
+ | `major`
17
+ | `premajor`
18
+ | `minor`
19
+ | `preminor`
20
+ | `patch`
21
+ | `prepatch`
22
+ | `prerelease`
23
+ | `release`
24
+ | `none`
25
+ }
26
+ export type ReleaseDeltaResolver = (
27
+ context: ReleaseDeltaContext,
28
+ ) => VersionTransition | undefined | Promise<VersionTransition | undefined>
29
+ export type VersionDelta = VersionTransition & { breaking: boolean }
30
+ export type SemverPolicyOptions = { beforeV1?: `major` | `minor` | `patch` }
31
+ export type ReleasePolicyFunction = (delta: VersionTransition) => boolean
32
+ export type ReleasePolicy = ReleasePolicyFunction
33
+ export const releaseSchema = type({
34
+ "+": `reject`,
35
+ "resolveDelta?": type(`Function`).as<ReleaseDeltaResolver>(),
36
+ "policy?": type(`Function`).as<ReleasePolicy>(),
37
+ })
38
+ export type ReleaseConfig = typeof releaseSchema.infer
39
+
40
+ /** Choose which version increments permit breaking changes. Prerelease-only increments do not. */
41
+ export function semverPolicy({
42
+ beforeV1 = `major`,
43
+ }: SemverPolicyOptions = {}): ReleasePolicyFunction {
44
+ if (![`major`, `minor`, `patch`].includes(beforeV1))
45
+ throw new Error(`Invalid beforeV1 policy.`)
46
+ return ({ previousVersion, version }) => {
47
+ const previous = new semver.SemVer(previousVersion)
48
+ const next = new semver.SemVer(version)
49
+ if (next.major > previous.major) return true
50
+ if (previous.major !== 0 || beforeV1 === `major`) return false
51
+ return (
52
+ next.minor > previous.minor ||
53
+ (beforeV1 === `patch` && next.patch > previous.patch)
54
+ )
55
+ }
56
+ }
57
+
58
+ /** Describe a version transition without deciding whether it permits breakage. */
59
+ export function versionTransition(
60
+ previousVersion: string,
61
+ version: string,
62
+ ): VersionTransition {
63
+ if (!semver.valid(previousVersion) || !semver.valid(version))
64
+ throw new Error(`Expected exact semantic versions.`)
65
+ return {
66
+ previousVersion,
67
+ version,
68
+ delta: semver.diff(previousVersion, version) ?? `none`,
69
+ }
70
+ }
71
+
72
+ /** Pure comparison; equal and older versions never permit breaking changes. */
73
+ export function versionDelta(
74
+ previousVersion: string,
75
+ version: string,
76
+ policy: ReleasePolicy = semverPolicy(),
77
+ ): VersionDelta {
78
+ const evidence = versionTransition(previousVersion, version)
79
+ const breaking = semver.gt(version, previousVersion)
80
+ ? policy(evidence)
81
+ : false
82
+ if (typeof breaking !== `boolean`)
83
+ throw new Error(`Release policy must return a boolean.`)
84
+ return { ...evidence, breaking }
85
+ }
86
+
87
+ export function validateRelease(
88
+ value: unknown,
89
+ ): asserts value is ReleaseConfig {
90
+ if (typeof value !== `object` || value === null || Array.isArray(value))
91
+ throw new Error(`release must be an object.`)
92
+ if (`version` in value)
93
+ throw new Error(
94
+ `release.version was removed. Use release.resolveDelta to return a version transition.`,
95
+ )
96
+ releaseSchema.assert(value)
97
+ }
98
+
99
+ /** Resolve and validate release evidence; policy is applied separately. */
100
+ export async function resolveReleaseDelta(
101
+ config: ReleaseConfig,
102
+ context: ReleaseDeltaContext,
103
+ ): Promise<VersionTransition | undefined> {
104
+ validateRelease(config)
105
+ const transition = config.resolveDelta
106
+ ? await config.resolveDelta(context)
107
+ : versionTransition(
108
+ context.baseline?.version ?? context.currentVersion,
109
+ context.currentVersion,
110
+ )
111
+ if (transition === undefined) return undefined
112
+ if (
113
+ typeof transition !== `object` ||
114
+ transition === null ||
115
+ Array.isArray(transition) ||
116
+ typeof transition.previousVersion !== `string` ||
117
+ !semver.valid(transition.previousVersion) ||
118
+ typeof transition.version !== `string` ||
119
+ !semver.valid(transition.version) ||
120
+ !semver.valid(context.currentVersion) ||
121
+ semver.lt(transition.version, context.currentVersion)
122
+ )
123
+ throw new Error(
124
+ `Invalid release transition for ${context.packageName}; expected exact versions and a current/newer target.`,
125
+ )
126
+ if (
127
+ context.baseline &&
128
+ transition.previousVersion !== context.baseline.version
129
+ )
130
+ throw new Error(
131
+ `Release transition for ${context.packageName} does not match the selected baseline.`,
132
+ )
133
+ const evidence = versionTransition(
134
+ transition.previousVersion,
135
+ transition.version,
136
+ )
137
+ if (transition.delta !== evidence.delta)
138
+ throw new Error(
139
+ `Release transition for ${context.packageName} has an inconsistent semver delta.`,
140
+ )
141
+ // Only evidence crosses this boundary; resolver-supplied policy decisions are ignored.
142
+ return evidence
143
+ }
144
+
145
+ /** Removed hooks must fail explicitly, including in untyped configurations. */
146
+ export function rejectCertificationOptions(value: object): void {
147
+ for (const key of [`certifyCommand`, `releaseIntent`, `certifyBreaking`]) {
148
+ if (key in value)
149
+ throw new Error(
150
+ `${key} was removed. Use release.resolveDelta and release.policy instead.`,
151
+ )
152
+ }
153
+ }
package/src/task.ts ADDED
@@ -0,0 +1,54 @@
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
+ if (typeof commandLine !== `string` || !commandLine.trim())
12
+ throw new Error(`Commands must be nonempty shell strings.`)
13
+ return await new Promise((resolve, reject) => {
14
+ const child = spawn(commandLine, {
15
+ cwd,
16
+ shell: true,
17
+ env: {
18
+ ...process.env,
19
+ NODE_PATH: ``,
20
+ PATH: [
21
+ path.join(cwd, `node_modules/.bin`),
22
+ path.join(cwd, `../node_modules/.bin`),
23
+ process.env.PATH,
24
+ ].join(path.delimiter),
25
+ },
26
+ stdio: [`ignore`, `pipe`, `pipe`],
27
+ })
28
+ let output = ``
29
+ const collect = (chunk: Buffer): void => {
30
+ // Bound diagnostics without interrupting a command that emits lots of output.
31
+ output = (output + chunk.toString()).slice(-1_000_000)
32
+ }
33
+ child.stdout.on(`data`, collect)
34
+ child.stderr.on(`data`, collect)
35
+ child.on(`error`, reject)
36
+ child.on(`close`, (code, signal) =>
37
+ resolve({ code: signal ? 2 : (code ?? 2), output }),
38
+ )
39
+ })
40
+ }
41
+
42
+ export async function prepare(commandLine: string, cwd: string): Promise<void> {
43
+ const result = await command(commandLine, cwd)
44
+ if (result.code !== 0)
45
+ throw new Error(
46
+ `Command failed (${result.code}) in ${cwd}: ${commandLine}\n${result.output}`,
47
+ )
48
+ }
49
+
50
+ export function errorMessage(error: unknown): string {
51
+ return error instanceof Error
52
+ ? `${error.message}${error.cause ? `\n${errorMessage(error.cause)}` : ``}`
53
+ : String(error)
54
+ }