@hublo/sentinel 1.1.0-alpha.2 → 1.1.0-alpha.4

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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  `sentinel` is a standalone, semver-versioned package (published to a registry, consumed by a repo as a normal dependency) that unifies a repo's tooling, config, and quality checks into one place, so projects stop copying config files everywhere and stop carrying a pile of duplicated tooling dependencies.
6
6
 
7
- > Status: **foundation + the TypeScript tool are shipped** (`@hublo/sentinel@0.1.0-alpha.x` on npm). The rest of this README is the design reference for the tools still to come, added one at a time on top of this foundation.
7
+ > Status: **the foundation, the TypeScript tool and the lint tool are shipped** (`@hublo/sentinel` on npm). The rest of this README is the design reference for the tools still to come, added one at a time on top of this foundation.
8
8
 
9
9
  ---
10
10
 
@@ -19,7 +19,7 @@ A large monorepo accumulates:
19
19
 
20
20
  ## What it gives you
21
21
 
22
- **The shift:** instead of today's **big-bang** (change every project and every tool at once, untested in isolation), evolution is **app-scoped and versioned**, evolving a project is a **version bump**, a new/swapped tool is just a **new runner**, and a new stack a **new flavour**. No repo-wide edits.
22
+ **The shift:** instead of today's **big-bang** (change every project and every tool at once, untested in isolation), evolution is **app-scoped and versioned**, evolving a project is a **version bump**, a new/swapped tool is just a **new runner**, and a new stack a **new preset**. No repo-wide edits.
23
23
 
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.
@@ -43,12 +43,14 @@ A large monorepo accumulates:
43
43
 
44
44
  sentinel writes **standard config files** into a project (each just `extends` a sentinel preset) and runs the checks. Your editor and the tools read those **normal files natively**, they never call sentinel at runtime, so nothing is coupled to it or brittle.
45
45
 
46
- > **Shipped today:** only the **TypeScript** tool, so `--init` writes the `tsconfig` stub, and `--run`/`--report`/`--status` work for `--typescript`. The `eslint.config.js` / `--lint` / `--test` snippets below illustrate the end state; those subpaths (`@hublo/sentinel/lint/*`, …) land with their tool ticket.
46
+ > **Shipped today:** the **TypeScript** and **lint** tools. The `--test` / `--build` snippets
47
+ > below illustrate the end state and land with their own ticket.
47
48
 
48
49
  **Step 1 — put a module on sentinel** (once per module, by a dev; the files are committed). Run from the app dir; `--init` does it all, nothing is hand-edited:
49
50
 
50
51
  ```bash
51
- sentinel --init --typescript --flavour <react|nest|node>
52
+ sentinel --init --typescript --preset <react|nest|node|svelte>
53
+ sentinel --init --lint --preset <react|nest|node|svelte>
52
54
  pnpm install # fetch what --init declared, then commit
53
55
  ```
54
56
 
@@ -56,10 +58,10 @@ pnpm install # fetch what --init declared, then commit
56
58
 
57
59
  Those files are tiny, they just point at a sentinel preset. What gets committed:
58
60
 
59
- ```js
60
- // eslint.config.js — generated; overrides go through the sentinel allowlist, not inline
61
- import react from '@hublo/sentinel/lint/react'
62
- export default react
61
+ ```jsonc
62
+ // .oxlintrc.json — generated. Adoption REPLACES eslint rather than sitting beside it, so
63
+ // the module's eslint config is DELETED and this is the only linter config it keeps.
64
+ { "extends": ["./node_modules/@hublo/sentinel/oxlint/react.json"] }
63
65
  ```
64
66
 
65
67
  ```jsonc
@@ -122,7 +124,7 @@ pnpm dlx @hublo/sentinel@<exact-version> --inspect --typescript --module <name>
122
124
  - [`docs/typescript-adoption.md`](docs/typescript-adoption.md) — the adoption cheat sheet: the two adoption steps, the command model (verb x type x location), options, reading a report, and troubleshooting.
123
125
  - [`docs/typescript-traces.md`](docs/typescript-traces.md) — a **generated, versioned** reference of live command + output traces (every verb, option, config result and edge case) against the mock monorepo. Regenerate after CLI changes with `pnpm docs:traces`.
124
126
 
125
- ## Architecture: `target → runner → flavour`
127
+ ## Architecture: `target → runner → preset`
126
128
 
127
129
  Every check is described by three layers:
128
130
 
@@ -130,12 +132,12 @@ Every check is described by three layers:
130
132
  | -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- |
131
133
  | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `test` `static-analysis` `runtime-analysis` |
132
134
  | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
133
- | **flavour** (preset) | detected / `--flavour` | the _variant_ per stack (strict by default) | `react` `nest` `node` (svelte declared, preset deferred) |
135
+ | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint only, no tsconfig preset yet) |
134
136
 
135
- A run is `target × runner × flavour`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
137
+ A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
136
138
 
137
139
  - `--runner` is an **optional override on a central default**. `sentinel --lint` uses the configured default runner, so swapping a tool globally is a one-place change; `--runner=biome` overrides for a single run (great for benchmarking eslint vs biome vs oxlint, and for gradual migration).
138
- - The **flavour is detected** from the module's dependencies (deterministic: a framework dep → its flavour, else `node`), and **`--flavour` overrides it**. Detection can be wrong where deps are hoisted at the repo root (it returns `node`), so pass `--flavour` for `--init` (that is where the preset is chosen). `--run`/`--report` don't depend on it, they run the tool on the committed config.
140
+ - The **preset is detected** from the module's dependencies (deterministic: a framework dep → its preset, else `node`), and **`--preset` overrides it**. Detection can be wrong where deps are hoisted at the repo root (it returns `node`), so pass `--preset` for `--init` (that is where the preset is chosen). `--run`/`--report` don't depend on it, they run the tool on the committed config.
139
141
 
140
142
  ### Adapters & the engine (ports & adapters)
141
143
 
@@ -143,14 +145,14 @@ A run is `target × runner × flavour`, e.g. `sentinel --run --lint --runner=esl
143
145
 
144
146
  - the **engine** (core) is tool-agnostic: it parses the CLI, resolves an adapter, and owns **all IO and repo structure**, finding the project root, reading, merging and writing files;
145
147
  - an **adapter** is the boundary to one tool (eslint, tsc, vitest, …). It carries the tool knowledge (how to run it, what its config means) and implements one contract (types in `src/core/types.ts`, optional base in `src/core/base-adapter.ts`);
146
- - a **context** is a plain data object the engine passes to an adapter for a run (`app`, `cwd`, optional `flavour`, `ci`, `fix`). It is data only, it never carries a filesystem capability.
148
+ - a **context** is a plain data object the engine passes to an adapter for a run (`app`, `cwd`, optional `preset`, `ci`, `fix`). It is data only, it never carries a filesystem capability.
147
149
 
148
150
  `sentinel` does not reimplement tools. The contract:
149
151
 
150
- - **`appliesTo(flavour)`** — which flavours this adapter handles (resolution filters on it, so a React-only adapter is never picked for Nest)
151
- - **`plan(flavour)`** — PURE: returns a declarative `UpdatePlan` of file operations (used by `--init`). The adapter never touches the disk; the engine applies the plan.
152
+ - **`appliesTo(preset)`** — which presets this adapter handles (resolution filters on it, so a React-only adapter is never picked for Nest)
153
+ - **`plan(preset)`** — PURE: returns a declarative `UpdatePlan` of file operations (used by `--init`). The adapter never touches the disk; the engine applies the plan.
152
154
  - **`run(ctx)`** — invoke the tool's bin against the project (used by `--run`)
153
- - **`inspect(flavour)`** — the adapter's resolved base config (used by `--inspect`)
155
+ - **`inspect(preset)`** — the adapter's resolved base config (used by `--inspect`)
154
156
  - **`report(ctx)`** — metrics (used by `--report`)
155
157
 
156
158
  **`--init` is a declarative plan, not file-writing inside the adapter.** The adapter describes intent as operations; the engine executes them:
@@ -170,7 +172,7 @@ So the tool-_meaning_ lives in the adapter and the read/merge/write _mechanics_
170
172
  ```mermaid
171
173
  flowchart LR
172
174
  CLI["sentinel --verb --target<br/>[--runner]"] --> D[dispatch]
173
- D --> R["registry.resolve<br/>(target, flavour, runner)"]
175
+ D --> R["registry.resolve<br/>(target, preset, runner)"]
174
176
  R --> A["adapter<br/>eslint / tsc / vitest / ..."]
175
177
  A -->|"--run"| Run["tool binary on the project"]
176
178
  A -->|"--init"| Upd["declarative plan → engine writes"]
@@ -200,6 +202,80 @@ The rules live in `sentinel`. Each module keeps a **thin, generated stub** per t
200
202
  - **Per-module install** — `@hublo/sentinel` is added per module, so adoption is **gradual** (migrate lot by lot; a module can adopt sentinel while its neighbour still uses the old config). Root configs are removed only once the **last** module has migrated.
201
203
  - **Runner binaries** (`eslint`, `typescript`, `vite`, `vitest`, …) are **resolved from the adopting module** at run time (sentinel looks for the tool in the module, then falls back to `PATH`), so the editor and nx keep using the exact binary the project already installs. sentinel does **not** declare them as dependencies today, so it does not pin their versions: the module still owns its own `typescript`. Having sentinel dictate those versions (as peer dependencies, so the whole toolchain rides the sentinel version) is the intended end state, and it lands with the tool tickets that actually bundle a runner.
202
204
 
205
+ ## What a preset does not enforce
206
+
207
+ A preset that silently enforces less than the config it replaced is worse than no preset, so
208
+ every rule that is not enforced carries a reason and is listed by `sentinel --inspect --lint`:
209
+
210
+ | State | Meaning | Can it be switched on? |
211
+ | -------------- | -------------------------------------------------------------------- | ------------------------------- |
212
+ | **deferred** | scheduled for a later migration wave | yes, on schedule |
213
+ | **disabled** | a decision: it argues with the architecture, or only false-positives | yes, by revisiting the decision |
214
+ | **downgraded** | it runs and reports, it just cannot fail the build | yes, once the debt is paid |
215
+
216
+ A small number of rules sit outside those three states, because the preset cannot carry them at
217
+ any severity. They are documented here rather than reported by `--inspect`: oxlint validates
218
+ rule and plugin names when it **parses** a config, so naming one would make the config fail to
219
+ parse and the module would lint nothing at all. They are still recorded as data in the preset,
220
+ so the tests can count every rule a module enforces and fail on anything unaccounted for.
221
+
222
+ ### Rules the presets do not carry (nest, react)
223
+
224
+ | Rule | Why |
225
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
226
+ | `typescript/member-ordering` | oxlint has no native rule, and the preset deliberately does not host `@typescript-eslint/eslint-plugin`: a plugin costs a dependency, a load step and a pass over every file, which is not worth one rule |
227
+ | `no-invalid-this` | no oxlint equivalent, and hosting does not help (the rule crashes on every file under oxlint's plugin API). Its coverage moves to TypeScript's `noImplicitThis` |
228
+ | `no-octal` | no oxlint equivalent |
229
+ | ~15 stylistic rules | formatting, which the formatter owns. They move to **oxfmt**, deliberately the last step of the migration, so nothing enforces them in between |
230
+
231
+ The formatting group is the only one that will ever shrink; the rest are settled losses.
232
+
233
+ `no-return-await` is **not** in that table, despite having no oxlint rule of that name. It is
234
+ deprecated in ESLint core and oxlint ships the typescript-eslint successor, so the presets carry
235
+ `typescript/return-await` with the `never` option, which is what the old rule meant. It is held
236
+ at `warn`: both modules set the old rule to error, but it was never actually reporting (the nx
237
+ lint target expanded to 2 files of 2888), and running it over every file finds 9 real violations
238
+ on bff-admin and 5 on host-admin. Fix those and it can be raised.
239
+
240
+ ### Rules with no oxlint equivalent (svelte)
241
+
242
+ Oxlint parses a `.svelte` file's `<script>` block and **not its template**. Measured on oxlint
243
+ 1.77 with a component carrying a violation in each half: the `<script>` violation is reported,
244
+ and `{@html}`, `{@debug}` and duplicate style properties in the template produce nothing.
245
+ Adding a svelte plugin does not fix this, because the missing piece is the **parser**, not the
246
+ rule implementations.
247
+
248
+ So sixteen rules the two svelte modules enforce today have **no oxlint equivalent**. Fourteen
249
+ read the template:
250
+
251
+ `svelte/comment-directive` · `svelte/no-at-debug-tags` · **`svelte/no-at-html-tags`** ·
252
+ `svelte/no-dupe-else-if-blocks` · `svelte/no-dupe-style-properties` ·
253
+ `svelte/no-dynamic-slot-name` · `svelte/no-inner-declarations` ·
254
+ `svelte/no-not-function-handler` · `svelte/no-object-in-text-mustaches` ·
255
+ `svelte/no-shorthand-style-property-overrides` · `svelte/no-unknown-style-directive-property` ·
256
+ `svelte/no-unused-svelte-ignore` · `svelte/system` · `svelte/valid-compile`
257
+
258
+ and two core rules oxlint does not implement: `no-dupe-args` (TypeScript rejects duplicate
259
+ parameter names anyway) and `no-octal`.
260
+
261
+ **The one worth knowing about is `svelte/no-at-html-tags`**, an XSS guard that fires on the
262
+ modules today. Adopting the svelte lint preset gives that check up.
263
+
264
+ These rules are **absent from the generated config rather than set to `off`**, and that is
265
+ forced, not stylistic. Oxlint rejects an unknown rule or plugin when it _parses_ a config, even
266
+ when the value is `off`:
267
+
268
+ ```console
269
+ $ oxlint -c '{"rules": {"svelte/no-at-html-tags": "off"}}'
270
+ Failed to parse oxlint configuration file.
271
+ x Plugin 'svelte' not found
272
+ ```
273
+
274
+ Listing them as disabled would not weaken the config, it would stop the config from parsing and
275
+ the module would lint nothing at all. They are recorded as data in the preset (`uncovered`), so
276
+ the preset tests can count every rule the modules enforce and fail on anything unaccounted for,
277
+ and they are documented here for the developers who own those modules.
278
+
203
279
  ## Composition & precedence
204
280
 
205
281
  A repo often has a base config that is **structural**, not just tooling, e.g. a
@@ -264,11 +340,11 @@ A command **composes** three axes: **verb + type + location**.
264
340
  ```
265
341
  sentinel <verb> [type] [options]
266
342
 
267
- VERBS --run execute the target's tool
268
- --inspect show the resolved configuration (incl. deferred rules)
269
- --report metrics and health
270
- --status adoption + conformity (coverage + drift), read from configs
271
- --init generate/apply the config stubs (writes; one module only)
343
+ VERBS --run execute the target's tool (--json for metrics + diagnostics)
344
+ --inspect the resolved configuration, what is deferred, adoption + drift
345
+ --init generate/apply the config stubs (writes; one module only)
346
+ --report deprecated --run --json
347
+ --status deprecated --inspect
272
348
 
273
349
  TYPES --lint --format --typescript --build --test
274
350
  --static-analysis --runtime-analysis --arch
@@ -278,27 +354,55 @@ LOCATION in a MODULE dir → that module (do NOT pass --module)
278
354
  at the workspace ROOT → --module <name> (one) · --ci (affected) · else all
279
355
 
280
356
  OPTIONS --module <name> from the root: scope to one module
281
- --flavour <name> override the detected stack preset (react, nest, ...)
357
+ --preset <name> override the detected stack preset (react, nest, ...)
282
358
  --runner <tool> override the default runner
283
359
  --ci from the root: affected only; non-zero exit on failure
284
360
  --fix auto-fix where applicable
285
361
  --dry-run preview a --init without writing
286
362
  --json machine-readable output (report / inspect / status / --dry-run)
363
+ -- <tool options> --run only: pass the rest to the tool itself (one type)
287
364
 
288
365
  EXAMPLES sentinel --run --typescript # in a module → that module
289
- sentinel --report --typescript --module bff-admin # from root → one module
290
- sentinel --report # from root → all types, all modules
291
- sentinel --status --typescript # from root → adoption coverage
292
- sentinel --status --ci # from root → fail CI on drift
293
- sentinel --init --typescript --flavour react # write stubs for the current module
366
+ sentinel --run --typescript -- --noImplicitAny # ask tsc a question of your own
367
+ sentinel --run --typescript --module bff-admin # from root → one module
368
+ sentinel --run --json # from root → all types, machine output
369
+ sentinel --inspect --typescript # from root → adoption coverage
370
+ sentinel --inspect --ci # from root → fail CI on drift
371
+ sentinel --init --typescript --preset react # write stubs for the current module
294
372
  ```
295
373
 
296
374
  `--run`/`--inspect`/`--report`/`--status` share one context rule (developer from a
297
375
  module, or from the root for a name / affected / all); `--init` writes, so it targets
298
376
  one module only (adopting every module at once is refused, adopt gradually).
299
377
 
378
+ ### Asking the tool your own question (`--`)
379
+
380
+ Everything after `--` goes to the tool, not to sentinel. It applies to `--run` only, and
381
+ needs exactly one type named, since one tool's options are not another's:
382
+
383
+ ```bash
384
+ sentinel --run --typescript -- --noImplicitAny # what would this deferred rule cost?
385
+ sentinel --run --lint -- --deny-warnings # what would zero warnings take?
386
+ ```
387
+
388
+ This exists because a preset **defers** rules to keep first adoption non-breaking, and
389
+ `--inspect` can only say that a rule is deferred. "Deferred" is honest, and it is not the
390
+ answer anyone wants: the question is how much debt is behind it. Rather than sentinel
391
+ modelling that question one flag at a time, the tool's own options are forwarded, so
392
+ anything the tool can already answer is one command away.
393
+
394
+ Two things to know:
395
+
396
+ - **A modified run always says so**, on stderr and in the `--json` envelope (`toolArgs`).
397
+ These options can weaken a check as easily as strengthen it, and `--run` is what CI
398
+ calls, so a green result must never be mistakable for a clean standard check.
399
+ - **For TypeScript it changes how tsc is invoked**, because build mode refuses compiler
400
+ options (`error TS5094`). Sentinel switches to project mode and checks each project the
401
+ solution `references`, since the solution itself holds no files. Emit is forced off, so a
402
+ question about the code never writes build output.
403
+
300
404
  **`--status` — adoption + conformity.** A cheap, workspace-wide read (no tool run, no
301
- flavour guessing): for each module it reads the committed `tsconfig` `extends` chain and
405
+ preset guessing): for each module it reads the committed `tsconfig` `extends` chain and
302
406
  reports whether it is **adopted** (extends a sentinel preset), which preset, and whether
303
407
  it is **conformant** (drift-free, a re-`update` would change nothing), plus a coverage
304
408
  footer. This is the **drift guard** as a command, `--status --ci` exits non-zero when an
@@ -320,14 +424,14 @@ src/
320
424
  core/
321
425
  types.ts # the adapter contract, pure types (Adapter, FileOperation, UpdatePlan)
322
426
  base-adapter.ts # optional convenience base class for adapters
323
- domain.ts # vocabulary + derived types (verbs, targets, flavours)
427
+ domain.ts # vocabulary + derived types (verbs, targets, presets)
324
428
  settings.ts # tunables (workspace-root marker, ...)
325
- registry.ts # register + flavour-aware resolve
429
+ registry.ts # register + preset-aware resolve
326
430
  dispatch.ts # verb → adapter method
327
431
  apply-plan.ts # the engine's filesystem port (applies --init operations)
328
432
  roles/<config-role>/ # lint, format, typescript, build, test
329
433
  adapters/<runner>/ # one adapter per tool (implements the contract)
330
- flavours/<stack>/ # config presets per stack (react, nest, svelte, ...)
434
+ presets/<stack>/ # config presets per stack (react, nest, svelte, ...)
331
435
  roles/{static-analysis,runtime-analysis}/ # analysis roles
332
436
  configs/ # fixed configs (internal, not exported)
333
437
  runners/ # one runner per sub-tool (duplication, complexity, ...)
@@ -336,9 +440,18 @@ tests/ # unit tests + tests/e2e (runs the built dist binar
336
440
  .github/workflows/ # ci.yml (PR checks) + publish.yml (manual, version-input publish)
337
441
  ```
338
442
 
339
- Subpath exports (in `package.json`) expose presets to consumers. Shipped today:
340
- `@hublo/sentinel/tsconfig/react` · `.../tsconfig/nest` · `.../tsconfig/node`. Other
341
- subpaths (e.g. `.../lint/react`) land with their tool ticket.
443
+ Presets reach consumers two different ways, decided by the tool rather than by us:
444
+
445
+ - **tsconfig, as subpath exports**: `@hublo/sentinel/tsconfig/react` · `.../tsconfig/nest` ·
446
+ `.../tsconfig/node`. tsc resolves `extends` through the `exports` map.
447
+ - **oxlint, as physical paths at the package root**: `oxlint/react.json`, `oxlint/nest.json`,
448
+ `oxlint/node.json`, `oxlint/svelte.json`, referenced as
449
+ `./node_modules/@hublo/sentinel/oxlint/<preset>.json`. Oxlint resolves every `extends`
450
+ entry as a PATH and never as a package specifier, so it never consults `exports` and the
451
+ physical layout IS the contract. The same is true of `jsPlugins`, which is why the local
452
+ Hublo plugin ships beside them in `plugins/`.
453
+
454
+ Presets for the remaining tools land with their own ticket.
342
455
 
343
456
  ## FAQ
344
457