breakage 0.9.0 → 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.
@@ -6,10 +6,10 @@ import { simpleGit } from "simple-git";
6
6
  import logger from "takua";
7
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";
9
+ import { chmod, copyFile, glob, lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from "node:fs/promises";
10
10
  import semver from "semver";
11
11
  import { type } from "arktype";
12
- import { randomUUID } from "node:crypto";
12
+ import { createHash, randomUUID } from "node:crypto";
13
13
  import { setTimeout } from "node:timers/promises";
14
14
  import { existsSync } from "node:fs";
15
15
  import { cli, options } from "comline";
@@ -455,6 +455,235 @@ function validateConfig(value) {
455
455
  function validatePeerRanges(peers) {
456
456
  for (const range of Object.values(peers)) eras(range);
457
457
  }
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
+ }
458
687
  // src/config.ts
459
688
  const configFields = {
460
689
  tagPattern: type(`string`),
@@ -477,7 +706,8 @@ const configSchema = type({
477
706
  "verbose?": configFields.verbose.or(`undefined`),
478
707
  "out?": configFields.out,
479
708
  "release?": releaseSchema,
480
- "npm?": npmConfigSchema
709
+ "npm?": npmConfigSchema,
710
+ "npmMatrix?": npmMatrixConfigSchema
481
711
  });
482
712
  /** The complete config, with no fields exposed to argv by this loader. */
483
713
  const configOptions = options(`Breakage configuration`, configSchema, {
@@ -490,7 +720,8 @@ const configOptions = options(`Breakage configuration`, configSchema, {
490
720
  verbose: { configOnly: true },
491
721
  out: { configOnly: true },
492
722
  release: { configOnly: true },
493
- npm: { configOnly: true }
723
+ npm: { configOnly: true },
724
+ npmMatrix: { configOnly: true }
494
725
  });
495
726
  /** Share the root release model with npm checks unless they supply their own. */
496
727
  function defineConfig(config) {
@@ -1029,7 +1260,7 @@ async function checkLocalReleases(config, base, manifest, representatives) {
1029
1260
  }
1030
1261
  }))).flat();
1031
1262
  }
1032
- async function runRepresentative(config, base, manifest, directory, coverageOnly, selection) {
1263
+ async function runRepresentative(config, base, manifest, directory, coverageOnly, selection, plannedSuites) {
1033
1264
  const result = {
1034
1265
  path: path.relative(base, directory).split(path.sep).join(`/`),
1035
1266
  status: `skipped`,
@@ -1052,6 +1283,7 @@ async function runRepresentative(config, base, manifest, directory, coverageOnly
1052
1283
  const peers = Object.keys(config.suites).filter((peer) => {
1053
1284
  const evidence = result.peers[peer];
1054
1285
  if (!evidence) return false;
1286
+ if (plannedSuites !== void 0) return plannedSuites.includes(peer);
1055
1287
  if (selection && `peer` in selection) return peer === selection.peer;
1056
1288
  if (selection && `representative` in selection) return true;
1057
1289
  return matches(evidence.version, manifest.peerDependencies[peer]);
@@ -1086,7 +1318,7 @@ async function runRepresentative(config, base, manifest, directory, coverageOnly
1086
1318
  return result;
1087
1319
  }
1088
1320
  }
1089
- async function run(config, baseDirectory, coverageOnly, selection) {
1321
+ async function run(config, baseDirectory, coverageOnly, selection, plannedSuites) {
1090
1322
  const report = {
1091
1323
  package: {
1092
1324
  name: `(unresolved)`,
@@ -1139,7 +1371,7 @@ async function run(config, baseDirectory, coverageOnly, selection) {
1139
1371
  } } : {}
1140
1372
  };
1141
1373
  const directories = await discover(config, base);
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)))]);
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)))]);
1143
1375
  report.support = support;
1144
1376
  report.representatives = representatives;
1145
1377
  if (!selection) {
@@ -1168,6 +1400,10 @@ async function npmCheck(config, options = {}) {
1168
1400
  async function npmTest(config, selection, options = {}) {
1169
1401
  return await run(config, options.baseDirectory ?? process.cwd(), false, selection);
1170
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
+ }
1171
1407
  function formatNpmReport(report) {
1172
1408
  const lines = [`${report.package.name}@${report.package.version} — npm ${report.mode}`];
1173
1409
  for (const result of report.representatives) {
@@ -1188,6 +1424,393 @@ function formatNpmReport(report) {
1188
1424
  if (report.mode === `coverage-only`) lines.push(`Coverage audit only; compatibility assertions were not executed.`);
1189
1425
  return `${lines.join(`\n`)}\n`;
1190
1426
  }
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
+ }
1191
1814
  // src/breakage.ts
1192
1815
  async function breakage(options) {
1193
1816
  rejectCertificationOptions(options);
@@ -1344,6 +1967,6 @@ async function breakage(options) {
1344
1967
  logMarks?.();
1345
1968
  }
1346
1969
  }
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 };
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 };
1348
1971
 
1349
- //# sourceMappingURL=breakage-DIY49fzP.js.map
1972
+ //# sourceMappingURL=breakage-DJiIiLzL.js.map