@hublo/sentinel 1.4.0-alpha.9 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-BXXNV6NP.js +6411 -0
  6. package/dist/chunk-ELZHIN6E.js +16 -0
  7. package/dist/chunk-ELZHIN6E.js.map +1 -0
  8. package/dist/chunk-H3GSGAX2.js +11 -0
  9. package/dist/chunk-H3GSGAX2.js.map +1 -0
  10. package/dist/{chunk-WLFE5RUU.js → chunk-KMKQDGI6.js} +6 -8
  11. package/dist/chunk-KMKQDGI6.js.map +1 -0
  12. package/dist/{chunk-PWV3BMDA.js → chunk-MMKBDO5V.js} +11 -2
  13. package/dist/chunk-MMKBDO5V.js.map +1 -0
  14. package/dist/{chunk-L7WS36XV.js → chunk-MWNOFSYR.js} +221 -4427
  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-BKD2ICT7.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
@@ -10,6 +10,7 @@ that decided a design choice say which choice.
10
10
  - [The one line that makes a per-module migration possible](#the-one-line-that-makes-a-per-module-migration-possible)
11
11
  - [What `--init --test` rewrites, and what it refuses](#what---init---test-rewrites-and-what-it-refuses)
12
12
  - [Nest services](#nest-services)
13
+ - [The two DOM matchers, which a react module gets without asking](#the-two-dom-matchers-which-a-react-module-gets-without-asking)
13
14
  - [The check that decides whether you are done](#the-check-that-decides-whether-you-are-done)
14
15
 
15
16
  ## What the ground actually looks like
@@ -131,19 +132,294 @@ import SIDE EFFECT, a decorator writing into a catalog when its module loads, so
131
132
  would make results depend on the order files ran in. That is the same fact the build role records
132
133
  next to `treeshake: { moduleSideEffects: true }`.
133
134
 
135
+ ## The two DOM matchers, which a react module gets without asking
136
+
137
+ Adopting the test role on a **react** module also registers two matchers. Nothing changes until a
138
+ test calls one: registering a matcher does not make anything fail.
139
+
140
+ ```ts
141
+ const { container } = render(<SaveButton />)
142
+
143
+ await expect(container).toBeAccessible() // axe-core
144
+ await expect(container).toBeValidHtml() // html-validate
145
+ ```
146
+
147
+ A **nest** module gets neither, because there is no DOM to assert on.
148
+
149
+ ⚠️ The generated config imports these by their package subpath, so they only resolve once
150
+ `pnpm install` has run. Skip that step and Vitest reports `Cannot find module
151
+ '<module>/@hublo/sentinel/test/setup/a11y'`, which names a path inside your module and reads like
152
+ a missing file rather than a missing install.
153
+
154
+ ### What they found on the first module that ran them
155
+
156
+ `libs/front/components`, the design system, on the first run after migrating. The `Checkbox` given
157
+ a `label` had no accessible name at all, and both engines said so independently:
158
+
159
+ | engine | message |
160
+ | --------------- | ----------------------------------------------------------- |
161
+ | `axe-core` | `aria-prohibited-attr`: aria-label cannot be used on a span |
162
+ | `axe-core` | `label`: form elements must have labels |
163
+ | `html-validate` | `aria-label-misuse` |
164
+ | `html-validate` | `input-missing-label` |
165
+
166
+ One line caused it: `aria-label` was passed to the component, so MUI put it on the root `<span>`
167
+ rather than on the `<input>`. What makes it worth repeating here is why nothing had caught it. The
168
+ component is used across an app of several hundred screens, its tests were green, and
169
+ `getByLabelText` FOUND it: the query returned the wrapper span, so a test could locate the
170
+ checkbox, click it, and never notice it was announced with no name. The name was invisible to
171
+ everything except a screen reader and these two engines.
172
+
173
+ ### What `toBeAccessible()` does and does not say
174
+
175
+ It runs axe on what you hand it and fails on any violation, naming the rule, the elements and the
176
+ page that explains the fix.
177
+
178
+ ⚠️ It never fails on a check axe calls INCOMPLETE. Under jsdom those are undecidable by
179
+ construction, colour contrast against a computed background being the usual one, and failing on
180
+ them teaches everyone to switch the matcher off. So a pass means _no violation axe could decide
181
+ here_, never _this is accessible_, and the message says so with the count.
182
+
183
+ Two refusals worth recognising:
184
+
185
+ | What it says | What to do |
186
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
187
+ | `needs the "jsdom" environment` | axe does not support happy-dom; set `environment: 'jsdom'`, or `// @vitest-environment jsdom` for one file |
188
+ | `needs a node that is IN the document` | axe analyses the page, so a detached node has nothing to analyse; pass `container`, not `asFragment()` |
189
+
190
+ ### What `toBeValidHtml()` does
191
+
192
+ It puts your markup inside a minimal valid page and validates the page. A component renders a
193
+ fragment, and a fragment has no doctype, no `lang`, no `<title>`; switching those rules off would
194
+ lose exactly the rules that catch what a component breaks inside a real page. Measured, the
195
+ wrapper buys `heading-level`, a skipped heading rank no fragment-level check can see, and costs no
196
+ false positive. A subject that already IS a document is left alone.
197
+
198
+ The engine is an implementation detail on purpose. The matcher is the contract, so `html-validate`
199
+ can be replaced by the W3C's own `vnu` the day this repo's CI carries a Java runtime, without
200
+ touching a single test.
201
+
202
+ ### The cost, since it is on by default
203
+
204
+ Both engines load on FIRST USE. Imported eagerly they cost 103ms per test FILE under Vitest's
205
+ isolation, which on `host-admin` and its 1460 files would add 150 seconds to every run for a
206
+ matcher almost no file calls. Lazily, a suite that never asserts on either pays 0.5ms.
207
+
208
+ ## After the migration: the four things you will actually hit
209
+
210
+ None of these is a defect, and the tool names each one in its own output. They are listed here
211
+ because they arrive after the rewrite, when the diff already looks finished, and each one costs an
212
+ hour the first time.
213
+
214
+ ### 1. `import/order`, which no formatter fixes
215
+
216
+ The migration formats what it writes with your module's own formatter, and a formatter does not
217
+ ORDER imports. So a module whose lint enforces `import/order` comes out lint-red on lines the
218
+ migration touched, and only those.
219
+
220
+ Measured: 95 errors on `institution`, 298 across the three `cloud` modules, every one that rule and
221
+ every one cleared by the module's own fixer.
222
+
223
+ ```bash
224
+ pnpm exec eslint <module> -c <module>/eslint.config.js --fix # or: nx run <module>:lint --fix
225
+ ```
226
+
227
+ Run it, then re-run the suite. Nothing else is expected to change.
228
+
229
+ ### 2. Your config files are named `vitest.*` now
230
+
231
+ An ignore written by NAME no longer covers them. This repo's root eslint config ignores
232
+ `**/jest.*.{ts,js,cjs,mjs}`, so `jest.config.ts` and `jest.setup.js` were never linted; their
233
+ replacements are and, across the front family, that is 136 files seen by the linter for the first
234
+ time. Nothing there is wrong, but it is a batch of findings that arrives with a migration that did
235
+ not cause them.
236
+
237
+ The same is true of the secret scanner. A `vitest.config.*` is a NEW file carrying the env values
238
+ `jest.config.*` already had, so `gitleaks` sees them for the first time and refuses the commit:
239
+
240
+ ```
241
+ Finding: HERMES_API_KEY: 'HERMESKEY'
242
+ File: libs/cloud/events-notifications/vitest.config.mts:24
243
+ ```
244
+
245
+ The values are not new, only the file is. This repo's `.gitleaks.toml` now exempts a test runner's
246
+ config, which its `.test.`/`.spec.` siblings already were.
247
+
248
+ ### 3. `legacyFakeTimers` is REMOVED, not translated
249
+
250
+ `useFakeTimers({ legacyFakeTimers: true })` selects jest's pre-27 clock. Vitest installs sinon's and
251
+ has nothing else, so no spelling keeps it. The option is dropped and the run SAYS so, because a
252
+ choice that loses something is no longer a transposition. The call then uses the modern clock, which
253
+ is what jest itself defaults to. Re-read any test that depended on the old one.
254
+
255
+ ### 4. The typecheck, and the modules where nothing declares one
256
+
257
+ `--run --test` passing says nothing about types, and the migration rewrites types as well as calls.
258
+ Run your module's `typecheck`.
259
+
260
+ ⚠️ And if your module declares none, run `tsc -b <module>/tsconfig.json` by hand at least once. Two
261
+ of the three `cloud` modules declare `test` and `lint` and no typecheck target, and the repo's
262
+ commit hook was the only thing that caught 21 type errors between them. Both were defects in this
263
+ package, both are fixed, and neither would have been visible to a declared target or to CI.
264
+
265
+ ## What the package ships so your untouched helpers keep compiling
266
+
267
+ A migration rewrites the module's test files and deliberately leaves shared helpers alone, including
268
+ helpers in OTHER projects that your specs import. Two declarations, named in `tsconfig.spec.json`,
269
+ keep those compiling:
270
+
271
+ | | what it answers |
272
+ | ------------------------ | ---------------------------------------------------------------------------------------------------- |
273
+ | `declare const jest` | `jest.fn()` at module scope in a helper, because the setup puts `vi` on `globalThis` under that name |
274
+ | `declare namespace jest` | `jest.Mock` and its seven siblings in TYPE position, which a const cannot answer |
275
+
276
+ The namespace keeps JEST's generic order on `Mock` and `SpyInstance`, where jest's first generic is
277
+ the return type and Vitest's is the whole signature. It serves files still written for jest, so it
278
+ has to read jest's spelling.
279
+
280
+ ## One rewrite worth recognising in the diff
281
+
282
+ const provider = require('../providers/x')
283
+ const provider = (await import('../providers/x')) as unknown as Record<string, Mock>
284
+
285
+ `require()` returns `any`, so `provider.getThing.mockResolvedValue({ id: 1 })` compiled. A dynamic
286
+ import returns the module's real namespace, where that method does not exist, and typing the binding
287
+ as `Mocked<typeof import(...)>` is worse: it demands the real return type and turns 20 errors into
288
+ 21, because those fixtures were always partial and `any` was hiding it.
289
+
290
+ `Record<string, Mock>` describes what the factory on the line above actually built. It gives up the
291
+ member NAMES, which `any` gave up too. Applied only when the SAME file mocks that specifier.
292
+
293
+ ## Going faster than jest ever was, if your module can
294
+
295
+ Vitest gives every test file its own module registry and its own environment, so nothing one file
296
+ does can reach the next. That is what makes a run expensive: the whole import graph is re-evaluated
297
+ once per file. Measured on `libs/front/components`, 230 files and 1418 tests, same machine and
298
+ session:
299
+
300
+ | | wall |
301
+ | ---------------------------------- | ---------- |
302
+ | jest, files in parallel | 15.9 s |
303
+ | vitest, isolated (the default) | 44.6 s |
304
+ | **vitest, files sharing a worker** | **13.0 s** |
305
+
306
+ On a real CI agent, same module: jest 158.7 s, isolated vitest 352.5 s, shared worker **92.7 s**.
307
+
308
+ You ask for it with one line:
309
+
310
+ ```ts
311
+ reactTestConfig({
312
+ root: import.meta.dirname,
313
+ workspaceRoot: path.resolve(import.meta.dirname, '../../..'),
314
+ isolate: false,
315
+ overrides: { ... },
316
+ })
317
+ ```
318
+
319
+ ### What sentinel does so that this is safe
320
+
321
+ Two things travel with the option, and neither is optional.
322
+
323
+ **A file boundary.** A setup file is re-executed for every test file even when the registry is
324
+ shared, so `afterAll` in a setup file IS the end of a file. Sentinel puts one there and gives back,
325
+ per file, what isolation used to reset for free: Testing Library's cleanup (it registers itself at
326
+ IMPORT time, so without this it arms for one file and 342 of 1418 tests fail with
327
+ `Found multiple elements`), an empty `document`, every spy, real timers, and the file's own mock
328
+ register.
329
+
330
+ That last one is worth knowing about. Measured on plain vitest, `vi.resetAllMocks()` DESTROYS a
331
+ mock's implementation while `clearAllMocks` and `restoreAllMocks` keep it. Under jest that could not
332
+ outlive the file. Sharing a worker it reaches every later file, so one `vi.resetAllMocks()` in one
333
+ hook leaves a shared fixture returning `undefined` for the rest of the run. Sentinel scopes those
334
+ three calls back to the file, and never touches what a setup file built.
335
+
336
+ **A split.** `vi.mock` has to be in place before the module it replaces is imported, and a module
337
+ already evaluated cannot be un-evaluated. So the files that declare a module mock keep their
338
+ isolation, in a second vitest project, and the rest share. The list is read from your sources on
339
+ every run, so adding a test never puts it in the wrong project. On `libs/front/components` that is
340
+ 13 files of 230.
341
+
342
+ ### What you may have to change
343
+
344
+ **One line, if your suite closes msw itself.** `server.close()` disposes the interceptors msw
345
+ installed and leaves a dead registry behind, so the next `listen()` finds it and interception
346
+ silently stops. Its boundary is the worker, not the file. If your setup has
347
+ `afterAll(() => server.close())`, remove it; if you use `@hublo/sentinel/test/setup/msw-lifecycle`,
348
+ this is already handled.
349
+
350
+ **Anything else `--validate` names.** A mutable singleton at module scope in product code, a test
351
+ writing to `globalThis` without cleaning up: these were forgiven by isolation and are not any more.
352
+
353
+ ### Nest does not get this, and the refusal says why
354
+
355
+ Measured on `apps/nest/microservices/agency`: 43 s to 40 s, seven percent, where a jsdom module
356
+ goes from 44.6 s to 13 s. A Nest suite's cost is its test bodies; a React suite's is re-evaluating
357
+ its import graph. Against that seven percent, Nest registers its metadata as an import SIDE EFFECT,
358
+ so a shared registry lets one suite see what another registered. The flavour refuses the option
359
+ rather than ignoring it.
360
+
361
+ ### The order is the proof, and `--validate` takes it
362
+
363
+ Isolation's whole product is that the order of your files cannot change the result. So when your
364
+ config says `isolate: false`, `--validate` runs the suite a SECOND time in a random file order and
365
+ compares that run against the same reference. One call, both claims: nothing was lost, and nothing
366
+ depends on the order.
367
+
368
+ This is not ceremony. Three runs of an identical configuration once gave three different failure
369
+ lists, because vitest's default sequencer puts previously-failed files first, and each new order
370
+ exposed a different leak. A single green run says very little.
371
+
372
+ ```console
373
+ ✗ front-components (main) — this suite shares a worker, and it depends on the ORDER of its files:
374
+ in a random order (seed 418293) it no longer matches. ... Reproduce with
375
+ `--sequence.shuffle.files --sequence.seed=418293`. Something a file does outlives it; until that
376
+ is found, the suite is not safe without isolation.
377
+ ```
378
+
379
+ ### Whether it is worth it for your module
380
+
381
+ Ask how many of your test files declare a module mock, since those keep their isolation:
382
+
383
+ ```console
384
+ $ rg -l 'vi\.mock\(|vi\.doMock\(' src --glob '*.{test,spec}.{ts,tsx}' | wc -l
385
+ ```
386
+
387
+ `libs/front/components` has 13 of 230 and gains 3.4x. `apps/front/host-admin` has 603 of 1463, so
388
+ most of its files would keep isolating and most of the cost would stay.
389
+
134
390
  ## The check that decides whether you are done
135
391
 
136
392
  Not "the suite is green". **The suite runs the same tests as before**. You do not have to arrange
137
- that comparison; the two commands do it between them:
393
+ that comparison; `--validate` does it, on either side of the migration:
138
394
 
139
395
  ```console
140
- $ sentinel --init --test # runs jest ONCE first, records every test name, then migrates
141
- $ pnpm install # the module's dependencies changed
142
- $ sentinel --run --test # runs vitest, compares against that recording, then spends it
396
+ $ sentinel --validate --test # on the JEST state: records every passing test name
397
+ $ sentinel --init --test # migrates
398
+ $ pnpm install # the module's dependencies changed
399
+ $ sentinel --validate --test # runs vitest, compares against that recording, then spends it
143
400
  ```
144
401
 
145
402
  A codemod touching thousands of files cannot be reviewed by hand. This comparison is the review.
146
403
 
404
+ ⚠️ **The first command comes first, and the tool enforces it.** Start with `--init` and the jest
405
+ config is already gone, so there is nothing left to record and nothing to compare against:
406
+
407
+ ```console
408
+ ✗ recruitment — already migrated, and no reference was recorded before it was, so there is
409
+ nothing to compare against. A proof has to be started BEFORE the migration:
410
+ `sentinel --validate --test` on the jest state, then `--init --test`, then this again.
411
+ ```
412
+
413
+ ### Why the proof is its own verb
414
+
415
+ Because of what SURVIVES a run. `--init` and `--migrate` write state that stays, which is their
416
+ whole purpose and why they are strict about what they touch. `--validate` may instrument the module
417
+ it is proving, provided nothing it writes remains. Those are different contracts, so they are
418
+ different verbs, and the proof can grow without touching a migration that is already proven.
419
+
420
+ It answers every type, too: `sentinel --validate --lint` is a well-formed command, so it says "not
421
+ available yet" rather than calling your sentence wrong.
422
+
147
423
  ### Why it cannot be one command
148
424
 
149
425
  The reference only exists BEFORE the migration, and the thing that produces it, the jest config, is
@@ -163,7 +439,7 @@ shorter.
163
439
  So the gate refuses a run that lost a name or invented one, and says which:
164
440
 
165
441
  ```console
166
- $ sentinel --run --test
442
+ $ sentinel --validate --test
167
443
  1 test(s) no longer exist: useHublerNetworkProfilesQuery builds the URL with employment
168
444
  statuses only. (reference: jest.config.ts, 2026-09-23T21:10:00.000Z) The reference is KEPT
169
445
  until a run passes it, so this does not go green by running again. Fix the suite and re-run,
@@ -198,6 +474,7 @@ have read that improvement as a regression.
198
474
  ```console
199
475
  $ nx run <module>:typecheck # BEFORE, and write that down too
200
476
  $ sentinel --init --test
477
+ $ pnpm install
201
478
  $ nx run <module>:typecheck # after
202
479
  ```
203
480
 
@@ -29,11 +29,27 @@ everywhere (`--run --json` and `--inspect` answer them), and `--migrate` is refu
29
29
  | `--typescript` | writes/extends the tsconfig + `typecheck` script | typechecks | resolved options, what is deferred, drift |
30
30
  | `--build` | points the module's Vite config at sentinel's toolchain + scripts + nx metadata | builds | runner, config file, whose Vite, how many overrides, adoption |
31
31
  | `--dev` | adopts the BUILD too: one plan writes both `build` and `serve` | serves; long-running, so never swept by an unqualified `--run` | refuses, and sends you to `--inspect --build`: it is that config |
32
- | `--test` | records the jest reference, THEN the Vitest config + scripts + nx metadata, and migrates every spec file | runs Vitest and compares against that reference | runner, which configs were found, adoption state, drift |
32
+ | `--test` | the Vitest config + scripts + nx metadata, and migrates every spec file | runs Vitest | runner, which configs were found, adoption state, drift |
33
33
 
34
34
  `--json` works for every verb, and stdout carries **only** the envelope, so `| jq` always
35
35
  parses. `--dry-run` applies to `--init` and writes nothing.
36
36
 
37
+ **`--validate` is a verb of its own**, and the proof of a migration lives there rather than on
38
+ `--init` or `--run`. Run before a migration it records what the module does today; run after, it
39
+ compares by test NAME and refuses a run that lost one or invented one. What separates it from
40
+ `--init` is what SURVIVES the run: `--init` and `--migrate` write state that remains, `--validate`
41
+ may instrument the module it is proving provided nothing it writes stays.
42
+
43
+ ```console
44
+ $ sentinel --validate --test # on the jest state: records the reference
45
+ $ sentinel --init --test # migrates
46
+ $ pnpm install
47
+ $ sentinel --validate --test # compares, then spends the reference
48
+ ```
49
+
50
+ It composes with every type, and answers "not available yet" for the ones with no proof rather
51
+ than calling a valid command wrong.
52
+
37
53
  `--init` is the one verb that targets exactly **one** module. Adopting everything at once
38
54
  would be a big-bang; migration is meant to be gradual and per-team.
39
55
 
@@ -7,6 +7,7 @@ Everything here was learned the expensive way. None of it is theory.
7
7
  - [The rule everything follows](#the-rule-everything-follows)
8
8
  - [Phase 0: prove the premise](#phase-0-prove-the-premise)
9
9
  - [Phase 1: measure the output](#phase-1-measure-the-output)
10
+ - [Measuring a migration, where both sides must be the same program](#measuring-a-migration-where-both-sides-must-be-the-same-program)
10
11
  - [Traps that report success](#traps-that-report-success)
11
12
  - [What CI does not cover](#what-ci-does-not-cover)
12
13
 
@@ -69,6 +70,34 @@ rm -rf apps/front/<x>/.output && nx run <x>:build # cache hit: same count re
69
70
  `console` once restored **0 of 464** files from a green cache hit. Its image runs `pnpm` rather
70
71
  than nx, so the one caller that would have failed never used the cache.
71
72
 
73
+ ## Measuring a migration, where both sides must be the same program
74
+
75
+ A migration is proved by comparing a suite BEFORE against the same suite AFTER. That comparison is
76
+ only worth something if the two runs differ in the one variable being changed. Three ways they
77
+ silently differed here, each one costing hours of attributing a real-looking red to the wrong
78
+ cause.
79
+
80
+ **Run the module's OWN installed binary.** Invoking a local build by path (`node
81
+ /path/to/sentinel/dist/bin/sentinel.js`) resolves the runner through SENTINEL's manifest, so the
82
+ suite runs against the sentinel repo's physical install of vitest rather than the monorepo's. Two
83
+ red modules in one campaign were that and nothing else. A campaign runs `npx
84
+ @hublo/sentinel@<version>` or the module's own script, never a path.
85
+
86
+ **Run from the workspace ROOT.** The adapter relocates vitest to the root with `--root <module>`,
87
+ because a module's config resolves workspace aliases from there. Running the same command from
88
+ inside the module changes what resolves, and the failure looks like the migration's fault.
89
+
90
+ **Let the module's environment in, deliberately.** nx loads the repo's dotenv files into a task:
91
+ 314 environment names under nx against 49 direct. One of them, set in a module's own `.env.local`,
92
+ made a single test fail, and the reference recorded without it reported a name that "disappeared".
93
+ This is the one place where the usual instinct, isolate, is WRONG: the two sides of a comparison
94
+ have to be the same program, so the proof loads the same files the module's own target loads.
95
+
96
+ **A name can leave both lists without failing.** Vitest reports the tests of a suite whose hook
97
+ threw as `skipped`, where jest counted them FAILED. The count moves, no test is red, and the
98
+ comparison must say "missing because skipped" rather than "lost", or it accuses the migration of
99
+ something the runner did.
100
+
72
101
  ## Traps that report success
73
102
 
74
103
  | Trap | What it looks like | What closes it |
@@ -79,9 +108,13 @@ than nx, so the one caller that would have failed never used the cache.
79
108
  | A clock reading vs an mtime | passes locally, fails in CI | stamp from the same filesystem |
80
109
  | A stale cache entry | a hit that restores nothing | `nx reset`; entries predating a module's `outputs` restore nothing forever |
81
110
  | `"$var:generate-x"` in zsh | a task name that does not exist | brace it: `"${var}:generate-x"` — `:g` is a zsh modifier |
111
+ | `rc=$?` after a pipeline | the status of `tail`, not yours | capture before piping, or `set -o pipefail` |
112
+ | `timeout` on macOS | an empty run read as green | it does not exist there; the command never ran |
113
+ | A local build by path | a red that is not the module's | resolves the runner through SENTINEL's install |
82
114
 
83
- The last one is the only one that lies toward FAILURE, which makes it visible but sends you
84
- hunting for causes elsewhere. It produced several "failures" that were only quoting.
115
+ `rc=$?` after a pipeline caught us three times in one day, and once produced a claim we had
116
+ already announced. The zsh one is the only entry that lies toward FAILURE, which makes it visible
117
+ but sends you hunting for causes elsewhere.
85
118
 
86
119
  ## What CI does not cover
87
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.4.0-alpha.9",
3
+ "version": "1.4.0",
4
4
  "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,14 @@
58
58
  "types": "./dist/roles/test/setup/jest-parity.d.ts",
59
59
  "import": "./dist/roles/test/setup/jest-parity.js"
60
60
  },
61
+ "./test/setup/file-boundary": {
62
+ "types": "./dist/roles/test/setup/file-boundary.d.ts",
63
+ "import": "./dist/roles/test/setup/file-boundary.js"
64
+ },
65
+ "./test/setup/file-boundary-close": {
66
+ "types": "./dist/roles/test/setup/file-boundary-close.d.ts",
67
+ "import": "./dist/roles/test/setup/file-boundary-close.js"
68
+ },
61
69
  "./test/msw": {
62
70
  "types": "./dist/roles/test/setup/msw-server.d.ts",
63
71
  "import": "./dist/roles/test/setup/msw-server.js"
@@ -66,6 +74,10 @@
66
74
  "types": "./dist/roles/test/tools/msw.d.ts",
67
75
  "import": "./dist/roles/test/tools/msw.js"
68
76
  },
77
+ "./test/tools/mock-extended": {
78
+ "types": "./dist/roles/test/setup/mock-extended.d.ts",
79
+ "import": "./dist/roles/test/setup/mock-extended.js"
80
+ },
69
81
  "./test/setup/msw-lifecycle": {
70
82
  "types": "./dist/roles/test/setup/msw-lifecycle.d.ts",
71
83
  "import": "./dist/roles/test/setup/msw-lifecycle.js"
@@ -73,10 +85,19 @@
73
85
  "./test/setup/workspace": {
74
86
  "types": "./dist/roles/test/setup/workspace-entry.d.ts",
75
87
  "import": "./dist/roles/test/setup/workspace-entry.js"
88
+ },
89
+ "./test/setup/a11y": {
90
+ "types": "./dist/roles/test/setup/a11y.d.ts",
91
+ "import": "./dist/roles/test/setup/a11y.js"
92
+ },
93
+ "./test/setup/w3c": {
94
+ "types": "./dist/roles/test/setup/w3c.d.ts",
95
+ "import": "./dist/roles/test/setup/w3c.js"
76
96
  }
77
97
  },
78
98
  "files": [
79
99
  "dist",
100
+ "types",
80
101
  "docs",
81
102
  "lint",
82
103
  "oxlint",
@@ -87,6 +108,7 @@
87
108
  "@tailwindcss/vite": "4.1.18",
88
109
  "@vitejs/plugin-react": "6.0.1",
89
110
  "@vitest/coverage-v8": "4.1.4",
111
+ "axe-core": "^4.13.0",
90
112
  "commander": "^13.0.0",
91
113
  "dotenv-flow": "4.1.0",
92
114
  "eslint-plugin-jest-dom": "5.5.0",
@@ -94,6 +116,7 @@
94
116
  "eslint-plugin-react-refresh": "0.4.19",
95
117
  "eslint-plugin-storybook": "10.3.5",
96
118
  "eslint-plugin-styled-components-a11y": "2.2.0",
119
+ "html-validate": "^11.16.0",
97
120
  "jsdom": "^24.1.3",
98
121
  "jsonc-parser": "^3.3.1",
99
122
  "msw": "1.3.3",
@@ -102,6 +125,7 @@
102
125
  "oxfmt": "0.63.0",
103
126
  "oxlint": "1.77.0",
104
127
  "oxlint-tsgolint": "7.0.2001",
128
+ "tinyglobby": "0.2.17",
105
129
  "typescript": "5.9.3",
106
130
  "vite": "8.2.2",
107
131
  "vite-plugin-svgr": "5.2.0",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The type-level half of the `jest` global the setup installs.
3
+ *
4
+ * ## What it pairs with
5
+ *
6
+ * The migration rewrites SPEC files and deliberately leaves helpers alone, because the setup puts
7
+ * Vitest's `vi` on `globalThis` under the name `jest`:
8
+ *
9
+ * // setup/jest-global.ts
10
+ * ;(globalThis as Record<string, unknown>).jest = vi
11
+ *
12
+ * That is what lets a module migrate on its own while a shared helper still calls `jest.fn()`, and
13
+ * it is why the suite of a migrated module is green with untouched helpers.
14
+ *
15
+ * The shim answers the RUNTIME. It says nothing to TypeScript, and the migration also replaces
16
+ * `"jest"` with `"vitest/globals"` in the module's `tsconfig.spec.json`, so the name stops being a
17
+ * value at compile time while it still is one at run time.
18
+ *
19
+ * Measured on `apps/nest/microservices/institution` with alpha.14, a module whose typecheck was
20
+ * green before it migrated and whose 2958 tests all pass after:
21
+ *
22
+ * src/app/institution.test-wrapper.ts(83,22)
23
+ * error TS2708: Cannot use namespace 'jest' as a value.
24
+ * tests/support/institution-prisma.test-wrapper.ts(58,22)
25
+ * error TS2708: Cannot use namespace 'jest' as a value.
26
+ *
27
+ * Both are helpers, neither is a spec, and both run correctly. This file states, for the type
28
+ * checker, exactly what the setup states for the runtime.
29
+ *
30
+ * ## Why `typeof vi` and not a hand-written subset
31
+ *
32
+ * The shim assigns the whole `vi`, so anything narrower would describe something the module does
33
+ * not run. Writing the truth costs one line and cannot drift.
34
+ *
35
+ * ## In a program that still carries `@types/jest`
36
+ *
37
+ * No conflict, and this one wins: measured on a fixture holding both, `jest.hoisted(() => 1)`
38
+ * compiles, and `hoisted` exists only on `vi`. So a module part-way through its migration gets the
39
+ * type of what actually executes rather than the type of the runner it is leaving.
40
+ */
41
+ declare const jest: (typeof import('vitest'))['vi']
42
+
43
+ /**
44
+ * The same answer for TYPE positions, which the const above does not reach.
45
+ *
46
+ * `declare const jest` makes `jest.fn()` compile and says nothing about `jest.Mock`, because a
47
+ * const has no type members. A helper that annotates with one gets:
48
+ *
49
+ * libs/front/tests/src/mocks/libs/analytics.mock.ts(2,22)
50
+ * error TS2694: Namespace 'jest' has no exported member 'Mock'.
51
+ *
52
+ * Measured on `libs/front/components`, whose typecheck was green before it migrated: 8 of these,
53
+ * every one of them in `libs/front/tests`, a project this module reads and does not own. Repo-wide
54
+ * the whole vocabulary is eight names, and here is each one's Vitest counterpart.
55
+ *
56
+ * ⚠️ `Mock` and `SpyInstance` take JEST's generics here, not Vitest's, and the difference is not
57
+ * cosmetic: jest's first generic is the RETURN type, Vitest's is the whole SIGNATURE. These
58
+ * declarations serve files that are still written for jest, so they must read jest's spelling;
59
+ * `codemod/spy-instance-type.ts` is the other half, and it converts the spelling in the files the
60
+ * migration does rewrite.
61
+ *
62
+ * A type-only namespace declares no value, which is why it merges with the const above instead of
63
+ * colliding with it.
64
+ *
65
+ * ## Alongside a program that still carries `@types/jest`
66
+ *
67
+ * No collision either, and it was measured rather than assumed: a fixture with
68
+ * `types: ["jest", "vitest/globals"]` plus this file compiles clean, and the same fixture WITHOUT
69
+ * this file also compiles, which is what proves `@types/jest` was really loaded. Declaration
70
+ * merging applies to the namespace the way it already applies to the const, so a module part-way
71
+ * through its migration is not broken by carrying both.
72
+ */
73
+ declare namespace jest {
74
+ type Mock<T = any, Y extends any[] = any[]> = import('vitest').Mock<(...args: Y) => T>
75
+ type Mocked<T> = import('vitest').Mocked<T>
76
+ type MockedObject<T> = import('vitest').MockedObject<T>
77
+ type MockedClass<T extends abstract new (...args: any[]) => any> = import('vitest').MockedClass<T>
78
+ type MockedFunction<T extends (...args: any[]) => any> = import('vitest').MockedFunction<T>
79
+ /** jest's own alias for `MockedFunction`. */
80
+ type MockedFn<T extends (...args: any[]) => any> = import('vitest').MockedFunction<T>
81
+ type SpyInstance<T = any, Y extends any[] = any[]> = import('vitest').MockInstance<
82
+ (...args: Y) => T
83
+ >
84
+ type SpiedFunction<T extends (...args: any[]) => any> = import('vitest').MockInstance<T>
85
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The type-level half of the `jest-mock-extended` alias.
3
+ *
4
+ * The generated Vitest config aliases `jest-mock-extended` to the fork sentinel ships, so at RUN
5
+ * time a migrated module has ONE copy. TypeScript does not read Vite's aliases, so it keeps
6
+ * resolving the jest package and a migrated spec ends up compiling two worlds at once:
7
+ *
8
+ * the value typeof mockDeep<Db>() -> CalledWithMock<…> extends jest.Mock
9
+ * the spec let spy: MockInstance -> vitest's own type
10
+ * TS2322 not assignable
11
+ *
12
+ * Measured on `apps/nest/microservices/agency`: one error, on a module whose typecheck was clean
13
+ * before it migrated. 34 files under `libs/` export a type built this way.
14
+ *
15
+ * ## Why it re-exports a SUBPATH of sentinel rather than `vitest-mock-extended`
16
+ *
17
+ * Because a bare `vitest-mock-extended` does not resolve from here, which was measured with
18
+ * `--traceResolution` after the first attempt emptied the module out:
19
+ *
20
+ * Resolving module 'vitest-mock-extended' from '…/agency/node_modules/@hublo/sentinel/types/…'
21
+ * Module name 'vitest-mock-extended' was not resolved.
22
+ *
23
+ * The module's `tsconfig.spec.json` names this file by PATH, so TypeScript keeps the symlink path
24
+ * pnpm installed and walks up from there: `…/sentinel/node_modules`, then the module's, then the
25
+ * workspace's. Sentinel's own dependency sits in the store beside its real directory, and none of
26
+ * those three is it. A file reached by RESOLUTION is realpathed into the store instead, and its
27
+ * dependencies are found: same trace, `@hublo/sentinel/test/tools/msw` → `msw` resolved.
28
+ *
29
+ * `skipLibCheck` is on in every module here, so the failed `export *` was silent and what surfaced
30
+ * was an empty module: 18 × `Module '"jest-mock-extended"' has no exported member 'mockDeep'`.
31
+ *
32
+ * ## Why THIS subpath
33
+ *
34
+ * `@hublo/sentinel/test/tools/mock-extended` is the very module the runtime alias points at, so the
35
+ * types a spec compiles against are the ones it runs against, down to the lifecycle helpers the
36
+ * fork adds. Re-exporting `vitest-mock-extended` directly would describe something adjacent to what
37
+ * executes.
38
+ *
39
+ * ## Why it does not couple modules to each other
40
+ *
41
+ * An ambient declaration applies to the PROGRAM that includes it, and nothing else. A module that
42
+ * includes it compiles against the Vitest types; a module still on jest, consuming the very same
43
+ * shared helper, keeps compiling against the jest ones. Proved on an isolated fixture: both
44
+ * programs green, over one unmodified shared lib.
45
+ *
46
+ * That is the whole point. Héla, 2026-09-24, on tying a module's migration to a library's:
47
+ * "ça crée des couplages et bloquent les migrations". lint, format and typescript each migrate one
48
+ * module at a time; this keeps the test role the same.
49
+ */
50
+ declare module 'jest-mock-extended' {
51
+ export * from '@hublo/sentinel/test/tools/mock-extended'
52
+ }