@hublo/sentinel 1.3.0 → 1.4.0-alpha.2

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 (48) hide show
  1. package/README.md +8 -4
  2. package/dist/bin/sentinel.d.ts +0 -1
  3. package/dist/bin/sentinel.js +22 -8
  4. package/dist/chunk-2XLX6PFR.js +132 -0
  5. package/dist/chunk-3TDUIKVQ.js +178 -0
  6. package/dist/{chunk-676GBPMS.js → chunk-4UIZJ3TR.js} +3399 -549
  7. package/dist/chunk-CPCUPK4J.js +70 -0
  8. package/dist/chunk-NX4GHIHF.js +25 -0
  9. package/dist/chunk-PWV3BMDA.js +15 -0
  10. package/dist/chunk-WLFE5RUU.js +264 -0
  11. package/dist/index.js +2 -1
  12. package/dist/roles/build/nest/toolchain.d.ts +4 -36
  13. package/dist/roles/build/nest/toolchain.js +10 -178
  14. package/dist/roles/build/nest/toolchain.js.map +1 -0
  15. package/dist/roles/build/toolchain.js.map +1 -0
  16. package/dist/roles/test/nest/toolchain.d.ts +29 -0
  17. package/dist/roles/test/nest/toolchain.js +289 -0
  18. package/dist/roles/test/nest/toolchain.js.map +1 -0
  19. package/dist/roles/test/react/toolchain.d.ts +62 -0
  20. package/dist/roles/test/react/toolchain.js +5 -0
  21. package/dist/roles/test/react/toolchain.js.map +1 -0
  22. package/dist/roles/test/setup/mock-extended.d.ts +46 -0
  23. package/dist/roles/test/setup/mock-extended.js +65 -0
  24. package/dist/roles/test/setup/mock-extended.js.map +1 -0
  25. package/dist/roles/test/setup/msw-lifecycle.d.ts +16 -0
  26. package/dist/roles/test/setup/msw-lifecycle.js +12 -0
  27. package/dist/roles/test/setup/msw-lifecycle.js.map +1 -0
  28. package/dist/roles/test/setup/msw-server.d.ts +3 -0
  29. package/dist/roles/test/setup/msw-server.js +10 -0
  30. package/dist/roles/test/setup/msw-server.js.map +1 -0
  31. package/dist/roles/test/setup/nest.d.ts +2 -0
  32. package/dist/roles/test/setup/nest.js +159 -0
  33. package/dist/roles/test/setup/nest.js.map +1 -0
  34. package/dist/roles/test/setup/workspace-entry.d.ts +2 -0
  35. package/dist/roles/test/setup/workspace-entry.js +8 -0
  36. package/dist/roles/test/setup/workspace-entry.js.map +1 -0
  37. package/dist/tsconfig-aliases-Ce6axdJ4.d.ts +36 -0
  38. package/docs/.gitkeep +0 -0
  39. package/docs/build-adoption.md +521 -0
  40. package/docs/format-adoption.md +321 -0
  41. package/docs/lint-adoption.md +290 -0
  42. package/docs/performance.md +49 -0
  43. package/docs/test-adoption.md +175 -0
  44. package/docs/typescript-adoption.md +184 -0
  45. package/docs/typescript-traces.md +798 -0
  46. package/docs/using-sentinel.md +180 -0
  47. package/docs/validating-a-change.md +101 -0
  48. package/package.json +32 -5
@@ -0,0 +1,521 @@
1
+ # Build adoption cheat sheet
2
+
3
+ Sentinel owns the Vite toolchain so a module can be built, moved and reasoned about on its own.
4
+
5
+ There are two families, and they get two different answers, from one rule: **sentinel provides
6
+ the FORM where the form is shared, and the PIECES where it is not.**
7
+
8
+ | | React apps | Nest services |
9
+ | ---------------------- | ------------------------------------- | ---------------------------------------------- |
10
+ | what sentinel gives | the packages, re-exported | the whole config, as `nestService()` |
11
+ | what your config keeps | everything below the imports | four lines |
12
+ | why | 4 apps, 4 genuinely different configs | 38 services, one shared 25-line webpack config |
13
+
14
+ Read [React](#react-apps) or [Nest](#nest-services). A SvelteKit module is declined with the
15
+ versions that would change that, not with a flat refusal: see
16
+ [below](#svelte-modules-and-what-they-need-first).
17
+
18
+ # React apps
19
+
20
+ Today `vite`, `@vitejs/plugin-react`, `@tailwindcss/vite`, `vite-plugin-svgr` and `nitro` sit in
21
+ the monorepo's **root** `package.json`, which is what makes a self-contained front app
22
+ impossible.
23
+
24
+ ## Adopt a module
25
+
26
+ ```console
27
+ $ pnpm add -D @hublo/sentinel
28
+ $ sentinel --init --build
29
+ $ pnpm install
30
+ ```
31
+
32
+ `--init` does the whole thing: the `build` script, the `serve` script, the nx target, the
33
+ removal of the build packages sentinel now owns, and **it points your `vite.config.ts` at
34
+ sentinel**.
35
+
36
+ ## What changes in your config: the imports, and nothing else
37
+
38
+ ```diff
39
+ - import tailwindcss from '@tailwindcss/vite'
40
+ - import react from '@vitejs/plugin-react'
41
+ - import { nitro } from 'nitro/vite'
42
+ - import { defineConfig, loadEnv } from 'vite'
43
+ - import svgr from 'vite-plugin-svgr'
44
+ + import { defineConfig, loadEnv, nitro, react, svgr, tailwindcss } from '@hublo/sentinel/build/react'
45
+ ```
46
+
47
+ That is the entire diff. Every attribute, every alias table, every path, every comment stays
48
+ exactly where it was, so your build produces what it produced before **by construction**: only
49
+ where the tools come from changed. Measured on `console`, `career` and `host-admin`, the file
50
+ is byte-identical below the import block.
51
+
52
+ Your own imports are untouched: TanStack, `@rolldown/plugin-babel`, `@front/theme/node`,
53
+ `node:path`. Sentinel takes only the five packages that sit in the workspace root, which is
54
+ what stops your app being buildable on its own.
55
+
56
+ If any import is in a shape sentinel cannot map — `import * as vite from 'vite'`, a
57
+ side-effect import, a file that does not parse — **nothing is written at all** and it tells you
58
+ why. A half-adopted build config is the one outcome worse than no adoption.
59
+
60
+ **Your module needs `"type": "module"`.** Sentinel is ESM only, and Vite decides how to load a
61
+ `.ts` config from the nearest `package.json`: without it, Vite loads the config as CommonJS,
62
+ `require`s sentinel, and fails with a message naming neither your module nor the fix. Sentinel
63
+ checks this before writing anything and tells you to add the field or rename the config to
64
+ `.mts`. Every front app in this repo already declares it, so this is about the next one.
65
+
66
+ ## What sentinel owns, and what stays yours
67
+
68
+ Sentinel owns the five packages that sit in the workspace root today: `vite`,
69
+ `@vitejs/plugin-react`, `@tailwindcss/vite`, `vite-plugin-svgr` and `nitro`. It re-exports
70
+ them, so your imports resolve from it instead of from the root.
71
+
72
+ **Everything else stays yours**, and that is the whole design: your plugin order, your ports,
73
+ your `base`, your sourcemap decision, your alias tables, your `optimizeDeps`, your conditional
74
+ plugins. Sentinel never reads them, so nothing in them can be lost.
75
+
76
+ Your own packages are untouched too. Verified: `@rolldown/plugin-babel`, `@locator/babel-jsx`,
77
+ `@tanstack/react-start` and `@tanstack/router-plugin` are declared by the apps themselves and
78
+ not at the root, so none of them is sentinel's to take.
79
+ `src/roles/build/presets/toolchain.json` records what is taken and what deliberately is not,
80
+ each with its reason.
81
+
82
+ A shared base of common VALUES was designed and dropped. The measurement is why: parsing the
83
+ three React apps leaf by leaf, what they truly share is six values, and the things that LOOK
84
+ shared are not (`root: __dirname` is the same text with a different value in each,
85
+ `build.sourcemap` is the same variable name computed three ways, `optimizeDeps` is not even
86
+ the same key). Every hard case in those configs is precisely what is not common, so a shared
87
+ shape absorbing them would stop being shared. It may come back as an opt-in later; it is not
88
+ part of adopting.
89
+
90
+ ## Commands
91
+
92
+ ```console
93
+ $ sentinel --run --build # build this module
94
+ $ sentinel --run --build --ci # non-zero on failure
95
+ $ sentinel --run --dev # start the dev server
96
+ $ sentinel --inspect --build # what it resolves to, and where it departs
97
+ $ sentinel --init --build --dry-run # show what would change, write nothing
98
+ ```
99
+
100
+ ## The `--` your `build` script ends with
101
+
102
+ `--init` writes `"build": "sentinel --run --build --"`, and that trailing `--` is doing work
103
+ rather than being a typo.
104
+
105
+ `pnpm run build --mode production` appends the option to the script, so the separator is what
106
+ decides who reads it:
107
+
108
+ ```console
109
+ # with it -> sentinel --run --build -- --mode production -> Vite gets --mode
110
+ # without it -> sentinel --run --build --mode production -> error: unknown option '--mode'
111
+ ```
112
+
113
+ Sentinel refuses flags it does not know rather than passing them on, which is what turns
114
+ `--dryrun` into an error instead of an option Vite silently ignores. The separator is how you say
115
+ "the rest is for the tool", and sentinel prints `NOT the standard check` when you use it, because
116
+ a build with extra flags is not the one CI runs.
117
+
118
+ `--inspect --build` reads the manifest and the config's TEXT. It never loads the config, so it
119
+ cannot fail on an app's own terms (career throws without `VITE_BRAND_ID`) and it stays cheap
120
+ across a sweep:
121
+
122
+ ```console
123
+ $ sentinel --inspect --build
124
+ ✓ console (react) build — runner=vite configFile=vite.config.ts vite=sentinel
125
+ overrides=0 prerequisite=pnpm run prepare:brand-runtime-artifacts adopted=true
126
+ ```
127
+
128
+ `vite=sentinel` is the field nothing else reports and the one worth reading: an adopted config
129
+ takes its plugins from sentinel, so a module answering `module` here is one build away from
130
+ running two copies of Vite against each other.
131
+
132
+ ## The dev server, and why `--init` migrates it too
133
+
134
+ Your app calls the toolchain **twice**: `build` and `serve`. `--init` migrates both, because
135
+ migrating one leaves the workspace root unable to drop the packages.
136
+
137
+ That is not a sentinel limitation, it is how pnpm links binaries. Measured with sentinel's exact
138
+ shape — `vite` as a dependency of a package your app depends on:
139
+
140
+ ```console
141
+ $ pnpm exec vite --version
142
+ Command "vite" not found
143
+ ```
144
+
145
+ pnpm links a **direct** dependency's binaries, not a transitive one's. Sentinel resolves its own
146
+ Vite and always did; it is your `serve` script calling `vite` directly that stops resolving.
147
+
148
+ `--init` rewrites only the tool call and keeps whatever wraps it:
149
+
150
+ ```
151
+ before: … --touch-file …/App.tsx -- pnpm --dir apps/front/console exec vite --host 0.0.0.0
152
+ after : … --touch-file …/App.tsx -- sentinel --run --dev -- --host 0.0.0.0
153
+ ```
154
+
155
+ Your own options travel through `--`. Vite's `dev` / `serve` subcommand is dropped, since the
156
+ verb already says which one this is. It rewrites `serve`, not `dev`, because that is where the
157
+ call lives in all three apps — `dev` is a one-line alias to it.
158
+
159
+ **The rewritten `serve` names its module** (`sentinel --run --dev --module console`). It has
160
+ to: the script does not call sentinel from your app, it hands the call to the `@front/theme`
161
+ watcher, which spawns from the WORKSPACE ROOT. From there sentinel sees every module, and a
162
+ dev server is a one-module command, so it refuses rather than starting several. Measured:
163
+ without the flag, `pnpm run serve` answers "this resolved to 439 [all]" and exits 1.
164
+
165
+ **`--dev` is never swept**, and it is refused across more than one module whoever asks. An
166
+ unqualified `sentinel --run` skips it entirely, so a sweep across the workspace answers a
167
+ question rather than launching a server per module.
168
+
169
+ **The read verbs refuse it too.** `--inspect --dev`, `--report --dev` and `--status --dev` exit
170
+ 1 and point at `--build`. A server is started, not read: it has no state of its own, since its
171
+ config, its resolution and its plugins are the build's, since one `--init` plan writes `build` and
172
+ `serve` together. An answer here could only repeat `--build`'s, and a second surface saying the
173
+ same thing is one more to keep true. The refusal names the way out in your own verb, and says
174
+ that `--init --dev` is how the target is adopted.
175
+
176
+ Unlike `--run --build`, it does **not** run `prebuild`: the artefact is produced by the watcher
177
+ that wraps this command, and running it again would race the watcher about to own the file.
178
+
179
+ **And it retires the `serve` target in your `project.json`**, when that target does nothing but
180
+ call the script. A dev server needs nothing from nx: it never terminates so it is never cached, it
181
+ writes no output to restore, and no caller hands it a flag the bundler would refuse. So the script
182
+ alone declares it and nx infers the target, which is not a theory — `host-admin` has no `serve`
183
+ target at all and its dev server runs.
184
+
185
+ This arrived late, and the reason is worth stating: `--init` rewrote the script and stopped there,
186
+ so an adopted module kept one dev server with two declarations, and `--inspect` called it
187
+ conformant. It is a drift check now.
188
+
189
+ A target that does **more** than delegate — a `dependsOn`, its own options, a different command —
190
+ is left exactly where it is, and named in `--inspect` instead. Removing it would drop behaviour
191
+ this role never declared, and that is a decision for whoever wrote it.
192
+
193
+ ## Your `vitest.config` moves too
194
+
195
+ An app calls the toolchain from more than one file, and `--init` follows it everywhere.
196
+ Measured here: `career` and `host-admin` import `loadEnv` from `vite` in their
197
+ `vitest.config.ts`, and `libs/front/ui` imports `mergeConfig`. Left behind, those imports stop
198
+ resolving the day the root drops the packages, and they fail your TESTS rather than your build,
199
+ long after the adoption that caused it.
200
+
201
+ Note that `vitest/config` exports a `mergeConfig` of its own. That one is vitest's and stays
202
+ where it is: the rewrite matches on the import specifier, never on the name.
203
+
204
+ ## Why the imports, and only the imports
205
+
206
+ An earlier design read your config and rebuilt it around `reactApp`. It was dropped, for a
207
+ reason worth stating: a Vite config is **code**, not configuration. `career`'s alias table
208
+ opens with `...frontThemeAliases`, a constant declared 130 lines earlier; `host-admin` has
209
+ three conditional plugin groups; one nitro block carries a comment explaining a rolldown SSR
210
+ interop bug. Moving expressions and hoping nothing referenced them fails as a build that
211
+ _succeeds_ and ships the wrong bundle, which is the one failure a migration tool must not have.
212
+
213
+ Leaving that edit to you was worse. It is real work on a file nobody enjoys touching, so it
214
+ would not get done, and the root would stay unblocked forever.
215
+
216
+ Changing only the imports removes the dilemma: sentinel **reads nothing**, so there is nothing
217
+ it can lose. The parsing is done with `oxc-parser` rather than by matching lines, because an
218
+ import can be renamed, wrapped, or sitting next to a commented-out import of the same package
219
+ (`host-admin` has one) — and a regex that gets any of those wrong edits a file nobody
220
+ re-reads.
221
+
222
+ ## `resolve.alias` is passed through, never read
223
+
224
+ Sentinel does not read, rewrite or resolve your alias table. It goes in as an option and comes
225
+ out unchanged, so nothing in it can be lost by adopting.
226
+
227
+ Your config file stays in your app, so `__dirname` is still your app's directory. Verified on
228
+ `console`: the 17 resolved `replacement` paths are identical before and after, pointing at the
229
+ app's real files rather than anything under sentinel.
230
+
231
+ Deriving the table from `tsconfig.base.json` — which already holds all 401 path mappings — is
232
+ its own ticket, with a build-output comparison as its acceptance criterion.
233
+
234
+ ## TanStack stays yours
235
+
236
+ `@tanstack/react-start` and `@tanstack/router-plugin` do **not** move to sentinel. 48 app source
237
+ files import the first directly: it is a framework your app codes against, not a tool that
238
+ builds it, and the router plugin generates your app's own route tree.
239
+
240
+ That is why you pass the factories in. Sentinel decides _when_ to call them — the router plugin
241
+ alone under test, `tanstackStart` and `nitro` otherwise — while the packages stay where they
242
+ belong. The day the bundler changes, `--migrate` removes that line.
243
+
244
+ ## Sentinel's Vite runs, not yours
245
+
246
+ The binary is resolved from **sentinel's** install first, which is the opposite of every other
247
+ role. An adopted config imports its plugins from `@hublo/sentinel/build/react`, so they are
248
+ bound to sentinel's Vite; a binary from your module — or, in this monorepo, from the workspace
249
+ root — hands the plugins a different Vite than the one running them. Nothing reports a version
250
+ conflict: a hook simply never fires.
251
+
252
+ `--run --build` says so out loud if it happens, and `--inspect --build` reports which install
253
+ answered.
254
+
255
+ ## `prebuild` runs, even in CI
256
+
257
+ If your module declares a `prebuild` script, `--run --build` runs it first.
258
+
259
+ npm's lifecycle fires `prebuild` before `build`, so `pnpm run build` was always safe — but
260
+ `sentinel --run --build` is what CI calls, and it is not. All three front apps here generate a
261
+ gitignored branding artefact in `prebuild`; skipping it produces an error naming a tool that is
262
+ not at fault.
263
+
264
+ It runs **once**: `npm_lifecycle_event` says whether the script runner already fired it.
265
+
266
+ ## Checking an adoption is COMPLETE, not merely started
267
+
268
+ `sentinel --inspect --build` reports a module as conformant only when everything adoption writes
269
+ is there. Seven things, and each of them shipped missing at least once:
270
+
271
+ | What it checks | What its absence cost |
272
+ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
273
+ | the `build` npm script | `pnpm run build` did nothing; only nx could build the module |
274
+ | the script invokes sentinel | the config pointed at sentinel while the command ran something else |
275
+ | `nx.targets.build` exists | the build ran on nx's DEFAULT inputs while every sibling target declared its own |
276
+ | `outputs`, when `cache: true` | nx cached the build and restored **0 of 464 files**, with the target green |
277
+ | `^default` among `inputs` | declaring `inputs` REPLACES nx's default, so the build stopped hashing its dependencies |
278
+ | `forwardAllArgs: false` | the image's `--generatePackageJson` reached Vite, which refuses it |
279
+ | no `build` target left in `project.json` | two declarations, and which one nx runs depends on the caller |
280
+ | no `serve` target left there either, once its script is adopted | the same fault on the dev server, and this check missed it for the whole role: `console` read `conformant=true drift=[]` with the target still on disk |
281
+
282
+ None of these was found by a check. Each was found by a person reading a diff, which is why they
283
+ are checks now: `--status` counts them across every module, so the next one is visible everywhere
284
+ rather than on the two files somebody happens to open.
285
+
286
+ **What it cannot check:** whether the translation LOST something. Once a service is adopted its
287
+ webpack target is gone, so an `assets` list that was dropped and one that never existed look
288
+ identical. That property belongs to the moment of translation, and is held by the refusals (a
289
+ target sentinel cannot translate faithfully is refused, not approximated) and by the tests that
290
+ pin what the translation reads.
291
+
292
+ ## Your config is CODE, and it runs before the build does
293
+
294
+ A Vite config is executed, not parsed. Whatever it reads from the environment, it reads at that
295
+ moment — before a single file is bundled, and before sentinel has done anything.
296
+
297
+ So a shell or a container missing one of those variables fails at config load, with an error from
298
+ your config rather than from the build. Measured here: `host-admin` and `career` both stop with
299
+
300
+ ```
301
+ Error: Missing branding environment variable "VITE_BRAND_ID". Expected one of: hublo, hublo-nova
302
+ ```
303
+
304
+ That variable is not sentinel's, and adoption neither adds nor removes the requirement: the same
305
+ config asked for it before. It is worth knowing because the message names your config and the
306
+ moment is early enough to look like the build role is broken.
307
+
308
+ The one thing to check when you move a build somewhere new — a fresh clone, another CI job, an
309
+ image — is that it carries the variables your config reads. `sentinel --init --build` says so at
310
+ the end of a React adoption, for the same reason.
311
+
312
+ # Nest services
313
+
314
+ ## Why this is happening now
315
+
316
+ Not a preference. Nx v24 removes the `@nx/webpack:webpack` executor and the `composePlugins` /
317
+ `withNx` helpers `tools/webpack-configs/` is built on, and nx's own migration generator refuses
318
+ all 38 services because they use `@nx/js:node` ([nrwl/nx#36389]). There is no version of "stay on
319
+ webpack" available, so the way out runs through here.
320
+
321
+ [nrwl/nx#36389]: https://github.com/nrwl/nx/issues/36389
322
+
323
+ ## Your build stops checking types, and gets faster
324
+
325
+ The webpack target ran `"compiler": "tsc"`, so it type-checked every file it compiled, as a side
326
+ effect of compiling them. The new build bundles and does not. That is deliberate: a build builds,
327
+ and `typecheck` is its own target, which CI already runs on every pull request.
328
+
329
+ You gain time, and it was measured on `main` rather than promised: median compile went from 36s to
330
+ 21s on `contract` (20 webpack builds against 10 Vite ones) and from 33s to 24s on `network`
331
+ (8 Vite builds).
332
+
333
+ You lose nothing on a pull request, and you gain coverage. Webpack compiled `tsconfig.app.json`,
334
+ which excludes `*.spec.ts`, `*.mock.ts`, `*.fixture.ts`, `*.steps.ts` and `*.integration-spec.ts`.
335
+ The `typecheck` target builds the projects your `tsconfig.json` REFERENCES, so the app and the
336
+ specs. Your test files are type-checked for the first time.
337
+
338
+ What genuinely changes is the deploy path: `build-and-publish-orchestrator` runs on a push to
339
+ `main` and type-checks nothing, where the old build would have failed there. The residual risk is
340
+ a type error that passes its own pull request and only appears once two of them meet on `main`.
341
+ Measured over the 12 weeks before adoption on those two services, the build's type check stopped
342
+ zero real errors, so nothing was added to the deploy workflow to cover it. A service that wants
343
+ the check back at build time adds it to its own `build` script, which stays its decision.
344
+
345
+ ## Adopt a service
346
+
347
+ ```console
348
+ $ pnpm add -D @hublo/sentinel
349
+ $ sentinel --init --build
350
+ $ pnpm install
351
+ ```
352
+
353
+ That writes both files below and needs no manual step. It is a TRANSLATION, not a scaffold:
354
+ every value the config needs is already declared in the webpack target your service builds with
355
+ today, and those are exactly the values a human miscounts. It is also safe to re-run, which is
356
+ how you verify your own adoption.
357
+
358
+ It **refuses** rather than guessing when your target is not one it reproduces: a service with
359
+ its own webpack config (one exists here, loading `.sql` files as raw text), or a `production`
360
+ configuration whose `fileReplacements` point at files that exist. It says which, and writes
361
+ nothing.
362
+
363
+ What it writes, so you can review it. The config, which must be **`vite.config.mts`**:
364
+
365
+ ```ts
366
+ import path from 'node:path'
367
+
368
+ import { nestService } from '@hublo/sentinel/build/nest'
369
+
370
+ export default nestService({
371
+ project: 'poe',
372
+ root: __dirname,
373
+ workspaceRoot: path.resolve(__dirname, '../../../..'),
374
+ outDir: 'dist/apps/nest/microservices/poe',
375
+ })
376
+ ```
377
+
378
+ `.mts` and not `.ts`: a Nest service is CommonJS, sentinel is ESM only, and Vite decides how to
379
+ load a `.ts` config from the nearest `package.json`. Without the extension it loads the config as
380
+ CommonJS, `require`s sentinel, and fails with a message naming neither your module nor the fix.
381
+
382
+ Then the npm script and the nx target, both in `package.json`, exactly as `--init` writes them on
383
+ `poe`:
384
+
385
+ ```json
386
+ "scripts": { "build": "sentinel --run --build --" },
387
+ "nx": {
388
+ "targets": {
389
+ "build": {
390
+ "executor": "nx:run-commands",
391
+ "options": {
392
+ "command": "pnpm --filter poe run build",
393
+ "forwardAllArgs": false
394
+ },
395
+ "cache": true,
396
+ "inputs": ["default", "^default", "{projectRoot}/vite.config.mts"],
397
+ "outputs": ["{workspaceRoot}/dist/apps/nest/microservices/poe"]
398
+ }
399
+ }
400
+ }
401
+ ```
402
+
403
+ **Why an explicit target here**, where lint, format and typescript let nx infer one from the script:
404
+ the image runs `nx run <project>:build --generatePackageJson`, and an inferred npm-script target
405
+ forwards unknown arguments, which sentinel and Vite would both refuse. `forwardAllArgs: false`
406
+ makes nx swallow the flag, which is what lets the Dockerfile stay identical for a migrated and a
407
+ non-migrated service alike. That is also why the two roles' blocks do not look alike: those three
408
+ declare `cache` and `inputs` and nothing more, because nothing else is needed for them.
409
+
410
+ **Why the command runs the SCRIPT and not sentinel.** It used to be `pnpm exec sentinel --run
411
+ --build` with a `cwd`, and that skipped the module's own `prebuild`: `console` prepares its brand
412
+ artefacts there, and npm fires `pre<script>` only when the script is what was invoked. Going
413
+ through `pnpm --filter <name> run build` keeps that hook, and it costs nothing on a service that
414
+ has none.
415
+
416
+ A Nest service has no dev server to migrate; that half is
417
+ [a React concern](#the-dev-server-and-why---init-migrates-it-too).
418
+
419
+ ## The check that decides whether you are done
420
+
421
+ Not "it builds", and not "it starts". Every service already has a `generate-swagger-file` target
422
+ that **runs the built bundle** and writes its OpenAPI contract into a committed file:
423
+
424
+ ```console
425
+ $ nx run <service>:build --generatePackageJson
426
+ $ nx run <service>:generate-swagger-file
427
+ $ git diff --exit-code libs/api-types/service-<service>/
428
+ ```
429
+
430
+ That proves the service boots **and** that its public API did not move, in one step. It is not
431
+ optional advice: the first version of this preset built and started, and moved the contract of
432
+ the second service it was tried on.
433
+
434
+ ## What the preset does for you, and why each part exists
435
+
436
+ **Decorator metadata, emitted by TypeScript.** esbuild cannot emit `emitDecoratorMetadata` and
437
+ Nest cannot live without it. The transform reads your module's own tsconfig, so the emit is
438
+ preserved rather than re-chosen. It is deliberately not swc, which is ten times faster and wrong:
439
+ swc drops the `=== "function"` guard tsc puts around a serialized type, so an enum imported into
440
+ a DTO reaches `@nestjs/swagger` instead of `Object` and the service throws
441
+ `A circular dependency has been detected` at boot.
442
+
443
+ **One output file per module.** Hoisting everything into one scope forces the bundler to rename
444
+ duplicate class names, and `@nestjs/swagger` keys its schemas on `class.name`, so a renamed class
445
+ silently becomes a renamed schema in your published API.
446
+
447
+ **The pruned `package.json` and lockfile** the image installs from, built from the nx project
448
+ graph exactly as `--generatePackageJson` did.
449
+
450
+ **A refusal to ship a manifest that would not install.** The build compares what the bundler left
451
+ external against what the manifest declares. This is the check that would have caught `tslib`
452
+ arriving through `importHelpers` without any module declaring it, which killed a pod while every
453
+ build was green.
454
+
455
+ ## If you need something the shape does not give you
456
+
457
+ Pass `overrides`, merged with Vite's own `mergeConfig`:
458
+
459
+ ```ts
460
+ export default nestService({
461
+ /* … */
462
+ overrides: { build: { sourcemap: false } },
463
+ })
464
+ ```
465
+
466
+ Or drop to the pieces, which the same entry point exports individually: `decoratorMetadata`,
467
+ `nodeManifest`, `tsconfigAliases`, plus Vite's `defineConfig`, `loadEnv` and `mergeConfig`.
468
+
469
+ # Both families
470
+
471
+ ## Svelte modules, and what they need first
472
+
473
+ Sentinel ships **Vite 8**. `@sveltejs/vite-plugin-svelte` caps at Vite 6 until its major 7, and
474
+ that major requires `svelte ^5.46.4`. So the build role cannot apply to a SvelteKit module still
475
+ on Svelte 5.19 or 5.25, and it says exactly that rather than "this is not a React app":
476
+
477
+ ```console
478
+ $ sentinel --init --build
479
+ build: the build role does not apply to this module yet:
480
+ @sveltejs/vite-plugin-svelte is at 5.0.3, needs >= 7.0.0; svelte is at 5.25.6,
481
+ needs >= 5.46.4. sentinel ships Vite 8, and @sveltejs/vite-plugin-svelte caps at
482
+ Vite 6 until its major 7. Nothing was written. Raise those versions, check the
483
+ module still builds and its tests still pass, then re-run `sentinel --init --build`.
484
+ ```
485
+
486
+ **Sentinel does not raise them for you**, and that is deliberate. Removing a tool it replaces and
487
+ raising the framework your source is written against are different acts: a reviewer sees the line
488
+ `5.19.9 -> 5.46.4`, nobody sees a behaviour change at runtime, and a green build does not prove
489
+ there is none. The decision belongs to whoever owns the module.
490
+
491
+ It is a **skip**, not a failure. `sentinel --init` on such a module still adopts lint, format and
492
+ typescript, and exits 0.
493
+
494
+ Adding sentinel for those roles does **not** disturb your Vite 6, verified under pnpm's isolated
495
+ layout: your plugin keeps resolving your own Vite, and sentinel resolves its own.
496
+
497
+ The requirements live in `presets/requirements.json`, one entry per preset, each carrying the
498
+ reason it exists.
499
+
500
+ ## Troubleshooting
501
+
502
+ **`--init --build` says there is no Vite config.** Then this module has not been given one yet.
503
+ Sentinel does not scaffold it, on either side: a config it invented would give the module a
504
+ second build path nobody reviewed. On the Nest side, write the four lines above yourself, which
505
+ is also how the diff stays readable to whoever approves it.
506
+
507
+ **`--init --build` says the preset does not handle this module.** Detection answers `node` for
508
+ a front app, because the build toolchain is declared at the workspace root and your app declares
509
+ none of it. Sentinel reads your Vite config instead: `@vitejs/plugin-react` or
510
+ `@tanstack/react-start` makes it React, and `@hublo/sentinel/build/nest` makes it Nest. If your
511
+ config imports neither, it genuinely is not one of the two.
512
+
513
+ **`--inspect --build` reports a `configError`.** Your config threw while loading. `career` does
514
+ this without `VITE_BRAND_ID`: the config is loaded for real, so it needs the same environment a
515
+ build needs. The message names what is missing.
516
+
517
+ **`--init --build` wrote nothing and named an import.** Sentinel refuses rather than guesses:
518
+ a namespace import (`import * as vite from 'vite'`), a side-effect import, or a config that
519
+ does not parse cannot be mapped onto named exports. Nothing is written, including the
520
+ `package.json` side, because a `build` script running sentinel's Vite against a config whose
521
+ plugins still come from the root is the two-copies failure this role exists to prevent.