breakage 0.8.4 → 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,1235 @@
1
+ import { createRequire } from "node:module";
2
+ import { exec, spawn } from "node:child_process";
3
+ import path from "node:path";
4
+ import { minimatch } from "minimatch";
5
+ import { simpleGit } from "simple-git";
6
+ import logger from "takua";
7
+ import { randomUUID } from "node:crypto";
8
+ import * as fs from "node:fs/promises";
9
+ import { chmod, copyFile, lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from "node:fs/promises";
10
+ import { setTimeout } from "node:timers/promises";
11
+ import semver from "semver";
12
+ //#region src/directory-lock.ts
13
+ async function withDirectoryLock(lock, action, waitForCleanup = false) {
14
+ const deadline = Date.now() + 6e4;
15
+ for (;;) try {
16
+ await mkdir(lock);
17
+ break;
18
+ } catch (thrown) {
19
+ if (thrown.code !== `EEXIST`) throw thrown;
20
+ if (!waitForCleanup && Date.now() >= deadline) throw new Error(`Timed out waiting for ${lock}. Check for an interrupted operation before removing this lock.`);
21
+ await setTimeout(20);
22
+ }
23
+ try {
24
+ return await action();
25
+ } finally {
26
+ await rmdir(lock);
27
+ }
28
+ }
29
+ //#endregion
30
+ //#region src/release-tag.ts
31
+ function compareInteger(a, b) {
32
+ return a.length - b.length || (a > b ? 1 : a < b ? -1 : 0);
33
+ }
34
+ function compareRelease(a, b) {
35
+ for (let index = 0; index < 3; index++) {
36
+ const difference = compareInteger(a.core[index], b.core[index]);
37
+ if (difference) return difference;
38
+ }
39
+ if (!a.prerelease.length || !b.prerelease.length) return Number(!a.prerelease.length) - Number(!b.prerelease.length);
40
+ for (let index = 0; index < Math.min(a.prerelease.length, b.prerelease.length); index++) {
41
+ const left = a.prerelease[index];
42
+ const right = b.prerelease[index];
43
+ const leftNumeric = /^\d+$/.test(left);
44
+ const rightNumeric = /^\d+$/.test(right);
45
+ const difference = leftNumeric && rightNumeric ? compareInteger(left, right) : leftNumeric !== rightNumeric ? Number(rightNumeric) - Number(leftNumeric) : left > right ? 1 : left < right ? -1 : 0;
46
+ if (difference) return difference;
47
+ }
48
+ return a.prerelease.length - b.prerelease.length;
49
+ }
50
+ function latestReleaseTag(remoteTags, tagPattern) {
51
+ const pattern = tagPattern ? new RegExp(tagPattern) : void 0;
52
+ let latest;
53
+ for (const line of remoteTags.split(`\n`)) {
54
+ if (pattern && !pattern.test(line)) continue;
55
+ const ref = line.split(`\t`)[1]?.replace(/\^\{\}$/, ``);
56
+ if (!ref?.startsWith(`refs/tags/`)) continue;
57
+ const name = ref.slice(`refs/tags/`.length);
58
+ const version = name.slice(name.lastIndexOf(`@`) + 1);
59
+ const match = /^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(version);
60
+ if (!match) continue;
61
+ const prerelease = match[4]?.split(`.`) ?? [];
62
+ if (prerelease.some((identifier) => /^0\d+$/.test(identifier))) continue;
63
+ const release = {
64
+ ref,
65
+ core: match.slice(1, 4),
66
+ prerelease
67
+ };
68
+ if (!latest || compareRelease(release, latest) > 0) latest = release;
69
+ }
70
+ return latest?.ref;
71
+ }
72
+ //#endregion
73
+ //#region src/baseline.ts
74
+ const objectId = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/;
75
+ function isBaseline(value) {
76
+ if (typeof value !== `object` || value === null) return false;
77
+ const record = value;
78
+ return record.version === 1 && (record.tagPattern === null || typeof record.tagPattern === `string`) && typeof record.packageDirectory === `string` && typeof record.ref === `string` && record.ref.startsWith(`refs/tags/`) && typeof record.commit === `string` && objectId.test(record.commit);
79
+ }
80
+ /** Resolve and fetch a release without running tests or changing the worktree. */
81
+ async function breakagePrelude({ out, tagPattern, baseDirname = process.cwd() }) {
82
+ const filename = path.resolve(baseDirname, out);
83
+ const temporaryPath = `${filename}.${randomUUID()}.tmp`;
84
+ let canInvalidate = false;
85
+ try {
86
+ const git = simpleGit(baseDirname);
87
+ if (!(await git.raw([
88
+ `check-ignore`,
89
+ `--`,
90
+ filename
91
+ ])).trim()) throw new Error(`Baseline output must be gitignored: ${filename}.`);
92
+ canInvalidate = true;
93
+ const remoteTags = await git.listRemote([`--tags`, `origin`]);
94
+ const ref = latestReleaseTag(remoteTags, tagPattern);
95
+ if (!ref) throw new Error(`No tags found matching the pattern "${tagPattern}".`);
96
+ const refs = new Map(remoteTags.trim().split(`\n`).map((line) => {
97
+ const [sha, name] = line.split(`\t`);
98
+ return [name, sha];
99
+ }));
100
+ const commit = refs.get(`${ref}^{}`) ?? refs.get(ref);
101
+ if (!commit || !objectId.test(commit)) throw new Error(`Could not resolve the release commit for ${ref}.`);
102
+ const commonGitDirectory = await git.revparse([`--path-format=absolute`, `--git-common-dir`]);
103
+ await withDirectoryLock(path.join(commonGitDirectory, `breakage-fetch.lock`), () => git.fetch([
104
+ `origin`,
105
+ commit,
106
+ `--no-tags`,
107
+ `--no-write-fetch-head`
108
+ ]));
109
+ if ((await git.raw([
110
+ `cat-file`,
111
+ `-t`,
112
+ commit
113
+ ])).trim() !== `commit`) throw new Error(`Release ${ref} does not resolve to a commit.`);
114
+ const baseline = {
115
+ version: 1,
116
+ tagPattern: tagPattern ?? null,
117
+ packageDirectory: await git.revparse([`--show-prefix`]),
118
+ ref,
119
+ commit
120
+ };
121
+ await mkdir(path.dirname(filename), { recursive: true });
122
+ await writeFile(temporaryPath, `${JSON.stringify(baseline, null, 2)}\n`, { flag: `wx` });
123
+ await rename(temporaryPath, filename);
124
+ return baseline;
125
+ } catch (cause) {
126
+ if (canInvalidate) await rm(filename, { force: true });
127
+ throw new Error(`Failed to prepare baseline ${filename}.`, { cause });
128
+ } finally {
129
+ await rm(temporaryPath, { force: true });
130
+ }
131
+ }
132
+ async function readBaseline(git, filename, { tagPattern }) {
133
+ try {
134
+ const baseline = JSON.parse(await readFile(filename, `utf8`));
135
+ if (!isBaseline(baseline)) throw new Error(`Invalid baseline fields.`);
136
+ if (baseline.tagPattern !== (tagPattern ?? null) || baseline.packageDirectory !== await git.revparse([`--show-prefix`])) throw new Error(`Baseline belongs to a different package or tag pattern.`);
137
+ if ((await git.raw([
138
+ `check-ref-format`,
139
+ `--normalize`,
140
+ baseline.ref
141
+ ])).trim() !== baseline.ref) throw new Error(`Invalid release ref.`);
142
+ if ((await git.raw([
143
+ `cat-file`,
144
+ `-t`,
145
+ baseline.commit
146
+ ])).trim() !== `commit`) throw new Error(`Pinned object is not a locally available commit.`);
147
+ return baseline;
148
+ } catch (cause) {
149
+ throw new Error(`Missing, invalid, or unusable baseline ${filename}. Run breakage prelude again.`, { cause });
150
+ }
151
+ }
152
+ //#endregion
153
+ //#region src/test-file-state.ts
154
+ function isActiveCheck(value) {
155
+ if (typeof value !== `object` || value === null) return false;
156
+ const record = value;
157
+ return typeof record.pid === `number` && Number.isSafeInteger(record.pid) && record.pid > 0 && typeof record.head === `string` && /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(record.head) && (record.recoveryRequired === void 0 || typeof record.recoveryRequired === `boolean`) && Array.isArray(record.paths) && record.paths.length > 0 && record.paths.every((file) => typeof file === `string` && !path.isAbsolute(file) && !path.win32.isAbsolute(file) && file.split(`/`).every((part) => part !== `` && part !== `.` && part !== `..`));
158
+ }
159
+ async function readRecoveryRecord(recordPath) {
160
+ try {
161
+ const record = JSON.parse(await readFile(recordPath, `utf8`));
162
+ if (!isActiveCheck(record)) throw new TypeError(`Invalid recovery record fields`);
163
+ return record;
164
+ } catch (cause) {
165
+ throw new Error(`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.`, { cause });
166
+ }
167
+ }
168
+ async function writeRecoveryRecord(recordPath, record) {
169
+ const temporaryPath = `${recordPath}.${randomUUID()}.tmp`;
170
+ try {
171
+ await writeFile(temporaryPath, JSON.stringify(record), { flag: `wx` });
172
+ await rename(temporaryPath, recordPath);
173
+ } finally {
174
+ await rm(temporaryPath, { force: true });
175
+ }
176
+ }
177
+ function assertContained(root, filename) {
178
+ const relative = path.relative(root, filename);
179
+ if (relative === `..` || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`Cannot replace tests outside the worktree: ${filename}.`);
180
+ }
181
+ async function validateParents(root, filenames) {
182
+ const checked = /* @__PURE__ */ new Set();
183
+ for (const filename of filenames) {
184
+ assertContained(root, filename);
185
+ for (let parent = path.dirname(filename);; parent = path.dirname(parent)) {
186
+ if (checked.has(parent)) break;
187
+ const stats = await lstat(parent).catch((thrown) => {
188
+ if (thrown.code !== `ENOENT`) throw thrown;
189
+ });
190
+ if (stats && !stats.isDirectory()) throw new Error(`Cannot replace tests through ${parent}.`);
191
+ checked.add(parent);
192
+ if (parent === root) break;
193
+ }
194
+ }
195
+ }
196
+ async function withTestFileState(git, baseDirname, action) {
197
+ const root = await realpath(await git.revparse([`--show-toplevel`]));
198
+ baseDirname = await realpath(baseDirname);
199
+ assertContained(root, baseDirname);
200
+ const stateDirectory = path.join(await git.revparse([`--absolute-git-dir`]), `breakage`);
201
+ await mkdir(stateDirectory, { recursive: true });
202
+ const lock = path.join(stateDirectory, `lock`);
203
+ return withDirectoryLock(lock, async () => {
204
+ const activeChecks = [];
205
+ for (const entry of await readdir(stateDirectory)) {
206
+ if (!entry.endsWith(`.json`)) continue;
207
+ const recordPath = path.join(stateDirectory, entry);
208
+ const record = await readRecoveryRecord(recordPath);
209
+ let interrupted = record.recoveryRequired;
210
+ try {
211
+ process.kill(record.pid, 0);
212
+ } catch (thrown) {
213
+ if (thrown.code !== `EPERM`) interrupted = true;
214
+ }
215
+ if (interrupted) throw new Error(`A breakage run needs recovery. Restore the test paths recorded in ${recordPath} before removing that record.`);
216
+ activeChecks.push(record);
217
+ }
218
+ const activePaths = activeChecks.flatMap((record) => record.paths);
219
+ return action({
220
+ isClean: async () => !await simpleGit(root).raw([
221
+ `--no-optional-locks`,
222
+ `status`,
223
+ `--porcelain`,
224
+ `--untracked-files=all`,
225
+ `--`,
226
+ `.`,
227
+ ...activePaths.map((file) => `:(top,literal,exclude)${file}`)
228
+ ]),
229
+ replaceTests: async (release, files) => {
230
+ const paths = files.map((file) => path.relative(root, path.resolve(baseDirname, file)).split(path.sep).join(`/`));
231
+ for (const file of paths) if (activePaths.some((active) => file === active || file.startsWith(`${active}/`) || active.startsWith(`${file}/`))) throw new Error(`Another breakage run is using ${file}. Concurrent checks must use disjoint test paths.`);
232
+ const head = await git.revparse([`HEAD`]);
233
+ const currentFiles = new Set((await git.raw([
234
+ `ls-tree`,
235
+ `-r`,
236
+ `--name-only`,
237
+ `-z`,
238
+ head
239
+ ])).split(`\0`).filter(Boolean));
240
+ const existing = files.filter((file) => currentFiles.has(file));
241
+ const absent = files.filter((file) => !currentFiles.has(file));
242
+ const modes = /* @__PURE__ */ new Map();
243
+ const filenames = files.map((file) => path.resolve(baseDirname, file));
244
+ await validateParents(root, filenames);
245
+ for (const file of files) {
246
+ const filename = path.resolve(baseDirname, file);
247
+ const stats = await lstat(filename).catch((thrown) => {
248
+ if (thrown.code !== `ENOENT`) throw thrown;
249
+ });
250
+ if (!currentFiles.has(file) && stats) throw new Error(`Cannot overwrite the existing untracked path ${filename}.`);
251
+ if (stats?.isFile()) modes.set(filename, stats.mode);
252
+ }
253
+ const recordPath = path.join(stateDirectory, `${randomUUID()}.json`);
254
+ const record = {
255
+ pid: process.pid,
256
+ paths,
257
+ head
258
+ };
259
+ await writeRecoveryRecord(recordPath, record);
260
+ const restore = async () => {
261
+ await writeRecoveryRecord(recordPath, {
262
+ ...record,
263
+ recoveryRequired: true
264
+ });
265
+ await validateParents(root, filenames);
266
+ if (existing.length) await git.raw([
267
+ `--literal-pathspecs`,
268
+ `restore`,
269
+ `--source=${head}`,
270
+ `--worktree`,
271
+ `--`,
272
+ ...existing
273
+ ]);
274
+ for (const file of absent) await rm(path.resolve(baseDirname, file), { force: true });
275
+ for (const [filename, mode] of modes) await chmod(filename, mode);
276
+ await rm(recordPath);
277
+ };
278
+ try {
279
+ await git.raw([
280
+ `--literal-pathspecs`,
281
+ `restore`,
282
+ `--source=${release}`,
283
+ `--worktree`,
284
+ `--`,
285
+ ...files
286
+ ]);
287
+ } catch (thrown) {
288
+ await restore();
289
+ throw thrown;
290
+ }
291
+ return () => withDirectoryLock(lock, restore, true);
292
+ }
293
+ });
294
+ });
295
+ }
296
+ //#endregion
297
+ //#region src/npm/command.ts
298
+ /** Capture output per environment so parallel runs cannot interleave their reports. */
299
+ async function command(commandLine, cwd) {
300
+ return await new Promise((resolve, reject) => {
301
+ const child = spawn(commandLine, {
302
+ cwd,
303
+ shell: true,
304
+ env: {
305
+ ...process.env,
306
+ NODE_PATH: ``,
307
+ PATH: [
308
+ path.join(cwd, `node_modules/.bin`),
309
+ path.join(cwd, `../node_modules/.bin`),
310
+ process.env.PATH
311
+ ].join(path.delimiter)
312
+ },
313
+ stdio: [
314
+ `ignore`,
315
+ `pipe`,
316
+ `pipe`
317
+ ]
318
+ });
319
+ let output = ``;
320
+ const collect = (chunk) => {
321
+ output = (output + chunk.toString()).slice(-1e6);
322
+ };
323
+ child.stdout.on(`data`, collect);
324
+ child.stderr.on(`data`, collect);
325
+ child.on(`error`, reject);
326
+ child.on(`close`, (code, signal) => resolve({
327
+ code: signal ? 2 : code ?? 2,
328
+ output
329
+ }));
330
+ });
331
+ }
332
+ async function prepare(commandLine, cwd) {
333
+ const result = await command(commandLine, cwd);
334
+ if (result.code !== 0) throw new Error(`Command failed (${result.code}) in ${cwd}: ${commandLine}\n${result.output}`);
335
+ }
336
+ function errorMessage(error) {
337
+ return error instanceof Error ? `${error.message}${error.cause ? `\n${errorMessage(error.cause)}` : ``}` : String(error);
338
+ }
339
+ //#endregion
340
+ //#region src/npm/manifest.ts
341
+ function record(value) {
342
+ return typeof value === `object` && value !== null && !Array.isArray(value);
343
+ }
344
+ function packageName(value) {
345
+ return /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
346
+ }
347
+ function parseManifest(value, label) {
348
+ if (!record(value) || typeof value.name !== `string` || !packageName(value.name) || typeof value.version !== `string` || !semver.valid(value.version)) throw new Error(`Invalid package identity in ${label}.`);
349
+ if (value.peerDependenciesMeta !== void 0 && (!record(value.peerDependenciesMeta) || Object.values(value.peerDependenciesMeta).some((entry) => !record(entry) || entry.optional !== void 0 && typeof entry.optional !== `boolean`))) throw new Error(`Invalid peerDependenciesMeta in ${label}.`);
350
+ for (const field of [
351
+ `dependencies`,
352
+ `optionalDependencies`,
353
+ `devDependencies`,
354
+ `peerDependencies`
355
+ ]) {
356
+ const entries = value[field];
357
+ if (entries !== void 0 && (!record(entries) || Object.entries(entries).some(([name, spec]) => !packageName(name) || typeof spec !== `string`))) throw new Error(`Invalid ${field} in ${label}.`);
358
+ }
359
+ if (value.breakage !== void 0 && (!record(value.breakage) || value.breakage.candidates !== void 0 && (!Array.isArray(value.breakage.candidates) || value.breakage.candidates.some((name) => typeof name !== `string` || !packageName(name))))) throw new Error(`Invalid candidate peers in ${label}.`);
360
+ return value;
361
+ }
362
+ async function readManifest(filename) {
363
+ return parseManifest(JSON.parse(await readFile(filename, `utf8`)), filename);
364
+ }
365
+ /** Current support is authored explicitly; only historical shorthand is resolved. */
366
+ async function normalizeWorkspacePeers(manifest, versionAtBaseline) {
367
+ if (!manifest.peerDependencies) return manifest;
368
+ const entries = await Promise.all(Object.entries(manifest.peerDependencies).map(async ([peer, range]) => {
369
+ if (!range.startsWith(`workspace:`)) return [peer, range];
370
+ const requested = range.slice(`workspace:`.length);
371
+ if ([
372
+ ``,
373
+ `*`,
374
+ `^`,
375
+ `~`
376
+ ].includes(requested)) {
377
+ if (!versionAtBaseline) throw new Error(`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.`);
378
+ const version = await versionAtBaseline(peer);
379
+ if (!semver.valid(version)) throw new Error(`Invalid historical workspace version for ${peer}: ${version}.`);
380
+ return [peer, `${requested === `^` || requested === `~` ? requested : ``}${version}`];
381
+ }
382
+ try {
383
+ eras(requested);
384
+ } catch {
385
+ throw new Error(`Unsupported workspace peer range for ${peer}: ${range}.`);
386
+ }
387
+ return [peer, requested];
388
+ }));
389
+ return {
390
+ ...manifest,
391
+ peerDependencies: Object.fromEntries(entries)
392
+ };
393
+ }
394
+ function eras(range) {
395
+ const parts = range.split(`||`).map((part) => part.trim());
396
+ if (parts.some((part) => !part || semver.validRange(part) === null)) throw new Error(`Invalid peer range: ${range}.`);
397
+ return parts;
398
+ }
399
+ /** Previews only represent an explicitly enrolled exact preview. */
400
+ function matches(version, range) {
401
+ return eras(range).some((era) => semver.prerelease(version) ? semver.valid(era.replace(/^=\s*/, ``)) === semver.valid(version) : semver.satisfies(version, era));
402
+ }
403
+ //#endregion
404
+ //#region src/npm/config.ts
405
+ function nonempty(value) {
406
+ return typeof value === `string` && value.trim().length > 0;
407
+ }
408
+ function validateConfig(value) {
409
+ if (!record(value)) throw new Error(`Configuration must have an npm object.`);
410
+ for (const key of [
411
+ `artifact`,
412
+ `lockfile`,
413
+ `prepareCommand`
414
+ ]) if (!nonempty(value[key])) throw new Error(`npm.${key} must be a nonempty string.`);
415
+ if (path.isAbsolute(value.lockfile) || value.lockfile.split(/[\\/]/).includes(`..`)) throw new Error(`npm.lockfile must stay inside each representative.`);
416
+ for (const key of [`packageDirectory`, `installCommand`]) if (value[key] !== void 0 && !nonempty(value[key])) throw new Error(`Invalid npm.${key}.`);
417
+ const globs = typeof value.representatives === `string` ? [value.representatives] : value.representatives;
418
+ if (!Array.isArray(globs) || !globs.length || globs.some((glob) => !nonempty(glob))) throw new Error(`npm.representatives must contain directory globs.`);
419
+ if (!record(value.suites) || !Object.keys(value.suites).length) throw new Error(`Configure at least one npm peer suite.`);
420
+ for (const [peer, suite] of Object.entries(value.suites)) if (!packageName(peer) || !record(suite) || !nonempty(suite.command) || !Array.isArray(suite.files) || !suite.files.length || suite.files.some((file) => !nonempty(file))) throw new Error(`Invalid npm suite for ${peer}.`);
421
+ if (!record(value.baseline) || (`firstRelease` in value.baseline ? value.baseline.firstRelease !== true || Object.keys(value.baseline).length !== 1 : Object.entries(value.baseline).some(([key, item]) => ![`tagPattern`, `baselineFile`].includes(key) || !nonempty(item)))) throw new Error(`Configure npm.baseline with tagPattern/baselineFile or { firstRelease: true }.`);
422
+ if (value.localPeers !== void 0) {
423
+ if (!record(value.localPeers)) throw new Error(`Invalid npm.localPeers.`);
424
+ for (const [peer, local] of Object.entries(value.localPeers)) if (!packageName(peer) || !record(local) || !nonempty(local.directory) || !nonempty(local.artifact)) throw new Error(`Invalid local peer ${peer}.`);
425
+ }
426
+ for (const hook of [`releaseIntent`, `certifyBreaking`]) if (value[hook] !== void 0 && typeof value[hook] !== `function`) throw new Error(`npm.${hook} must be a function.`);
427
+ }
428
+ function validatePeerRanges(peers) {
429
+ for (const range of Object.values(peers)) eras(range);
430
+ }
431
+ //#endregion
432
+ //#region src/npm/release.ts
433
+ async function intent(config, context) {
434
+ if (!config.releaseIntent) return { status: `none` };
435
+ const result = await config.releaseIntent(context);
436
+ if (record(result) && result.status === `none`) return { status: `none` };
437
+ if (!record(result) || result.status !== `release` || typeof result.version !== `string` || !semver.valid(result.version) || semver.lt(result.version, context.currentVersion)) throw new Error(`Invalid release intent for ${context.packageName}; expected none or a current/newer exact version.`);
438
+ return {
439
+ status: `release`,
440
+ version: result.version
441
+ };
442
+ }
443
+ /**
444
+ * Compare the union, including adjacent alternatives. semver.subset compares each
445
+ * conjunction to a single conjunction and cannot prove coverage by their union.
446
+ * Membership can change only at comparator boundaries or prerelease tuple gates.
447
+ */
448
+ function includesRange(current, previous) {
449
+ const ranges = [new semver.Range(previous), new semver.Range(current)];
450
+ const samples = /* @__PURE__ */ new Set([`0.0.0`, `0.0.0-0`]);
451
+ for (const range of ranges) for (const conjunction of range.set) for (const comparator of conjunction) {
452
+ const version = comparator.semver;
453
+ if (typeof version === `symbol`) continue;
454
+ const base = `${version.major}.${version.minor}.${version.patch}`;
455
+ samples.add(version.version);
456
+ samples.add(base);
457
+ samples.add(`${base}-0`);
458
+ if (version.prerelease.length) samples.add(`${version.version}.0`);
459
+ const next = semver.inc(base, `patch`);
460
+ if (next) {
461
+ samples.add(next);
462
+ samples.add(`${next}-0`);
463
+ }
464
+ }
465
+ return [...samples].every((version) => !semver.satisfies(version, previous) || semver.satisfies(version, current));
466
+ }
467
+ function supportChanges(previous, current) {
468
+ const before = previous.peerDependencies ?? {};
469
+ const after = current.peerDependencies ?? {};
470
+ validatePeerRanges(before);
471
+ validatePeerRanges(after);
472
+ return Object.entries(after).flatMap(([peer, range]) => {
473
+ const old = before[peer];
474
+ const wasOptional = previous.peerDependenciesMeta?.[peer]?.optional === true;
475
+ const isOptional = current.peerDependenciesMeta?.[peer]?.optional === true;
476
+ return old === void 0 && isOptional || old !== void 0 && includesRange(range, old) && !(wasOptional && !isOptional) ? [] : [{
477
+ peer,
478
+ reason: old === void 0 || wasOptional && !isOptional ? `required-peer` : `narrowed-range`,
479
+ previous: old ?? null,
480
+ current: range
481
+ }];
482
+ });
483
+ }
484
+ async function previousManifest(config, directory) {
485
+ if (`firstRelease` in config.baseline) return void 0;
486
+ const git = simpleGit(directory);
487
+ let commit;
488
+ if (config.baseline.baselineFile) commit = (await readBaseline(git, path.resolve(directory, config.baseline.baselineFile), config.baseline)).commit;
489
+ else {
490
+ const tags = await git.listRemote([`--tags`, `origin`]);
491
+ const ref = latestReleaseTag(tags, config.baseline.tagPattern);
492
+ if (!ref) throw new Error(`No npm release baseline found; configure a tag pattern or explicitly declare firstRelease.`);
493
+ const refs = new Map(tags.trim().split(`\n`).map((line) => {
494
+ const [sha, name] = line.split(`\t`);
495
+ return [name, sha];
496
+ }));
497
+ const resolved = refs.get(`${ref}^{}`) ?? refs.get(ref);
498
+ if (!resolved || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(resolved)) throw new Error(`Invalid release commit for ${ref}.`);
499
+ commit = resolved;
500
+ const common = await git.revparse([`--path-format=absolute`, `--git-common-dir`]);
501
+ await withDirectoryLock(path.join(common, `breakage-fetch.lock`), () => git.fetch([
502
+ `origin`,
503
+ commit,
504
+ `--no-tags`,
505
+ `--no-write-fetch-head`
506
+ ]));
507
+ }
508
+ const prefix = await git.revparse([`--show-prefix`]);
509
+ return await normalizeWorkspacePeers(parseManifest(JSON.parse(await git.show([`${commit}:${prefix}package.json`])), `release ${commit}`), async (peer) => {
510
+ const local = config.localPeers?.[peer];
511
+ if (!local) throw new Error(`Historical workspace peer ${peer} needs a localPeers directory to resolve release ${commit}.`);
512
+ const root = await git.revparse([`--show-toplevel`]);
513
+ const relative = path.relative(root, path.resolve(directory, local.directory));
514
+ if (relative === `..` || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`Historical workspace peer ${peer} escapes the release repository.`);
515
+ const filename = path.join(relative, `package.json`).split(path.sep).join(`/`);
516
+ const historical = parseManifest(JSON.parse(await git.show([`${commit}:${filename}`])), `release ${commit}:${filename}`);
517
+ if (historical.name !== peer) throw new Error(`Historical workspace peer identity mismatch for ${peer}.`);
518
+ return historical.version;
519
+ });
520
+ }
521
+ //#endregion
522
+ //#region src/npm/environment.ts
523
+ function within(parent, child) {
524
+ const relative = path.relative(parent, child);
525
+ return relative === `` || !relative.startsWith(`..${path.sep}`) && relative !== `..` && !path.isAbsolute(relative);
526
+ }
527
+ async function exists(filename) {
528
+ try {
529
+ await lstat(filename);
530
+ return true;
531
+ } catch (error) {
532
+ if (error.code === `ENOENT`) return false;
533
+ throw error;
534
+ }
535
+ }
536
+ async function discover(config, base) {
537
+ const found = /* @__PURE__ */ new Set();
538
+ for await (const name of fs.glob(config.representatives, {
539
+ cwd: base,
540
+ exclude: [
541
+ `**/node_modules/**`,
542
+ `**/test/**`,
543
+ `**/.git/**`
544
+ ]
545
+ })) {
546
+ const directory = path.resolve(base, name);
547
+ if (!(await lstat(directory)).isDirectory()) throw new Error(`Representative is not a directory: ${name}.`);
548
+ found.add(directory);
549
+ }
550
+ if (!found.size) throw new Error(`No representative directories matched the configured globs.`);
551
+ return [...found].sort();
552
+ }
553
+ /** A freshly created ignored stage is also the exclusive ownership lock. */
554
+ async function withStage(directory, action) {
555
+ const stage = path.join(directory, `test`);
556
+ const git = simpleGit(directory);
557
+ const root = await git.revparse([`--show-toplevel`]);
558
+ if (!within(await realpath(root), await realpath(directory))) throw new Error(`Representative escapes the repository: ${directory}.`);
559
+ if (!(await git.raw([
560
+ `check-ignore`,
561
+ `--`,
562
+ `${stage}/`
563
+ ])).trim() || (await git.raw([
564
+ `ls-files`,
565
+ `--`,
566
+ stage
567
+ ])).trim()) throw new Error(`Representative test/ must be gitignored and contain no tracked files: ${stage}.`);
568
+ try {
569
+ await mkdir(stage);
570
+ } catch (cause) {
571
+ throw new Error(`Cannot own ${stage}; another run or preexisting files may be present. Remove it only after checking ownership.`, { cause });
572
+ }
573
+ try {
574
+ return await action(stage);
575
+ } finally {
576
+ await rm(stage, {
577
+ recursive: true,
578
+ force: true
579
+ });
580
+ }
581
+ }
582
+ async function installed(directory, peer) {
583
+ const location = path.join(directory, `node_modules`, peer);
584
+ const physical = await realpath(location);
585
+ if (!physical.split(path.sep).includes(`node_modules`)) throw new Error(`Peer ${peer} resolves to workspace source: ${physical}.`);
586
+ const manifest = await readManifest(path.join(physical, `package.json`));
587
+ if (manifest.name !== peer) throw new Error(`Expected ${peer}, found ${manifest.name} in ${location}.`);
588
+ return {
589
+ version: manifest.version,
590
+ path: physical,
591
+ source: `installed`
592
+ };
593
+ }
594
+ function representativeDeclarations(manifest) {
595
+ for (const field of [
596
+ `dependencies`,
597
+ `devDependencies`,
598
+ `optionalDependencies`,
599
+ `peerDependencies`
600
+ ]) for (const [name, specifier] of Object.entries(manifest[field] ?? {})) {
601
+ const version = semver.parse(specifier);
602
+ const exact = version && `${version.version}${version.build.length ? `+${version.build.join(`.`)}` : ``}`;
603
+ if (!exact || specifier !== exact) throw new Error(`Representative ${manifest.name} must pin ${field}.${name} to an exact version, such as 1.0.0; received ${JSON.stringify(specifier)}. Declare support ranges in the package under test, and use breakage.candidates for coordinated local releases.`);
604
+ }
605
+ for (const [peer, development] of Object.entries(manifest.devDependencies ?? {})) for (const field of [`dependencies`, `optionalDependencies`]) {
606
+ const runtime = manifest[field]?.[peer];
607
+ if (runtime !== void 0 && runtime !== development) throw new Error(`Conflicting representative declarations for ${peer}: devDependencies has ${development}, but ${field} has ${runtime}. Use one consistent requirement.`);
608
+ }
609
+ return {
610
+ ...manifest.devDependencies,
611
+ ...manifest.dependencies,
612
+ ...manifest.optionalDependencies
613
+ };
614
+ }
615
+ /** Select exact pins without acquiring or preparing their stages. */
616
+ async function maySelectEnvironment(config, base, directory, packageManifest, selection) {
617
+ const manifest = await readManifest(path.join(directory, `package.json`));
618
+ const declarations = representativeDeclarations(manifest);
619
+ const { peer, range } = selection;
620
+ if (peer === packageManifest.name) return matches(packageManifest.version, range);
621
+ if (manifest.breakage?.candidates?.includes(peer)) {
622
+ const local = config.localPeers?.[peer];
623
+ if (!local) throw new Error(`Candidate ${peer} has no localPeers configuration.`);
624
+ const packageDirectory = path.resolve(base, local.directory);
625
+ const current = await readManifest(path.join(packageDirectory, `package.json`));
626
+ if (current.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
627
+ const release = await intent(config, {
628
+ packageName: peer,
629
+ packageDirectory,
630
+ currentVersion: current.version
631
+ });
632
+ if (release.status === `none`) throw new Error(`Candidate ${peer} has no certified release intent.`);
633
+ return matches(release.version, range);
634
+ }
635
+ const declared = declarations[peer];
636
+ return declared !== void 0 && matches(declared, range);
637
+ }
638
+ async function prepareEnvironment(config, base, directory, stage, packageManifest) {
639
+ const peerNames = Object.keys(packageManifest.peerDependencies ?? {});
640
+ const filename = path.join(directory, `package.json`);
641
+ const lockfile = path.join(directory, config.lockfile);
642
+ for (const input of [filename, lockfile]) if (!(await lstat(input)).isFile() || !within(directory, await realpath(input))) throw new Error(`Installation inputs must be regular files inside the representative: ${input}.`);
643
+ const inputs = await Promise.all([readFile(filename), readFile(lockfile)]);
644
+ const manifest = await readManifest(filename);
645
+ const declarations = representativeDeclarations(manifest);
646
+ const candidates = {};
647
+ for (const peer of manifest.breakage?.candidates ?? []) {
648
+ if (!peerNames.includes(peer)) throw new Error(`Candidate ${peer} is not a declared peer of the package under test.`);
649
+ const local = config.localPeers?.[peer];
650
+ if (!local) throw new Error(`Candidate ${peer} has no localPeers configuration.`);
651
+ const localDirectory = path.resolve(base, local.directory);
652
+ const localManifest = await normalizeWorkspacePeers(await readManifest(path.join(localDirectory, `package.json`)));
653
+ if (localManifest.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
654
+ validatePeerRanges(localManifest.peerDependencies ?? {});
655
+ const evidence = await intent(config, {
656
+ packageName: peer,
657
+ packageDirectory: localDirectory,
658
+ currentVersion: localManifest.version
659
+ });
660
+ if (evidence.status === `none`) throw new Error(`Candidate ${peer} has no certified release intent.`);
661
+ candidates[peer] = {
662
+ directory: localDirectory,
663
+ artifact: path.resolve(base, local.artifact),
664
+ currentVersion: localManifest.version,
665
+ version: evidence.version,
666
+ manifest: localManifest
667
+ };
668
+ }
669
+ const relevant = [.../* @__PURE__ */ new Set([...peerNames, ...Object.values(candidates).flatMap((candidate) => Object.keys(candidate.manifest.peerDependencies ?? {}))])].filter((peer) => peer === packageManifest.name || declarations[peer] !== void 0 || candidates[peer] !== void 0);
670
+ const before = {};
671
+ for (const peer of relevant) if (peer !== packageManifest.name && !candidates[peer] && await exists(path.join(directory, `node_modules`, peer))) before[peer] = (await installed(directory, peer)).version;
672
+ const preparation = await prepare(config.prepareCommand, directory).then(() => ({ ok: true }), (error) => ({
673
+ ok: false,
674
+ error
675
+ }));
676
+ const after = await Promise.all([filename, lockfile].map((input) => readFile(input).catch(() => Buffer.alloc(0))));
677
+ if (inputs.some((input, index) => !input.equals(after[index]))) {
678
+ await Promise.all([filename, lockfile].map(async (input, index) => {
679
+ await rm(input, { force: true });
680
+ await writeFile(input, inputs[index], { flag: `wx` });
681
+ }));
682
+ throw new Error(`Frozen preparation changed the manifest or lockfile in ${directory}; restored the original inputs.`, { cause: preparation.ok ? void 0 : preparation.error });
683
+ }
684
+ if (!preparation.ok) throw preparation.error;
685
+ const peers = {};
686
+ for (const peer of relevant) {
687
+ const candidate = candidates[peer];
688
+ const staged = candidate !== void 0 || peer === packageManifest.name;
689
+ if (!staged && Object.hasOwn(manifest.optionalDependencies ?? {}, peer) && !await exists(path.join(directory, `node_modules`, peer))) continue;
690
+ const evidence = staged ? {
691
+ version: candidate?.version ?? packageManifest.version,
692
+ path: path.join(stage, `node_modules`, peer),
693
+ source: `candidate`
694
+ } : await installed(directory, peer);
695
+ const specifier = declarations[peer];
696
+ if (!(staged && specifier === void 0) && evidence.version !== specifier) throw new Error(`Representative ${peer}@${evidence.version} does not match its manifest pin ${specifier ?? `(missing)`}.`);
697
+ if (before[peer] && before[peer] !== evidence.version) throw new Error(`Stale installed ${peer}: frozen preparation changed ${before[peer]} to ${evidence.version}.`);
698
+ for (const [localPeer, local] of Object.entries(config.localPeers ?? {})) {
699
+ if (staged) continue;
700
+ const localDirectory = path.resolve(base, local.directory);
701
+ if (!peerNames.includes(localPeer) && !relevant.includes(localPeer) && !await exists(localDirectory)) continue;
702
+ if (within(await realpath(localDirectory), evidence.path)) throw new Error(`Released peer ${peer} resolves into a local workspace package.`);
703
+ }
704
+ peers[peer] = evidence;
705
+ }
706
+ return {
707
+ directory,
708
+ stage,
709
+ manifest,
710
+ peers,
711
+ candidates
712
+ };
713
+ }
714
+ /** Validate the complete coordinated formulation before an audit or execution. */
715
+ function checkCandidatePeers(environment, focused) {
716
+ const conflicts = [];
717
+ for (const [name, candidate] of Object.entries(environment.candidates)) for (const [peer, range] of Object.entries(candidate.manifest.peerDependencies ?? {})) {
718
+ const evidence = environment.peers[peer];
719
+ if (!evidence) {
720
+ if (candidate.manifest.peerDependenciesMeta?.[peer]?.optional) continue;
721
+ throw new Error(`Complete representative is missing required peer ${peer}: ${range} of coordinated candidate ${name}@${candidate.version}.`);
722
+ }
723
+ if (!semver.satisfies(evidence.version, range)) conflicts.push(`Coordinated candidate ${name}@${candidate.version} requires ${peer}: ${range}, but the representative provides ${peer}@${evidence.version}.`);
724
+ }
725
+ if (conflicts.length && !focused) throw new Error(conflicts.join(`\n`));
726
+ return conflicts.map((conflict) => `${conflict} Running focused exploration outside declared support.`);
727
+ }
728
+ async function copySuite(base, stage, suite) {
729
+ for (const pattern of suite.files) {
730
+ if (path.isAbsolute(pattern) || pattern.split(/[\\/]/).includes(`..`)) throw new Error(`Suite pattern must stay inside the package: ${pattern}.`);
731
+ let count = 0;
732
+ for await (const filename of fs.glob(pattern, {
733
+ cwd: base,
734
+ exclude: [
735
+ `**/node_modules/**`,
736
+ `**/.git/**`,
737
+ path.relative(base, stage),
738
+ `${path.relative(base, stage)}/**`
739
+ ]
740
+ })) {
741
+ const source = path.resolve(base, filename);
742
+ const stat = await lstat(source);
743
+ if (stat.isDirectory()) continue;
744
+ if (!stat.isFile() || !within(await realpath(base), await realpath(source))) throw new Error(`Suite inputs must be regular package files: ${filename}.`);
745
+ let segment = base;
746
+ for (const part of filename.split(path.sep)) {
747
+ segment = path.join(segment, part);
748
+ if ((await lstat(segment)).isSymbolicLink()) throw new Error(`Suite inputs cannot follow symlinks: ${filename}.`);
749
+ }
750
+ if ([
751
+ `package.json`,
752
+ `package-lock.json`,
753
+ `npm-shrinkwrap.json`,
754
+ `.npmrc`
755
+ ].includes(filename) || filename.split(path.sep).includes(`node_modules`)) throw new Error(`Suite input collides with the consumer installation: ${filename}.`);
756
+ const destination = path.resolve(stage, filename);
757
+ if (!within(stage, destination)) throw new Error(`Suite path escapes its stage: ${filename}.`);
758
+ await mkdir(path.dirname(destination), { recursive: true });
759
+ await copyFile(source, destination);
760
+ count++;
761
+ }
762
+ if (!count) throw new Error(`Suite pattern matched no files: ${pattern}.`);
763
+ }
764
+ }
765
+ async function findPackage(from, name, installation) {
766
+ const origin = await realpath(from);
767
+ const parts = origin.split(path.sep);
768
+ const modules = parts.lastIndexOf(`node_modules`);
769
+ const boundary = within(installation, origin) ? installation : modules < 0 ? origin : parts.slice(0, modules + 1).join(path.sep);
770
+ const lookup = createRequire(path.join(origin, `__breakage_probe__.ts`)).resolve.paths(name) ?? [];
771
+ for (const directory of lookup) {
772
+ const location = path.join(directory, name);
773
+ if (await exists(location)) {
774
+ if (!within(installation, directory) && !within(boundary, directory)) throw new Error(`Dependency ${name} resolves outside the representative installation: ${location}.`);
775
+ return await realpath(location);
776
+ }
777
+ }
778
+ }
779
+ async function resolvePackage(from, name, installation) {
780
+ const location = await findPackage(from, name, installation);
781
+ if (location !== void 0) return location;
782
+ throw new Error(`Cannot resolve ${name} from ${from}.`);
783
+ }
784
+ async function stageCandidate(config, base, manifest, environment, suites) {
785
+ const { stage, candidates } = environment;
786
+ const artifacts = {
787
+ [manifest.name]: path.resolve(base, config.artifact),
788
+ ...Object.fromEntries(Object.entries(candidates).map(([name, entry]) => [name, entry.artifact]))
789
+ };
790
+ if (candidates[manifest.name]) throw new Error(`The package under test cannot also be its own candidate peer.`);
791
+ for (const artifact of Object.values(artifacts)) if (!(await lstat(artifact)).isFile()) throw new Error(`Candidate artifact must be a packed file: ${artifact}.`);
792
+ await writeFile(path.join(stage, `package.json`), JSON.stringify({
793
+ name: `breakage-consumer`,
794
+ version: `0.0.0`,
795
+ private: true,
796
+ type: environment.manifest.type ?? `module`,
797
+ dependencies: Object.fromEntries(Object.entries(artifacts).map(([name, artifact]) => [name, `file:${artifact}`]))
798
+ }));
799
+ await prepare(config.installCommand ?? `npm install --prefix . --workspaces=false --ignore-scripts --legacy-peer-deps --package-lock=false --no-audit --no-fund`, stage);
800
+ for (const [name] of Object.entries(artifacts)) {
801
+ const directory = path.join(stage, `node_modules`, name);
802
+ if (!within(stage, await realpath(directory))) throw new Error(`Candidate ${name} must be installed inside the test stage.`);
803
+ const filename = path.join(directory, `package.json`);
804
+ const packed = await readManifest(filename);
805
+ const candidate = candidates[name];
806
+ if (packed.name !== name || (candidate ? ![candidate.currentVersion, candidate.version].includes(packed.version) : packed.version !== manifest.version)) throw new Error(`Candidate archive identity/version mismatch for ${name}@${packed.version}.`);
807
+ const expectedManifest = candidate?.manifest ?? manifest;
808
+ if (Object.keys({
809
+ ...packed.peerDependencies,
810
+ ...expectedManifest.peerDependencies
811
+ }).some((peer) => packed.peerDependencies?.[peer] !== expectedManifest.peerDependencies?.[peer] || Boolean(packed.peerDependenciesMeta?.[peer]?.optional) !== Boolean(expectedManifest.peerDependenciesMeta?.[peer]?.optional))) throw new Error(`Candidate archive has stale peer declarations; rebuild and pack ${name}.`);
812
+ if (candidate) {
813
+ const evidence = environment.peers[name];
814
+ if (evidence) evidence.archiveVersion = packed.version;
815
+ if (packed.version !== candidate.version) {
816
+ const raw = JSON.parse(await readFile(filename, `utf8`));
817
+ const temporary = path.join(path.dirname(filename), `.breakage-${randomUUID()}.json`);
818
+ await writeFile(temporary, JSON.stringify({
819
+ ...raw,
820
+ version: candidate.version
821
+ }), { flag: `wx` });
822
+ try {
823
+ await rename(temporary, filename);
824
+ } finally {
825
+ await rm(temporary, { force: true });
826
+ }
827
+ }
828
+ }
829
+ }
830
+ for (const suite of suites) await copySuite(base, stage, suite);
831
+ await verifyResolution(manifest, environment);
832
+ }
833
+ async function verifyResolution(manifest, environment) {
834
+ const { stage, peers } = environment;
835
+ const installation = await realpath(environment.directory);
836
+ const packages = [manifest.name, ...Object.keys(environment.candidates)];
837
+ for (const [peer, evidence] of Object.entries(peers)) {
838
+ const expected = await realpath(evidence.path);
839
+ if (await resolvePackage(stage, peer, installation) !== expected) throw new Error(`Tests resolve a shadow copy of ${peer}.`);
840
+ for (const name of packages) {
841
+ if (name === peer) continue;
842
+ if (await resolvePackage(path.join(stage, `node_modules`, name), peer, installation) !== expected) throw new Error(`Candidate ${name} resolves a different instance of ${peer} than its tests.`);
843
+ }
844
+ const actual = await readManifest(path.join(expected, `package.json`));
845
+ if (actual.name !== peer || actual.version !== evidence.version) throw new Error(`Peer identity changed during execution: ${peer}.`);
846
+ }
847
+ const visited = /* @__PURE__ */ new Set();
848
+ const pending = packages.map((name) => path.join(stage, `node_modules`, name));
849
+ while (pending.length) {
850
+ const directory = await realpath(pending.pop());
851
+ if (visited.has(directory)) continue;
852
+ if (!directory.split(path.sep).includes(`node_modules`)) throw new Error(`Runtime dependency resolves to workspace source: ${directory}.`);
853
+ visited.add(directory);
854
+ const installedManifest = await readManifest(path.join(directory, `package.json`));
855
+ for (const peer of Object.keys(installedManifest.peerDependencies ?? {})) {
856
+ const evidence = peers[peer];
857
+ if (evidence && await resolvePackage(directory, peer, installation) !== await realpath(evidence.path)) throw new Error(`Runtime dependency ${installedManifest.name} resolves a different instance of ${peer}.`);
858
+ }
859
+ const optional = installedManifest.optionalDependencies ?? {};
860
+ for (const dependency of Object.keys({
861
+ ...installedManifest.dependencies,
862
+ ...optional
863
+ })) if (Object.hasOwn(optional, dependency)) {
864
+ const location = await findPackage(directory, dependency, installation);
865
+ if (location !== void 0) pending.push(location);
866
+ } else pending.push(await resolvePackage(directory, dependency, installation));
867
+ }
868
+ }
869
+ //#endregion
870
+ //#region src/npm/check.ts
871
+ async function checkSupport(config, base, manifest) {
872
+ try {
873
+ const previous = await previousManifest(config, base);
874
+ if (!previous) return {
875
+ status: `first-release`,
876
+ changes: []
877
+ };
878
+ if (previous.name !== manifest.name) throw new Error(`Release baseline belongs to ${previous.name}, not ${manifest.name}.`);
879
+ const changes = supportChanges(previous, manifest);
880
+ if (!changes.length) return {
881
+ status: `unchanged`,
882
+ previousVersion: previous.version,
883
+ changes
884
+ };
885
+ const evidence = await intent(config, {
886
+ packageName: manifest.name,
887
+ packageDirectory: base,
888
+ currentVersion: manifest.version
889
+ });
890
+ if (evidence.status === `none`) return {
891
+ status: `end-of-support`,
892
+ previousVersion: previous.version,
893
+ changes
894
+ };
895
+ if (!semver.gt(evidence.version, previous.version)) throw new Error(`The intended release must be newer than baseline ${previous.version}.`);
896
+ const release = {
897
+ version: evidence.version,
898
+ delta: semver.diff(previous.version, evidence.version) ?? `none`
899
+ };
900
+ const certified = config.certifyBreaking ? await config.certifyBreaking({
901
+ previousVersion: previous.version,
902
+ release,
903
+ changes
904
+ }) : false;
905
+ if (typeof certified !== `boolean`) throw new Error(`certifyBreaking must return a boolean.`);
906
+ return {
907
+ status: certified ? `certified` : `end-of-support`,
908
+ previousVersion: previous.version,
909
+ changes,
910
+ release
911
+ };
912
+ } catch (error) {
913
+ return {
914
+ status: `setup-error`,
915
+ changes: [],
916
+ error: errorMessage(error)
917
+ };
918
+ }
919
+ }
920
+ async function checkLocalReleases(config, base, manifest, representatives) {
921
+ return (await Promise.all(Object.entries(config.localPeers ?? {}).map(async ([peer, local]) => {
922
+ const range = manifest.peerDependencies?.[peer];
923
+ if (range === void 0) return [];
924
+ try {
925
+ const directory = path.resolve(base, local.directory);
926
+ const current = await readManifest(path.join(directory, `package.json`));
927
+ if (current.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
928
+ const release = await intent(config, {
929
+ packageName: peer,
930
+ packageDirectory: directory,
931
+ currentVersion: current.version
932
+ });
933
+ if (release.status === `none`) return [];
934
+ if (!matches(release.version, range)) return [`Declared support for ${peer}: ${range} does not include its planned local release ${release.version}. Update the peer range to include the intended version.`];
935
+ if (!representatives.some((result) => result.peers[peer]?.source === `candidate` && result.peers[peer]?.version === release.version && result.suites.some((suite) => suite.peer === peer))) return [`No coordinated representative covers planned local peer ${peer}@${release.version}. Enroll ${peer} with breakage.candidates and configure its relationship suite.`];
936
+ return [];
937
+ } catch (error) {
938
+ return [`Cannot inspect planned local release for ${peer}: ${errorMessage(error)}`];
939
+ }
940
+ }))).flat();
941
+ }
942
+ async function runRepresentative(config, base, manifest, directory, coverageOnly, selection) {
943
+ const result = {
944
+ path: path.relative(base, directory).split(path.sep).join(`/`),
945
+ status: `skipped`,
946
+ peers: {},
947
+ suites: [],
948
+ warnings: []
949
+ };
950
+ if (selection && `representative` in selection && path.resolve(base, selection.representative) !== directory) return result;
951
+ try {
952
+ if (selection && `peer` in selection && !await maySelectEnvironment(config, base, directory, manifest, selection)) return result;
953
+ return await withStage(directory, async (stage) => {
954
+ const environment = await prepareEnvironment(config, base, directory, stage, manifest);
955
+ result.peers = environment.peers;
956
+ for (const peer of Object.keys(manifest.peerDependencies ?? {})) if (!result.peers[peer] && !manifest.peerDependenciesMeta?.[peer]?.optional) throw new Error(`Complete representative is missing required peer ${peer}.`);
957
+ for (const [peer, evidence] of Object.entries(result.peers)) {
958
+ const range = manifest.peerDependencies?.[peer];
959
+ if (range !== void 0 && !matches(evidence.version, range)) result.warnings.push(`${peer}@${evidence.version} is outside declared support.`);
960
+ }
961
+ if (selection && `peer` in selection && (!result.peers[selection.peer] || !matches(result.peers[selection.peer].version, selection.range))) return result;
962
+ const peers = Object.keys(config.suites).filter((peer) => {
963
+ const evidence = result.peers[peer];
964
+ if (!evidence) return false;
965
+ if (selection && `peer` in selection) return peer === selection.peer;
966
+ if (selection && `representative` in selection) return true;
967
+ return matches(evidence.version, manifest.peerDependencies[peer]);
968
+ });
969
+ result.suites = peers.map((peer) => ({
970
+ peer,
971
+ status: `not-run`
972
+ }));
973
+ if (!peers.length) return result;
974
+ result.warnings.push(...checkCandidatePeers(environment, !!selection));
975
+ if (coverageOnly) {
976
+ result.status = `audited`;
977
+ return result;
978
+ }
979
+ await stageCandidate(config, base, manifest, environment, peers.map((peer) => config.suites[peer]));
980
+ for (const suite of result.suites) try {
981
+ await verifyResolution(manifest, environment);
982
+ const execution = await command(config.suites[suite.peer].command, stage);
983
+ await verifyResolution(manifest, environment);
984
+ suite.status = execution.code === 0 ? `passed` : execution.code === 1 ? `compatibility-failure` : `setup-error`;
985
+ if (execution.output) suite.output = execution.output;
986
+ } catch (error) {
987
+ suite.status = `setup-error`;
988
+ suite.output = errorMessage(error);
989
+ }
990
+ result.status = result.suites.some((suite) => suite.status === `setup-error`) ? `setup-error` : result.suites.some((suite) => suite.status === `compatibility-failure`) ? `compatibility-failure` : `passed`;
991
+ return result;
992
+ });
993
+ } catch (error) {
994
+ result.status = `setup-error`;
995
+ result.error = errorMessage(error);
996
+ return result;
997
+ }
998
+ }
999
+ async function run(config, baseDirectory, coverageOnly, selection) {
1000
+ const report = {
1001
+ package: {
1002
+ name: `(unresolved)`,
1003
+ version: `(unresolved)`
1004
+ },
1005
+ mode: selection ? `test` : coverageOnly ? `coverage-only` : `check`,
1006
+ exitCode: 0,
1007
+ representatives: [],
1008
+ coverage: [],
1009
+ support: {
1010
+ status: `not-checked`,
1011
+ changes: []
1012
+ },
1013
+ errors: []
1014
+ };
1015
+ try {
1016
+ validateConfig(config);
1017
+ const base = path.resolve(baseDirectory, config.packageDirectory ?? `.`);
1018
+ const manifest = await normalizeWorkspacePeers(await readManifest(path.join(base, `package.json`)));
1019
+ report.package = {
1020
+ name: manifest.name,
1021
+ version: manifest.version
1022
+ };
1023
+ validatePeerRanges(manifest.peerDependencies ?? {});
1024
+ for (const peer of Object.keys(config.suites)) if (!manifest.peerDependencies?.[peer]) throw new Error(`Suite ${peer} has no peerDependencies declaration.`);
1025
+ for (const peer of Object.keys(config.localPeers ?? {})) if (manifest.peerDependencies?.[peer] && !config.suites[peer]) throw new Error(`Declared local peer ${peer} needs a relationship suite.`);
1026
+ if (selection && `peer` in selection) {
1027
+ if (!config.suites[selection.peer]) throw new Error(`No relationship suite configured for ${selection.peer}.`);
1028
+ eras(selection.range);
1029
+ }
1030
+ const releases = /* @__PURE__ */ new Map();
1031
+ const configuredHook = config.releaseIntent;
1032
+ const effective = {
1033
+ ...config,
1034
+ releaseIntent: (context) => {
1035
+ let pending = releases.get(context.packageDirectory);
1036
+ if (!pending) {
1037
+ pending = Promise.resolve().then(() => configuredHook ? configuredHook(context) : { status: `none` });
1038
+ releases.set(context.packageDirectory, pending);
1039
+ }
1040
+ return pending;
1041
+ }
1042
+ };
1043
+ const directories = await discover(config, base);
1044
+ const [support, representatives] = await Promise.all([selection ? Promise.resolve(report.support) : checkSupport(effective, base, manifest), Promise.all(directories.map((directory) => runRepresentative(effective, base, manifest, directory, coverageOnly, selection)))]);
1045
+ report.support = support;
1046
+ report.representatives = representatives;
1047
+ if (!selection) {
1048
+ report.errors.push(...await checkLocalReleases(effective, base, manifest, representatives));
1049
+ for (const peer of Object.keys(config.suites)) for (const era of eras(manifest.peerDependencies[peer])) {
1050
+ const matching = representatives.filter((representative) => representative.peers[peer] && matches(representative.peers[peer].version, era)).map((representative) => representative.path);
1051
+ report.coverage.push({
1052
+ peer,
1053
+ era,
1054
+ representatives: matching
1055
+ });
1056
+ if (!matching.length) report.errors.push(`No representative covers ${peer}: ${era}.`);
1057
+ }
1058
+ } else if (!representatives.some((representative) => representative.suites.length > 0)) report.errors.push(`No representative matched the requested test selection.`);
1059
+ if (support.status === `setup-error` || report.errors.length || representatives.some((result) => result.status === `setup-error`)) report.exitCode = 2;
1060
+ else if (support.status === `end-of-support` || representatives.some((result) => result.status === `compatibility-failure`)) report.exitCode = 1;
1061
+ } catch (error) {
1062
+ report.errors.push(errorMessage(error));
1063
+ report.exitCode = 2;
1064
+ }
1065
+ return report;
1066
+ }
1067
+ async function npmCheck(config, options = {}) {
1068
+ return await run(config, options.baseDirectory ?? process.cwd(), options.coverageOnly ?? false);
1069
+ }
1070
+ async function npmTest(config, selection, options = {}) {
1071
+ return await run(config, options.baseDirectory ?? process.cwd(), false, selection);
1072
+ }
1073
+ function formatNpmReport(report) {
1074
+ const lines = [`${report.package.name}@${report.package.version} — npm ${report.mode}`];
1075
+ for (const result of report.representatives) {
1076
+ lines.push(`${result.path}: ${result.status}`);
1077
+ for (const [peer, evidence] of Object.entries(result.peers)) lines.push(` ${peer}@${evidence.version}${evidence.source === `candidate` ? ` (conditional on publication)` : ` (installed)`}`);
1078
+ for (const suite of result.suites) {
1079
+ lines.push(` ${suite.peer}: ${suite.status}`);
1080
+ if (suite.status !== `passed` && suite.output) lines.push(suite.output);
1081
+ }
1082
+ for (const warning of result.warnings) lines.push(` Warning: ${warning}`);
1083
+ if (result.error) lines.push(` ${result.error}`);
1084
+ }
1085
+ lines.push(`Support: ${report.support.status}`);
1086
+ for (const change of report.support.changes) lines.push(` ${change.peer}: ${change.previous ?? `(unrestricted)`} → ${change.current ?? `(unrestricted)`} (${change.reason})`);
1087
+ if (report.support.release) lines.push(` Intended release: ${report.support.release.version} (${report.support.release.delta})`);
1088
+ if (report.support.error) lines.push(report.support.error);
1089
+ lines.push(...report.errors);
1090
+ if (report.mode === `coverage-only`) lines.push(`Coverage audit only; compatibility assertions were not executed.`);
1091
+ return `${lines.join(`\n`)}\n`;
1092
+ }
1093
+ //#endregion
1094
+ //#region src/breakage.ts
1095
+ async function breakage({ tagPattern, baselineFile, testPattern, testCommand, certifyCommand, baseDirname = process.cwd(), verbose = false }) {
1096
+ let mark;
1097
+ let logMarks;
1098
+ if (verbose) {
1099
+ const chronicle = logger.makeChronicle();
1100
+ mark = chronicle.mark;
1101
+ logMarks = chronicle.logMarks;
1102
+ }
1103
+ mark?.(`breakage`);
1104
+ const git = simpleGit(baseDirname);
1105
+ mark?.(`spawn git`);
1106
+ const isGitRepo = await git.checkIsRepo();
1107
+ mark?.(`is git repository`);
1108
+ if (!isGitRepo) return {
1109
+ summary: `The git repository must be clean to run this command.`,
1110
+ gitWasClean: false
1111
+ };
1112
+ if (!await withTestFileState(git, baseDirname, (state) => state.isClean())) return {
1113
+ summary: `The git repository must be clean to run this command.`,
1114
+ gitWasClean: false
1115
+ };
1116
+ let latestReleaseTag$1;
1117
+ let releaseCommit;
1118
+ if (baselineFile !== void 0) {
1119
+ const baseline = await readBaseline(git, path.resolve(baseDirname, baselineFile), { tagPattern });
1120
+ latestReleaseTag$1 = baseline.ref;
1121
+ releaseCommit = baseline.commit;
1122
+ mark?.(`read pinned release baseline`);
1123
+ } else {
1124
+ const tagsRemote = await git.listRemote([`--tags`, `origin`]);
1125
+ mark?.(`list remote tags`);
1126
+ latestReleaseTag$1 = latestReleaseTag(tagsRemote, tagPattern);
1127
+ mark?.(`found latest release tag`);
1128
+ if (!latestReleaseTag$1) return {
1129
+ summary: `No tags found matching the pattern "${tagPattern}".`,
1130
+ gitWasClean: true,
1131
+ lastReleaseFound: false
1132
+ };
1133
+ const commonGitDirectory = await git.revparse([`--path-format=absolute`, `--git-common-dir`]);
1134
+ await withDirectoryLock(path.join(commonGitDirectory, `breakage-fetch.lock`), () => git.fetch([
1135
+ `origin`,
1136
+ `${latestReleaseTag$1}:${latestReleaseTag$1}`,
1137
+ `--no-tags`,
1138
+ `--no-write-fetch-head`
1139
+ ]));
1140
+ releaseCommit = await git.revparse([`${latestReleaseTag$1}^{commit}`]);
1141
+ mark?.(`fetched latest release tag`);
1142
+ }
1143
+ let productionFiles;
1144
+ try {
1145
+ productionFiles = (await git.raw([
1146
+ `ls-tree`,
1147
+ `-r`,
1148
+ `--name-only`,
1149
+ `-z`,
1150
+ releaseCommit
1151
+ ])).split(`\0`).filter(Boolean);
1152
+ } catch (thrown) {
1153
+ mark?.(`failed to list production files`);
1154
+ const message = thrown instanceof Error ? thrown.message : typeof thrown === `string` ? thrown : void 0;
1155
+ return {
1156
+ summary: `Failed to list production files${message ? `: ${message}` : `.`}`,
1157
+ gitWasClean: true,
1158
+ lastReleaseFound: true,
1159
+ lastReleaseTag: latestReleaseTag$1,
1160
+ testsWereFound: false
1161
+ };
1162
+ }
1163
+ mark?.(`listed all files checked into git`);
1164
+ const productionTestFiles = productionFiles.filter((file) => minimatch(file, testPattern));
1165
+ mark?.(`filtered to public test files`);
1166
+ const prepared = await withTestFileState(git, baseDirname, async (state) => {
1167
+ if (!await state.isClean()) return {
1168
+ summary: `The git repository must be clean to run this command.`,
1169
+ gitWasClean: false
1170
+ };
1171
+ if (productionTestFiles.length === 0) return {
1172
+ summary: `No tests were found matching the pattern "${testPattern}".`,
1173
+ gitWasClean: true,
1174
+ lastReleaseFound: true,
1175
+ lastReleaseTag: latestReleaseTag$1,
1176
+ testsWereFound: false
1177
+ };
1178
+ const restore = await state.replaceTests(releaseCommit, productionTestFiles);
1179
+ mark?.(`restored public tests from latest release tag`);
1180
+ return {
1181
+ latestReleaseTag: latestReleaseTag$1,
1182
+ productionTestFiles,
1183
+ restore
1184
+ };
1185
+ });
1186
+ if (prepared.summary !== void 0) return prepared;
1187
+ const { restore } = prepared;
1188
+ try {
1189
+ const noBreakingChangesDetected = await new Promise((resolve, reject) => {
1190
+ const result = exec(testCommand, { cwd: baseDirname }, (_, __, stderr) => {
1191
+ if (result.exitCode === 0) resolve({
1192
+ summary: `No breaking changes were detected.`,
1193
+ gitWasClean: true,
1194
+ lastReleaseFound: true,
1195
+ lastReleaseTag: latestReleaseTag$1,
1196
+ testsWereFound: true,
1197
+ testsFound: productionTestFiles,
1198
+ breakingChangesFound: false
1199
+ });
1200
+ else reject(stderr);
1201
+ });
1202
+ });
1203
+ mark?.(`passed tests`);
1204
+ return noBreakingChangesDetected;
1205
+ } catch (thrown) {
1206
+ mark?.(`failed tests`);
1207
+ return await new Promise((resolve) => {
1208
+ const result = exec(certifyCommand, { cwd: baseDirname }, (_, stdout, stderr) => {
1209
+ const wereCertified = result.exitCode === 0;
1210
+ mark?.(`${wereCertified ? `passed` : `failed`} certification`);
1211
+ resolve({
1212
+ summary: wereCertified ? `Breaking changes were found and certified.` : `Breaking changes were found, but not certified.`,
1213
+ gitWasClean: true,
1214
+ lastReleaseFound: true,
1215
+ lastReleaseTag: latestReleaseTag$1,
1216
+ testsWereFound: true,
1217
+ testsFound: productionTestFiles,
1218
+ breakingChangesFound: true,
1219
+ testResult: thrown,
1220
+ breakingChangesCertified: wereCertified,
1221
+ certificationStdout: stdout,
1222
+ certificationStderr: stderr
1223
+ });
1224
+ });
1225
+ });
1226
+ } finally {
1227
+ await restore();
1228
+ mark?.(`restored original public tests`);
1229
+ logMarks?.();
1230
+ }
1231
+ }
1232
+ //#endregion
1233
+ export { validateConfig as a, breakagePrelude as c, npmTest as i, formatNpmReport as n, record as o, npmCheck as r, errorMessage as s, breakage as t };
1234
+
1235
+ //# sourceMappingURL=breakage-B7dJsgcu.js.map