@hublo/sentinel 1.4.0-alpha.2 → 1.4.0-alpha.3

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.
@@ -5394,6 +5394,9 @@ function registerLint() {
5394
5394
 
5395
5395
  // src/roles/test/adapters/vitest/vitest.adapter.ts
5396
5396
  import { spawnSync as spawnSync5 } from "child_process";
5397
+ import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync30 } from "fs";
5398
+ import { tmpdir as tmpdir2 } from "os";
5399
+ import { join as join37 } from "path";
5397
5400
 
5398
5401
  // src/roles/test/presets/toolchain.json
5399
5402
  var toolchain_default2 = {
@@ -5470,7 +5473,7 @@ function testTarget(options) {
5470
5473
  }
5471
5474
 
5472
5475
  // src/roles/test/jest-migration.ts
5473
- import { existsSync as existsSync28, readFileSync as readFileSync28 } from "fs";
5476
+ import { existsSync as existsSync29, readFileSync as readFileSync28 } from "fs";
5474
5477
  import { join as join35 } from "path";
5475
5478
 
5476
5479
  // src/roles/test/codemod/migrate-module.ts
@@ -7570,9 +7573,13 @@ function migrateModuleTests(cwd, setupFiles = []) {
7570
7573
  }
7571
7574
 
7572
7575
  // src/roles/test/nest/generate-config.ts
7573
- import { readdirSync as readdirSync7, readFileSync as readFileSync26, statSync as statSync7 } from "fs";
7576
+ import { existsSync as existsSync26, readdirSync as readdirSync7, readFileSync as readFileSync26, statSync as statSync7 } from "fs";
7574
7577
  import { join as join32, relative as relative6 } from "path";
7575
7578
  var NEST_TEST_PRESET_SPECIFIER = "@hublo/sentinel/test/nest";
7579
+ var REACT_TEST_PRESET_SPECIFIER = "@hublo/sentinel/test/react";
7580
+ function flavourOf(reading) {
7581
+ return reading.testEnvironment === "jsdom" ? "react" : "nest";
7582
+ }
7576
7583
  var SENTINEL_TEST_SETUP = "@hublo/sentinel/test/setup/nest";
7577
7584
  var SENTINEL_MSW_SETUP = "@hublo/sentinel/test/setup/msw-lifecycle";
7578
7585
  var SENTINEL_WORKSPACE_SETUP = "@hublo/sentinel/test/setup/workspace";
@@ -7643,14 +7650,29 @@ function readModuleTraits(cwd) {
7643
7650
  return traits;
7644
7651
  }
7645
7652
  var withoutRootDir = (value) => value.replaceAll("<rootDir>/", "./").replaceAll("<rootDir>", ".");
7646
- function setupBlock(reading, msw) {
7653
+ function ownsMswLifecycle(cwd, reading) {
7654
+ const declared = [...reading.setupFiles ?? [], ...reading.setupFilesAfterEnv ?? []].filter(
7655
+ (file) => !file.endsWith("jest.setup.after.env.js")
7656
+ );
7657
+ for (const raw of declared) {
7658
+ const file = join32(cwd, raw.replaceAll("<rootDir>/", "").replaceAll("<rootDir>", "."));
7659
+ if (!existsSync26(file)) continue;
7660
+ try {
7661
+ if (/\.listen\s*\(/.test(readFileSync26(file, "utf8"))) return true;
7662
+ } catch {
7663
+ continue;
7664
+ }
7665
+ }
7666
+ return false;
7667
+ }
7668
+ function setupBlock(cwd, reading, msw) {
7647
7669
  const declared = [...reading.setupFiles ?? [], ...reading.setupFilesAfterEnv ?? []];
7648
7670
  const namedTheRootSetup = declared.some((file) => file.endsWith("jest.setup.after.env.js"));
7649
7671
  const own = declared.filter((file) => !file.endsWith("jest.setup.after.env.js")).map((file) => `'${withoutRootDir(file)}'`);
7650
7672
  const sentinel = [
7651
7673
  `'${SENTINEL_TEST_SETUP}'`,
7652
7674
  ...namedTheRootSetup ? [`'${SENTINEL_WORKSPACE_SETUP}'`] : [],
7653
- ...msw ? [`'${SENTINEL_MSW_SETUP}'`] : []
7675
+ ...msw && !ownsMswLifecycle(cwd, reading) ? [`'${SENTINEL_MSW_SETUP}'`] : []
7654
7676
  ];
7655
7677
  return ` setupFiles: [${[...sentinel, ...own].join(", ")}],`;
7656
7678
  }
@@ -7712,7 +7734,7 @@ function generateNestTestConfig(cwd, reading, target = "test") {
7712
7734
  const up = hopsToRoot(cwd, workspaceRoot);
7713
7735
  const test = [
7714
7736
  environmentBlock(reading),
7715
- setupBlock(reading, traits.msw),
7737
+ setupBlock(cwd, reading, traits.msw),
7716
7738
  envBlock(reading),
7717
7739
  timeoutBlock(reading),
7718
7740
  concurrencyBlock(cwd, reading, target),
@@ -7727,7 +7749,7 @@ function generateNestTestConfig(cwd, reading, target = "test") {
7727
7749
  `import path from 'node:path'`,
7728
7750
  "",
7729
7751
  ...needsDefaults ? [`import { configDefaults } from 'vitest/config'`, ""] : [],
7730
- `import { defineConfig, nestTestConfig } from '${NEST_TEST_PRESET_SPECIFIER}'`
7752
+ flavourOf(reading) === "react" ? `import { defineConfig, reactTestConfig } from '${REACT_TEST_PRESET_SPECIFIER}'` : `import { defineConfig, nestTestConfig } from '${NEST_TEST_PRESET_SPECIFIER}'`
7731
7753
  ].join("\n");
7732
7754
  const overrides = [aliasBlock(reading), " test: {", ...test, " },"].filter(
7733
7755
  (line) => line !== ""
@@ -7738,7 +7760,7 @@ function generateNestTestConfig(cwd, reading, target = "test") {
7738
7760
  source: `${imports}
7739
7761
 
7740
7762
  export default defineConfig(
7741
- nestTestConfig({
7763
+ ${flavourOf(reading) === "react" ? "reactTestConfig" : "nestTestConfig"}({
7742
7764
  root: import.meta.dirname,
7743
7765
  workspaceRoot: path.resolve(import.meta.dirname, '${up}'),${decoratorLine}
7744
7766
  overrides: {
@@ -7751,7 +7773,7 @@ ${overrides.join("\n")}
7751
7773
  }
7752
7774
 
7753
7775
  // src/roles/test/nest/read-jest-config.ts
7754
- import { existsSync as existsSync26, readFileSync as readFileSync27 } from "fs";
7776
+ import { existsSync as existsSync27, readFileSync as readFileSync27 } from "fs";
7755
7777
  import { join as join33 } from "path";
7756
7778
  var CARRIED = [
7757
7779
  "testEnvironment",
@@ -7942,7 +7964,7 @@ function extendedConfig(source, cwd) {
7942
7964
  (name) => name.replace(/^\.\//, "")
7943
7965
  );
7944
7966
  const file = candidates.find(
7945
- (name) => /^jest[\w.-]*\.(ts|js)$/.test(name) && existsSync26(join33(cwd, name))
7967
+ (name) => /^jest[\w.-]*\.(ts|js)$/.test(name) && existsSync27(join33(cwd, name))
7946
7968
  );
7947
7969
  if (file === void 0) continue;
7948
7970
  if (!new RegExp(String.raw`\{\s*\.\.\.${binding}\b`).test(source)) continue;
@@ -7969,8 +7991,8 @@ function sideEffects(source, extended) {
7969
7991
  return found;
7970
7992
  }
7971
7993
  function readJestConfig(cwd, named) {
7972
- const file = named ?? JEST_CONFIG_FILES.find((name) => existsSync26(join33(cwd, name)));
7973
- if (file === void 0 || !existsSync26(join33(cwd, file))) return void 0;
7994
+ const file = named ?? JEST_CONFIG_FILES.find((name) => existsSync27(join33(cwd, name)));
7995
+ if (file === void 0 || !existsSync27(join33(cwd, file))) return void 0;
7974
7996
  let source;
7975
7997
  try {
7976
7998
  source = readFileSync27(join33(cwd, file), "utf8");
@@ -8054,7 +8076,7 @@ function readJestConfig(cwd, named) {
8054
8076
  }
8055
8077
 
8056
8078
  // src/roles/test/nest/run-modes.ts
8057
- import { existsSync as existsSync27 } from "fs";
8079
+ import { existsSync as existsSync28 } from "fs";
8058
8080
  import { basename as basename2, join as join34 } from "path";
8059
8081
  var JEST_EXECUTOR = "@nx/jest:jest";
8060
8082
  var PRIMARY_CONFIG_FILE = "vitest.config.mts";
@@ -8076,7 +8098,7 @@ function runModes(cwd, fallback) {
8076
8098
  const jestConfig = entry.options?.jestConfig;
8077
8099
  if (jestConfig === void 0) continue;
8078
8100
  const name = configName(jestConfig);
8079
- if (!existsSync27(join34(cwd, name))) continue;
8101
+ if (!existsSync28(join34(cwd, name))) continue;
8080
8102
  declared.push({
8081
8103
  target,
8082
8104
  jestConfig: name,
@@ -8105,7 +8127,7 @@ var SPEC_TSCONFIG = "tsconfig.spec.json";
8105
8127
  var VITEST_GLOBAL_TYPES = "vitest/globals";
8106
8128
  function specTypesOperation(cwd) {
8107
8129
  const path = join35(cwd, SPEC_TSCONFIG);
8108
- if (!existsSync28(path)) return void 0;
8130
+ if (!existsSync29(path)) return void 0;
8109
8131
  let config;
8110
8132
  try {
8111
8133
  config = parseJsonc(
@@ -8363,6 +8385,311 @@ function plan3(context) {
8363
8385
  return { operations, notes };
8364
8386
  }
8365
8387
 
8388
+ // src/roles/test/reference.ts
8389
+ import { execFileSync } from "child_process";
8390
+ import { existsSync as existsSync30, mkdtempSync, readFileSync as readFileSync29, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
8391
+ import { tmpdir } from "os";
8392
+ import { join as join36 } from "path";
8393
+
8394
+ // src/roles/test/baseline.ts
8395
+ function readSnapshot(report) {
8396
+ const files = report?.testResults ?? [];
8397
+ const passing = [];
8398
+ const failing = [];
8399
+ const unloadable = [];
8400
+ for (const file of files) {
8401
+ const assertions = file?.assertionResults ?? [];
8402
+ if (file?.status === "failed" && assertions.length === 0) {
8403
+ unloadable.push(typeof file.name === "string" ? file.name : "an unnamed file");
8404
+ }
8405
+ for (const assertion of assertions) {
8406
+ const name = assertion?.fullName;
8407
+ if (typeof name !== "string" || name === "") continue;
8408
+ if (assertion.status === "passed") passing.push(name);
8409
+ else if (assertion.status === "failed") failing.push(name);
8410
+ }
8411
+ }
8412
+ const claimed = report?.numFailedTests;
8413
+ return {
8414
+ files: files.length,
8415
+ passing: passing.sort(),
8416
+ failing: failing.sort(),
8417
+ unloadable: unloadable.sort(),
8418
+ claimedFailing: typeof claimed === "number" ? claimed : failing.length
8419
+ };
8420
+ }
8421
+ function difference(left, right) {
8422
+ const remaining = /* @__PURE__ */ new Map();
8423
+ for (const name of right) remaining.set(name, (remaining.get(name) ?? 0) + 1);
8424
+ const missing = [];
8425
+ for (const name of left) {
8426
+ const left_ = remaining.get(name) ?? 0;
8427
+ if (left_ === 0) {
8428
+ missing.push(name);
8429
+ continue;
8430
+ }
8431
+ remaining.set(name, left_ - 1);
8432
+ }
8433
+ return missing.sort();
8434
+ }
8435
+ var withoutRenderingQuotes = (name) => name.replaceAll(String.raw`\'`, "'").replaceAll(String.raw`\"`, '"').replaceAll(/['"`]\s*\+\s*['"`]/g, "").replaceAll(/(^|[\s:,[{(])(''|"")($|[\s,)\]}])/g, "$1<empty-string>$3").replaceAll(/['"`]/g, "");
8436
+ function withoutRenderingNoise(name) {
8437
+ const spelled = withSortedKeys(
8438
+ withoutUndefinedEntries(
8439
+ withoutErrorRendering(
8440
+ withoutNaN(
8441
+ withoutTimestamps(withoutRenderingQuotes(withoutClassBodies(withoutStackFrames(name))))
8442
+ )
8443
+ )
8444
+ )
8445
+ );
8446
+ return withoutArrayBrackets(
8447
+ spelled.replaceAll(/\[\s+/g, "[").replaceAll(/\s+\]/g, "]").replaceAll(/\{\s+/g, "{").replaceAll(/\s+\}/g, "}").replaceAll(/(?<=[[{,\s])\+0\b/g, "0").replaceAll(/:\s+/g, ":").replaceAll(/,\s+/g, ",")
8448
+ );
8449
+ }
8450
+ function withoutArrayBrackets(name) {
8451
+ return name.replaceAll(
8452
+ /\[([^[\]]*)\]/g,
8453
+ (match, inner) => (
8454
+ // `[Object]` and `[Array]` are the formatter saying it STOPPED, not an array literal.
8455
+ // Stripping their brackets would erase the marker `collapsedOf` pairs on.
8456
+ /^(?:Object|Array)$/.test(inner) ? match : inner.replaceAll(/,\s+/g, ",")
8457
+ )
8458
+ );
8459
+ }
8460
+ var withoutClassBodies = (name) => name.replaceAll(
8461
+ /class\s+([A-Za-z_$][\w$]*)\s*\{\s*\}/g,
8462
+ (_match, className) => `class ${className}`
8463
+ ).replaceAll(
8464
+ /class\s+([A-Za-z_$][\w$]*)[\s\S]*?\n\}/g,
8465
+ (_match, className) => `class ${className}`
8466
+ );
8467
+ var withoutTimestamps = (name) => name.replaceAll(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, "<instant>");
8468
+ var withoutNaN = (name) => name.replaceAll(/\bNaN\b/g, "null");
8469
+ function entriesOf(inner) {
8470
+ const parts = [];
8471
+ let depth = 0;
8472
+ let start = 0;
8473
+ for (let index = 0; index < inner.length; index += 1) {
8474
+ const character = inner[index];
8475
+ if (character === "[" || character === "(") depth += 1;
8476
+ else if (character === "]" || character === ")") depth -= 1;
8477
+ else if (character === "," && depth === 0) {
8478
+ parts.push(inner.slice(start, index));
8479
+ start = index + 1;
8480
+ }
8481
+ if (depth < 0) return void 0;
8482
+ }
8483
+ if (depth !== 0) return void 0;
8484
+ parts.push(inner.slice(start));
8485
+ return parts.map((part) => part.trim()).filter(Boolean);
8486
+ }
8487
+ var sortedGroup = (match, inner) => {
8488
+ if (!inner.includes(":")) return match;
8489
+ const parts = entriesOf(inner);
8490
+ if (parts === void 0) return match;
8491
+ if (parts.length < 2 || !parts.every((part) => part.includes(":"))) return match;
8492
+ return `{${[...parts].sort().join(",")}}`;
8493
+ };
8494
+ var HELD = "\u241F";
8495
+ var withSortedKeys = (name) => {
8496
+ const held = [];
8497
+ let current = name;
8498
+ for (let depth = 0; depth < 24; depth += 1) {
8499
+ const next = current.replaceAll(/\{([^{}]*)\}/g, (match, inner) => {
8500
+ held.push(sortedGroup(match, inner));
8501
+ return `${HELD}${held.length - 1}${HELD}`;
8502
+ });
8503
+ if (next === current) break;
8504
+ current = next;
8505
+ }
8506
+ for (let depth = 0; depth < 24 && current.includes(HELD); depth += 1) {
8507
+ current = current.replaceAll(
8508
+ new RegExp(`${HELD}(\\d+)${HELD}`, "g"),
8509
+ (_match, index) => held[Number(index)]
8510
+ );
8511
+ }
8512
+ return current;
8513
+ };
8514
+ var withoutStackFrames = (name) => {
8515
+ const frame = name.search(/\n\s+at\s/);
8516
+ return frame === -1 ? name : name.slice(0, frame);
8517
+ };
8518
+ var withoutErrorRendering = (name) => name.replaceAll(/\[[A-Za-z]*(?:Error|Exception):[^\][]*\]/g, "{}");
8519
+ var withoutUndefinedEntries = (name) => name.replaceAll(/([{,])\s*[A-Za-z_$][\w$]*\s*:\s*undefined\s*(?=[,}])/g, "$1").replaceAll(/\{\s*,/g, "{").replaceAll(/,\s*\}/g, "}").replaceAll(/\{\s*\}/g, "{}");
8520
+ function collapsedOf(candidate, name) {
8521
+ if (!/\[(?:Object|Array)\]/.test(candidate)) return false;
8522
+ const pattern = candidate.split(/\[(?:Object|Array)\]/).map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
8523
+ return new RegExp(`^${pattern}$`, "s").test(name);
8524
+ }
8525
+ function truncationOf(candidate, name) {
8526
+ const cut = candidate.indexOf("\u2026");
8527
+ if (cut === -1) return false;
8528
+ const head = candidate.slice(0, cut);
8529
+ const tail = candidate.slice(cut + 1);
8530
+ return name.startsWith(head) && name.endsWith(tail);
8531
+ }
8532
+ function pairRenamed(missing, added) {
8533
+ const remaining = [...added];
8534
+ const renamed = [];
8535
+ const unmatched = [];
8536
+ for (const name of missing) {
8537
+ const normalised = withoutRenderingNoise(name);
8538
+ const at = remaining.findIndex((candidate) => withoutRenderingNoise(candidate) === normalised);
8539
+ if (at === -1) {
8540
+ unmatched.push(name);
8541
+ continue;
8542
+ }
8543
+ renamed.push({ before: name, after: remaining[at] });
8544
+ remaining.splice(at, 1);
8545
+ }
8546
+ const lost = [];
8547
+ for (const name of unmatched) {
8548
+ const normalised = withoutRenderingNoise(name);
8549
+ const at = remaining.findIndex(
8550
+ (candidate) => truncationOf(withoutRenderingNoise(candidate), normalised) || collapsedOf(normalised, withoutRenderingNoise(candidate))
8551
+ );
8552
+ if (at === -1) {
8553
+ lost.push(name);
8554
+ continue;
8555
+ }
8556
+ renamed.push({ before: name, after: remaining[at] });
8557
+ remaining.splice(at, 1);
8558
+ }
8559
+ return { renamed, missing: lost, added: remaining };
8560
+ }
8561
+ function compareSnapshots(before, after) {
8562
+ const beforeAll = [...before.passing, ...before.failing];
8563
+ const afterAll = [...after.passing, ...after.failing];
8564
+ const paired = pairRenamed(difference(beforeAll, afterAll), difference(afterAll, beforeAll));
8565
+ const renamedBefore = new Set(paired.renamed.map((pair) => pair.before));
8566
+ const regressed = difference(before.passing, after.passing).filter(
8567
+ (name) => !paired.missing.includes(name) && !renamedBefore.has(name)
8568
+ );
8569
+ const failedBefore = new Set(before.failing);
8570
+ const recovered = after.passing.filter((name) => failedBefore.has(name));
8571
+ return {
8572
+ identical: paired.missing.length === 0 && paired.added.length === 0 && regressed.length === 0,
8573
+ missing: paired.missing,
8574
+ added: paired.added,
8575
+ regressed,
8576
+ renamed: paired.renamed,
8577
+ recovered,
8578
+ alreadyFailing: before.failing.length
8579
+ };
8580
+ }
8581
+ function unusableAsBaseline(snapshot) {
8582
+ if (snapshot.unloadable.length > 0) {
8583
+ return `${snapshot.unloadable.length} file(s) in the reference run did not load, so they reported no test at all and the run is short by however many they hold: ${snapshot.unloadable.slice(0, 3).join(", ")}. A reference that quietly lost tests cannot prove anything about the run after it`;
8584
+ }
8585
+ if (snapshot.claimedFailing > snapshot.failing.length) {
8586
+ return `the reference run says ${snapshot.claimedFailing} test(s) failed and names only ${snapshot.failing.length} of them, so the comparison has nothing to pair the rest against and would report them as invented by the migration`;
8587
+ }
8588
+ if (snapshot.passing.length === 0) {
8589
+ return `the reference run reports no passing test at all, so a green run after would prove nothing: there is no claim to preserve`;
8590
+ }
8591
+ return void 0;
8592
+ }
8593
+ function describeComparison(comparison) {
8594
+ const renamedNote = comparison.renamed.length > 0 ? ` ${comparison.renamed.length} test(s) kept their identity and changed name, because the runners render an each title's interpolated value differently (e.g. "${comparison.renamed[0]?.before.slice(-40)}" -> "${comparison.renamed[0]?.after.slice(-40)}").` : "";
8595
+ const alreadyRedNote = comparison.alreadyFailing > 0 ? ` ${comparison.alreadyFailing} test(s) were already failing before the migration and are outside this comparison, which covers only what passed.` : "";
8596
+ const recoveredNote = comparison.recovered.length > 0 ? ` ${comparison.recovered.length} test(s) were failing before and pass now.` : "";
8597
+ if (comparison.identical) {
8598
+ return `every test that ran before runs and passes now.${recoveredNote}${renamedNote}${alreadyRedNote}`;
8599
+ }
8600
+ const parts = [];
8601
+ if (comparison.recovered.length > 0) {
8602
+ parts.push(`${comparison.recovered.length} test(s) were failing before and pass now`);
8603
+ }
8604
+ if (comparison.missing.length > 0) {
8605
+ parts.push(
8606
+ `${comparison.missing.length} test(s) no longer exist: ${comparison.missing.slice(0, 3).join(", ")}`
8607
+ );
8608
+ }
8609
+ if (comparison.added.length > 0) {
8610
+ parts.push(
8611
+ `${comparison.added.length} test(s) appeared, which a migration should not do: ${comparison.added.slice(0, 3).join(", ")}`
8612
+ );
8613
+ }
8614
+ if (comparison.regressed.length > 0) {
8615
+ parts.push(
8616
+ `${comparison.regressed.length} test(s) passed before and do not now: ${comparison.regressed.slice(0, 3).join(", ")}`
8617
+ );
8618
+ }
8619
+ return `${parts.join("; ")}${renamedNote}${alreadyRedNote}`;
8620
+ }
8621
+
8622
+ // src/roles/test/reference.ts
8623
+ var REFERENCE_FILE = ".sentinel-test-reference.json";
8624
+ function runJest(cwd, config) {
8625
+ const bin = resolveBin(cwd, "jest");
8626
+ if (bin === void 0) return void 0;
8627
+ const root = findWorkspaceRoot(cwd) ?? cwd;
8628
+ const out = join36(mkdtempSync(join36(tmpdir(), "sentinel-reference-")), "jest.json");
8629
+ try {
8630
+ execFileSync(bin, ["-c", join36(cwd, config), "--json", `--outputFile=${out}`, "--silent"], {
8631
+ cwd: root,
8632
+ stdio: ["ignore", "ignore", "ignore"],
8633
+ maxBuffer: 1 << 28
8634
+ });
8635
+ } catch {
8636
+ }
8637
+ if (!existsSync30(out)) return void 0;
8638
+ try {
8639
+ return JSON.parse(readFileSync29(out, "utf8"));
8640
+ } catch {
8641
+ return void 0;
8642
+ }
8643
+ }
8644
+ function captureReference(cwd, config) {
8645
+ const report = runJest(cwd, config);
8646
+ if (report === void 0) {
8647
+ return {
8648
+ captured: false,
8649
+ message: `could not run jest to record a reference, so this migration cannot be PROVED. It is still applied: a module whose jest run is broken can move, it just cannot be compared.`
8650
+ };
8651
+ }
8652
+ const snapshot = readSnapshot(report);
8653
+ const unusable = unusableAsBaseline(snapshot);
8654
+ if (unusable !== void 0) {
8655
+ return {
8656
+ captured: false,
8657
+ message: `no reference recorded: ${unusable} The migration is applied anyway, unproved.`
8658
+ };
8659
+ }
8660
+ const stored = {
8661
+ runner: "jest",
8662
+ config,
8663
+ takenAt: (/* @__PURE__ */ new Date()).toISOString(),
8664
+ snapshot
8665
+ };
8666
+ writeFileSync3(join36(cwd, REFERENCE_FILE), `${JSON.stringify(stored, void 0, 2)}
8667
+ `);
8668
+ return {
8669
+ captured: true,
8670
+ message: `recorded ${snapshot.passing.length} passing test name(s) from ${config} as the reference. The next \`sentinel --run --test\` compares against it.`
8671
+ };
8672
+ }
8673
+ function hasReference(cwd) {
8674
+ return existsSync30(join36(cwd, REFERENCE_FILE));
8675
+ }
8676
+ function compareAgainstReference(cwd, report) {
8677
+ const path = join36(cwd, REFERENCE_FILE);
8678
+ let stored;
8679
+ try {
8680
+ stored = JSON.parse(readFileSync29(path, "utf8"));
8681
+ } catch {
8682
+ rmSync3(path, { force: true });
8683
+ return { ok: true, message: `the recorded reference could not be read, so nothing was proved.` };
8684
+ }
8685
+ const comparison = compareSnapshots(stored.snapshot, readSnapshot(report));
8686
+ rmSync3(path, { force: true });
8687
+ return {
8688
+ ok: comparison.identical,
8689
+ message: `${describeComparison(comparison)} (reference: ${stored.config}, ${stored.takenAt})`
8690
+ };
8691
+ }
8692
+
8366
8693
  // src/roles/test/adapters/vitest/vitest.adapter.ts
8367
8694
  function resolveVitest(cwd) {
8368
8695
  const fromSentinel = binFromOwnInstall("vitest", "vitest");
@@ -8376,6 +8703,22 @@ var VitestAdapter = class extends BaseAdapter {
8376
8703
  appliesTo(_preset) {
8377
8704
  return true;
8378
8705
  }
8706
+ /**
8707
+ * Record what this module's tests do TODAY, at the last moment it is still true.
8708
+ *
8709
+ * The plan about to be applied deletes the jest config, and with it any way of asking again. A
8710
+ * migration that cannot be compared to what came before is a migration nobody can check, which
8711
+ * is the whole reason this role has a baseline gate at all.
8712
+ */
8713
+ async beforeInit(ctx) {
8714
+ const adoption = readTestAdoption(ctx.cwd);
8715
+ if (adoption.state !== "jest") return Promise.resolve();
8716
+ const [config] = adoption.jestConfigs;
8717
+ if (config === void 0) return Promise.resolve();
8718
+ this.say(`recording the jest reference before migrating...`);
8719
+ this.say(captureReference(ctx.cwd, config).message);
8720
+ return Promise.resolve();
8721
+ }
8379
8722
  plan(context) {
8380
8723
  return plan3(context);
8381
8724
  }
@@ -8422,13 +8765,31 @@ var VitestAdapter = class extends BaseAdapter {
8422
8765
  }
8423
8766
  const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: [] });
8424
8767
  const watching = options.includes("--watch") || options.includes("-w");
8425
- const argv = watching ? [...options, ...paths] : ["run", ...options, ...paths];
8768
+ const proving = !watching && hasReference(ctx.cwd);
8769
+ const reportFile = proving ? join37(mkdtempSync2(join37(tmpdir2(), "sentinel-run-")), "vitest.json") : void 0;
8770
+ const reporters = reportFile === void 0 ? [] : ["--reporter=default", "--reporter=json", `--outputFile=${reportFile}`];
8771
+ const argv = watching ? [...options, ...paths] : ["run", ...reporters, ...options, ...paths];
8426
8772
  const result = spawnSync5(bin, argv, { cwd: ctx.cwd, stdio: "inherit" });
8427
8773
  if (result.error) {
8428
8774
  this.say(`could not run vitest (${result.error.message})`);
8429
8775
  return Promise.resolve({ ok: false, code: 1 });
8430
8776
  }
8431
8777
  const code = result.status ?? 1;
8778
+ if (reportFile !== void 0) {
8779
+ let report;
8780
+ try {
8781
+ report = JSON.parse(readFileSync30(reportFile, "utf8"));
8782
+ } catch {
8783
+ report = void 0;
8784
+ }
8785
+ if (report === void 0) {
8786
+ this.say(`vitest wrote no report, so this run proved nothing against the reference.`);
8787
+ } else {
8788
+ const outcome = compareAgainstReference(ctx.cwd, report);
8789
+ this.say(outcome.message);
8790
+ if (!outcome.ok) return Promise.resolve({ ok: false, code: code === 0 ? 1 : code });
8791
+ }
8792
+ }
8432
8793
  return Promise.resolve({ ok: code === 0, code });
8433
8794
  }
8434
8795
  say(message) {
@@ -8445,9 +8806,9 @@ function registerTest() {
8445
8806
 
8446
8807
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
8447
8808
  import { spawnSync as spawnSync6 } from "child_process";
8448
- import { existsSync as existsSync29, readFileSync as readFileSync30 } from "fs";
8809
+ import { existsSync as existsSync31, readFileSync as readFileSync32 } from "fs";
8449
8810
  import { createRequire as createRequire6 } from "module";
8450
- import { join as join37 } from "path";
8811
+ import { join as join39 } from "path";
8451
8812
 
8452
8813
  // src/roles/typescript/presets/nest.json
8453
8814
  var nest_default2 = {
@@ -8628,8 +8989,8 @@ function readTsconfigAdoption(cwd) {
8628
8989
  }
8629
8990
 
8630
8991
  // src/roles/typescript/adapters/tsc/plan.ts
8631
- import { readFileSync as readFileSync29 } from "fs";
8632
- import { join as join36 } from "path";
8992
+ import { readFileSync as readFileSync31 } from "fs";
8993
+ import { join as join38 } from "path";
8633
8994
 
8634
8995
  // src/roles/typescript/typecheck-script.ts
8635
8996
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -8722,7 +9083,7 @@ function planAdoption(context) {
8722
9083
  };
8723
9084
  }
8724
9085
  const existing = parseJsonc(
8725
- readFileSync29(join36(context.cwd, target.path), "utf8"),
9086
+ readFileSync31(join38(context.cwd, target.path), "utf8"),
8726
9087
  target.path
8727
9088
  );
8728
9089
  const extendsChain = composeExtends(existing.extends, preset);
@@ -8860,7 +9221,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
8860
9221
  let chain;
8861
9222
  try {
8862
9223
  const parsed = parseJsonc(
8863
- readFileSync30(join37(cwd, target.path), "utf8"),
9224
+ readFileSync32(join39(cwd, target.path), "utf8"),
8864
9225
  target.path
8865
9226
  );
8866
9227
  chain = parsed.extends;
@@ -8873,7 +9234,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
8873
9234
  );
8874
9235
  if (preset === void 0) return void 0;
8875
9236
  try {
8876
- createRequire6(join37(cwd, "noop.js")).resolve(preset);
9237
+ createRequire6(join39(cwd, "noop.js")).resolve(preset);
8877
9238
  return void 0;
8878
9239
  } catch {
8879
9240
  return preset;
@@ -8969,7 +9330,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
8969
9330
  referencedProjects(cwd, config) {
8970
9331
  try {
8971
9332
  const parsed = parseJsonc(
8972
- readFileSync30(join37(cwd, config), "utf8"),
9333
+ readFileSync32(join39(cwd, config), "utf8"),
8973
9334
  config
8974
9335
  );
8975
9336
  const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
@@ -8985,7 +9346,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
8985
9346
  * check.
8986
9347
  */
8987
9348
  typecheckTarget(cwd) {
8988
- if (existsSync29(join37(cwd, "tsconfig.json"))) return "tsconfig.json";
9349
+ if (existsSync31(join39(cwd, "tsconfig.json"))) return "tsconfig.json";
8989
9350
  const target = resolveTsconfigTarget(cwd);
8990
9351
  return target.reason === "none" ? null : target.path;
8991
9352
  }
@@ -9190,7 +9551,7 @@ function replaceLines(current, replacements) {
9190
9551
  }
9191
9552
 
9192
9553
  // src/core/apply-plan.ts
9193
- import { existsSync as existsSync30, readFileSync as readFileSync31, renameSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
9554
+ import { existsSync as existsSync32, readFileSync as readFileSync33, renameSync, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
9194
9555
  import { resolve as resolve5, sep } from "path";
9195
9556
  import { applyEdits as applyEdits3, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
9196
9557
 
@@ -9209,7 +9570,7 @@ function resolveWithinRoot(cwd, relativePath) {
9209
9570
  return absolutePath;
9210
9571
  }
9211
9572
  function readIfExists(absolutePath) {
9212
- return existsSync30(absolutePath) ? readFileSync31(absolutePath, "utf8") : void 0;
9573
+ return existsSync32(absolutePath) ? readFileSync33(absolutePath, "utf8") : void 0;
9213
9574
  }
9214
9575
  function* leaves(value, prefix = []) {
9215
9576
  for (const [key, keyValue] of Object.entries(value)) {
@@ -9311,14 +9672,14 @@ function preparePlan(cwd, plan4) {
9311
9672
  }
9312
9673
  function writeFileAtomic(absolutePath, contents) {
9313
9674
  const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
9314
- writeFileSync3(tempPath, contents);
9675
+ writeFileSync4(tempPath, contents);
9315
9676
  renameSync(tempPath, absolutePath);
9316
9677
  }
9317
9678
  function applyPlan(cwd, plan4) {
9318
9679
  const changed = preparePlan(cwd, plan4).filter((file) => file.before !== file.after);
9319
9680
  for (const file of changed) {
9320
9681
  if (file.deleted) {
9321
- rmSync3(file.absolutePath, { force: true });
9682
+ rmSync4(file.absolutePath, { force: true });
9322
9683
  continue;
9323
9684
  }
9324
9685
  writeFileAtomic(file.absolutePath, file.after);
@@ -9327,8 +9688,8 @@ function applyPlan(cwd, plan4) {
9327
9688
  }
9328
9689
 
9329
9690
  // src/core/config/preset-evidence.ts
9330
- import { existsSync as existsSync31, readdirSync as readdirSync8, readFileSync as readFileSync32 } from "fs";
9331
- import { join as join38 } from "path";
9691
+ import { existsSync as existsSync33, readdirSync as readdirSync8, readFileSync as readFileSync34 } from "fs";
9692
+ import { join as join40 } from "path";
9332
9693
  var PATH_SIGNALS = [
9333
9694
  {
9334
9695
  preset: "nest",
@@ -9362,12 +9723,12 @@ function sourceImports(cwd, pattern) {
9362
9723
  }
9363
9724
  for (const entry of entries) {
9364
9725
  if (entry.isDirectory()) {
9365
- if (!NOT_SCANNED.has(entry.name)) stack.push(join38(dir, entry.name));
9726
+ if (!NOT_SCANNED.has(entry.name)) stack.push(join40(dir, entry.name));
9366
9727
  continue;
9367
9728
  }
9368
9729
  if (!SOURCE_FILE2.test(entry.name)) continue;
9369
9730
  try {
9370
- if (pattern.test(readFileSync32(join38(dir, entry.name), "utf8"))) return true;
9731
+ if (pattern.test(readFileSync34(join40(dir, entry.name), "utf8"))) return true;
9371
9732
  } catch {
9372
9733
  continue;
9373
9734
  }
@@ -9376,11 +9737,11 @@ function sourceImports(cwd, pattern) {
9376
9737
  return false;
9377
9738
  }
9378
9739
  function dependencyNames(cwd) {
9379
- const path = join38(cwd, "package.json");
9380
- if (!existsSync31(path)) return [];
9740
+ const path = join40(cwd, "package.json");
9741
+ if (!existsSync33(path)) return [];
9381
9742
  try {
9382
9743
  const manifest = parseJsonc(
9383
- readFileSync32(path, "utf8"),
9744
+ readFileSync34(path, "utf8"),
9384
9745
  path
9385
9746
  );
9386
9747
  return [
@@ -9403,7 +9764,7 @@ function declaresJsx(cwd) {
9403
9764
  for (const name of entries) {
9404
9765
  try {
9405
9766
  const config = parseJsonc(
9406
- readFileSync32(join38(cwd, name), "utf8"),
9767
+ readFileSync34(join40(cwd, name), "utf8"),
9407
9768
  name
9408
9769
  );
9409
9770
  if (config.compilerOptions?.jsx !== void 0) return true;
@@ -9548,6 +9909,15 @@ async function dispatch(opts) {
9548
9909
  if (opts.dryRun) {
9549
9910
  return previewPlan(opts, plan4);
9550
9911
  }
9912
+ if (adapter.beforeInit) {
9913
+ await adapter.beforeInit({
9914
+ module: basename3(opts.cwd),
9915
+ cwd: opts.cwd,
9916
+ preset,
9917
+ ci: false,
9918
+ fix: false
9919
+ });
9920
+ }
9551
9921
  for (const change of applyPlan(opts.cwd, plan4)) {
9552
9922
  process.stderr.write(` ${change.deleted ? "removed" : "wrote"} ${change.path}
9553
9923
  `);
@@ -9591,4 +9961,4 @@ export {
9591
9961
  detectFramework,
9592
9962
  dispatch
9593
9963
  };
9594
- //# sourceMappingURL=chunk-4UIZJ3TR.js.map
9964
+ //# sourceMappingURL=chunk-MUQWAZQN.js.map