breakage 0.8.5 → 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 (42) hide show
  1. package/README.md +163 -47
  2. package/dist/{breakage-B7dJsgcu.js → breakage-DIY49fzP.js} +331 -217
  3. package/dist/breakage-DIY49fzP.js.map +1 -0
  4. package/dist/breakage.d.ts +111 -55
  5. package/dist/breakage.d.ts.map +1 -1
  6. package/dist/breakage.js +3 -1
  7. package/dist/breakage.x.js +78 -80
  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 +55 -33
  18. package/package.json +45 -6
  19. package/src/breakage.ts +125 -73
  20. package/src/breakage.x.ts +148 -127
  21. package/src/changesets.ts +51 -0
  22. package/src/config.ts +128 -0
  23. package/src/npm/check.ts +51 -50
  24. package/src/npm/cli.ts +26 -22
  25. package/src/npm/config.ts +38 -6
  26. package/src/npm/environment.ts +10 -10
  27. package/src/npm/release.ts +9 -25
  28. package/src/npm/types.ts +5 -45
  29. package/src/release.ts +153 -0
  30. package/src/{npm/command.ts → task.ts} +2 -0
  31. package/dist/breakage-B7dJsgcu.js.map +0 -1
  32. package/dist/breakage.$configPath.schema.json +0 -33
  33. package/dist/breakage.help.schema.json +0 -6
  34. package/dist/breakage.main.schema.json +0 -33
  35. package/dist/breakage.npm.check.schema.json +0 -16
  36. package/dist/breakage.npm.help.schema.json +0 -6
  37. package/dist/breakage.npm.schema.json +0 -6
  38. package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
  39. package/dist/breakage.npm.test.schema.json +0 -16
  40. package/dist/breakage.prelude.$configPath.schema.json +0 -31
  41. package/dist/breakage.prelude.schema.json +0 -31
  42. package/dist/breakage.schema.schema.json +0 -10
@@ -1,15 +1,125 @@
1
+ import { a as validateRelease, n as releaseSchema, o as versionDelta, r as resolveReleaseDelta, t as rejectCertificationOptions } from "./release-BsdIDT4g.js";
1
2
  import { createRequire } from "node:module";
2
- import { exec, spawn } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { minimatch } from "minimatch";
5
5
  import { simpleGit } from "simple-git";
6
6
  import logger from "takua";
7
- import { randomUUID } from "node:crypto";
7
+ import { spawn } from "node:child_process";
8
8
  import * as fs from "node:fs/promises";
9
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
10
  import semver from "semver";
12
- //#region src/directory-lock.ts
11
+ import { type } from "arktype";
12
+ import { randomUUID } from "node:crypto";
13
+ import { setTimeout } from "node:timers/promises";
14
+ import { existsSync } from "node:fs";
15
+ import { cli, options } from "comline";
16
+ // src/task.ts
17
+ /** Capture output per environment so parallel runs cannot interleave their reports. */
18
+ async function command(commandLine, cwd) {
19
+ if (typeof commandLine !== `string` || !commandLine.trim()) throw new Error(`Commands must be nonempty shell strings.`);
20
+ return await new Promise((resolve, reject) => {
21
+ const child = spawn(commandLine, {
22
+ cwd,
23
+ shell: true,
24
+ env: {
25
+ ...process.env,
26
+ NODE_PATH: ``,
27
+ PATH: [
28
+ path.join(cwd, `node_modules/.bin`),
29
+ path.join(cwd, `../node_modules/.bin`),
30
+ process.env.PATH
31
+ ].join(path.delimiter)
32
+ },
33
+ stdio: [
34
+ `ignore`,
35
+ `pipe`,
36
+ `pipe`
37
+ ]
38
+ });
39
+ let output = ``;
40
+ const collect = (chunk) => {
41
+ output = (output + chunk.toString()).slice(-1e6);
42
+ };
43
+ child.stdout.on(`data`, collect);
44
+ child.stderr.on(`data`, collect);
45
+ child.on(`error`, reject);
46
+ child.on(`close`, (code, signal) => resolve({
47
+ code: signal ? 2 : code ?? 2,
48
+ output
49
+ }));
50
+ });
51
+ }
52
+ async function prepare(commandLine, cwd) {
53
+ const result = await command(commandLine, cwd);
54
+ if (result.code !== 0) throw new Error(`Command failed (${result.code}) in ${cwd}: ${commandLine}\n${result.output}`);
55
+ }
56
+ function errorMessage(error) {
57
+ return error instanceof Error ? `${error.message}${error.cause ? `\n${errorMessage(error.cause)}` : ``}` : String(error);
58
+ }
59
+ // src/npm/manifest.ts
60
+ function record(value) {
61
+ return typeof value === `object` && value !== null && !Array.isArray(value);
62
+ }
63
+ function packageName(value) {
64
+ return /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
65
+ }
66
+ function parseManifest(value, label) {
67
+ 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}.`);
68
+ 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}.`);
69
+ for (const field of [
70
+ `dependencies`,
71
+ `optionalDependencies`,
72
+ `devDependencies`,
73
+ `peerDependencies`
74
+ ]) {
75
+ const entries = value[field];
76
+ if (entries !== void 0 && (!record(entries) || Object.entries(entries).some(([name, spec]) => !packageName(name) || typeof spec !== `string`))) throw new Error(`Invalid ${field} in ${label}.`);
77
+ }
78
+ 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}.`);
79
+ return value;
80
+ }
81
+ async function readManifest(filename) {
82
+ return parseManifest(JSON.parse(await readFile(filename, `utf8`)), filename);
83
+ }
84
+ /** Current support is authored explicitly; only historical shorthand is resolved. */
85
+ async function normalizeWorkspacePeers(manifest, versionAtBaseline) {
86
+ if (!manifest.peerDependencies) return manifest;
87
+ const entries = await Promise.all(Object.entries(manifest.peerDependencies).map(async ([peer, range]) => {
88
+ if (!range.startsWith(`workspace:`)) return [peer, range];
89
+ const requested = range.slice(`workspace:`.length);
90
+ if ([
91
+ ``,
92
+ `*`,
93
+ `^`,
94
+ `~`
95
+ ].includes(requested)) {
96
+ 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.`);
97
+ const version = await versionAtBaseline(peer);
98
+ if (!semver.valid(version)) throw new Error(`Invalid historical workspace version for ${peer}: ${version}.`);
99
+ return [peer, `${requested === `^` || requested === `~` ? requested : ``}${version}`];
100
+ }
101
+ try {
102
+ eras(requested);
103
+ } catch {
104
+ throw new Error(`Unsupported workspace peer range for ${peer}: ${range}.`);
105
+ }
106
+ return [peer, requested];
107
+ }));
108
+ return {
109
+ ...manifest,
110
+ peerDependencies: Object.fromEntries(entries)
111
+ };
112
+ }
113
+ function eras(range) {
114
+ const parts = range.split(`||`).map((part) => part.trim());
115
+ if (parts.some((part) => !part || semver.validRange(part) === null)) throw new Error(`Invalid peer range: ${range}.`);
116
+ return parts;
117
+ }
118
+ /** Previews only represent an explicitly enrolled exact preview. */
119
+ function matches(version, range) {
120
+ return eras(range).some((era) => semver.prerelease(version) ? semver.valid(era.replace(/^=\s*/, ``)) === semver.valid(version) : semver.satisfies(version, era));
121
+ }
122
+ // src/directory-lock.ts
13
123
  async function withDirectoryLock(lock, action, waitForCleanup = false) {
14
124
  const deadline = Date.now() + 6e4;
15
125
  for (;;) try {
@@ -26,8 +136,7 @@ async function withDirectoryLock(lock, action, waitForCleanup = false) {
26
136
  await rmdir(lock);
27
137
  }
28
138
  }
29
- //#endregion
30
- //#region src/release-tag.ts
139
+ // src/release-tag.ts
31
140
  function compareInteger(a, b) {
32
141
  return a.length - b.length || (a > b ? 1 : a < b ? -1 : 0);
33
142
  }
@@ -69,8 +178,7 @@ function latestReleaseTag(remoteTags, tagPattern) {
69
178
  }
70
179
  return latest?.ref;
71
180
  }
72
- //#endregion
73
- //#region src/baseline.ts
181
+ // src/baseline.ts
74
182
  const objectId = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/;
75
183
  function isBaseline(value) {
76
184
  if (typeof value !== `object` || value === null) return false;
@@ -149,8 +257,7 @@ async function readBaseline(git, filename, { tagPattern }) {
149
257
  throw new Error(`Missing, invalid, or unusable baseline ${filename}. Run breakage prelude again.`, { cause });
150
258
  }
151
259
  }
152
- //#endregion
153
- //#region src/test-file-state.ts
260
+ // src/test-file-state.ts
154
261
  function isActiveCheck(value) {
155
262
  if (typeof value !== `object` || value === null) return false;
156
263
  const record = value;
@@ -293,127 +400,48 @@ async function withTestFileState(git, baseDirname, action) {
293
400
  });
294
401
  });
295
402
  }
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
403
+ // src/npm/config.ts
404
+ const npmSuiteSchema = type({
405
+ "+": `reject`,
406
+ files: `string[]`,
407
+ command: `string`
408
+ });
409
+ const npmConfigSchema = type({
410
+ "+": `reject`,
411
+ "release?": releaseSchema,
412
+ "packageDirectory?": `string`,
413
+ artifact: `string`,
414
+ representatives: `string | string[]`,
415
+ lockfile: `string`,
416
+ prepareCommand: `string`,
417
+ "installCommand?": `string`,
418
+ suites: { "[string]": npmSuiteSchema },
419
+ "localPeers?": { "[string]": {
420
+ "+": `reject`,
421
+ directory: `string`,
422
+ artifact: `string`
423
+ } },
424
+ baseline: type({
425
+ "+": `reject`,
426
+ firstRelease: `true`
427
+ }).or({
428
+ "+": `reject`,
429
+ "tagPattern?": `string`,
430
+ "baselineFile?": `string`
431
+ })
432
+ });
405
433
  function nonempty(value) {
406
434
  return typeof value === `string` && value.trim().length > 0;
407
435
  }
408
436
  function validateConfig(value) {
409
437
  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.`);
438
+ rejectCertificationOptions(value);
439
+ npmConfigSchema.assert(value);
440
+ for (const key of [`artifact`, `lockfile`]) if (!nonempty(value[key])) throw new Error(`npm.${key} must be a nonempty string.`);
415
441
  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}.`);
442
+ if (!nonempty(value.prepareCommand)) throw new Error(`npm.prepareCommand must be a nonempty shell command string.`);
443
+ if (value.installCommand !== void 0 && !nonempty(value.installCommand)) throw new Error(`Invalid npm.installCommand.`);
444
+ for (const key of [`packageDirectory`]) if (value[key] !== void 0 && !nonempty(value[key])) throw new Error(`Invalid npm.${key}.`);
417
445
  const globs = typeof value.representatives === `string` ? [value.representatives] : value.representatives;
418
446
  if (!Array.isArray(globs) || !globs.length || globs.some((glob) => !nonempty(glob))) throw new Error(`npm.representatives must contain directory globs.`);
419
447
  if (!record(value.suites) || !Object.keys(value.suites).length) throw new Error(`Configure at least one npm peer suite.`);
@@ -423,23 +451,96 @@ function validateConfig(value) {
423
451
  if (!record(value.localPeers)) throw new Error(`Invalid npm.localPeers.`);
424
452
  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
453
  }
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
454
  }
428
455
  function validatePeerRanges(peers) {
429
456
  for (const range of Object.values(peers)) eras(range);
430
457
  }
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.`);
458
+ // src/config.ts
459
+ const configFields = {
460
+ tagPattern: type(`string`),
461
+ baselineFile: type(`string`),
462
+ testPattern: type(`string`),
463
+ testCommand: type(`string`),
464
+ test: type(`string`),
465
+ baseDirname: type(`string`),
466
+ verbose: type(`boolean`),
467
+ out: type(`string`)
468
+ };
469
+ const configSchema = type({
470
+ "+": `reject`,
471
+ "tagPattern?": configFields.tagPattern.or(`undefined`),
472
+ "baselineFile?": configFields.baselineFile.or(`undefined`),
473
+ "testPattern?": configFields.testPattern,
474
+ "testCommand?": configFields.testCommand,
475
+ "test?": configFields.test,
476
+ "baseDirname?": configFields.baseDirname,
477
+ "verbose?": configFields.verbose.or(`undefined`),
478
+ "out?": configFields.out,
479
+ "release?": releaseSchema,
480
+ "npm?": npmConfigSchema
481
+ });
482
+ /** The complete config, with no fields exposed to argv by this loader. */
483
+ const configOptions = options(`Breakage configuration`, configSchema, {
484
+ tagPattern: { configOnly: true },
485
+ baselineFile: { configOnly: true },
486
+ testPattern: { configOnly: true },
487
+ testCommand: { configOnly: true },
488
+ test: { configOnly: true },
489
+ baseDirname: { configOnly: true },
490
+ verbose: { configOnly: true },
491
+ out: { configOnly: true },
492
+ release: { configOnly: true },
493
+ npm: { configOnly: true }
494
+ });
495
+ /** Share the root release model with npm checks unless they supply their own. */
496
+ function defineConfig(config) {
497
+ if (config && typeof config === `object`) {
498
+ rejectCertificationOptions(config);
499
+ if (config.npm) rejectCertificationOptions(config.npm);
500
+ if (config.release !== void 0) validateRelease(config.release);
501
+ if (config.npm?.release !== void 0) validateRelease(config.npm.release);
502
+ }
503
+ configSchema.assert(config);
438
504
  return {
439
- status: `release`,
440
- version: result.version
505
+ ...config,
506
+ ...config.npm ? { npm: {
507
+ ...config.npm,
508
+ ...config.release && config.npm.release === void 0 ? { release: config.release } : {}
509
+ } } : {}
441
510
  };
442
511
  }
512
+ function defaultConfig(directory) {
513
+ const filename = path.join(directory, `breakage.config.ts`);
514
+ if (!existsSync(filename) && existsSync(path.join(directory, `breakage.config.json`))) throw new Error(`JSON configuration was removed. Migrate breakage.config.json to breakage.config.ts.`);
515
+ return filename;
516
+ }
517
+ /** Comline ignores absent discovered files; explicit paths must exist. */
518
+ function configPath(filename, required = true) {
519
+ const absolute = path.resolve(filename);
520
+ if (path.extname(absolute) !== `.ts`) throw new Error(`Configuration must be a .ts module with a default export; JSON configuration was removed.`);
521
+ if (required && !existsSync(absolute)) throw new Error(`Configuration file does not exist: ${absolute}`);
522
+ return absolute;
523
+ }
524
+ /** Apply application semantics after Comline validates and merges configuration. */
525
+ function resolveConfig(config, filename) {
526
+ return {
527
+ ...defineConfig(config),
528
+ baseDirname: path.resolve(filename ? path.dirname(filename) : process.cwd(), config.baseDirname ?? `.`)
529
+ };
530
+ }
531
+ /** Use Comline's module loading and full-schema validation for programmatic and npm consumers. */
532
+ async function loadConfig(filename) {
533
+ const absolute = configPath(filename);
534
+ return resolveConfig((await cli({
535
+ cliName: `breakage`,
536
+ routeOptions: { "": configOptions },
537
+ discoverConfigPath: () => absolute
538
+ }).async([process.execPath, `breakage`])).inputs.opts, absolute);
539
+ }
540
+ // src/npm/release.ts
541
+ async function releaseVersion(config, context) {
542
+ return config.release ? (await resolveReleaseDelta(config.release, context))?.version : void 0;
543
+ }
443
544
  /**
444
545
  * Compare the union, including adjacent alternatives. semver.subset compares each
445
546
  * conjunction to a single conjunction and cannot prove coverage by their union.
@@ -518,8 +619,7 @@ async function previousManifest(config, directory) {
518
619
  return historical.version;
519
620
  });
520
621
  }
521
- //#endregion
522
- //#region src/npm/environment.ts
622
+ // src/npm/environment.ts
523
623
  function within(parent, child) {
524
624
  const relative = path.relative(parent, child);
525
625
  return relative === `` || !relative.startsWith(`..${path.sep}`) && relative !== `..` && !path.isAbsolute(relative);
@@ -624,13 +724,13 @@ async function maySelectEnvironment(config, base, directory, packageManifest, se
624
724
  const packageDirectory = path.resolve(base, local.directory);
625
725
  const current = await readManifest(path.join(packageDirectory, `package.json`));
626
726
  if (current.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
627
- const release = await intent(config, {
727
+ const release = await releaseVersion(config, {
628
728
  packageName: peer,
629
729
  packageDirectory,
630
730
  currentVersion: current.version
631
731
  });
632
- if (release.status === `none`) throw new Error(`Candidate ${peer} has no certified release intent.`);
633
- return matches(release.version, range);
732
+ if (release === void 0) throw new Error(`Candidate ${peer} has no release version.`);
733
+ return matches(release, range);
634
734
  }
635
735
  const declared = declarations[peer];
636
736
  return declared !== void 0 && matches(declared, range);
@@ -652,17 +752,17 @@ async function prepareEnvironment(config, base, directory, stage, packageManifes
652
752
  const localManifest = await normalizeWorkspacePeers(await readManifest(path.join(localDirectory, `package.json`)));
653
753
  if (localManifest.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
654
754
  validatePeerRanges(localManifest.peerDependencies ?? {});
655
- const evidence = await intent(config, {
755
+ const evidence = await releaseVersion(config, {
656
756
  packageName: peer,
657
757
  packageDirectory: localDirectory,
658
758
  currentVersion: localManifest.version
659
759
  });
660
- if (evidence.status === `none`) throw new Error(`Candidate ${peer} has no certified release intent.`);
760
+ if (evidence === void 0) throw new Error(`Candidate ${peer} has no release version.`);
661
761
  candidates[peer] = {
662
762
  directory: localDirectory,
663
763
  artifact: path.resolve(base, local.artifact),
664
764
  currentVersion: localManifest.version,
665
- version: evidence.version,
765
+ version: evidence,
666
766
  manifest: localManifest
667
767
  };
668
768
  }
@@ -866,8 +966,7 @@ async function verifyResolution(manifest, environment) {
866
966
  } else pending.push(await resolvePackage(directory, dependency, installation));
867
967
  }
868
968
  }
869
- //#endregion
870
- //#region src/npm/check.ts
969
+ // src/npm/check.ts
871
970
  async function checkSupport(config, base, manifest) {
872
971
  try {
873
972
  const previous = await previousManifest(config, base);
@@ -882,29 +981,20 @@ async function checkSupport(config, base, manifest) {
882
981
  previousVersion: previous.version,
883
982
  changes
884
983
  };
885
- const evidence = await intent(config, {
984
+ const evidence = config.release ? await resolveReleaseDelta(config.release, {
886
985
  packageName: manifest.name,
887
986
  packageDirectory: base,
888
- currentVersion: manifest.version
889
- });
890
- if (evidence.status === `none`) return {
987
+ currentVersion: manifest.version,
988
+ baseline: { version: previous.version }
989
+ }) : void 0;
990
+ if (evidence === void 0) return {
891
991
  status: `end-of-support`,
892
992
  previousVersion: previous.version,
893
993
  changes
894
994
  };
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.`);
995
+ const release = versionDelta(evidence.previousVersion, evidence.version, config.release?.policy);
906
996
  return {
907
- status: certified ? `certified` : `end-of-support`,
997
+ status: release.breaking ? `breaking-release` : `end-of-support`,
908
998
  previousVersion: previous.version,
909
999
  changes,
910
1000
  release
@@ -925,14 +1015,14 @@ async function checkLocalReleases(config, base, manifest, representatives) {
925
1015
  const directory = path.resolve(base, local.directory);
926
1016
  const current = await readManifest(path.join(directory, `package.json`));
927
1017
  if (current.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
928
- const release = await intent(config, {
1018
+ const release = await releaseVersion(config, {
929
1019
  packageName: peer,
930
1020
  packageDirectory: directory,
931
1021
  currentVersion: current.version
932
1022
  });
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.`];
1023
+ if (release === void 0) return [];
1024
+ if (!matches(release, range)) return [`Declared support for ${peer}: ${range} does not include its planned local release ${release}. Update the peer range to include the intended version.`];
1025
+ if (!representatives.some((result) => result.peers[peer]?.source === `candidate` && result.peers[peer]?.version === release && result.suites.some((suite) => suite.peer === peer))) return [`No coordinated representative covers planned local peer ${peer}@${release}. Enroll ${peer} with breakage.candidates and configure its relationship suite.`];
936
1026
  return [];
937
1027
  } catch (error) {
938
1028
  return [`Cannot inspect planned local release for ${peer}: ${errorMessage(error)}`];
@@ -1028,17 +1118,25 @@ async function run(config, baseDirectory, coverageOnly, selection) {
1028
1118
  eras(selection.range);
1029
1119
  }
1030
1120
  const releases = /* @__PURE__ */ new Map();
1031
- const configuredHook = config.releaseIntent;
1121
+ const resolveDelta = (context) => {
1122
+ const key = JSON.stringify([
1123
+ context.packageDirectory,
1124
+ context.currentVersion,
1125
+ context.baseline
1126
+ ]);
1127
+ let pending = releases.get(key);
1128
+ if (!pending) {
1129
+ pending = resolveReleaseDelta(config.release, context);
1130
+ releases.set(key, pending);
1131
+ }
1132
+ return pending;
1133
+ };
1032
1134
  const effective = {
1033
1135
  ...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
- }
1136
+ ...config.release ? { release: {
1137
+ ...config.release,
1138
+ resolveDelta
1139
+ } } : {}
1042
1140
  };
1043
1141
  const directories = await discover(config, base);
1044
1142
  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)))]);
@@ -1090,9 +1188,14 @@ function formatNpmReport(report) {
1090
1188
  if (report.mode === `coverage-only`) lines.push(`Coverage audit only; compatibility assertions were not executed.`);
1091
1189
  return `${lines.join(`\n`)}\n`;
1092
1190
  }
1093
- //#endregion
1094
- //#region src/breakage.ts
1095
- async function breakage({ tagPattern, baselineFile, testPattern, testCommand, certifyCommand, baseDirname = process.cwd(), verbose = false }) {
1191
+ // src/breakage.ts
1192
+ async function breakage(options) {
1193
+ rejectCertificationOptions(options);
1194
+ const { tagPattern, baselineFile, testPattern, testCommand, test, release, baseDirname = process.cwd(), verbose = false } = options;
1195
+ if (!testPattern || !test && !testCommand) throw new Error(`required arguments were not provided: testPattern and test or testCommand.`);
1196
+ if (test !== void 0 && testCommand !== void 0) throw new Error(`Choose test or testCommand, not both.`);
1197
+ if (typeof (test ?? testCommand) !== `string` || !(test ?? testCommand).trim()) throw new Error(`test must be a nonempty shell command string.`);
1198
+ if (release !== void 0) validateRelease(release);
1096
1199
  let mark;
1097
1200
  let logMarks;
1098
1201
  if (verbose) {
@@ -1163,6 +1266,24 @@ async function breakage({ tagPattern, baselineFile, testPattern, testCommand, ce
1163
1266
  mark?.(`listed all files checked into git`);
1164
1267
  const productionTestFiles = productionFiles.filter((file) => minimatch(file, testPattern));
1165
1268
  mark?.(`filtered to public test files`);
1269
+ let releaseEvidence;
1270
+ if (release) {
1271
+ const current = await readManifest(path.join(baseDirname, `package.json`));
1272
+ const prefix = await git.revparse([`--show-prefix`]);
1273
+ const previous = parseManifest(JSON.parse(await git.show([`${releaseCommit}:${prefix}package.json`])), `baseline`);
1274
+ if (previous.name !== current.name) throw new Error(`Release baseline belongs to another package.`);
1275
+ const transition = await resolveReleaseDelta(release, {
1276
+ packageName: current.name,
1277
+ packageDirectory: path.resolve(baseDirname),
1278
+ currentVersion: current.version,
1279
+ baseline: {
1280
+ version: previous.version,
1281
+ ref: latestReleaseTag$1,
1282
+ commit: releaseCommit
1283
+ }
1284
+ });
1285
+ if (transition !== void 0) releaseEvidence = versionDelta(transition.previousVersion, transition.version, release.policy);
1286
+ }
1166
1287
  const prepared = await withTestFileState(git, baseDirname, async (state) => {
1167
1288
  if (!await state.isClean()) return {
1168
1289
  summary: `The git repository must be clean to run this command.`,
@@ -1186,50 +1307,43 @@ async function breakage({ tagPattern, baselineFile, testPattern, testCommand, ce
1186
1307
  if (prepared.summary !== void 0) return prepared;
1187
1308
  const { restore } = prepared;
1188
1309
  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
- });
1310
+ const result = await command(test ?? testCommand, baseDirname);
1311
+ if (result.code > 1) return {
1312
+ gitWasClean: true,
1313
+ setupError: result.output,
1314
+ summary: `Public test setup failed (${result.code}).`
1315
+ };
1316
+ const common = {
1317
+ gitWasClean: true,
1318
+ lastReleaseFound: true,
1319
+ lastReleaseTag: latestReleaseTag$1,
1320
+ testsWereFound: true,
1321
+ testsFound: productionTestFiles,
1322
+ ...releaseEvidence ? { release: releaseEvidence } : {}
1323
+ };
1324
+ if (result.code === 0) return {
1325
+ ...common,
1326
+ breakingChangesFound: false,
1327
+ summary: `No breaking changes were detected.`
1328
+ };
1329
+ return {
1330
+ ...common,
1331
+ breakingChangesFound: true,
1332
+ testResult: result.output,
1333
+ summary: releaseEvidence?.breaking ? `Breaking changes are covered by release ${releaseEvidence.previousVersion} → ${releaseEvidence.version}.` : `Breaking changes require a breaking release.`
1334
+ };
1335
+ } catch (error) {
1336
+ return {
1337
+ gitWasClean: true,
1338
+ setupError: errorMessage(error),
1339
+ summary: `Public test setup failed: ${errorMessage(error)}`
1340
+ };
1226
1341
  } finally {
1227
1342
  await restore();
1228
1343
  mark?.(`restored original public tests`);
1229
1344
  logMarks?.();
1230
1345
  }
1231
1346
  }
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 };
1347
+ export { configFields as a, configSchema as c, loadConfig as d, resolveConfig as f, errorMessage as h, npmTest as i, defaultConfig as l, breakagePrelude as m, formatNpmReport as n, configOptions as o, validateConfig as p, npmCheck as r, configPath as s, breakage as t, defineConfig as u };
1234
1348
 
1235
- //# sourceMappingURL=breakage-B7dJsgcu.js.map
1349
+ //# sourceMappingURL=breakage-DIY49fzP.js.map