@hublo/sentinel 1.1.0-alpha.3 → 1.1.0-alpha.5

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,23 +43,47 @@ 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**, **lint** and **format** tools. The `--test` / `--build`
47
+ > snippets 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 --preset <react|nest|node|svelte> # every role sentinel ships
52
53
  pnpm install # fetch what --init declared, then commit
53
54
  ```
54
55
 
56
+ With no target named, `--init` adopts every role, in the order that makes the result correct:
57
+ the linter's autofix runs, then the formatter runs **last** and formats everything every role
58
+ wrote. Naming one target (`--init --lint`) adopts just that role and leaves the rest alone.
59
+
55
60
  `--init` writes the config stubs, the `typecheck`/`lint`/... scripts, and pins the `@hublo/sentinel` devDependency into the module (no manual `pnpm add`); it scaffolds a `package.json` for a `project.json`-only module. It also applies, once, the workspace prep that module needs at the root, only when the root's own config shows it is needed (e.g. an i18next singleton override when the repo runs a second TypeScript, a release-age allow-list when the repo uses that pnpm gate). See the [adoption cheat sheet](docs/typescript-adoption.md) for the full list.
56
61
 
57
62
  Those files are tiny, they just point at a sentinel preset. What gets committed:
58
63
 
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
64
+ ```jsonc
65
+ // .oxlintrc.json — generated. Adoption REPLACES eslint rather than sitting beside it, so
66
+ // the module's eslint config is DELETED and this is the only linter config it keeps.
67
+ // The RULES live in the preset, so a preset change arrives by reinstalling. `ignorePatterns`
68
+ // cannot: it does not cross an `extends` boundary, so it is written here, which is what
69
+ // makes the file correct for your editor and for `oxlint -c` on its own.
70
+ {
71
+ "extends": ["./node_modules/@hublo/sentinel/oxlint/react.json"],
72
+ "ignorePatterns": ["**/dist/**", "**/node_modules/**", "..."],
73
+ }
74
+ ```
75
+
76
+ ```jsonc
77
+ // .oxfmtrc.json — generated, and the one config that is NOT a stub: oxfmt has no `extends`
78
+ // and ignores the key silently, so the preset's values are written in. Anything the module
79
+ // deliberately formats differently is declared in `$sentinel.local` and survives a re-init.
80
+ {
81
+ "$sentinel": { "preset": "base", "version": "1.1.0", "local": [] },
82
+ "printWidth": 80,
83
+ "semi": false,
84
+ "singleQuote": true,
85
+ "sortImports": { "groups": ["builtin", "external", "internal", ["parent", "index"], "sibling"] },
86
+ }
63
87
  ```
64
88
 
65
89
  ```jsonc
@@ -68,7 +92,7 @@ export default react
68
92
  {
69
93
  "extends": ["../../tsconfig.base.json", "@hublo/sentinel/tsconfig/react"],
70
94
  "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } },
71
- "include": ["src"]
95
+ "include": ["src"],
72
96
  }
73
97
  ```
74
98
 
@@ -80,11 +104,15 @@ And the app's `package.json` scripts route every check through the one CLI (run
80
104
  // package.json
81
105
  {
82
106
  "scripts": {
83
- "lint": "sentinel --run --lint",
84
- "lint:fix": "sentinel --run --lint --fix",
107
+ // `lint` checks BOTH, the way `prettier --check . && eslint .` used to. In the :fix
108
+ // pair the formatter runs last: the linter's autofix rewrites code.
109
+ "lint": "sentinel --run --lint && sentinel --run --format",
110
+ "lint:fix": "sentinel --run --lint --fix && sentinel --run --format --fix",
111
+ "format": "sentinel --run --format",
112
+ "format:fix": "sentinel --run --format --fix",
85
113
  "typecheck": "sentinel --run --typescript",
86
- "test": "sentinel --run --test"
87
- }
114
+ "test": "sentinel --run --test",
115
+ },
88
116
  }
89
117
  ```
90
118
 
@@ -119,10 +147,14 @@ pnpm dlx @hublo/sentinel@<exact-version> --inspect --typescript --module <name>
119
147
 
120
148
  ## Docs & cheat sheets
121
149
 
150
+ - [`docs/performance.md`](docs/performance.md) — the measured baseline (`pnpm bench`), so a regression is something you can see rather than argue about, and what is deliberately left unoptimised.
151
+ - [`docs/using-sentinel.md`](docs/using-sentinel.md) — every verb for every role, scoping a run to some files, what a fully adopted module looks like, and how versions move between modules.
152
+ - [`docs/lint-adoption.md`](docs/lint-adoption.md) — migrating a module from ESLint to Oxlint: the two steps, what `--init` writes and removes, the two react tiers, and the failures worth recognising.
153
+ - [`docs/format-adoption.md`](docs/format-adoption.md) — migrating a module from Prettier to oxfmt: why this config is materialized rather than a stub, how a module keeps its own formatting, and what did not survive the move.
122
154
  - [`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
155
  - [`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
156
 
125
- ## Architecture: `target → runner → flavour`
157
+ ## Architecture: `target → runner → preset`
126
158
 
127
159
  Every check is described by three layers:
128
160
 
@@ -130,12 +162,12 @@ Every check is described by three layers:
130
162
  | -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- |
131
163
  | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `test` `static-analysis` `runtime-analysis` |
132
164
  | **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) |
165
+ | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint only, no tsconfig preset yet) |
134
166
 
135
- A run is `target × runner × flavour`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
167
+ A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
136
168
 
137
169
  - `--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.
170
+ - 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
171
 
140
172
  ### Adapters & the engine (ports & adapters)
141
173
 
@@ -143,14 +175,14 @@ A run is `target × runner × flavour`, e.g. `sentinel --run --lint --runner=esl
143
175
 
144
176
  - 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
177
  - 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.
178
+ - 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
179
 
148
180
  `sentinel` does not reimplement tools. The contract:
149
181
 
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.
182
+ - **`appliesTo(preset)`** — which presets this adapter handles (resolution filters on it, so a React-only adapter is never picked for Nest)
183
+ - **`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
184
  - **`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`)
185
+ - **`inspect(preset)`** — the adapter's resolved base config (used by `--inspect`)
154
186
  - **`report(ctx)`** — metrics (used by `--report`)
155
187
 
156
188
  **`--init` is a declarative plan, not file-writing inside the adapter.** The adapter describes intent as operations; the engine executes them:
@@ -170,7 +202,7 @@ So the tool-_meaning_ lives in the adapter and the read/merge/write _mechanics_
170
202
  ```mermaid
171
203
  flowchart LR
172
204
  CLI["sentinel --verb --target<br/>[--runner]"] --> D[dispatch]
173
- D --> R["registry.resolve<br/>(target, flavour, runner)"]
205
+ D --> R["registry.resolve<br/>(target, preset, runner)"]
174
206
  R --> A["adapter<br/>eslint / tsc / vitest / ..."]
175
207
  A -->|"--run"| Run["tool binary on the project"]
176
208
  A -->|"--init"| Upd["declarative plan → engine writes"]
@@ -200,6 +232,80 @@ The rules live in `sentinel`. Each module keeps a **thin, generated stub** per t
200
232
  - **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
233
  - **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
234
 
235
+ ## What a preset does not enforce
236
+
237
+ A preset that silently enforces less than the config it replaced is worse than no preset, so
238
+ every rule that is not enforced carries a reason and is listed by `sentinel --inspect --lint`:
239
+
240
+ | State | Meaning | Can it be switched on? |
241
+ | -------------- | -------------------------------------------------------------------- | ------------------------------- |
242
+ | **deferred** | scheduled for a later migration wave | yes, on schedule |
243
+ | **disabled** | a decision: it argues with the architecture, or only false-positives | yes, by revisiting the decision |
244
+ | **downgraded** | it runs and reports, it just cannot fail the build | yes, once the debt is paid |
245
+
246
+ A small number of rules sit outside those three states, because the preset cannot carry them at
247
+ any severity. They are documented here rather than reported by `--inspect`: oxlint validates
248
+ rule and plugin names when it **parses** a config, so naming one would make the config fail to
249
+ parse and the module would lint nothing at all. They are still recorded as data in the preset,
250
+ so the tests can count every rule a module enforces and fail on anything unaccounted for.
251
+
252
+ ### Rules the presets do not carry (nest, react)
253
+
254
+ | Rule | Why |
255
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
256
+ | `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 |
257
+ | `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` |
258
+ | `no-octal` | no oxlint equivalent |
259
+ | ~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 |
260
+
261
+ The formatting group is the only one that will ever shrink; the rest are settled losses.
262
+
263
+ `no-return-await` is **not** in that table, despite having no oxlint rule of that name. It is
264
+ deprecated in ESLint core and oxlint ships the typescript-eslint successor, so the presets carry
265
+ `typescript/return-await` with the `never` option, which is what the old rule meant. It is held
266
+ at `warn`: both modules set the old rule to error, but it was never actually reporting (the nx
267
+ lint target expanded to 2 files of 2888), and running it over every file finds 9 real violations
268
+ on bff-admin and 5 on host-admin. Fix those and it can be raised.
269
+
270
+ ### Rules with no oxlint equivalent (svelte)
271
+
272
+ Oxlint parses a `.svelte` file's `<script>` block and **not its template**. Measured on oxlint
273
+ 1.77 with a component carrying a violation in each half: the `<script>` violation is reported,
274
+ and `{@html}`, `{@debug}` and duplicate style properties in the template produce nothing.
275
+ Adding a svelte plugin does not fix this, because the missing piece is the **parser**, not the
276
+ rule implementations.
277
+
278
+ So sixteen rules the two svelte modules enforce today have **no oxlint equivalent**. Fourteen
279
+ read the template:
280
+
281
+ `svelte/comment-directive` · `svelte/no-at-debug-tags` · **`svelte/no-at-html-tags`** ·
282
+ `svelte/no-dupe-else-if-blocks` · `svelte/no-dupe-style-properties` ·
283
+ `svelte/no-dynamic-slot-name` · `svelte/no-inner-declarations` ·
284
+ `svelte/no-not-function-handler` · `svelte/no-object-in-text-mustaches` ·
285
+ `svelte/no-shorthand-style-property-overrides` · `svelte/no-unknown-style-directive-property` ·
286
+ `svelte/no-unused-svelte-ignore` · `svelte/system` · `svelte/valid-compile`
287
+
288
+ and two core rules oxlint does not implement: `no-dupe-args` (TypeScript rejects duplicate
289
+ parameter names anyway) and `no-octal`.
290
+
291
+ **The one worth knowing about is `svelte/no-at-html-tags`**, an XSS guard that fires on the
292
+ modules today. Adopting the svelte lint preset gives that check up.
293
+
294
+ These rules are **absent from the generated config rather than set to `off`**, and that is
295
+ forced, not stylistic. Oxlint rejects an unknown rule or plugin when it _parses_ a config, even
296
+ when the value is `off`:
297
+
298
+ ```console
299
+ $ oxlint -c '{"rules": {"svelte/no-at-html-tags": "off"}}'
300
+ Failed to parse oxlint configuration file.
301
+ x Plugin 'svelte' not found
302
+ ```
303
+
304
+ Listing them as disabled would not weaken the config, it would stop the config from parsing and
305
+ the module would lint nothing at all. They are recorded as data in the preset (`uncovered`), so
306
+ the preset tests can count every rule the modules enforce and fail on anything unaccounted for,
307
+ and they are documented here for the developers who own those modules.
308
+
203
309
  ## Composition & precedence
204
310
 
205
311
  A repo often has a base config that is **structural**, not just tooling, e.g. a
@@ -264,11 +370,11 @@ A command **composes** three axes: **verb + type + location**.
264
370
  ```
265
371
  sentinel <verb> [type] [options]
266
372
 
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)
373
+ VERBS --run execute the target's tool (--json for metrics + diagnostics)
374
+ --inspect the resolved configuration, what is deferred, adoption + drift
375
+ --init generate/apply the config stubs (writes; one module only)
376
+ --report deprecated --run --json
377
+ --status deprecated --inspect
272
378
 
273
379
  TYPES --lint --format --typescript --build --test
274
380
  --static-analysis --runtime-analysis --arch
@@ -278,27 +384,55 @@ LOCATION in a MODULE dir → that module (do NOT pass --module)
278
384
  at the workspace ROOT → --module <name> (one) · --ci (affected) · else all
279
385
 
280
386
  OPTIONS --module <name> from the root: scope to one module
281
- --flavour <name> override the detected stack preset (react, nest, ...)
387
+ --preset <name> override the detected stack preset (react, nest, ...)
282
388
  --runner <tool> override the default runner
283
389
  --ci from the root: affected only; non-zero exit on failure
284
390
  --fix auto-fix where applicable
285
391
  --dry-run preview a --init without writing
286
392
  --json machine-readable output (report / inspect / status / --dry-run)
393
+ -- <tool options> --run only: pass the rest to the tool itself (one type)
287
394
 
288
395
  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
396
+ sentinel --run --typescript -- --noImplicitAny # ask tsc a question of your own
397
+ sentinel --run --typescript --module bff-admin # from root → one module
398
+ sentinel --run --json # from root → all types, machine output
399
+ sentinel --inspect --typescript # from root → adoption coverage
400
+ sentinel --inspect --ci # from root → fail CI on drift
401
+ sentinel --init --typescript --preset react # write stubs for the current module
294
402
  ```
295
403
 
296
404
  `--run`/`--inspect`/`--report`/`--status` share one context rule (developer from a
297
405
  module, or from the root for a name / affected / all); `--init` writes, so it targets
298
406
  one module only (adopting every module at once is refused, adopt gradually).
299
407
 
408
+ ### Asking the tool your own question (`--`)
409
+
410
+ Everything after `--` goes to the tool, not to sentinel. It applies to `--run` only, and
411
+ needs exactly one type named, since one tool's options are not another's:
412
+
413
+ ```bash
414
+ sentinel --run --typescript -- --noImplicitAny # what would this deferred rule cost?
415
+ sentinel --run --lint -- --deny-warnings # what would zero warnings take?
416
+ ```
417
+
418
+ This exists because a preset **defers** rules to keep first adoption non-breaking, and
419
+ `--inspect` can only say that a rule is deferred. "Deferred" is honest, and it is not the
420
+ answer anyone wants: the question is how much debt is behind it. Rather than sentinel
421
+ modelling that question one flag at a time, the tool's own options are forwarded, so
422
+ anything the tool can already answer is one command away.
423
+
424
+ Two things to know:
425
+
426
+ - **A modified run always says so**, on stderr and in the `--json` envelope (`toolArgs`).
427
+ These options can weaken a check as easily as strengthen it, and `--run` is what CI
428
+ calls, so a green result must never be mistakable for a clean standard check.
429
+ - **For TypeScript it changes how tsc is invoked**, because build mode refuses compiler
430
+ options (`error TS5094`). Sentinel switches to project mode and checks each project the
431
+ solution `references`, since the solution itself holds no files. Emit is forced off, so a
432
+ question about the code never writes build output.
433
+
300
434
  **`--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
435
+ preset guessing): for each module it reads the committed `tsconfig` `extends` chain and
302
436
  reports whether it is **adopted** (extends a sentinel preset), which preset, and whether
303
437
  it is **conformant** (drift-free, a re-`update` would change nothing), plus a coverage
304
438
  footer. This is the **drift guard** as a command, `--status --ci` exits non-zero when an
@@ -320,14 +454,14 @@ src/
320
454
  core/
321
455
  types.ts # the adapter contract, pure types (Adapter, FileOperation, UpdatePlan)
322
456
  base-adapter.ts # optional convenience base class for adapters
323
- domain.ts # vocabulary + derived types (verbs, targets, flavours)
457
+ domain.ts # vocabulary + derived types (verbs, targets, presets)
324
458
  settings.ts # tunables (workspace-root marker, ...)
325
- registry.ts # register + flavour-aware resolve
459
+ registry.ts # register + preset-aware resolve
326
460
  dispatch.ts # verb → adapter method
327
461
  apply-plan.ts # the engine's filesystem port (applies --init operations)
328
462
  roles/<config-role>/ # lint, format, typescript, build, test
329
463
  adapters/<runner>/ # one adapter per tool (implements the contract)
330
- flavours/<stack>/ # config presets per stack (react, nest, svelte, ...)
464
+ presets/<stack>/ # config presets per stack (react, nest, svelte, ...)
331
465
  roles/{static-analysis,runtime-analysis}/ # analysis roles
332
466
  configs/ # fixed configs (internal, not exported)
333
467
  runners/ # one runner per sub-tool (duplication, complexity, ...)
@@ -336,9 +470,18 @@ tests/ # unit tests + tests/e2e (runs the built dist binar
336
470
  .github/workflows/ # ci.yml (PR checks) + publish.yml (manual, version-input publish)
337
471
  ```
338
472
 
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.
473
+ Presets reach consumers two different ways, decided by the tool rather than by us:
474
+
475
+ - **tsconfig, as subpath exports**: `@hublo/sentinel/tsconfig/react` · `.../tsconfig/nest` ·
476
+ `.../tsconfig/node`. tsc resolves `extends` through the `exports` map.
477
+ - **oxlint, as physical paths at the package root**: `oxlint/react.json`, `oxlint/nest.json`,
478
+ `oxlint/node.json`, `oxlint/svelte.json`, referenced as
479
+ `./node_modules/@hublo/sentinel/oxlint/<preset>.json`. Oxlint resolves every `extends`
480
+ entry as a PATH and never as a package specifier, so it never consults `exports` and the
481
+ physical layout IS the contract. The same is true of `jsPlugins`, which is why the local
482
+ Hublo plugin ships beside them in `plugins/`.
483
+
484
+ Presets for the remaining tools land with their own ticket.
342
485
 
343
486
  ## FAQ
344
487
 
@@ -358,7 +501,7 @@ Yes. Per app (app A defaults to eslint, app B to biome), or even in the same app
358
501
  Most tools expose `extends` or a plugin mechanism to compose config, so the stub just points at the sentinel preset. For the rare tool that doesn't, sentinel exposes the config **directly**, it generates the full config from its preset (still one source, drift-checked).
359
502
 
360
503
  **What does an nx `project.json` look like?**
361
- nx is a **task runner**: it just runs the target's script. So `project.json` barely changes, the lint / test / typecheck targets run the `package.json` scripts (which call sentinel), or stay nx-inferred. nx keeps the graph, affected set, and cache; sentinel provides the config + execution.
504
+ nx is a **task runner**: it just runs the target's script, so the command belongs in `package.json` and nx infers a target from it (verified: it does this even when the module also has a `project.json`). Adoption therefore REMOVES the role's target from `project.json`, leaving every other one alone, and writes only what nx alone needs (cache inputs) into the `nx` block of `package.json`. Each role a module adopts decouples one more thing from nx and shrinks that file. nx keeps the graph, affected set, and cache; sentinel provides the config + execution.
362
505
 
363
506
  ## Roadmap
364
507