@hublo/sentinel 1.4.0-alpha.9 → 1.4.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.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/sentinel.js +45 -101
  3. package/dist/{chunk-7PUVK4YM.js → chunk-5VNYQIFD.js} +224 -13
  4. package/dist/chunk-5VNYQIFD.js.map +1 -0
  5. package/dist/chunk-DYJ6J43A.js +6419 -0
  6. package/dist/chunk-ELZHIN6E.js +16 -0
  7. package/dist/chunk-ELZHIN6E.js.map +1 -0
  8. package/dist/{chunk-L7WS36XV.js → chunk-GRK2KRFI.js} +221 -4427
  9. package/dist/chunk-H3GSGAX2.js +11 -0
  10. package/dist/chunk-H3GSGAX2.js.map +1 -0
  11. package/dist/{chunk-WLFE5RUU.js → chunk-KMKQDGI6.js} +6 -8
  12. package/dist/chunk-KMKQDGI6.js.map +1 -0
  13. package/dist/{chunk-PWV3BMDA.js → chunk-MMKBDO5V.js} +11 -2
  14. package/dist/chunk-MMKBDO5V.js.map +1 -0
  15. package/dist/chunk-NUOQXAYR.js +14 -0
  16. package/dist/chunk-NUOQXAYR.js.map +1 -0
  17. package/dist/chunk-O7REVMOC.js +38 -0
  18. package/dist/chunk-O7REVMOC.js.map +1 -0
  19. package/dist/chunk-QXFCZON7.js +16 -0
  20. package/dist/chunk-QXFCZON7.js.map +1 -0
  21. package/dist/{chunk-3TDUIKVQ.js → chunk-SWWQ7X7B.js} +11 -8
  22. package/dist/{chunk-3TDUIKVQ.js.map → chunk-SWWQ7X7B.js.map} +1 -1
  23. package/dist/{chunk-CPCUPK4J.js → chunk-Z7L4FGKP.js} +5 -12
  24. package/dist/chunk-Z7L4FGKP.js.map +1 -0
  25. package/dist/index.d.ts +28 -5
  26. package/dist/index.js +7 -3
  27. package/dist/roles/build/nest/toolchain.js +11 -33
  28. package/dist/roles/build/nest/toolchain.js.map +1 -1
  29. package/dist/roles/test/nest/toolchain.js +3 -2
  30. package/dist/roles/test/nest/toolchain.js.map +1 -1
  31. package/dist/roles/test/react/toolchain.js +3 -2
  32. package/dist/roles/test/react/toolchain.js.map +1 -1
  33. package/dist/roles/test/setup/a11y.d.ts +45 -0
  34. package/dist/roles/test/setup/a11y.js +72 -0
  35. package/dist/roles/test/setup/a11y.js.map +1 -0
  36. package/dist/roles/test/setup/file-boundary-close.d.ts +2 -0
  37. package/dist/roles/test/setup/file-boundary-close.js +8 -0
  38. package/dist/roles/test/setup/file-boundary-close.js.map +1 -0
  39. package/dist/roles/test/setup/file-boundary.d.ts +2 -0
  40. package/dist/roles/test/setup/file-boundary.js +62 -0
  41. package/dist/roles/test/setup/file-boundary.js.map +1 -0
  42. package/dist/roles/test/setup/jest-parity.js +19 -2
  43. package/dist/roles/test/setup/jest-parity.js.map +1 -1
  44. package/dist/roles/test/setup/msw-lifecycle.js +2 -1
  45. package/dist/roles/test/setup/msw-lifecycle.js.map +1 -1
  46. package/dist/roles/test/setup/msw-server.js +2 -1
  47. package/dist/roles/test/setup/msw-server.js.map +1 -1
  48. package/dist/roles/test/setup/w3c.d.ts +75 -0
  49. package/dist/roles/test/setup/w3c.js +49 -0
  50. package/dist/roles/test/setup/w3c.js.map +1 -0
  51. package/dist/roles/test/setup/workspace-entry.js +3 -2
  52. package/dist/roles/test/setup/workspace-entry.js.map +1 -1
  53. package/dist/roles/test/shared-test-config.d.ts +13 -0
  54. package/dist/roles/test/shared-test-config.js +3 -2
  55. package/dist/validate-57H7GK5B.js +170 -0
  56. package/docs/test-adoption.md +282 -5
  57. package/docs/using-sentinel.md +17 -1
  58. package/docs/validating-a-change.md +35 -2
  59. package/package.json +25 -1
  60. package/types/jest-global.d.ts +85 -0
  61. package/types/mock-extended.d.ts +52 -0
  62. package/dist/chunk-7PUVK4YM.js.map +0 -1
  63. package/dist/chunk-CPCUPK4J.js.map +0 -1
  64. package/dist/chunk-PWV3BMDA.js.map +0 -1
  65. package/dist/chunk-WLFE5RUU.js.map +0 -1
package/README.md CHANGED
@@ -24,6 +24,7 @@ A large monorepo accumulates:
24
24
  - **One source of truth for config** — every project just `extends @hublo/sentinel/...`; the actual rules live in one versioned place. Change a rule once, everyone gets it on the next version bump.
25
25
  - **One source of truth for tooling dependencies** — a project depends on `@hublo/sentinel`, not on a scattered pile of eslint / vitest / plugin devDeps. Bump one version and the whole toolchain moves, atomically, tested in isolation first.
26
26
  - **`--init` sets a module up** — the one command generates the stubs the first time (**adopt**), regenerates them after a change like a runner swap (**refresh**), and applies the workspace prep the module needs. Run it module by module to roll out gradually. (`--migrate`, for changing an already-initialized setup, is a reserved future verb.)
27
+ - **`--validate` proves the move did not change anything** — a verb of its own, because what separates it from `--init` is what SURVIVES the run: `--init` and `--migrate` write state that REMAINS, `--validate` may instrument the module it is proving provided nothing it writes stays. Run before a migration it records what the module does today; run after, it compares and refuses a run that lost a test or invented one. It composes with every type, and answers "not available yet" for the ones without a proof rather than calling a valid command wrong.
27
28
  - **Move one module at a time** — installed per module, so you adopt at your pace; a module can adopt sentinel while its neighbour keeps the old setup. No big-bang.
28
29
  - **Swap tools without touching projects** — change eslint → biome (or benchmark them) in one place; `--init` regenerates the stubs.
29
30
  - **No silent drift** — the guard keeps every project's config converged on the source of truth.
@@ -14,118 +14,33 @@ import {
14
14
  dispatch,
15
15
  palette,
16
16
  registerAdapters,
17
- resolve,
18
- resolveBin
19
- } from "../chunk-L7WS36XV.js";
17
+ resolve
18
+ } from "../chunk-GRK2KRFI.js";
19
+ import {
20
+ resolveContext
21
+ } from "../chunk-DYJ6J43A.js";
22
+ import "../chunk-O7REVMOC.js";
23
+ import "../chunk-QXFCZON7.js";
20
24
  import {
21
- WORKSPACE_ROOT_MARKER,
22
25
  ensureWorkspacePrep,
23
26
  findWorkspaceRoot,
24
27
  inspectWorkspacePrep,
25
- moduleName,
26
28
  readOwnVersion,
27
29
  readProjectPackageJson
28
- } from "../chunk-WLFE5RUU.js";
30
+ } from "../chunk-KMKQDGI6.js";
29
31
 
30
32
  // bin/sentinel.ts
31
33
  import { program } from "commander";
32
34
 
33
- // src/core/context.ts
34
- import { existsSync } from "fs";
35
- import { join as join2 } from "path";
36
-
37
- // src/core/discover-modules.ts
38
- import { execFileSync } from "child_process";
39
- import { mkdtempSync, readFileSync, rmSync } from "fs";
40
- import { tmpdir } from "os";
41
- import { join } from "path";
42
- function runNx(cwd2, args) {
43
- const nx = resolveBin(cwd2, "nx") ?? "nx";
44
- try {
45
- return execFileSync(nx, args, {
46
- cwd: cwd2,
47
- encoding: "utf8",
48
- env: { ...process.env, NX_DAEMON: "false" }
49
- });
50
- } catch (error) {
51
- const message = error instanceof Error ? error.message : String(error);
52
- throw new Error(
53
- `sentinel: could not run nx (${message}). Is nx installed in this workspace, and are you at its root?`,
54
- { cause: error }
55
- );
56
- }
57
- }
58
- function readGraph(cwd2) {
59
- const dir = mkdtempSync(join(tmpdir(), "sentinel-nx-"));
60
- const file = join(dir, "graph.json");
61
- try {
62
- runNx(cwd2, ["graph", "--file", file]);
63
- const parsed = JSON.parse(readFileSync(file, "utf8"));
64
- const nodes = parsed.graph?.nodes;
65
- if (!nodes || typeof nodes !== "object") {
66
- throw new Error(
67
- "sentinel: unexpected nx graph output (no graph.nodes); the installed nx version may be incompatible."
68
- );
69
- }
70
- return Object.entries(nodes).map(([name, node]) => ({
71
- name,
72
- root: join(cwd2, node.data.root)
73
- }));
74
- } finally {
75
- rmSync(dir, { recursive: true, force: true });
76
- }
77
- }
78
- function discoverModules(cwd2, options = {}) {
79
- const modules = readGraph(cwd2);
80
- if (!options.affected) return modules;
81
- const affected = new Set(
82
- JSON.parse(runNx(cwd2, ["show", "projects", "--affected", "--json"]))
83
- );
84
- return modules.filter((module) => affected.has(module.name));
85
- }
86
-
87
- // src/core/context.ts
88
- var MODULE_MARKERS = ["package.json", "project.json"];
89
- function isModuleDir(cwd2) {
90
- return MODULE_MARKERS.some((marker) => existsSync(join2(cwd2, marker)));
91
- }
92
- function resolveContext(cwd2, opts2) {
93
- const atRoot = existsSync(join2(cwd2, WORKSPACE_ROOT_MARKER));
94
- if (!atRoot && isModuleDir(cwd2)) {
95
- if (opts2.module) {
96
- throw new Error(
97
- "You are in a module directory: drop --module (the context is the current module)."
98
- );
99
- }
100
- if (opts2.ci) {
101
- throw new Error("--ci selects the affected set from the workspace root; run it there.");
102
- }
103
- const name = moduleName(cwd2);
104
- return { modules: [{ name, root: cwd2 }], scope: "cwd-module" };
105
- }
106
- if (atRoot) {
107
- if (opts2.module) {
108
- const found = discoverModules(cwd2).find((module) => module.name === opts2.module);
109
- if (!found) throw new Error(`module "${opts2.module}" not found in the workspace.`);
110
- return { modules: [found], scope: "named-module" };
111
- }
112
- if (opts2.ci) return { modules: discoverModules(cwd2, { affected: true }), scope: "affected" };
113
- return { modules: discoverModules(cwd2), scope: "all" };
114
- }
115
- throw new Error(
116
- `Run sentinel from a module directory or the workspace root (found neither ${MODULE_MARKERS.join("/")} nor ${WORKSPACE_ROOT_MARKER} here).`
117
- );
118
- }
119
-
120
35
  // src/roles/build/declined.ts
121
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
122
- import { join as join3 } from "path";
36
+ import { existsSync, readFileSync } from "node:fs";
37
+ import { join } from "node:path";
123
38
  var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
124
39
  function buildsWithDeprecatedExecutor(cwd2) {
125
- const projectJson = join3(cwd2, "project.json");
126
- if (!existsSync2(projectJson)) return false;
40
+ const projectJson = join(cwd2, "project.json");
41
+ if (!existsSync(projectJson)) return false;
127
42
  try {
128
- const project = JSON.parse(readFileSync2(projectJson, "utf8"));
43
+ const project = JSON.parse(readFileSync(projectJson, "utf8"));
129
44
  return project.targets?.build?.executor === DEPRECATED_NX_BUILDER;
130
45
  } catch {
131
46
  return false;
@@ -218,6 +133,32 @@ sentinel (${type}): ${asMessage(error)}
218
133
  return worst;
219
134
  }
220
135
 
136
+ // src/cli/run-validate.ts
137
+ async function runValidate(ctx) {
138
+ const { modules } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
139
+ const unsupported = ctx.targets.filter((target) => target !== "test");
140
+ if (unsupported.length > 0) {
141
+ const named = unsupported.map((target) => `--${target}`).join(", ");
142
+ process.stdout.write(
143
+ ` \xB7 ${named}: not available yet. \`--validate\` answers \`--test\` today, by recording a suite before a migration and comparing it after. Nothing was checked for ${named}.
144
+ `
145
+ );
146
+ }
147
+ if (!ctx.targets.includes("test")) return 1;
148
+ const { validateModuleTests } = await import("../validate-57H7GK5B.js");
149
+ const outcomes = modules.flatMap(
150
+ (module) => validateModuleTests(module.name, module.root)
151
+ );
152
+ for (const outcome of outcomes) {
153
+ const suite = outcome.suite === "main" ? "" : ` [${outcome.suite}]`;
154
+ process.stdout.write(
155
+ ` ${outcome.ok ? "\u2713" : "\u2717"} ${outcome.module}${suite} \u2014 ${outcome.message}
156
+ `
157
+ );
158
+ }
159
+ return outcomes.every((outcome) => outcome.ok) ? 0 : 1;
160
+ }
161
+
221
162
  // src/core/orchestrate.ts
222
163
  async function analyse(params) {
223
164
  const results = [];
@@ -645,13 +586,16 @@ program.name("sentinel").description("One CLI that guards code health: presets,
645
586
  "before",
646
587
  [
647
588
  "A check composes: verb + type + location.",
648
- " verb what to do: --run --inspect --report --status --init (--migrate: planned)",
589
+ " verb what to do: --run --inspect --report --status --init --validate (--migrate: planned)",
649
590
  " type which check: --lint --typescript ... (omit = all types; or --all)",
650
591
  " where run from a MODULE dir \u2192 that module; from the ROOT \u2192 --module <name>,",
651
592
  " --ci (affected), or all modules. --init targets one module only.",
652
593
  ""
653
594
  ].join("\n")
654
- ).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option(
595
+ ).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option(
596
+ "--validate",
597
+ "prove a migration kept the suite: record the reference, then compare against it"
598
+ ).option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option(
655
599
  "--module <name>",
656
600
  "from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
657
601
  ).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option("--json", "machine-readable output, for every verb").option(
@@ -783,7 +727,7 @@ async function main() {
783
727
  dryRun: Boolean(opts.dryRun),
784
728
  fail: (message) => program.error(message)
785
729
  };
786
- const exitCode = verb === "init" ? await runInit(context) : await runVerb(context);
730
+ const exitCode = verb === "init" ? await runInit(context) : verb === "validate" ? await runValidate(context) : await runVerb(context);
787
731
  await exitWithoutTruncating(exitCode);
788
732
  }
789
733
  main().catch((error) => {
@@ -1,13 +1,90 @@
1
1
  import {
2
2
  decoratorMetadata,
3
3
  tsconfigAliases
4
- } from "./chunk-3TDUIKVQ.js";
4
+ } from "./chunk-SWWQ7X7B.js";
5
5
 
6
6
  // src/roles/test/shared-test-config.ts
7
- import { existsSync } from "fs";
8
- import { createRequire } from "module";
9
- import { dirname, join } from "path";
10
- import { fileURLToPath } from "url";
7
+ import { existsSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { dirname, join as join2 } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ // src/roles/test/isolation-split.ts
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { globSync } from "tinyglobby";
16
+ var DECLARES_A_MODULE_MOCK = /\b(?:vi|jest)\.(?:mock|doMock)\s*\(/;
17
+ function testFilesUnder(root, include) {
18
+ return globSync([...include], { cwd: root, ignore: ["**/node_modules/**"] }).map(String).sort();
19
+ }
20
+ function splitByModuleMock(root, files) {
21
+ const mocked = [];
22
+ const shared = [];
23
+ for (const file of files) {
24
+ const source = readSource(join(root, file));
25
+ if (source !== void 0 && DECLARES_A_MODULE_MOCK.test(source)) mocked.push(file);
26
+ else shared.push(file);
27
+ }
28
+ return { mocked, shared };
29
+ }
30
+ function asIsolationSplit(config, root) {
31
+ const include = config.test?.include ?? [];
32
+ const files = testFilesUnder(root, include);
33
+ if (files.length === 0) return { ...config, test: { ...config.test, isolate: false } };
34
+ const { mocked, shared } = splitByModuleMock(root, files);
35
+ return {
36
+ ...config,
37
+ test: {
38
+ // Kept at the root, where a project cannot answer for the whole run.
39
+ coverage: config.test?.coverage,
40
+ projects: [project(config, "shared", shared, false), project(config, "mocked", mocked, true)]
41
+ }
42
+ };
43
+ }
44
+ function project(config, name, include, isolate) {
45
+ return {
46
+ ...config,
47
+ test: {
48
+ ...config.test,
49
+ name,
50
+ include,
51
+ isolate,
52
+ /*
53
+ * A project with nothing to run is still cheaper than a conditional here: Vitest starts it,
54
+ * finds no file and reports none. Leaving both projects in place keeps the shape of the
55
+ * config the same for every module, which is what makes a generated config reviewable.
56
+ */
57
+ coverage: void 0
58
+ }
59
+ };
60
+ }
61
+ function readSource(path) {
62
+ try {
63
+ return readFileSync(path, "utf8");
64
+ } catch {
65
+ return void 0;
66
+ }
67
+ }
68
+
69
+ // src/roles/test/msw-server-redirect.ts
70
+ var WORKSPACE_MSW_SERVER = /(^|\/)msw\/server\.[cm]?[jt]sx?$/;
71
+ function mswServerRedirect(serverFile, workspaceRoot) {
72
+ return {
73
+ name: "sentinel:msw-server-redirect",
74
+ // Before the resolver settles on the workspace file, since the point is to replace it.
75
+ enforce: "pre",
76
+ async resolveId(source, importer, options) {
77
+ if (source === serverFile) return void 0;
78
+ const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
79
+ if (resolved === null || resolved.external) return void 0;
80
+ const id = resolved.id;
81
+ if (!id.startsWith(workspaceRoot)) return void 0;
82
+ if (id.includes("/node_modules/")) return void 0;
83
+ if (!WORKSPACE_MSW_SERVER.test(id)) return void 0;
84
+ return serverFile;
85
+ }
86
+ };
87
+ }
11
88
 
12
89
  // src/roles/test/react/jest-export-conditions.ts
13
90
  var ABSENT_UNDER_JEST = /* @__PURE__ */ new Set(["development", "development|production"]);
@@ -72,8 +149,18 @@ function vitestMockExtendedPath() {
72
149
  }
73
150
  }
74
151
  }
152
+ function mswServerFile() {
153
+ return siblingFile("./setup/msw-server.js");
154
+ }
155
+ function nextI18nextAlias(root, workspaceRoot) {
156
+ for (const base of [root, workspaceRoot]) {
157
+ const esm = join2(base, "node_modules", "next-i18next", "dist", "esm", "index.js");
158
+ if (existsSync(esm)) return [{ find: /^next-i18next$/, replacement: esm }];
159
+ }
160
+ return [];
161
+ }
75
162
  function mswAliases() {
76
- const server = siblingFile("./setup/msw-server.js");
163
+ const server = mswServerFile();
77
164
  const own = resolveFrom("msw");
78
165
  return [
79
166
  ...own === void 0 ? [] : [{ find: /^msw$/, replacement: own }],
@@ -82,25 +169,25 @@ function mswAliases() {
82
169
  }
83
170
  function luxonAlias(root, workspaceRoot) {
84
171
  for (const base of [root, workspaceRoot]) {
85
- const manifest = join(base, "node_modules", "luxon", "package.json");
172
+ const manifest = join2(base, "node_modules", "luxon", "package.json");
86
173
  if (existsSync(manifest)) return [{ find: /^luxon$/, replacement: dirname(manifest) }];
87
174
  }
88
175
  return [];
89
176
  }
90
177
  function axiosAlias(root, workspaceRoot) {
91
178
  for (const base of [root, workspaceRoot]) {
92
- const manifest = join(base, "node_modules", "axios", "package.json");
179
+ const manifest = join2(base, "node_modules", "axios", "package.json");
93
180
  if (existsSync(manifest)) return [{ find: /^axios$/, replacement: dirname(manifest) }];
94
181
  }
95
182
  return [];
96
183
  }
97
184
  function prismaRuntimeAlias(workspaceRoot) {
98
- const prisma = join(workspaceRoot, "node_modules", "@prisma");
185
+ const prisma = join2(workspaceRoot, "node_modules", "@prisma");
99
186
  if (!existsSync(prisma)) return [];
100
187
  return [
101
188
  {
102
189
  find: /^@prisma\/([^/]+)\/runtime\/library$/,
103
- replacement: join(prisma, "$1", "runtime", "library.js")
190
+ replacement: join2(prisma, "$1", "runtime", "library.js")
104
191
  }
105
192
  ];
106
193
  }
@@ -158,6 +245,13 @@ function baseConfig(options) {
158
245
  */
159
246
  plugins: [
160
247
  jestExportConditions(),
248
+ /*
249
+ * The alias beside it covers a file naming the workspace's msw server by its package
250
+ * specifier. This covers the one that reaches the same module RELATIVELY, which an alias
251
+ * cannot: `./server` is a spelling thousands of unrelated files use, so the redirect is
252
+ * decided on the resolved path instead. See `msw-server-redirect.ts` for the measurement.
253
+ */
254
+ ...mswServerFile() === void 0 ? [] : [mswServerRedirect(mswServerFile(), workspaceRoot)],
161
255
  ...options.lowerDecoratorsWithTypeScript ? [decoratorMetadata({ root })] : []
162
256
  ],
163
257
  /*
@@ -192,6 +286,7 @@ function baseConfig(options) {
192
286
  alias: [
193
287
  ...axiosAlias(root, workspaceRoot),
194
288
  ...luxonAlias(root, workspaceRoot),
289
+ ...nextI18nextAlias(root, workspaceRoot),
195
290
  /*
196
291
  * Nest only, and measured: 1186 files under `apps/nest` and `libs/nest` mention `@prisma/`,
197
292
  * and ZERO under `apps/front` and `libs/front`. A React module paying for an alias to a
@@ -223,6 +318,37 @@ function baseConfig(options) {
223
318
  globals: true,
224
319
  environment: "node",
225
320
  root,
321
+ /*
322
+ * The per-test budget, widened from Vitest's 5000 ms default.
323
+ *
324
+ * ⚠️ NOT because the migration made anything slower. Measured on the same machine, the same
325
+ * files, both runners:
326
+ *
327
+ * regulation-balance, first test jest 2283 ms vitest 2411 ms
328
+ * prisma-breaking-change-detector jest 2558 ms vitest 2363 ms
329
+ * planning-requirement, first test vitest 1972 ms
330
+ *
331
+ * Vitest is within 6% on the slowest and FASTER on the others, and faster overall on every
332
+ * file (regulation-balance: 3.40 s under jest, 2.23 s under vitest). What these tests have
333
+ * in common is that they pay a one-off cost inside a test: loading a Nest module graph, or
334
+ * spawning a Prisma CLI process. Against 5000 ms that is a margin of about TWO, and a shared
335
+ * CI agent running three tasks at once eats it. All three timed out on CI under Vitest; on
336
+ * that evidence jest was simply on the lucky side of the same line.
337
+ *
338
+ * 15000 ms is six times the slowest measured test, so an agent three times slower than this
339
+ * machine still has a factor of two in hand.
340
+ *
341
+ * ⚠️ What it costs, said plainly: a test that HANGS now takes 15 s to fail instead of 5. It
342
+ * hides nothing else, since a hang still fails. A module that declared its own budget keeps
343
+ * it, because the module's own config is merged over this one.
344
+ */
345
+ testTimeout: 15e3,
346
+ /*
347
+ * The same budget for hooks. jest's one `testTimeout` covered both; Vitest splits them and
348
+ * defaults `hookTimeout` to 10 s, which is how `mission`'s `beforeAll` container start was
349
+ * cut off at 10012 ms. A hook doing the work a test does needs the budget a test has.
350
+ */
351
+ hookTimeout: 15e3,
226
352
  /*
227
353
  * What the repo's jest preset actually matched, copied rather than approximated:
228
354
  * `**\/?(*.)+(spec|test).[jt]s?(x)`.
@@ -281,7 +407,42 @@ function baseConfig(options) {
281
407
  * A module that declared its own concurrency overrides this, in both environments, exactly as
282
408
  * it does today.
283
409
  */
284
- fileParallelism: !process.env.CI,
410
+ /*
411
+ * ⚠️ PARALLEL on CI too, since 26/09, and that reverses what the note above decided.
412
+ *
413
+ * The transposition was faithful and expensive. Measured on `libs/front/components`, 1418
414
+ * tests green on every line, on the repo's own 4-core agents:
415
+ *
416
+ * jest, as CI runs it 158.7 s
417
+ * vitest serial, the transposition 644.5 s 4.1x slower
418
+ * vitest, 3 workers 324.7 s 2.05x slower
419
+ *
420
+ * Serialising costs vitest far more than it costs jest, because vitest re-evaluates the
421
+ * module graph per file through Vite's SSR runner: 555 ms of `import` and 478 ms of
422
+ * `environment` per file against 139 ms of actual tests. Parallelism does not remove that
423
+ * cost, it pays it on several cores at once.
424
+ *
425
+ * Every other documented lever was measured and does nothing or breaks the suite:
426
+ * `deps.optimizer.ssr`, `NODE_COMPILE_CACHE` and `fsModuleCache` all cache TRANSFORMATION
427
+ * where the cost is EVALUATION; `pool: threads` is noise; `vmThreads` collapses into
428
+ * `no-isolate` when serial and fails 473 tests; `--no-isolate` fails 351.
429
+ *
430
+ * ⚠️ And NO worker cap, which is deliberate after reading vitest's own sizing:
431
+ *
432
+ * getDefaultThreadsCount: config.watch ? max(numCpus / 2, 1) : max(numCpus - 1, 1)
433
+ *
434
+ * `cores - 1` is 3 on this repo's 4-core agents, which is exactly the pool the 324.7 s above
435
+ * was measured with, and 9 on a 10-core laptop. Pinning a number would slow every developer
436
+ * to serve the agent, and a percentage would only re-describe a default that already adapts.
437
+ * So one variable changes here and the tool keeps sizing its own pool.
438
+ *
439
+ * Memory was the reason to hesitate and it was measured rather than assumed: a full jsdom
440
+ * run holds 1.1 GB at three workers and 1.9 GB at six, on agents with 32 GB. The OOM this
441
+ * repo suffered was three nx tasks near 7.45 GB each, which is not what a test worker costs.
442
+ *
443
+ * A module that declares its own concurrency still overrides this, as before.
444
+ */
445
+ fileParallelism: true,
285
446
  /*
286
447
  * ⚠️ Sentinel's own setup files go through VITE, not round it, and that is load-bearing.
287
448
  *
@@ -309,8 +470,25 @@ function baseConfig(options) {
309
470
  * The narrow form `/@hublo\/sentinel\/dist\/roles\//` was tried and does NOT work: the pin
310
471
  * itself lives in a shared chunk at the dist root, which stays external. It has to be the
311
472
  * whole package.
473
+ *
474
+ * ## ⚠️ And `vitest` itself, for the same reason one level further out
475
+ *
476
+ * Inlining sentinel is not enough: its own `import { expect } from 'vitest'` still leaves
477
+ * through Node, which loads a SECOND instance of the runner. The snapshot client is
478
+ * per-instance state, so the instance the test file uses was never set up for that file:
479
+ *
480
+ * Error: The snapshot state for '…/asyncapi.module.spec.ts' is not found.
481
+ * Did you call 'SnapshotClient.setup()'?
482
+ *
483
+ * Isolated to the smallest possible case: an EMPTY setup file under `node_modules` passes,
484
+ * and the same file containing nothing but `import { expect } from 'vitest'` fails, while
485
+ * the identical import from a setup file INSIDE the module passes. So it is the location,
486
+ * not the import, and the remedy has to reach the import too.
487
+ *
488
+ * Measured on `libs/nest/asyncapi`: sentinel alone 1 failed of 13, sentinel and `vitest`
489
+ * 13 passed, 525 ms against 570 ms. Not a trade.
312
490
  */
313
- server: { deps: { inline: [/@hublo\/sentinel/] } }
491
+ server: { deps: { inline: [/@hublo\/sentinel/, "vitest"] } }
314
492
  }
315
493
  };
316
494
  }
@@ -323,6 +501,39 @@ function asAliasArray(alias) {
323
501
  }));
324
502
  }
325
503
  function sharedTestConfig(options) {
504
+ return withIsolationChoice(mergedConfig(options), options);
505
+ }
506
+ function withIsolationChoice(config, options) {
507
+ if (options.isolate !== false) return config;
508
+ if (options.flavour === "nest") {
509
+ throw new Error(
510
+ "sentinel test: `isolate: false` is not available for a Nest suite. Nest registers its metadata as an import side effect, so sharing a module registry lets one suite see what another registered. Measured on `agency`, it would save 7% (43s to 40s) where a React module saves 71%, so the trade is not worth making. Remove the option."
511
+ );
512
+ }
513
+ const bracketed = {
514
+ ...config,
515
+ test: {
516
+ ...config.test,
517
+ /*
518
+ * The boundary brackets the module's own setup files rather than joining them. The closing
519
+ * entry has to run after the module's, because what it records as "infrastructure" is
520
+ * whatever the setup files built, and `setupFiles` order is the only way to say "last".
521
+ */
522
+ setupFiles: [
523
+ "@hublo/sentinel/test/setup/file-boundary",
524
+ ...asSetupList(config.test?.setupFiles),
525
+ "@hublo/sentinel/test/setup/file-boundary-close"
526
+ ]
527
+ }
528
+ };
529
+ return asIsolationSplit(bracketed, options.root);
530
+ }
531
+ function asSetupList(setupFiles) {
532
+ if (typeof setupFiles === "string") return [setupFiles];
533
+ if (Array.isArray(setupFiles)) return setupFiles;
534
+ return [];
535
+ }
536
+ function mergedConfig(options) {
326
537
  const base = baseConfig(options);
327
538
  if (options.overrides === void 0) return base;
328
539
  return {
@@ -394,4 +605,4 @@ export {
394
605
  jestExportConditions,
395
606
  sharedTestConfig
396
607
  };
397
- //# sourceMappingURL=chunk-7PUVK4YM.js.map
608
+ //# sourceMappingURL=chunk-5VNYQIFD.js.map