breakage 0.8.5 → 0.9.1
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.
- package/README.md +163 -47
- package/dist/{breakage-B7dJsgcu.js → breakage-DJiIiLzL.js} +958 -221
- package/dist/breakage-DJiIiLzL.js.map +1 -0
- package/dist/breakage.d.ts +239 -71
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +3 -1
- package/dist/breakage.x.js +216 -79
- package/dist/breakage.x.js.map +1 -1
- package/dist/changesets.d.ts +8 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +27 -0
- package/dist/changesets.js.map +1 -0
- package/dist/release-BsdIDT4g.js +69 -0
- package/dist/release-BsdIDT4g.js.map +1 -0
- package/dist/release-D47BqRuB.d.ts +40 -0
- package/dist/release-D47BqRuB.d.ts.map +1 -0
- package/npm.md +198 -44
- package/package.json +45 -6
- package/src/breakage.ts +142 -73
- package/src/breakage.x.ts +174 -127
- package/src/changesets.ts +51 -0
- package/src/config.ts +131 -0
- package/src/npm/check.ts +70 -50
- package/src/npm/cli.ts +30 -22
- package/src/npm/config.ts +38 -6
- package/src/npm/environment.ts +10 -10
- package/src/npm/matrix-cli.ts +170 -0
- package/src/npm/matrix-model.ts +395 -0
- package/src/npm/matrix.ts +657 -0
- package/src/npm/release.ts +9 -25
- package/src/npm/types.ts +5 -45
- package/src/release.ts +153 -0
- package/src/{npm/command.ts → task.ts} +2 -0
- package/dist/breakage-B7dJsgcu.js.map +0 -1
- package/dist/breakage.$configPath.schema.json +0 -33
- package/dist/breakage.help.schema.json +0 -6
- package/dist/breakage.main.schema.json +0 -33
- package/dist/breakage.npm.check.schema.json +0 -16
- package/dist/breakage.npm.help.schema.json +0 -6
- package/dist/breakage.npm.schema.json +0 -6
- package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
- package/dist/breakage.npm.test.schema.json +0 -16
- package/dist/breakage.prelude.$configPath.schema.json +0 -31
- package/dist/breakage.prelude.schema.json +0 -31
- 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 {
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
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";
|
|
9
|
+
import { chmod, copyFile, glob, lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
11
10
|
import semver from "semver";
|
|
12
|
-
|
|
11
|
+
import { type } from "arktype";
|
|
12
|
+
import { createHash, 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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,327 @@ 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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
458
|
+
// src/npm/matrix-model.ts
|
|
459
|
+
const npmMatrixConfigSchema = type({
|
|
460
|
+
"+": `reject`,
|
|
461
|
+
configs: `string | string[]`,
|
|
462
|
+
prepare: type(`string`).or(type(`Function`).as()),
|
|
463
|
+
"label?": type(`Function`).as()
|
|
464
|
+
});
|
|
465
|
+
const transition = type({
|
|
466
|
+
previousVersion: `string`,
|
|
467
|
+
version: `string`,
|
|
468
|
+
delta: `'major' | 'premajor' | 'minor' | 'preminor' | 'patch' | 'prepatch' | 'prerelease' | 'release' | 'none'`
|
|
469
|
+
});
|
|
470
|
+
const identity = type({
|
|
471
|
+
version: `string`,
|
|
472
|
+
source: `'installed' | 'candidate'`
|
|
473
|
+
});
|
|
474
|
+
const reportSchema = type({
|
|
475
|
+
package: {
|
|
476
|
+
name: `string`,
|
|
477
|
+
version: `string`
|
|
478
|
+
},
|
|
479
|
+
mode: `'check' | 'test' | 'coverage-only'`,
|
|
480
|
+
exitCode: `0 | 1 | 2`,
|
|
481
|
+
errors: `string[]`,
|
|
482
|
+
coverage: type({
|
|
483
|
+
peer: `string`,
|
|
484
|
+
era: `string`,
|
|
485
|
+
representatives: `string[]`
|
|
486
|
+
}).array(),
|
|
487
|
+
support: {
|
|
488
|
+
status: `'unchanged' | 'breaking-release' | 'end-of-support' | 'first-release' | 'not-checked' | 'setup-error'`,
|
|
489
|
+
changes: type({
|
|
490
|
+
peer: `string`,
|
|
491
|
+
reason: `'narrowed-range' | 'required-peer'`,
|
|
492
|
+
previous: `string | null`,
|
|
493
|
+
current: `string | null`
|
|
494
|
+
}).array(),
|
|
495
|
+
"previousVersion?": `string`,
|
|
496
|
+
"release?": transition.and({ breaking: `boolean` }),
|
|
497
|
+
"error?": `string`
|
|
498
|
+
},
|
|
499
|
+
representatives: type({
|
|
500
|
+
path: `string`,
|
|
501
|
+
status: `'passed' | 'audited' | 'skipped' | 'compatibility-failure' | 'setup-error'`,
|
|
502
|
+
peers: { "[string]": identity.and({
|
|
503
|
+
path: `string`,
|
|
504
|
+
"archiveVersion?": `string`
|
|
505
|
+
}) },
|
|
506
|
+
suites: type({
|
|
507
|
+
peer: `string`,
|
|
508
|
+
status: `'passed' | 'compatibility-failure' | 'setup-error' | 'not-run'`,
|
|
509
|
+
"output?": `string`
|
|
510
|
+
}).array(),
|
|
511
|
+
warnings: `string[]`,
|
|
512
|
+
"error?": `string`
|
|
513
|
+
}).array()
|
|
514
|
+
}).as();
|
|
515
|
+
const npmPlanSchema = type({
|
|
516
|
+
"+": `reject`,
|
|
517
|
+
formatVersion: `1`,
|
|
518
|
+
id: `string`,
|
|
519
|
+
runId: `string`,
|
|
520
|
+
commit: `string`,
|
|
521
|
+
configs: `string[]`,
|
|
522
|
+
packages: type({
|
|
523
|
+
config: `string`,
|
|
524
|
+
directory: `string`,
|
|
525
|
+
name: `string`,
|
|
526
|
+
configDigest: `string`,
|
|
527
|
+
inputDigest: `string`,
|
|
528
|
+
audit: reportSchema,
|
|
529
|
+
releases: type({
|
|
530
|
+
context: {
|
|
531
|
+
packageName: `string`,
|
|
532
|
+
packageDirectory: `string`,
|
|
533
|
+
currentVersion: `string`,
|
|
534
|
+
"baseline?": {
|
|
535
|
+
version: `string`,
|
|
536
|
+
"ref?": `string`,
|
|
537
|
+
"commit?": `string`
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
"transition?": transition
|
|
541
|
+
}).array()
|
|
542
|
+
}).array(),
|
|
543
|
+
environments: type({
|
|
544
|
+
id: `string`,
|
|
545
|
+
label: `string`,
|
|
546
|
+
packageName: `string`,
|
|
547
|
+
representative: `string`,
|
|
548
|
+
suites: `string[]`,
|
|
549
|
+
peers: { "[string]": identity }
|
|
550
|
+
}).array(),
|
|
551
|
+
archives: type({
|
|
552
|
+
path: `string`,
|
|
553
|
+
file: `string`,
|
|
554
|
+
sha256: `string`
|
|
555
|
+
}).array()
|
|
556
|
+
});
|
|
557
|
+
const npmReceiptSchema = type({
|
|
558
|
+
"+": `reject`,
|
|
559
|
+
formatVersion: `1`,
|
|
560
|
+
planId: `string`,
|
|
561
|
+
id: `string`,
|
|
562
|
+
exitCode: `0 | 1 | 2`,
|
|
563
|
+
"report?": reportSchema,
|
|
564
|
+
"error?": `string`
|
|
565
|
+
});
|
|
566
|
+
/** Stable object ordering; arrays retain their semantic order. */
|
|
567
|
+
function canonical(value) {
|
|
568
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(`,`)}]`;
|
|
569
|
+
if (value && typeof value === `object`) return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(`,`)}}`;
|
|
570
|
+
return JSON.stringify(value) ?? `undefined`;
|
|
571
|
+
}
|
|
572
|
+
function digest(value) {
|
|
573
|
+
return createHash(`sha256`).update(value).digest(`hex`);
|
|
574
|
+
}
|
|
575
|
+
function planDigest(plan) {
|
|
576
|
+
return digest(canonical(plan));
|
|
577
|
+
}
|
|
578
|
+
function peerIdentities(peers) {
|
|
579
|
+
return Object.fromEntries(Object.entries(peers).map(([name, { version, source }]) => [name, {
|
|
580
|
+
version,
|
|
581
|
+
source
|
|
582
|
+
}]));
|
|
583
|
+
}
|
|
584
|
+
function requireMatrix(condition, message) {
|
|
585
|
+
if (!condition) throw new Error(message);
|
|
586
|
+
}
|
|
587
|
+
function sameNames(actual, expected, message) {
|
|
588
|
+
const counts = (names) => {
|
|
589
|
+
const result = /* @__PURE__ */ new Map();
|
|
590
|
+
for (const name of names) result.set(name, (result.get(name) ?? 0) + 1);
|
|
591
|
+
return result;
|
|
592
|
+
};
|
|
593
|
+
const actualCounts = counts(actual);
|
|
594
|
+
const expectedCounts = counts(expected);
|
|
595
|
+
const missing = [...expectedCounts].filter(([name, count]) => (actualCounts.get(name) ?? 0) < count).map(([name]) => name);
|
|
596
|
+
const extra = [...actualCounts].filter(([name, count]) => count > (expectedCounts.get(name) ?? 0)).map(([name]) => name);
|
|
597
|
+
requireMatrix(!missing.length && !extra.length, `${message}${missing.length ? ` Missing: ${missing.join(`, `)}.` : ``}${extra.length ? ` Unexpected or duplicated: ${extra.join(`, `)}.` : ``}`);
|
|
598
|
+
}
|
|
599
|
+
/** Validate the audit as well as the plan envelope; empty or partial plans never pass. */
|
|
600
|
+
function validateNpmPlan(value) {
|
|
601
|
+
const plan = npmPlanSchema.assert(value);
|
|
602
|
+
const { id, ...payload } = plan;
|
|
603
|
+
requireMatrix(id === planDigest(payload), `Plan digest does not match its contents.`);
|
|
604
|
+
requireMatrix(plan.runId.length > 0 && /^[a-f0-9]{40,64}$/.test(plan.commit), `Invalid plan source identity.`);
|
|
605
|
+
requireMatrix(plan.configs.length && plan.packages.length && plan.environments.length && plan.archives.length, `Plan must contain packages, environments, and archives.`);
|
|
606
|
+
for (const [name, values] of [
|
|
607
|
+
[`package`, plan.packages.map((p) => p.name)],
|
|
608
|
+
[`configuration`, plan.packages.map((p) => p.config)],
|
|
609
|
+
[`environment`, plan.environments.map((e) => e.id)],
|
|
610
|
+
[`archive`, plan.archives.map((a) => a.path)]
|
|
611
|
+
]) requireMatrix(new Set(values).size === values.length, `Duplicate ${name} identities in plan.`);
|
|
612
|
+
for (const archive of plan.archives) requireMatrix(/^[a-f0-9]{64}$/.test(archive.sha256) && archive.file === `archives/${archive.sha256}.tgz`, `Invalid archive digest or bundle path: ${archive.path}.`);
|
|
613
|
+
sameNames([...new Set(plan.environments.map((e) => e.packageName))], plan.packages.map((p) => p.name), `Plan environments do not match package audits.`);
|
|
614
|
+
for (const pkg of plan.packages) {
|
|
615
|
+
const audit = pkg.audit;
|
|
616
|
+
requireMatrix(audit.package.name === pkg.name && audit.mode === `coverage-only` && audit.exitCode === 0 && audit.errors.length === 0 && !audit.support.error, `Coverage audit failed for ${pkg.name}.`);
|
|
617
|
+
requireMatrix([
|
|
618
|
+
`unchanged`,
|
|
619
|
+
`breaking-release`,
|
|
620
|
+
`first-release`
|
|
621
|
+
].includes(audit.support.status), `Support audit did not pass for ${pkg.name}.`);
|
|
622
|
+
requireMatrix(audit.coverage.length && audit.coverage.every((c) => c.representatives.length), `Uncovered peer era for ${pkg.name}.`);
|
|
623
|
+
const expected = plan.environments.filter((e) => e.packageName === pkg.name);
|
|
624
|
+
requireMatrix(new Set(expected.map((e) => e.representative)).size === expected.length, `Duplicate representative paths for ${pkg.name}.`);
|
|
625
|
+
sameNames(audit.representatives.map((r) => r.path), expected.map((e) => e.representative), `Missing or duplicate audited environments for ${pkg.name}.`);
|
|
626
|
+
for (const env of expected) {
|
|
627
|
+
const result = audit.representatives.find((r) => r.path === env.representative);
|
|
628
|
+
requireMatrix(result.status === `audited` && !result.error && env.suites.length, `Environment was not audited: ${pkg.name} / ${env.representative}.`);
|
|
629
|
+
sameNames(result.suites.map((s) => s.peer), env.suites, `Audited suites differ: ${env.id}.`);
|
|
630
|
+
requireMatrix(new Set(env.suites).size === env.suites.length && result.suites.every((s) => s.status === `not-run`), `Invalid audited suites: ${env.id}.`);
|
|
631
|
+
requireMatrix(canonical(peerIdentities(result.peers)) === canonical(env.peers), `Audited peer identities differ: ${env.id}.`);
|
|
632
|
+
}
|
|
633
|
+
for (const coverage of audit.coverage) for (const name of coverage.representatives) requireMatrix(expected.some((e) => e.representative === name && e.suites.includes(coverage.peer)), `Coverage references an unplanned suite: ${pkg.name} / ${name} / ${coverage.peer}.`);
|
|
634
|
+
}
|
|
635
|
+
return plan;
|
|
636
|
+
}
|
|
637
|
+
/** Pure receipt verification; runner-local installation paths are diagnostic only. */
|
|
638
|
+
function verifyNpmReceipts(value, receipts) {
|
|
639
|
+
const errors = [];
|
|
640
|
+
let environments = 0;
|
|
641
|
+
try {
|
|
642
|
+
const plan = validateNpmPlan(value);
|
|
643
|
+
environments = plan.environments.length;
|
|
644
|
+
const parsed = receipts.map((r, i) => {
|
|
645
|
+
try {
|
|
646
|
+
return npmReceiptSchema.assert(r);
|
|
647
|
+
} catch (cause) {
|
|
648
|
+
throw new Error(`Malformed receipt ${i + 1}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
const environmentName = (id) => {
|
|
652
|
+
const env = plan.environments.find((e) => e.id === id);
|
|
653
|
+
return env ? `${env.packageName} / ${env.representative} (${id})` : id;
|
|
654
|
+
};
|
|
655
|
+
sameNames(parsed.map((r) => environmentName(r.id)), plan.environments.map((e) => environmentName(e.id)), `Missing, duplicate, or unexpected environment receipts.`);
|
|
656
|
+
for (const env of plan.environments) try {
|
|
657
|
+
const receipt = parsed.find((r) => r.id === env.id);
|
|
658
|
+
requireMatrix(receipt.planId === plan.id, `Receipt belongs to a different plan.`);
|
|
659
|
+
requireMatrix(receipt.exitCode === 0 && !receipt.error && receipt.report, receipt.error ?? `Environment execution failed.`);
|
|
660
|
+
const report = receipt.report;
|
|
661
|
+
const pkg = plan.packages.find((p) => p.name === env.packageName);
|
|
662
|
+
requireMatrix(canonical(report.package) === canonical(pkg.audit.package), `Wrong package identity.`);
|
|
663
|
+
requireMatrix(report.mode === `test` && report.exitCode === 0 && report.errors.length === 0 && report.support.status === `not-checked` && !report.support.error, `Receipt does not contain a successful execution report.`);
|
|
664
|
+
sameNames(report.representatives.map((r) => r.path), pkg.audit.representatives.map((r) => r.path), `Missing or duplicate representatives in execution report.`);
|
|
665
|
+
for (const result of report.representatives) {
|
|
666
|
+
if (result.path !== env.representative) {
|
|
667
|
+
requireMatrix(result.status === `skipped` && result.suites.length === 0 && !result.error, `Unexpected execution: ${result.path}.`);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
requireMatrix(result.status === `passed` && !result.error, `Environment did not execute successfully.`);
|
|
671
|
+
sameNames(result.suites.map((s) => s.peer), env.suites, `Missing, duplicate, or unexpected relationship suites.`);
|
|
672
|
+
for (const suite of result.suites) requireMatrix(suite.status === `passed`, `Suite did not pass: ${suite.peer}.`);
|
|
673
|
+
requireMatrix(canonical(peerIdentities(result.peers)) === canonical(env.peers), `Installed peer identities differ from the audit.`);
|
|
674
|
+
}
|
|
675
|
+
} catch (error) {
|
|
676
|
+
errors.push(`${env.packageName} / ${env.representative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
677
|
+
}
|
|
678
|
+
} catch (error) {
|
|
679
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
exitCode: errors.length ? 2 : 0,
|
|
683
|
+
errors,
|
|
684
|
+
environments
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
// src/config.ts
|
|
688
|
+
const configFields = {
|
|
689
|
+
tagPattern: type(`string`),
|
|
690
|
+
baselineFile: type(`string`),
|
|
691
|
+
testPattern: type(`string`),
|
|
692
|
+
testCommand: type(`string`),
|
|
693
|
+
test: type(`string`),
|
|
694
|
+
baseDirname: type(`string`),
|
|
695
|
+
verbose: type(`boolean`),
|
|
696
|
+
out: type(`string`)
|
|
697
|
+
};
|
|
698
|
+
const configSchema = type({
|
|
699
|
+
"+": `reject`,
|
|
700
|
+
"tagPattern?": configFields.tagPattern.or(`undefined`),
|
|
701
|
+
"baselineFile?": configFields.baselineFile.or(`undefined`),
|
|
702
|
+
"testPattern?": configFields.testPattern,
|
|
703
|
+
"testCommand?": configFields.testCommand,
|
|
704
|
+
"test?": configFields.test,
|
|
705
|
+
"baseDirname?": configFields.baseDirname,
|
|
706
|
+
"verbose?": configFields.verbose.or(`undefined`),
|
|
707
|
+
"out?": configFields.out,
|
|
708
|
+
"release?": releaseSchema,
|
|
709
|
+
"npm?": npmConfigSchema,
|
|
710
|
+
"npmMatrix?": npmMatrixConfigSchema
|
|
711
|
+
});
|
|
712
|
+
/** The complete config, with no fields exposed to argv by this loader. */
|
|
713
|
+
const configOptions = options(`Breakage configuration`, configSchema, {
|
|
714
|
+
tagPattern: { configOnly: true },
|
|
715
|
+
baselineFile: { configOnly: true },
|
|
716
|
+
testPattern: { configOnly: true },
|
|
717
|
+
testCommand: { configOnly: true },
|
|
718
|
+
test: { configOnly: true },
|
|
719
|
+
baseDirname: { configOnly: true },
|
|
720
|
+
verbose: { configOnly: true },
|
|
721
|
+
out: { configOnly: true },
|
|
722
|
+
release: { configOnly: true },
|
|
723
|
+
npm: { configOnly: true },
|
|
724
|
+
npmMatrix: { configOnly: true }
|
|
725
|
+
});
|
|
726
|
+
/** Share the root release model with npm checks unless they supply their own. */
|
|
727
|
+
function defineConfig(config) {
|
|
728
|
+
if (config && typeof config === `object`) {
|
|
729
|
+
rejectCertificationOptions(config);
|
|
730
|
+
if (config.npm) rejectCertificationOptions(config.npm);
|
|
731
|
+
if (config.release !== void 0) validateRelease(config.release);
|
|
732
|
+
if (config.npm?.release !== void 0) validateRelease(config.npm.release);
|
|
733
|
+
}
|
|
734
|
+
configSchema.assert(config);
|
|
438
735
|
return {
|
|
439
|
-
|
|
440
|
-
|
|
736
|
+
...config,
|
|
737
|
+
...config.npm ? { npm: {
|
|
738
|
+
...config.npm,
|
|
739
|
+
...config.release && config.npm.release === void 0 ? { release: config.release } : {}
|
|
740
|
+
} } : {}
|
|
441
741
|
};
|
|
442
742
|
}
|
|
743
|
+
function defaultConfig(directory) {
|
|
744
|
+
const filename = path.join(directory, `breakage.config.ts`);
|
|
745
|
+
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.`);
|
|
746
|
+
return filename;
|
|
747
|
+
}
|
|
748
|
+
/** Comline ignores absent discovered files; explicit paths must exist. */
|
|
749
|
+
function configPath(filename, required = true) {
|
|
750
|
+
const absolute = path.resolve(filename);
|
|
751
|
+
if (path.extname(absolute) !== `.ts`) throw new Error(`Configuration must be a .ts module with a default export; JSON configuration was removed.`);
|
|
752
|
+
if (required && !existsSync(absolute)) throw new Error(`Configuration file does not exist: ${absolute}`);
|
|
753
|
+
return absolute;
|
|
754
|
+
}
|
|
755
|
+
/** Apply application semantics after Comline validates and merges configuration. */
|
|
756
|
+
function resolveConfig(config, filename) {
|
|
757
|
+
return {
|
|
758
|
+
...defineConfig(config),
|
|
759
|
+
baseDirname: path.resolve(filename ? path.dirname(filename) : process.cwd(), config.baseDirname ?? `.`)
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/** Use Comline's module loading and full-schema validation for programmatic and npm consumers. */
|
|
763
|
+
async function loadConfig(filename) {
|
|
764
|
+
const absolute = configPath(filename);
|
|
765
|
+
return resolveConfig((await cli({
|
|
766
|
+
cliName: `breakage`,
|
|
767
|
+
routeOptions: { "": configOptions },
|
|
768
|
+
discoverConfigPath: () => absolute
|
|
769
|
+
}).async([process.execPath, `breakage`])).inputs.opts, absolute);
|
|
770
|
+
}
|
|
771
|
+
// src/npm/release.ts
|
|
772
|
+
async function releaseVersion(config, context) {
|
|
773
|
+
return config.release ? (await resolveReleaseDelta(config.release, context))?.version : void 0;
|
|
774
|
+
}
|
|
443
775
|
/**
|
|
444
776
|
* Compare the union, including adjacent alternatives. semver.subset compares each
|
|
445
777
|
* conjunction to a single conjunction and cannot prove coverage by their union.
|
|
@@ -518,8 +850,7 @@ async function previousManifest(config, directory) {
|
|
|
518
850
|
return historical.version;
|
|
519
851
|
});
|
|
520
852
|
}
|
|
521
|
-
|
|
522
|
-
//#region src/npm/environment.ts
|
|
853
|
+
// src/npm/environment.ts
|
|
523
854
|
function within(parent, child) {
|
|
524
855
|
const relative = path.relative(parent, child);
|
|
525
856
|
return relative === `` || !relative.startsWith(`..${path.sep}`) && relative !== `..` && !path.isAbsolute(relative);
|
|
@@ -624,13 +955,13 @@ async function maySelectEnvironment(config, base, directory, packageManifest, se
|
|
|
624
955
|
const packageDirectory = path.resolve(base, local.directory);
|
|
625
956
|
const current = await readManifest(path.join(packageDirectory, `package.json`));
|
|
626
957
|
if (current.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
|
|
627
|
-
const release = await
|
|
958
|
+
const release = await releaseVersion(config, {
|
|
628
959
|
packageName: peer,
|
|
629
960
|
packageDirectory,
|
|
630
961
|
currentVersion: current.version
|
|
631
962
|
});
|
|
632
|
-
if (release
|
|
633
|
-
return matches(release
|
|
963
|
+
if (release === void 0) throw new Error(`Candidate ${peer} has no release version.`);
|
|
964
|
+
return matches(release, range);
|
|
634
965
|
}
|
|
635
966
|
const declared = declarations[peer];
|
|
636
967
|
return declared !== void 0 && matches(declared, range);
|
|
@@ -652,17 +983,17 @@ async function prepareEnvironment(config, base, directory, stage, packageManifes
|
|
|
652
983
|
const localManifest = await normalizeWorkspacePeers(await readManifest(path.join(localDirectory, `package.json`)));
|
|
653
984
|
if (localManifest.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
|
|
654
985
|
validatePeerRanges(localManifest.peerDependencies ?? {});
|
|
655
|
-
const evidence = await
|
|
986
|
+
const evidence = await releaseVersion(config, {
|
|
656
987
|
packageName: peer,
|
|
657
988
|
packageDirectory: localDirectory,
|
|
658
989
|
currentVersion: localManifest.version
|
|
659
990
|
});
|
|
660
|
-
if (evidence
|
|
991
|
+
if (evidence === void 0) throw new Error(`Candidate ${peer} has no release version.`);
|
|
661
992
|
candidates[peer] = {
|
|
662
993
|
directory: localDirectory,
|
|
663
994
|
artifact: path.resolve(base, local.artifact),
|
|
664
995
|
currentVersion: localManifest.version,
|
|
665
|
-
version: evidence
|
|
996
|
+
version: evidence,
|
|
666
997
|
manifest: localManifest
|
|
667
998
|
};
|
|
668
999
|
}
|
|
@@ -866,8 +1197,7 @@ async function verifyResolution(manifest, environment) {
|
|
|
866
1197
|
} else pending.push(await resolvePackage(directory, dependency, installation));
|
|
867
1198
|
}
|
|
868
1199
|
}
|
|
869
|
-
|
|
870
|
-
//#region src/npm/check.ts
|
|
1200
|
+
// src/npm/check.ts
|
|
871
1201
|
async function checkSupport(config, base, manifest) {
|
|
872
1202
|
try {
|
|
873
1203
|
const previous = await previousManifest(config, base);
|
|
@@ -882,29 +1212,20 @@ async function checkSupport(config, base, manifest) {
|
|
|
882
1212
|
previousVersion: previous.version,
|
|
883
1213
|
changes
|
|
884
1214
|
};
|
|
885
|
-
const evidence = await
|
|
1215
|
+
const evidence = config.release ? await resolveReleaseDelta(config.release, {
|
|
886
1216
|
packageName: manifest.name,
|
|
887
1217
|
packageDirectory: base,
|
|
888
|
-
currentVersion: manifest.version
|
|
889
|
-
|
|
890
|
-
|
|
1218
|
+
currentVersion: manifest.version,
|
|
1219
|
+
baseline: { version: previous.version }
|
|
1220
|
+
}) : void 0;
|
|
1221
|
+
if (evidence === void 0) return {
|
|
891
1222
|
status: `end-of-support`,
|
|
892
1223
|
previousVersion: previous.version,
|
|
893
1224
|
changes
|
|
894
1225
|
};
|
|
895
|
-
|
|
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.`);
|
|
1226
|
+
const release = versionDelta(evidence.previousVersion, evidence.version, config.release?.policy);
|
|
906
1227
|
return {
|
|
907
|
-
status:
|
|
1228
|
+
status: release.breaking ? `breaking-release` : `end-of-support`,
|
|
908
1229
|
previousVersion: previous.version,
|
|
909
1230
|
changes,
|
|
910
1231
|
release
|
|
@@ -925,21 +1246,21 @@ async function checkLocalReleases(config, base, manifest, representatives) {
|
|
|
925
1246
|
const directory = path.resolve(base, local.directory);
|
|
926
1247
|
const current = await readManifest(path.join(directory, `package.json`));
|
|
927
1248
|
if (current.name !== peer) throw new Error(`Local peer identity mismatch for ${peer}.`);
|
|
928
|
-
const release = await
|
|
1249
|
+
const release = await releaseVersion(config, {
|
|
929
1250
|
packageName: peer,
|
|
930
1251
|
packageDirectory: directory,
|
|
931
1252
|
currentVersion: current.version
|
|
932
1253
|
});
|
|
933
|
-
if (release
|
|
934
|
-
if (!matches(release
|
|
935
|
-
if (!representatives.some((result) => result.peers[peer]?.source === `candidate` && result.peers[peer]?.version === release
|
|
1254
|
+
if (release === void 0) return [];
|
|
1255
|
+
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.`];
|
|
1256
|
+
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
1257
|
return [];
|
|
937
1258
|
} catch (error) {
|
|
938
1259
|
return [`Cannot inspect planned local release for ${peer}: ${errorMessage(error)}`];
|
|
939
1260
|
}
|
|
940
1261
|
}))).flat();
|
|
941
1262
|
}
|
|
942
|
-
async function runRepresentative(config, base, manifest, directory, coverageOnly, selection) {
|
|
1263
|
+
async function runRepresentative(config, base, manifest, directory, coverageOnly, selection, plannedSuites) {
|
|
943
1264
|
const result = {
|
|
944
1265
|
path: path.relative(base, directory).split(path.sep).join(`/`),
|
|
945
1266
|
status: `skipped`,
|
|
@@ -962,6 +1283,7 @@ async function runRepresentative(config, base, manifest, directory, coverageOnly
|
|
|
962
1283
|
const peers = Object.keys(config.suites).filter((peer) => {
|
|
963
1284
|
const evidence = result.peers[peer];
|
|
964
1285
|
if (!evidence) return false;
|
|
1286
|
+
if (plannedSuites !== void 0) return plannedSuites.includes(peer);
|
|
965
1287
|
if (selection && `peer` in selection) return peer === selection.peer;
|
|
966
1288
|
if (selection && `representative` in selection) return true;
|
|
967
1289
|
return matches(evidence.version, manifest.peerDependencies[peer]);
|
|
@@ -996,7 +1318,7 @@ async function runRepresentative(config, base, manifest, directory, coverageOnly
|
|
|
996
1318
|
return result;
|
|
997
1319
|
}
|
|
998
1320
|
}
|
|
999
|
-
async function run(config, baseDirectory, coverageOnly, selection) {
|
|
1321
|
+
async function run(config, baseDirectory, coverageOnly, selection, plannedSuites) {
|
|
1000
1322
|
const report = {
|
|
1001
1323
|
package: {
|
|
1002
1324
|
name: `(unresolved)`,
|
|
@@ -1028,20 +1350,28 @@ async function run(config, baseDirectory, coverageOnly, selection) {
|
|
|
1028
1350
|
eras(selection.range);
|
|
1029
1351
|
}
|
|
1030
1352
|
const releases = /* @__PURE__ */ new Map();
|
|
1031
|
-
const
|
|
1353
|
+
const resolveDelta = (context) => {
|
|
1354
|
+
const key = JSON.stringify([
|
|
1355
|
+
context.packageDirectory,
|
|
1356
|
+
context.currentVersion,
|
|
1357
|
+
context.baseline
|
|
1358
|
+
]);
|
|
1359
|
+
let pending = releases.get(key);
|
|
1360
|
+
if (!pending) {
|
|
1361
|
+
pending = resolveReleaseDelta(config.release, context);
|
|
1362
|
+
releases.set(key, pending);
|
|
1363
|
+
}
|
|
1364
|
+
return pending;
|
|
1365
|
+
};
|
|
1032
1366
|
const effective = {
|
|
1033
1367
|
...config,
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
releases.set(context.packageDirectory, pending);
|
|
1039
|
-
}
|
|
1040
|
-
return pending;
|
|
1041
|
-
}
|
|
1368
|
+
...config.release ? { release: {
|
|
1369
|
+
...config.release,
|
|
1370
|
+
resolveDelta
|
|
1371
|
+
} } : {}
|
|
1042
1372
|
};
|
|
1043
1373
|
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)))]);
|
|
1374
|
+
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, plannedSuites)))]);
|
|
1045
1375
|
report.support = support;
|
|
1046
1376
|
report.representatives = representatives;
|
|
1047
1377
|
if (!selection) {
|
|
@@ -1070,6 +1400,10 @@ async function npmCheck(config, options = {}) {
|
|
|
1070
1400
|
async function npmTest(config, selection, options = {}) {
|
|
1071
1401
|
return await run(config, options.baseDirectory ?? process.cwd(), false, selection);
|
|
1072
1402
|
}
|
|
1403
|
+
/** Internal matrix entrypoint: keep the full config for validation and peer identity checks. */
|
|
1404
|
+
async function npmTestPlanned(config, selection, options = {}) {
|
|
1405
|
+
return await run(config, options.baseDirectory ?? process.cwd(), false, { representative: selection.representative }, selection.suites);
|
|
1406
|
+
}
|
|
1073
1407
|
function formatNpmReport(report) {
|
|
1074
1408
|
const lines = [`${report.package.name}@${report.package.version} — npm ${report.mode}`];
|
|
1075
1409
|
for (const result of report.representatives) {
|
|
@@ -1090,9 +1424,401 @@ function formatNpmReport(report) {
|
|
|
1090
1424
|
if (report.mode === `coverage-only`) lines.push(`Coverage audit only; compatibility assertions were not executed.`);
|
|
1091
1425
|
return `${lines.join(`\n`)}\n`;
|
|
1092
1426
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1427
|
+
// src/npm/matrix.ts
|
|
1428
|
+
function relative(root, filename) {
|
|
1429
|
+
const result = path.relative(root, path.resolve(filename)).split(path.sep).join(`/`) || `.`;
|
|
1430
|
+
requireMatrix(!result.startsWith(`../`) && result !== `..` && !path.isAbsolute(result), `Matrix input escapes the source checkout: ${filename}.`);
|
|
1431
|
+
return result;
|
|
1432
|
+
}
|
|
1433
|
+
function portablePath(value) {
|
|
1434
|
+
requireMatrix(value.length && !value.includes(`\\`) && !path.posix.isAbsolute(value) && !/^[A-Za-z]:/.test(value) && !value.split(`/`).includes(`..`), `Invalid portable path: ${value}.`);
|
|
1435
|
+
}
|
|
1436
|
+
async function contained(root, filename) {
|
|
1437
|
+
portablePath(filename);
|
|
1438
|
+
const absolute = path.resolve(root, filename);
|
|
1439
|
+
let ancestor = absolute;
|
|
1440
|
+
for (;;) try {
|
|
1441
|
+
await lstat(ancestor);
|
|
1442
|
+
break;
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
if (error.code !== `ENOENT`) throw error;
|
|
1445
|
+
ancestor = path.dirname(ancestor);
|
|
1446
|
+
}
|
|
1447
|
+
requireMatrix(within(await realpath(root), await realpath(ancestor)), `Path escapes its root through a symlink: ${filename}.`);
|
|
1448
|
+
return absolute;
|
|
1449
|
+
}
|
|
1450
|
+
async function fileDigest(filename) {
|
|
1451
|
+
requireMatrix((await lstat(filename)).isFile(), `Expected a regular file: ${filename}.`);
|
|
1452
|
+
return digest(await readFile(filename));
|
|
1453
|
+
}
|
|
1454
|
+
async function sourceIdentity(root) {
|
|
1455
|
+
const git = simpleGit(root);
|
|
1456
|
+
requireMatrix(await realpath(await git.revparse([`--show-toplevel`])) === await realpath(root), `Matrix rootDirectory must be the Git checkout root.`);
|
|
1457
|
+
requireMatrix(!(await git.status()).files.length, `Matrix planning and execution require a clean checkout, including untracked files. Ignore generated archives, bundles, and receipts.`);
|
|
1458
|
+
return await git.revparse([`HEAD`]);
|
|
1459
|
+
}
|
|
1460
|
+
async function ignoredOutput(root, filename) {
|
|
1461
|
+
const git = simpleGit(root);
|
|
1462
|
+
requireMatrix(!(await git.raw([
|
|
1463
|
+
`ls-files`,
|
|
1464
|
+
`--`,
|
|
1465
|
+
filename
|
|
1466
|
+
])).trim() && (await git.raw([
|
|
1467
|
+
`check-ignore`,
|
|
1468
|
+
`--`,
|
|
1469
|
+
filename
|
|
1470
|
+
])).trim(), `Matrix output must be gitignored and untracked: ${filename}.`);
|
|
1471
|
+
}
|
|
1472
|
+
async function discoverConfigs(root, patterns) {
|
|
1473
|
+
const files = /* @__PURE__ */ new Set();
|
|
1474
|
+
for (const pattern of patterns) portablePath(pattern);
|
|
1475
|
+
for await (const filename of glob(patterns, {
|
|
1476
|
+
cwd: root,
|
|
1477
|
+
exclude: [
|
|
1478
|
+
`**/node_modules/**`,
|
|
1479
|
+
`**/.git/**`,
|
|
1480
|
+
`**/test/**`
|
|
1481
|
+
]
|
|
1482
|
+
})) {
|
|
1483
|
+
const name = relative(root, path.resolve(root, filename));
|
|
1484
|
+
if ((await loadConfig(await contained(root, name))).npm) files.add(name);
|
|
1485
|
+
}
|
|
1486
|
+
requireMatrix(files.size, `No npm configurations matched the matrix configs.`);
|
|
1487
|
+
return [...files].sort();
|
|
1488
|
+
}
|
|
1489
|
+
function configDigest(config, root, base) {
|
|
1490
|
+
return digest(canonical({
|
|
1491
|
+
...config,
|
|
1492
|
+
packageDirectory: relative(root, base),
|
|
1493
|
+
artifact: relative(root, path.resolve(base, config.artifact)),
|
|
1494
|
+
baseline: `baselineFile` in config.baseline && config.baseline.baselineFile ? {
|
|
1495
|
+
...config.baseline,
|
|
1496
|
+
baselineFile: relative(root, path.resolve(base, config.baseline.baselineFile))
|
|
1497
|
+
} : config.baseline,
|
|
1498
|
+
localPeers: Object.fromEntries(Object.entries(config.localPeers ?? {}).map(([name, peer]) => [name, {
|
|
1499
|
+
directory: relative(root, path.resolve(base, peer.directory)),
|
|
1500
|
+
artifact: relative(root, path.resolve(base, peer.artifact))
|
|
1501
|
+
}])),
|
|
1502
|
+
release: config.release ? {
|
|
1503
|
+
resolveDelta: !!config.release.resolveDelta,
|
|
1504
|
+
policy: !!config.release.policy
|
|
1505
|
+
} : void 0
|
|
1506
|
+
}));
|
|
1507
|
+
}
|
|
1508
|
+
async function packageConfig(root, filename) {
|
|
1509
|
+
const config = (await loadConfig(await contained(root, filename))).npm;
|
|
1510
|
+
validateConfig(config);
|
|
1511
|
+
const baseDirectory = path.dirname(path.resolve(root, filename));
|
|
1512
|
+
const base = path.resolve(baseDirectory, config.packageDirectory ?? `.`);
|
|
1513
|
+
await contained(root, relative(root, base));
|
|
1514
|
+
return {
|
|
1515
|
+
config,
|
|
1516
|
+
base,
|
|
1517
|
+
baseDirectory
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
function portableContext(root, context) {
|
|
1521
|
+
return {
|
|
1522
|
+
...context,
|
|
1523
|
+
packageDirectory: relative(root, context.packageDirectory)
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
async function archiveRequests(root, config, base) {
|
|
1527
|
+
const manifest = await readManifest(path.join(base, `package.json`));
|
|
1528
|
+
const requests = [{
|
|
1529
|
+
packageName: manifest.name,
|
|
1530
|
+
directory: relative(root, base),
|
|
1531
|
+
artifact: relative(root, path.resolve(base, config.artifact))
|
|
1532
|
+
}];
|
|
1533
|
+
for (const [packageName, peer] of Object.entries(config.localPeers ?? {})) {
|
|
1534
|
+
const directory = relative(root, path.resolve(base, peer.directory));
|
|
1535
|
+
try {
|
|
1536
|
+
await lstat(await contained(root, directory));
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
if (error.code === `ENOENT` && !manifest.peerDependencies?.[packageName]) continue;
|
|
1539
|
+
throw error;
|
|
1540
|
+
}
|
|
1541
|
+
requests.push({
|
|
1542
|
+
packageName,
|
|
1543
|
+
directory,
|
|
1544
|
+
artifact: relative(root, path.resolve(base, peer.artifact))
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
return requests;
|
|
1548
|
+
}
|
|
1549
|
+
async function inputDigest(root, filename, config, base) {
|
|
1550
|
+
const files = /* @__PURE__ */ new Set([filename, relative(root, path.join(base, `package.json`))]);
|
|
1551
|
+
const directories = await discover(config, base);
|
|
1552
|
+
for (const directory of directories) for (const name of [`package.json`, config.lockfile]) files.add(relative(root, path.join(directory, name)));
|
|
1553
|
+
for (const request of await archiveRequests(root, config, base)) files.add(relative(root, path.resolve(root, request.directory, `package.json`)));
|
|
1554
|
+
if (`baselineFile` in config.baseline && config.baseline.baselineFile) files.add(relative(root, path.resolve(base, config.baseline.baselineFile)));
|
|
1555
|
+
const exclude = [
|
|
1556
|
+
`**/node_modules/**`,
|
|
1557
|
+
`**/.git/**`,
|
|
1558
|
+
...directories.flatMap((directory) => {
|
|
1559
|
+
const stage = relative(base, path.join(directory, `test`));
|
|
1560
|
+
return [stage, `${stage}/**`];
|
|
1561
|
+
})
|
|
1562
|
+
];
|
|
1563
|
+
for (const suite of Object.values(config.suites)) for (const pattern of suite.files) {
|
|
1564
|
+
portablePath(pattern);
|
|
1565
|
+
let count = 0;
|
|
1566
|
+
for await (const file of glob(pattern, {
|
|
1567
|
+
cwd: base,
|
|
1568
|
+
exclude
|
|
1569
|
+
})) {
|
|
1570
|
+
const absolute = path.resolve(base, file);
|
|
1571
|
+
if ((await lstat(absolute)).isDirectory()) continue;
|
|
1572
|
+
files.add(relative(root, absolute));
|
|
1573
|
+
count++;
|
|
1574
|
+
}
|
|
1575
|
+
requireMatrix(count, `Suite pattern matched no files: ${pattern}.`);
|
|
1576
|
+
}
|
|
1577
|
+
return digest(canonical(await Promise.all([...files].sort().map(async (file) => [file, await fileDigest(await contained(root, file))]))));
|
|
1578
|
+
}
|
|
1579
|
+
/** Audit all configured packages and prepare one immutable, portable archive bundle. */
|
|
1580
|
+
async function npmPlan(config, options = {}) {
|
|
1581
|
+
npmMatrixConfigSchema.assert(config);
|
|
1582
|
+
const root = path.resolve(options.rootDirectory ?? process.cwd());
|
|
1583
|
+
const output = options.bundleDirectory ?? `.breakage/npm-plan`;
|
|
1584
|
+
const bundle = await contained(root, relative(root, path.resolve(root, output)));
|
|
1585
|
+
await ignoredOutput(root, path.join(bundle, `plan.json`));
|
|
1586
|
+
await mkdir(bundle, { recursive: true });
|
|
1587
|
+
return await withDirectoryLock(path.join(bundle, `.lock`), async () => {
|
|
1588
|
+
await rm(path.join(bundle, `plan.json`), { force: true });
|
|
1589
|
+
const commit = await sourceIdentity(root);
|
|
1590
|
+
const patterns = typeof config.configs === `string` ? [config.configs] : config.configs;
|
|
1591
|
+
const files = await discoverConfigs(root, patterns);
|
|
1592
|
+
const packages = [];
|
|
1593
|
+
const environments = [];
|
|
1594
|
+
const requests = /* @__PURE__ */ new Map();
|
|
1595
|
+
for (const filename of files) {
|
|
1596
|
+
const { config: npm, base, baseDirectory } = await packageConfig(root, filename);
|
|
1597
|
+
const manifest = await readManifest(path.join(base, `package.json`));
|
|
1598
|
+
for (const peer of Object.keys(manifest.peerDependencies ?? {})) requireMatrix(npm.suites[peer] || manifest.peerDependenciesMeta?.[peer]?.optional, `Missing relationship suite for ${manifest.name}: ${peer}.`);
|
|
1599
|
+
const releases = [];
|
|
1600
|
+
const audit = await npmCheck({
|
|
1601
|
+
...npm,
|
|
1602
|
+
...npm.release ? { release: {
|
|
1603
|
+
...npm.release,
|
|
1604
|
+
resolveDelta: async (context) => {
|
|
1605
|
+
const transition = await resolveReleaseDelta(npm.release, context);
|
|
1606
|
+
releases.push({
|
|
1607
|
+
context: portableContext(root, context),
|
|
1608
|
+
...transition ? { transition } : {}
|
|
1609
|
+
});
|
|
1610
|
+
return transition;
|
|
1611
|
+
}
|
|
1612
|
+
} } : {}
|
|
1613
|
+
}, {
|
|
1614
|
+
baseDirectory,
|
|
1615
|
+
coverageOnly: true
|
|
1616
|
+
});
|
|
1617
|
+
requireMatrix(audit.exitCode === 0, `Cannot plan ${manifest.name}:\n${formatNpmReport(audit)}`);
|
|
1618
|
+
for (const result of audit.representatives) {
|
|
1619
|
+
requireMatrix(result.status === `audited` && result.suites.length, `Environment has no executable audited suites: ${manifest.name} / ${result.path}.`);
|
|
1620
|
+
const environment = JSON.parse(await readFile(path.join(base, result.path, `package.json`), `utf8`));
|
|
1621
|
+
const label = config.label?.({
|
|
1622
|
+
packageName: manifest.name,
|
|
1623
|
+
representative: result.path,
|
|
1624
|
+
peers: result.peers,
|
|
1625
|
+
...typeof environment.description === `string` ? { description: environment.description } : {}
|
|
1626
|
+
}) ?? `${manifest.name}: ${result.path}`;
|
|
1627
|
+
requireMatrix(typeof label === `string` && label.trim(), `Invalid label for ${manifest.name} / ${result.path}.`);
|
|
1628
|
+
environments.push({
|
|
1629
|
+
id: digest(`${filename}\0${result.path}`).slice(0, 24),
|
|
1630
|
+
label,
|
|
1631
|
+
packageName: manifest.name,
|
|
1632
|
+
representative: result.path,
|
|
1633
|
+
suites: result.suites.map((s) => s.peer).sort(),
|
|
1634
|
+
peers: peerIdentities(result.peers)
|
|
1635
|
+
});
|
|
1636
|
+
for (const peer of Object.values(result.peers)) peer.path = `(runner-local)`;
|
|
1637
|
+
}
|
|
1638
|
+
packages.push({
|
|
1639
|
+
config: filename,
|
|
1640
|
+
directory: relative(root, base),
|
|
1641
|
+
name: manifest.name,
|
|
1642
|
+
configDigest: configDigest(npm, root, base),
|
|
1643
|
+
inputDigest: await inputDigest(root, filename, npm, base),
|
|
1644
|
+
audit,
|
|
1645
|
+
releases: releases.sort((a, b) => canonical(a.context).localeCompare(canonical(b.context)))
|
|
1646
|
+
});
|
|
1647
|
+
for (const request of await archiveRequests(root, npm, base)) {
|
|
1648
|
+
const previous = requests.get(request.artifact);
|
|
1649
|
+
requireMatrix(!previous || canonical(previous) === canonical(request), `Conflicting archive requests: ${request.artifact}.`);
|
|
1650
|
+
requests.set(request.artifact, request);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
const archives = [...requests.values()].sort((a, b) => a.artifact.localeCompare(b.artifact));
|
|
1654
|
+
for (const request of archives) {
|
|
1655
|
+
await contained(root, request.directory);
|
|
1656
|
+
await contained(root, request.artifact);
|
|
1657
|
+
await ignoredOutput(root, path.resolve(root, request.artifact));
|
|
1658
|
+
}
|
|
1659
|
+
if (typeof config.prepare === `string`) await prepare(config.prepare, root);
|
|
1660
|
+
else await config.prepare({
|
|
1661
|
+
rootDirectory: root,
|
|
1662
|
+
archives
|
|
1663
|
+
});
|
|
1664
|
+
const packed = [];
|
|
1665
|
+
for (const archive of archives) {
|
|
1666
|
+
const filename = await contained(root, archive.artifact);
|
|
1667
|
+
const sha256 = await fileDigest(filename);
|
|
1668
|
+
const file = `archives/${sha256}.tgz`;
|
|
1669
|
+
await mkdir(path.join(bundle, `archives`), { recursive: true });
|
|
1670
|
+
await copyFile(filename, await contained(bundle, file));
|
|
1671
|
+
packed.push({
|
|
1672
|
+
path: archive.artifact,
|
|
1673
|
+
file,
|
|
1674
|
+
sha256
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
requireMatrix(await sourceIdentity(root) === commit, `Source changed while preparing the matrix.`);
|
|
1678
|
+
const payload = {
|
|
1679
|
+
formatVersion: 1,
|
|
1680
|
+
runId: randomUUID(),
|
|
1681
|
+
commit,
|
|
1682
|
+
configs: patterns,
|
|
1683
|
+
packages,
|
|
1684
|
+
environments,
|
|
1685
|
+
archives: packed
|
|
1686
|
+
};
|
|
1687
|
+
const plan = validateNpmPlan({
|
|
1688
|
+
...payload,
|
|
1689
|
+
id: planDigest(payload)
|
|
1690
|
+
});
|
|
1691
|
+
await validateWorkspace(plan, {
|
|
1692
|
+
rootDirectory: root,
|
|
1693
|
+
bundleDirectory: bundle
|
|
1694
|
+
});
|
|
1695
|
+
await writeMatrixJson(path.join(bundle, `plan.json`), plan);
|
|
1696
|
+
return plan;
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
async function validateWorkspace(plan, options) {
|
|
1700
|
+
const root = path.resolve(options.rootDirectory ?? process.cwd());
|
|
1701
|
+
const bundle = path.resolve(root, options.bundleDirectory ?? `.breakage/npm-plan`);
|
|
1702
|
+
requireMatrix(await sourceIdentity(root) === plan.commit, `Plan belongs to a different source commit.`);
|
|
1703
|
+
requireMatrix(canonical(await discoverConfigs(root, plan.configs)) === canonical(plan.packages.map((p) => p.config).sort()), `Plan no longer enumerates every configured npm package.`);
|
|
1704
|
+
const configs = /* @__PURE__ */ new Map();
|
|
1705
|
+
const requiredArchives = /* @__PURE__ */ new Set();
|
|
1706
|
+
for (const pkg of plan.packages) {
|
|
1707
|
+
const loaded = await packageConfig(root, pkg.config);
|
|
1708
|
+
const { config, base } = loaded;
|
|
1709
|
+
for (const request of await archiveRequests(root, config, base)) requiredArchives.add(request.artifact);
|
|
1710
|
+
requireMatrix(relative(root, base) === pkg.directory && configDigest(config, root, base) === pkg.configDigest, `Configuration changed for ${pkg.name}.`);
|
|
1711
|
+
requireMatrix(await inputDigest(root, pkg.config, config, base) === pkg.inputDigest, `Contract, manifest, or lockfile inputs changed for ${pkg.name}.`);
|
|
1712
|
+
requireMatrix(canonical((await discover(config, base)).map((d) => relative(base, d)).sort()) === canonical(pkg.audit.representatives.map((r) => r.path).sort()), `Representative discovery changed for ${pkg.name}.`);
|
|
1713
|
+
for (const release of pkg.releases) {
|
|
1714
|
+
requireMatrix(config.release, `Release configuration disappeared for ${pkg.name}.`);
|
|
1715
|
+
requireMatrix(canonical(await resolveReleaseDelta(config.release, {
|
|
1716
|
+
...release.context,
|
|
1717
|
+
packageDirectory: await contained(root, release.context.packageDirectory)
|
|
1718
|
+
})) === canonical(release.transition), `Release evidence changed for ${pkg.name} / ${release.context.packageName}.`);
|
|
1719
|
+
}
|
|
1720
|
+
if (pkg.audit.support.release) {
|
|
1721
|
+
const expected = pkg.audit.support.release;
|
|
1722
|
+
requireMatrix(canonical(versionDelta(expected.previousVersion, expected.version, config.release?.policy)) === canonical(expected), `Release policy changed for ${pkg.name}.`);
|
|
1723
|
+
}
|
|
1724
|
+
configs.set(pkg.name, loaded);
|
|
1725
|
+
}
|
|
1726
|
+
requireMatrix(canonical([...requiredArchives].sort()) === canonical(plan.archives.map((a) => a.path).sort()), `Planned archives do not match the configured packages and local peers.`);
|
|
1727
|
+
for (const archive of plan.archives) {
|
|
1728
|
+
portablePath(archive.path);
|
|
1729
|
+
requireMatrix(archive.file === `archives/${archive.sha256}.tgz`, `Invalid bundled archive path: ${archive.file}.`);
|
|
1730
|
+
requireMatrix(await fileDigest(await contained(bundle, archive.file)) === archive.sha256, `Candidate archive changed: ${archive.path}.`);
|
|
1731
|
+
}
|
|
1732
|
+
return {
|
|
1733
|
+
root,
|
|
1734
|
+
bundle,
|
|
1735
|
+
configs
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
/** Execute exactly one audited environment, producing failure evidence whenever the plan is valid. */
|
|
1739
|
+
async function npmExecute(value, id, options = {}) {
|
|
1740
|
+
const plan = validateNpmPlan(value);
|
|
1741
|
+
const receipt = {
|
|
1742
|
+
formatVersion: 1,
|
|
1743
|
+
planId: plan.id,
|
|
1744
|
+
id,
|
|
1745
|
+
exitCode: 2
|
|
1746
|
+
};
|
|
1747
|
+
try {
|
|
1748
|
+
const env = plan.environments.find((e) => e.id === id);
|
|
1749
|
+
requireMatrix(env, `Unknown planned environment: ${id}.`);
|
|
1750
|
+
const { root, bundle, configs } = await validateWorkspace(plan, options);
|
|
1751
|
+
const { config, baseDirectory } = configs.get(env.packageName);
|
|
1752
|
+
const byPath = new Map(plan.archives.map((a) => [a.path, path.join(bundle, a.file)]));
|
|
1753
|
+
const base = path.resolve(baseDirectory, config.packageDirectory ?? `.`);
|
|
1754
|
+
const artifactFor = (filename) => {
|
|
1755
|
+
const archive = byPath.get(relative(root, path.resolve(base, filename)));
|
|
1756
|
+
requireMatrix(archive, `Plan is missing archive: ${filename}.`);
|
|
1757
|
+
return archive;
|
|
1758
|
+
};
|
|
1759
|
+
receipt.report = await npmTestPlanned({
|
|
1760
|
+
...config,
|
|
1761
|
+
artifact: artifactFor(config.artifact),
|
|
1762
|
+
localPeers: Object.fromEntries(Object.entries(config.localPeers ?? {}).map(([name, peer]) => [name, {
|
|
1763
|
+
...peer,
|
|
1764
|
+
artifact: byPath.get(relative(root, path.resolve(base, peer.artifact))) ?? peer.artifact
|
|
1765
|
+
}]))
|
|
1766
|
+
}, {
|
|
1767
|
+
representative: env.representative,
|
|
1768
|
+
suites: env.suites
|
|
1769
|
+
}, { baseDirectory });
|
|
1770
|
+
receipt.exitCode = receipt.report.exitCode;
|
|
1771
|
+
await validateWorkspace(plan, options);
|
|
1772
|
+
if (receipt.exitCode === 0) {
|
|
1773
|
+
const result = receipt.report.representatives.find((r) => r.path === env.representative);
|
|
1774
|
+
requireMatrix(result?.status === `passed` && canonical(result.suites.map((s) => s.peer).sort()) === canonical(env.suites) && result.suites.every((s) => s.status === `passed`) && canonical(peerIdentities(result.peers)) === canonical(env.peers), `Execution differs from the planned suites or peer identities: ${env.label}.`);
|
|
1775
|
+
}
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
receipt.exitCode = 2;
|
|
1778
|
+
receipt.error = errorMessage(error);
|
|
1779
|
+
}
|
|
1780
|
+
return receipt;
|
|
1781
|
+
}
|
|
1782
|
+
/** Verify source/config/archive identities and then require all planned execution receipts. */
|
|
1783
|
+
async function npmVerify(value, receipts, options = {}) {
|
|
1784
|
+
try {
|
|
1785
|
+
const plan = validateNpmPlan(value);
|
|
1786
|
+
await validateWorkspace(plan, options);
|
|
1787
|
+
return verifyNpmReceipts(plan, receipts);
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
return {
|
|
1790
|
+
exitCode: 2,
|
|
1791
|
+
errors: [errorMessage(error)],
|
|
1792
|
+
environments: 0
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
function npmMatrix(value) {
|
|
1797
|
+
return { include: validateNpmPlan(value).environments.map(({ id, label, packageName, representative }) => ({
|
|
1798
|
+
id,
|
|
1799
|
+
label,
|
|
1800
|
+
packageName,
|
|
1801
|
+
representative
|
|
1802
|
+
})) };
|
|
1803
|
+
}
|
|
1804
|
+
async function writeMatrixJson(filename, value) {
|
|
1805
|
+
await mkdir(path.dirname(filename), { recursive: true });
|
|
1806
|
+
const temporary = `${filename}.${randomUUID()}.tmp`;
|
|
1807
|
+
try {
|
|
1808
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: `wx` });
|
|
1809
|
+
await rename(temporary, filename);
|
|
1810
|
+
} finally {
|
|
1811
|
+
await rm(temporary, { force: true });
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
// src/breakage.ts
|
|
1815
|
+
async function breakage(options) {
|
|
1816
|
+
rejectCertificationOptions(options);
|
|
1817
|
+
const { tagPattern, baselineFile, testPattern, testCommand, test, release, baseDirname = process.cwd(), verbose = false } = options;
|
|
1818
|
+
if (!testPattern || !test && !testCommand) throw new Error(`required arguments were not provided: testPattern and test or testCommand.`);
|
|
1819
|
+
if (test !== void 0 && testCommand !== void 0) throw new Error(`Choose test or testCommand, not both.`);
|
|
1820
|
+
if (typeof (test ?? testCommand) !== `string` || !(test ?? testCommand).trim()) throw new Error(`test must be a nonempty shell command string.`);
|
|
1821
|
+
if (release !== void 0) validateRelease(release);
|
|
1096
1822
|
let mark;
|
|
1097
1823
|
let logMarks;
|
|
1098
1824
|
if (verbose) {
|
|
@@ -1163,6 +1889,24 @@ async function breakage({ tagPattern, baselineFile, testPattern, testCommand, ce
|
|
|
1163
1889
|
mark?.(`listed all files checked into git`);
|
|
1164
1890
|
const productionTestFiles = productionFiles.filter((file) => minimatch(file, testPattern));
|
|
1165
1891
|
mark?.(`filtered to public test files`);
|
|
1892
|
+
let releaseEvidence;
|
|
1893
|
+
if (release) {
|
|
1894
|
+
const current = await readManifest(path.join(baseDirname, `package.json`));
|
|
1895
|
+
const prefix = await git.revparse([`--show-prefix`]);
|
|
1896
|
+
const previous = parseManifest(JSON.parse(await git.show([`${releaseCommit}:${prefix}package.json`])), `baseline`);
|
|
1897
|
+
if (previous.name !== current.name) throw new Error(`Release baseline belongs to another package.`);
|
|
1898
|
+
const transition = await resolveReleaseDelta(release, {
|
|
1899
|
+
packageName: current.name,
|
|
1900
|
+
packageDirectory: path.resolve(baseDirname),
|
|
1901
|
+
currentVersion: current.version,
|
|
1902
|
+
baseline: {
|
|
1903
|
+
version: previous.version,
|
|
1904
|
+
ref: latestReleaseTag$1,
|
|
1905
|
+
commit: releaseCommit
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
if (transition !== void 0) releaseEvidence = versionDelta(transition.previousVersion, transition.version, release.policy);
|
|
1909
|
+
}
|
|
1166
1910
|
const prepared = await withTestFileState(git, baseDirname, async (state) => {
|
|
1167
1911
|
if (!await state.isClean()) return {
|
|
1168
1912
|
summary: `The git repository must be clean to run this command.`,
|
|
@@ -1186,50 +1930,43 @@ async function breakage({ tagPattern, baselineFile, testPattern, testCommand, ce
|
|
|
1186
1930
|
if (prepared.summary !== void 0) return prepared;
|
|
1187
1931
|
const { restore } = prepared;
|
|
1188
1932
|
try {
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
breakingChangesCertified: wereCertified,
|
|
1221
|
-
certificationStdout: stdout,
|
|
1222
|
-
certificationStderr: stderr
|
|
1223
|
-
});
|
|
1224
|
-
});
|
|
1225
|
-
});
|
|
1933
|
+
const result = await command(test ?? testCommand, baseDirname);
|
|
1934
|
+
if (result.code > 1) return {
|
|
1935
|
+
gitWasClean: true,
|
|
1936
|
+
setupError: result.output,
|
|
1937
|
+
summary: `Public test setup failed (${result.code}).`
|
|
1938
|
+
};
|
|
1939
|
+
const common = {
|
|
1940
|
+
gitWasClean: true,
|
|
1941
|
+
lastReleaseFound: true,
|
|
1942
|
+
lastReleaseTag: latestReleaseTag$1,
|
|
1943
|
+
testsWereFound: true,
|
|
1944
|
+
testsFound: productionTestFiles,
|
|
1945
|
+
...releaseEvidence ? { release: releaseEvidence } : {}
|
|
1946
|
+
};
|
|
1947
|
+
if (result.code === 0) return {
|
|
1948
|
+
...common,
|
|
1949
|
+
breakingChangesFound: false,
|
|
1950
|
+
summary: `No breaking changes were detected.`
|
|
1951
|
+
};
|
|
1952
|
+
return {
|
|
1953
|
+
...common,
|
|
1954
|
+
breakingChangesFound: true,
|
|
1955
|
+
testResult: result.output,
|
|
1956
|
+
summary: releaseEvidence?.breaking ? `Breaking changes are covered by release ${releaseEvidence.previousVersion} → ${releaseEvidence.version}.` : `Breaking changes require a breaking release.`
|
|
1957
|
+
};
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
return {
|
|
1960
|
+
gitWasClean: true,
|
|
1961
|
+
setupError: errorMessage(error),
|
|
1962
|
+
summary: `Public test setup failed: ${errorMessage(error)}`
|
|
1963
|
+
};
|
|
1226
1964
|
} finally {
|
|
1227
1965
|
await restore();
|
|
1228
1966
|
mark?.(`restored original public tests`);
|
|
1229
1967
|
logMarks?.();
|
|
1230
1968
|
}
|
|
1231
1969
|
}
|
|
1232
|
-
|
|
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 };
|
|
1970
|
+
export { resolveConfig as _, npmVerify as a, breakagePrelude as b, npmCheck as c, configOptions as d, configPath as f, loadConfig as g, defineConfig as h, npmPlan as i, npmTest as l, defaultConfig as m, npmExecute as n, writeMatrixJson as o, configSchema as p, npmMatrix as r, formatNpmReport as s, breakage as t, configFields as u, verifyNpmReceipts as v, errorMessage as x, validateConfig as y };
|
|
1234
1971
|
|
|
1235
|
-
//# sourceMappingURL=breakage-
|
|
1972
|
+
//# sourceMappingURL=breakage-DJiIiLzL.js.map
|